1
0
forked from boos/BoosAPI

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

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

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

Note: app icons are still placeholder assets pending the final BoosAPI
logo. The updater signing keys live outside the repo and are not
included.
This commit is contained in:
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,3 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@@ -0,0 +1,99 @@
<script setup lang="ts">
defineProps<{ node: {
type: 'thinking'
content: string
loading?: boolean
} }>()
</script>
<template>
<div class="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 class="flex-shrink-0 mt-1">
<!-- decorative thinking SVG icon -->
<div class="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 class="w-5 h-5" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" 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" stroke-width="0.8" fill="currentColor" opacity="0.9" />
</svg>
</div>
</div>
<div class="flex-1">
<div class="flex items-baseline gap-3">
<strong class="text-sm">Thinking</strong>
<span class="text-xs text-slate-500 dark:text-slate-300">(assistant)</span>
<!-- keep dots in DOM to avoid layout shift; toggle visibility with classes -->
<span class="ml-2" aria-hidden="true">
<span class="thinking-dots" :class="[node.loading ? 'visible' : 'hidden']" aria-hidden="true">
<span class="dot dot-1" />
<span class="dot dot-2" />
<span class="dot dot-3" />
</span>
</span>
</div>
<div class="mt-1 text-sm leading-relaxed text-slate-800 dark:text-slate-100">
<!-- sr-only live region only present when loading to announce change -->
<span v-if="node.loading" class="sr-only" aria-live="polite">Thinking…</span>
<transition name="fade" mode="out-in">
<div key="{{ node.loading ? 'loading' : 'ready' }}" class="content-area">
<div v-if="node.loading" class="partial-content">
{{ node.content }}
</div>
<div v-else class="full-content">
{{ node.content }}
</div>
</div>
</transition>
</div>
</div>
</div>
</template>
<style scoped>
.thinking-node {
color: #0f172a;
}
.dark .thinking-node {
color: #e6f0ff;
}
/* Animated dots for thinking state */
.thinking-dots {
display: inline-flex;
align-items: center;
gap: 6px;
width: 36px;
justify-content: flex-start;
height: 12px; /* reserve vertical space so toggling doesn't collapse layout */
transition: opacity 160ms linear, transform 160ms linear;
opacity: 0;
}
.thinking-dots .dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: #1e3a8a; /* blue-800 */
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 }
}
/* ensure content area doesn't shift when dots appear */
.content-area { min-height: 1.25rem; }
.partial-content, .full-content { transition: opacity 140ms ease; }
.partial-content { opacity: 0.9 }
.full-content { opacity: 1 }
/* Vue transition classes for fade */
.fade-enter-from, .fade-leave-to { opacity: 0 }
.fade-enter-active, .fade-leave-active { transition: opacity 160ms ease }
.fade-enter-to, .fade-leave-from { opacity: 1 }
</style>
@@ -0,0 +1,7 @@
[build]
publish = ".output/public"
command = "cd .. && npx pnpm i && pnpm --filter stream-markdown-parser build && pnpm run build && cd playground-nuxt && npm run build"
[build.environment]
NPM_FLAGS = "--version"
NODE_VERSION = "22"
@@ -0,0 +1,57 @@
import { defineNuxtConfig } from 'nuxt/config'
export default defineNuxtConfig({
devtools: { enabled: true },
app: {
head: {
title: 'markstream-nuxt Playground | Nuxt demo',
meta: [
{
name: 'description',
content: 'markstream-nuxt playground for streaming Markdown, Mermaid, KaTeX, and docs-site integration demos.',
},
],
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
],
},
},
modules: [
'@nuxtjs/tailwindcss',
],
css: [
'@/assets/tailwind.css',
'markstream-vue/index.css',
'katex/dist/katex.min.css',
],
nitro: {
preset: 'static',
publicAssets: [
{ dir: 'public' },
{ dir: '../playground/public', baseURL: '/' },
],
},
vite: {
optimizeDeps: {
include: [
'@antv/infographic',
'@floating-ui/dom',
'@terrastruct/d2',
'katex',
'katex/dist/contrib/mhchem',
'mermaid',
'stream-markdown',
'vue-i18n',
],
exclude: ['monaco-editor', 'stream-monaco', 'markstream-vue'],
},
worker: {
format: 'es',
},
},
runtimeConfig: {
public: {
demoTitle: 'Nuxt + Vue Renderer Markdown',
},
},
})
@@ -0,0 +1,26 @@
{
"name": "playground-nuxt",
"type": "module",
"private": true,
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"preview": "nuxt preview",
"typecheck": "nuxt typecheck"
},
"dependencies": {
"@iconify-json/carbon": "^1.2.20",
"@iconify/vue": "^5.0.0",
"@nuxtjs/tailwindcss": "^6.14.0",
"@unocss/reset": "^0.61.9",
"@vueuse/core": "^13.9.0",
"katex": "^0.16.44",
"markstream-vue": "workspace:*",
"mermaid": "^11.13.0",
"monaco-editor": "^0.52.2",
"nuxt": "^4.4.2",
"stream-markdown": "latest",
"stream-monaco": "latest",
"vue-i18n": "^11.3.0"
}
}
@@ -0,0 +1,585 @@
<script setup lang="ts">
import { useRouter } from '#imports'
import { Icon } from '@iconify/vue'
import { useDark, useInterval, useLocalStorage, useToggle } from '@vueuse/core'
import MarkdownRender, { getUseMonaco, setCustomComponents, setKaTeXWorker, setMermaidWorker } from 'markstream-vue'
import KatexWorker from 'markstream-vue/workers/katexRenderer.worker?worker&inline'
import MermaidWorker from 'markstream-vue/workers/mermaidParser.worker?worker&inline'
import { computed, onBeforeUnmount, onMounted, ref, watchEffect } from 'vue'
import { streamContent } from '../../playground/src/const/markdown'
import ThinkingNode from '../components/ThinkingNode.vue'
const content = ref<string>('')
const streamDelay = useLocalStorage<number>('vmr-settings-stream-delay', 16)
const streamChunkSize = useLocalStorage<number>('vmr-settings-stream-chunk-size', 1)
const normalizedChunkSize = computed(() => Math.max(1, Math.floor(streamChunkSize.value) || 1))
if (process.client) {
getUseMonaco()
setKaTeXWorker(new KatexWorker())
setMermaidWorker(new MermaidWorker())
}
const router = useRouter()
function goToTest() {
router.push('/test').catch(() => {
window.location.href = '/test'
})
}
watchEffect(() => {
const parsedDelay = Number(streamDelay.value)
const fallbackDelay = Number.isFinite(parsedDelay) ? parsedDelay : 16
const boundedDelay = Math.min(200, Math.max(4, fallbackDelay))
if (streamDelay.value !== boundedDelay)
streamDelay.value = boundedDelay
})
watchEffect(() => {
const parsedChunk = Number(streamChunkSize.value)
const fallbackChunk = Number.isFinite(parsedChunk) ? parsedChunk : 1
const normalizedChunk = Math.floor(fallbackChunk) || 1
const boundedChunk = Math.min(16, Math.max(1, normalizedChunk))
if (streamChunkSize.value !== boundedChunk)
streamChunkSize.value = boundedChunk
})
useInterval(streamDelay, {
callback() {
const cur = content.value.length
if (cur >= streamContent.length)
return
const chunkSize = normalizedChunkSize.value
const nextChunk = streamContent.slice(cur, cur + chunkSize)
content.value += nextChunk
},
})
setCustomComponents('playground-demo', { thinking: ThinkingNode })
const parseOptions = {
preTransformTokens: (tokens: any[]) => {
return tokens.map((token) => {
if (token.type === 'inline' && token.content.includes('<thinking')) {
token.children = token.children.map((t: any) => {
if (t.type === 'html_block' && t.tag === 'thinking') {
const m = t.content.match(/<thinking([^>]*)>/)
const attrs = []
if (m) {
const attrString = m[1]
const attrRegex = /([^\s=]+)(?:="([^"]*)")?/g
let match
while ((match = attrRegex.exec(attrString)) !== null) {
const attrName = match[1]
const attrValue = match[2] || true
attrs.push({ name: attrName, value: attrValue })
}
}
// eslint-disable-next-line regexp/no-super-linear-backtracking
const thinkingContent = t.content.replace(/<thinking[^>]*>/, '').replace(/<\/*t*h*i*n*k*i*n*g*>*\n*$/, '')
return {
type: 'thinking',
loading: t.loading,
attrs,
content: thinkingContent,
}
}
return t
})
}
return token
})
},
}
const isDark = useDark()
const toggleTheme = useToggle(isDark)
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',
]
const selectedTheme = useLocalStorage<string>('vmr-settings-selected-theme', 'vitesse-dark')
function formatThemeName(themeName: string) {
return themeName
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
const showSettings = ref(false)
const messagesContainer = ref<HTMLElement | null>(null)
let __roContainer: ResizeObserver | null = null
let __roContent: ResizeObserver | null = null
let __mo: MutationObserver | null = null
let __scheduled = false
let __minHeightDisabled = false
let __overflowConfirmations = 0
let __clearConfirmations = 0
function scheduleCheckMinHeight() {
if (__scheduled)
return
__scheduled = true
requestAnimationFrame(() => {
__scheduled = false
const container = messagesContainer.value
if (!container)
return
const contentEl = Array.from(container.children).find(child =>
(child as HTMLElement).classList?.contains('markdown-renderer'),
) as HTMLElement | undefined
if (!contentEl)
return
const REQUIRED_OVERFLOW_CONFIRMATIONS = 2
const REQUIRED_CLEAR_CONFIRMATIONS = 3
const hadClass = contentEl.classList.contains('disable-min-height')
if (__minHeightDisabled || hadClass) {
contentEl.classList.add('disable-min-height')
const containerDelta = container.scrollHeight - container.clientHeight
const shouldRemove = containerDelta > 1
if (shouldRemove) {
__clearConfirmations = 0
__minHeightDisabled = true
}
else {
__clearConfirmations++
if (__clearConfirmations >= REQUIRED_CLEAR_CONFIRMATIONS) {
__minHeightDisabled = false
__overflowConfirmations = 0
contentEl.classList.remove('disable-min-height')
}
}
return
}
// Not latched: probe by temporarily unsetting min-height (same rAF tick).
contentEl.classList.add('disable-min-height')
const containerDelta = container.scrollHeight - container.clientHeight
const probeOverflow = containerDelta > 1
if (probeOverflow)
__overflowConfirmations++
else
__overflowConfirmations = 0
const shouldRemove = __overflowConfirmations >= REQUIRED_OVERFLOW_CONFIRMATIONS
if (shouldRemove) {
__minHeightDisabled = true
__clearConfirmations = 0
try {
__roContainer?.disconnect()
__roContent?.disconnect()
__mo?.disconnect()
}
finally {
__roContainer = null
__roContent = null
__mo = null
}
}
else {
contentEl.classList.remove('disable-min-height')
}
})
}
onMounted(() => {
const container = messagesContainer.value
if (!container)
return
requestAnimationFrame(scheduleCheckMinHeight)
__roContainer = new ResizeObserver(scheduleCheckMinHeight)
__roContainer.observe(container)
const tryObserveContent = () => {
const el = Array.from(container.children).find(child =>
(child as HTMLElement).classList?.contains('markdown-renderer'),
) as HTMLElement | undefined
if (el) {
if (__roContent)
__roContent.disconnect()
__roContent = new ResizeObserver(scheduleCheckMinHeight)
__roContent.observe(el)
}
}
tryObserveContent()
__mo = new MutationObserver(() => {
tryObserveContent()
scheduleCheckMinHeight()
})
__mo.observe(container, { childList: true, subtree: true })
})
onBeforeUnmount(() => {
__roContainer?.disconnect()
__roContent?.disconnect()
__mo?.disconnect()
})
</script>
<template>
<div class="flex items-center justify-center p-4 app-container h-full bg-gray-50 dark:bg-gray-900">
<div class="fixed top-4 right-4 z-10">
<button
class="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"
:class="{ 'ring-2 ring-blue-500/50': showSettings }"
@click="showSettings = !showSettings"
>
<Icon
icon="carbon:settings"
class="w-5 h-5 text-gray-600 dark:text-gray-400 transition-transform duration-200"
:class="{ 'rotate-90': showSettings }"
/>
</button>
<Transition
enter-active-class="transition ease-out duration-300"
enter-from-class="opacity-0 scale-95 translate-y-2"
enter-to-class="opacity-100 scale-100 translate-y-0"
leave-active-class="transition ease-in duration-200"
leave-from-class="opacity-100 scale-100 translate-y-0"
leave-to-class="opacity-0 scale-95 translate-y-2"
>
<div
v-if="showSettings"
class="absolute top-12 right-0 mt-2 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] origin-top-right"
@click.stop
>
<div class="space-y-2">
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
Code Theme
</label>
<div class="relative theme-selector">
<select
v-model="selectedTheme"
class="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"
aria-label="Code block theme"
@click.stop
@change.stop
>
<option v-for="t in themes" :key="t" :value="t">
{{ formatThemeName(t) }}
</option>
</select>
<div class="absolute inset-y-0 right-0 flex items-center pr-2 pointer-events-none">
<Icon icon="carbon:chevron-down" class="w-4 h-4 text-gray-400 dark:text-gray-500" />
</div>
</div>
</div>
<div class="space-y-2">
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
Stream Delay
</label>
<div class="flex items-center gap-3">
<input v-model.number="streamDelay" type="range" min="4" max="200" step="4" class="flex-1 cursor-pointer">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-12 text-right">
{{ streamDelay }}ms
</span>
</div>
</div>
<div class="space-y-2">
<label class="block text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
Chunk Size
</label>
<div class="flex items-center gap-3">
<input v-model.number="streamChunkSize" type="range" min="1" max="16" step="1" class="flex-1 cursor-pointer">
<span class="text-xs font-medium text-gray-600 dark:text-gray-400 w-12 text-right">
{{ normalizedChunkSize }}
</span>
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700" />
<div class="flex items-center justify-between">
<label class="text-xs font-semibold text-gray-600 dark:text-gray-400 uppercase tracking-wide">
Dark Mode
</label>
<button
class="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',
}"
@click.stop="toggleTheme()"
>
<div
class="absolute top-0.5 w-5 h-5 bg-white rounded-full flex items-center justify-center shadow-md hover:shadow-lg"
: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',
}"
>
<Transition
enter-active-class="transition-all duration-300 ease-out"
leave-active-class="transition-all duration-200 ease-in"
enter-from-class="opacity-0 scale-0 rotate-90"
enter-to-class="opacity-100 scale-100 rotate-0"
leave-from-class="opacity-100 scale-100 rotate-0"
leave-to-class="opacity-0 scale-0 rotate-90"
mode="out-in"
>
<Icon
v-if="isDark"
key="moon"
icon="carbon:moon"
class="w-3 h-3 text-blue-600 drop-shadow-sm"
/>
<Icon
v-else
key="sun"
icon="carbon:sun"
class="w-3 h-3 text-yellow-500 drop-shadow-sm"
/>
</Transition>
</div>
</button>
</div>
</div>
</Transition>
</div>
<div class="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 class="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 class="flex items-center justify-between gap-3">
<div class="flex items-center gap-3">
<div class="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" class="w-5 h-5 text-white" />
</div>
<div>
<h1 class="text-lg font-semibold text-gray-800 dark:text-gray-100">
markstream-vue
</h1>
<p class="text-xs text-gray-500 dark:text-gray-400">
Streaming markdown demo
</p>
</div>
</div>
<div class="flex">
<a
href="https://github.com/Simon-He95/markstream-vue"
target="_blank"
rel="noopener noreferrer"
class="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" class="w-4 h-4" />
<span>Star</span>
</a>
<button
class="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"
@click="goToTest"
>
<Icon icon="carbon:rocket" class="w-4 h-4" />
<span>Test</span>
</button>
</div>
</div>
</div>
<main ref="messagesContainer" class="chatbot-messages flex-1 overflow-y-auto mr-[1px] mb-4 flex flex-col-reverse">
<ClientOnly>
<MarkdownRender
:content="content"
:code-block-dark-theme="selectedTheme || undefined"
:code-block-light-theme="selectedTheme || undefined"
:themes="themes"
:is-dark="isDark"
:parse-options="parseOptions"
custom-id="playground-demo"
class="p-6"
/>
</ClientOnly>
</main>
</div>
</div>
</template>
<style scoped>
.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 {
scroll-behavior: smooth;
overscroll-behavior: contain;
}
.chatbot-messages > .markdown-renderer {
min-height: 100%;
box-sizing: border-box;
}
.chatbot-messages > .markdown-renderer.disable-min-height {
min-height: unset !important;
}
.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;
}
:deep(.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);
}
}
:deep(.is-rendering) {
position: relative;
animation: renderingGlow 2s ease-in-out infinite;
}
</style>
@@ -0,0 +1,379 @@
<script setup lang="ts">
/* eslint-disable vue/one-component-per-file */
import type { PropType } from 'vue'
import katex from 'katex'
import MarkdownRender, { enableKatex, getUseMonaco, setCustomComponents, setKaTeXWorker, setMermaidWorker } from 'markstream-vue'
import KatexWorker from 'markstream-vue/workers/katexRenderer.worker?worker&inline'
import MermaidWorker from 'markstream-vue/workers/mermaidParser.worker?worker&inline'
import EditorWorker from 'monaco-editor/esm/vs/editor/editor.worker?worker&inline'
import CssWorker from 'monaco-editor/esm/vs/language/css/css.worker?worker&inline'
import HtmlWorker from 'monaco-editor/esm/vs/language/html/html.worker?worker&inline'
import JsonWorker from 'monaco-editor/esm/vs/language/json/json.worker?worker&inline'
import TsWorker from 'monaco-editor/esm/vs/language/typescript/ts.worker?worker&inline'
import { computed, defineComponent, h, onBeforeUnmount, onMounted, ref } from 'vue'
interface NodeLike {
content?: string
raw?: string
code?: string
}
function createDiagramFallback(kind: string, label: string) {
return defineComponent({
name: `${label}SsrFallback`,
props: {
node: {
type: Object as PropType<NodeLike>,
required: true,
},
},
setup(props) {
return () => h('div', {
'class': 'my-4 rounded-lg border border-dashed border-slate-300 bg-slate-50 text-slate-900',
'data-ssr-fallback': kind,
'data-markstream-mode': 'fallback',
}, [
h('div', {
class: 'border-b border-slate-200 px-4 py-2 text-sm font-mono text-slate-600',
}, `${label} fallback`),
h('pre', {
class: 'm-0 overflow-x-auto px-4 py-4 text-sm whitespace-pre-wrap',
}, [
h('code', String(props.node.code ?? props.node.raw ?? '')),
]),
])
},
})
}
const SsrBadgeNode = defineComponent({
name: 'SsrBadgeNode',
props: {
node: {
type: Object as PropType<NodeLike>,
required: true,
},
},
setup(props) {
return () => h('span', {
'class': 'inline-flex items-center rounded-full border border-emerald-300 bg-emerald-50 px-2 py-0.5 text-xs font-semibold text-emerald-700',
'data-ssr-custom': 'badge',
}, String(props.node.content ?? ''))
},
})
const SsrMathInlineFallback = defineComponent({
name: 'SsrMathInlineFallback',
props: {
node: {
type: Object as PropType<NodeLike>,
required: true,
},
},
setup(props) {
return () => h('span', {
'class': 'font-mono text-sm text-slate-700',
'data-ssr-fallback': 'math-inline',
'data-markstream-mode': 'fallback',
}, String(props.node.raw ?? props.node.content ?? ''))
},
})
const SsrMathBlockFallback = defineComponent({
name: 'SsrMathBlockFallback',
props: {
node: {
type: Object as PropType<NodeLike>,
required: true,
},
},
setup(props) {
return () => h('pre', {
'class': 'm-0 overflow-x-auto rounded-lg border border-dashed border-slate-300 bg-slate-50 px-4 py-4 text-left text-sm whitespace-pre-wrap text-slate-900',
'data-ssr-fallback': 'math-block',
'data-markstream-mode': 'fallback',
}, String(props.node.raw ?? props.node.content ?? ''))
},
})
const SsrMermaidFallback = createDiagramFallback('mermaid', 'Mermaid')
const SsrD2Fallback = createDiagramFallback('d2', 'D2')
const SsrInfographicFallback = createDiagramFallback('infographic', 'Infographic')
const BASIC_SCOPE = 'ssr-lab-basic'
const FALLBACK_SCOPE = 'ssr-lab-disabled'
setCustomComponents(BASIC_SCOPE, {
'ssr-badge': SsrBadgeNode,
})
setCustomComponents(FALLBACK_SCOPE, {
math_inline: SsrMathInlineFallback,
math_block: SsrMathBlockFallback,
mermaid: SsrMermaidFallback,
d2: SsrD2Fallback,
infographic: SsrInfographicFallback,
})
enableKatex(() => katex)
if (process.client) {
const existingMonacoEnvironment = (globalThis as any).MonacoEnvironment
if (!existingMonacoEnvironment?.getWorker && !existingMonacoEnvironment?.getWorkerUrl) {
;(globalThis as any).MonacoEnvironment = {
getWorker(_: unknown, label: string) {
if (label === 'json')
return new JsonWorker()
if (label === 'css' || label === 'scss' || label === 'less')
return new CssWorker()
if (label === 'html' || label === 'handlebars' || label === 'razor')
return new HtmlWorker()
if (label === 'typescript' || label === 'javascript')
return new TsWorker()
return new EditorWorker()
},
}
}
getUseMonaco()
setKaTeXWorker(new KatexWorker())
setMermaidWorker(new MermaidWorker())
}
const basicMarkdown = `
# SSR Basic Coverage
Inline HTML stays visible: <mark data-ssr-inline-html="1">inline html</mark>.
Trusted custom tag: <ssr-badge>Custom HTML tag rendered on the server</ssr-badge>
> Blockquote content should be present in the first HTML response.
- [x] Checklist survives SSR
- [ ] Pending item stays visible
| Name | Role |
| --- | --- |
| Simon | Maintainer |
| Markstream | Renderer |
Footnotes also render on the server.[^1]
![Markstream icon](/vue-markdown-icon.svg "Markstream icon")
<div class="rounded border border-slate-200 bg-slate-50 px-3 py-2" data-ssr-html-block="1">
<strong>HTML block</strong> content is already present in SSR output.
</div>
[^1]: Footnote content is part of the server HTML.
`.trim()
const enhancedMarkdown = `
# SSR Enhanced Coverage
Inline math renders to KaTeX: $E = mc^2$.
Block math should already be HTML:
$$
\\int_0^1 x^2 \\, dx = \\frac{1}{3}
$$
\`\`\`diff
--- a/file.txt
+++ b/file.txt
@@
- old line
+ new line
\`\`\`
\`\`\`ts
export const greet = (name: string) => \`hello \${name}\`
\`\`\`
\`\`\`mermaid
graph TD
Start --> Render
Render --> Hydrate
\`\`\`
\`\`\`d2
client: Browser
server: SSR
client -> server: request
server -> client: html
\`\`\`
\`\`\`infographic
infographic list-row-simple-horizontal-arrow
data
items
- label SSR
desc First paint
- label Hydration
desc Attach interactivity
- label Enhanced
desc Rich preview
\`\`\`
`.trim()
const fallbackMarkdown = `
# Disabled Enhancements
Inline math fallback: $a^2 + b^2 = c^2$.
$$
\\sum_{n=1}^{3} n = 6
$$
\`\`\`mermaid
graph LR
Raw --> Stable
\`\`\`
\`\`\`d2
stable -> fallback
\`\`\`
\`\`\`infographic
infographic list-row-simple-horizontal-arrow
data
items
- label Disabled
desc Static fallback
- label Stable
desc SSR-friendly
\`\`\`
`.trim()
const hydrated = ref(false)
const status = ref({
code: false,
math: false,
mermaid: false,
d2: false,
infographic: false,
})
let statusTimer: ReturnType<typeof setInterval> | null = null
function updateStatus() {
if (typeof document === 'undefined')
return
status.value = {
code: !!document.querySelector('[data-ssr-case="enhanced"] [data-markstream-code-block="1"][data-markstream-enhanced="true"]'),
math: !!document.querySelector('[data-ssr-case="enhanced"] [data-markstream-math][data-markstream-mode="katex"] .katex'),
mermaid: !!document.querySelector('[data-ssr-case="enhanced"] [data-markstream-mermaid="1"][data-markstream-mode="preview"] ._mermaid svg'),
d2: !!document.querySelector('[data-ssr-case="enhanced"] [data-markstream-d2="1"][data-markstream-mode="preview"] svg.markstream-d2-root-svg'),
infographic: !!document.querySelector('[data-ssr-case="enhanced"] [data-markstream-infographic="1"][data-markstream-mode="preview"] svg'),
}
}
const readyCount = computed(() => Object.values(status.value).filter(Boolean).length)
onMounted(() => {
hydrated.value = true
updateStatus()
statusTimer = setInterval(updateStatus, 250)
})
onBeforeUnmount(() => {
if (statusTimer)
clearInterval(statusTimer)
})
</script>
<template>
<main class="min-h-screen bg-[radial-gradient(circle_at_top,#f8fafc,white_55%)] px-4 py-10 text-slate-900 sm:px-6">
<div class="mx-auto flex w-full max-w-6xl flex-col gap-8">
<header class="rounded-3xl border border-slate-200 bg-white/90 px-6 py-6 shadow-sm backdrop-blur">
<p class="text-xs font-semibold uppercase tracking-[0.24em] text-slate-500">
markstream-vue / Nuxt SSR
</p>
<div class="mt-3 flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div class="max-w-3xl">
<h1 class="text-3xl font-semibold tracking-tight text-slate-950">
SSR Lab
</h1>
<p class="mt-2 text-sm leading-6 text-slate-600">
This route exists to verify first-response HTML, hydration stability, and rich-node enhancement in one fixed Nuxt page.
</p>
</div>
<div
class="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-sm"
data-ssr-case="hydration"
:data-ssr-hydrated="hydrated ? 'true' : 'false'"
>
<div class="font-medium text-slate-700">
Hydration status
</div>
<div class="mt-1 text-slate-600">
Hydrated: <span data-ssr-status="hydrated">{{ hydrated ? 'yes' : 'no' }}</span>
</div>
<div class="text-slate-600">
Enhanced widgets ready: <span data-ssr-status="ready-count">{{ readyCount }}</span>/5
</div>
</div>
</div>
</header>
<section data-ssr-case="basic" class="rounded-3xl border border-slate-200 bg-white px-6 py-6 shadow-sm">
<div class="mb-4">
<h2 class="text-xl font-semibold text-slate-950">
Basic markdown and HTML
</h2>
<p class="mt-1 text-sm text-slate-600">
Titles, lists, tables, footnotes, HTML inline/block, custom tags, and images must all be present in the raw SSR response.
</p>
</div>
<MarkdownRender
:content="basicMarkdown"
:final="true"
:custom-html-tags="['ssr-badge']"
:custom-id="BASIC_SCOPE"
/>
</section>
<section data-ssr-case="enhanced" class="rounded-3xl border border-slate-200 bg-white px-6 py-6 shadow-sm">
<div class="mb-4">
<h2 class="text-xl font-semibold text-slate-950">
Rich nodes with client enhancement
</h2>
<p class="mt-1 text-sm text-slate-600">
SSR should send readable HTML immediately, then upgrade math, code, Mermaid, D2, and Infographic after hydration.
</p>
</div>
<MarkdownRender
:content="enhancedMarkdown"
:final="true"
/>
</section>
<section data-ssr-case="fallback" class="rounded-3xl border border-slate-200 bg-white px-6 py-6 shadow-sm">
<div class="mb-4">
<h2 class="text-xl font-semibold text-slate-950">
Disabled enhancement fallback
</h2>
<p class="mt-1 text-sm text-slate-600">
This section simulates unavailable heavy peers through scoped custom overrides so SSR fallback stays stable on the same page.
</p>
</div>
<MarkdownRender
:content="fallbackMarkdown"
:final="true"
:custom-id="FALLBACK_SCOPE"
/>
</section>
<section class="rounded-3xl border border-slate-200 bg-slate-950 px-6 py-6 text-slate-100 shadow-sm">
<h2 class="text-xl font-semibold">
What the e2e checks
</h2>
<ul class="mt-3 list-disc space-y-2 pl-5 text-sm text-slate-300">
<li>The raw HTML for <code>/ssr-lab</code> already contains the basic content and SSR fallbacks.</li>
<li>Hydration flips the status card to <code>yes</code> without Vue mismatch warnings.</li>
<li>Enhanced widgets become ready in the rich-node section while the disabled section stays in fallback mode.</li>
</ul>
</section>
</div>
</main>
</template>
@@ -0,0 +1,604 @@
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { compressToEncodedURIComponent, decompressFromEncodedURIComponent } from 'lz-string'
import MarkdownRender, {
CodeBlockNode,
disableKatex,
disableMermaid,
enableKatex,
enableMermaid,
getUseMonaco,
isKatexEnabled,
isMermaidEnabled,
MarkdownCodeBlockNode,
PreCodeNode,
setCustomComponents,
setKaTeXWorker,
setMermaidWorker,
} from 'markstream-vue'
import KatexWorker from 'markstream-vue/workers/katexRenderer.worker?worker&inline'
import MermaidWorker from 'markstream-vue/workers/mermaidParser.worker?worker&inline'
import { onMounted, ref, watch } from 'vue'
import { resolveMarkdownTextareaPaste } from '../../playground-shared/markdownPaste'
import 'katex/dist/katex.min.css'
// 用户输入(直接作为 preview 的内容)
const input = ref<string>(`# Hello
这是一个测试页面。左侧编辑输入,右侧实时预览渲染结果。
示例包含:
- **加粗**、*斜体*、` + '`inline code`' + `
强调链接:
- **[DR (Danmarks Radio)](https://www.dr.dk/nyheder)**
- **[DR **(Danmarks Radio)](https://www.dr.dk/nyheder)**
- **[DR (Danmarks Radio)**](https://www.dr.dk/nyheder)**
- **[DR **(Danmarks Radio)**](https://www.dr.dk/nyheder)**
- 代码块:
\`\`\`js
console.log('hello')
\`\`\`
数学:$E=mc^2$
Mermaid 示例:
\`\`\`mermaid
graph TD
A-->B
\`\`\`
[AntV Infographic](https://infographic.antv.vision/) 示例:
\`\`\`infographic
infographic list-row-simple-horizontal-arrow
data
items
- label 步骤 1
desc 开始
- label 步骤 2
desc 进行中
- label 步骤 3
desc 完成
\`\`\`
`)
// 流式渲染相关状态
const streamContent = ref<string>('')
const isStreaming = ref(false)
const streamSpeed = useLocalStorage<number>('vmr-test-stream-speed', 1) // 每次添加的字符数,可调整速度
const streamInterval = useLocalStorage<number>('vmr-test-stream-interval', 16) // 每次更新的时间间隔(毫秒)
const showStreamSettings = useLocalStorage<boolean>('vmr-test-show-settings', false) // 是否显示流式渲染设置
// 渲染配置相关(用于测试不同代码块/渲染模式)
const renderMode = useLocalStorage<'monaco' | 'pre' | 'markdown'>('vmr-test-render-mode', 'monaco')
const codeBlockStream = useLocalStorage<boolean>('vmr-test-code-stream', true)
const viewportPriority = useLocalStorage<boolean>('vmr-test-viewport-priority', true)
const batchRendering = useLocalStorage<boolean>('vmr-test-batch-rendering', true)
const typewriter = useLocalStorage<boolean>('vmr-test-typewriter', true)
const debugParse = useLocalStorage<boolean>('vmr-test-debug-parse', false)
const mathEnabled = useLocalStorage<boolean>('vmr-test-math-enabled', isKatexEnabled())
const mermaidEnabled = useLocalStorage<boolean>('vmr-test-mermaid-enabled', isMermaidEnabled())
// 预加载 Monaco 编辑器和 worker
if (process.client) {
getUseMonaco()
setKaTeXWorker(new KatexWorker())
setMermaidWorker(new MermaidWorker())
}
// 分享链接相关
const shareUrl = ref<string>('')
const tooLong = ref(false)
const notice = ref<string>('')
const noticeType = ref<'success' | 'error' | 'info'>('success')
const isWorking = ref(false)
const isCopied = ref(false)
const issueUrl = ref<string>('')
const MAX_URL_LEN = 2000 // warning threshold — browsers/servers differ; adjust as needed
// Use lz-string to compress to a URL-safe encoded component
function encodeForUrl(str: string) {
try {
return compressToEncodedURIComponent(str)
}
catch {
return ''
}
}
function decodeFromUrl(s: string) {
try {
return decompressFromEncodedURIComponent(s) || ''
}
catch {
return ''
}
}
function generateShareLink() {
const encodedRaw = encodeURIComponent(input.value)
const compressed = encodeForUrl(input.value)
if (!compressed && !encodedRaw)
return
// Choose the shorter representation: compressed (URL-safe) or raw encoded
const data = (compressed && compressed.length < encodedRaw.length) ? compressed : `raw:${encodedRaw}`
const url = new URL(window.location.href)
url.hash = `data=${data}`
const full = url.toString()
console.log(full.length)
if (full.length > MAX_URL_LEN) {
// mark as too long, do not place the huge URL in address bar
tooLong.value = true
shareUrl.value = ''
// prepare an issue URL so user can open it directly
issueUrl.value = buildIssueUrl(input.value)
showToast('内容过长,无法嵌入到 URL。你可以打开 Issue 并提交。', 'info', 5000)
return
}
tooLong.value = false
shareUrl.value = full
window.history.replaceState(undefined, '', full)
}
function buildIssueUrl(text: string) {
const base = 'https://github.com/Simon-He95/markstream-vue/issues/new?template=bug_report.yml'
const body = `**Reproduction input**:\n\nPlease find the reproduction input below:\n\n\`\`\`markdown\n${text}\n\`\`\``
return `${base}&body=${encodeURIComponent(body)}`
}
async function copyShareLink() {
const u = shareUrl.value || window.location.href
try {
await navigator.clipboard.writeText(u)
return true
}
catch (e) {
console.warn('copy failed', e)
return false
}
}
function showToast(msg: string, type: 'success' | 'error' | 'info' = 'success', duration = 2000) {
notice.value = msg
noticeType.value = type
if (duration > 0)
setTimeout(() => (notice.value = ''), duration)
}
async function generateAndCopy() {
// generate share URL then copy it
isWorking.value = true
isCopied.value = false
generateShareLink()
if (tooLong.value) {
isWorking.value = false
return
}
const ok = await copyShareLink()
isWorking.value = false
if (ok) {
isCopied.value = true
showToast('已复制链接到剪贴板', 'success', 2000)
setTimeout(() => (isCopied.value = false), 2000)
}
else {
showToast('复制链接失败,请手动复制或在 HTTPS/localhost 下重试', 'error', 4000)
}
}
async function copyRawInput() {
try {
const url = buildIssueUrl(input.value)
issueUrl.value = url
await navigator.clipboard.writeText(url)
showToast('已复制 issue 链接到剪贴板,打开链接并提交即可。', 'success', 3500)
}
catch (e) {
console.warn('copy failed', e)
showToast('复制失败,请手动选中并复制输入内容。', 'error', 3500)
}
}
function openIssueInNewTab() {
if (!issueUrl.value)
issueUrl.value = buildIssueUrl(input.value)
try {
window.open(issueUrl.value, '_blank')
}
catch {
// fallback: set location
window.location.href = issueUrl.value
}
}
function handleEditorPaste(event: ClipboardEvent) {
const textarea = event.currentTarget
if (!(textarea instanceof HTMLTextAreaElement))
return
const pasted = event.clipboardData?.getData('text/plain') ?? ''
const next = resolveMarkdownTextareaPaste(textarea, pasted)
if (!next)
return
event.preventDefault()
textarea.value = next.nextValue
textarea.selectionStart = next.selectionStart
textarea.selectionEnd = next.selectionEnd
input.value = next.nextValue
}
function restoreFromUrl() {
try {
const hash = window.location.hash || ''
if (!hash)
return
const m = hash.match(/data=([^&]+)/)
if (m && m[1]) {
const payload = m[1]
// support `raw:` fallback where we stored uncompressed (encoded) content
if (payload.startsWith('raw:')) {
try {
input.value = decodeURIComponent(payload.slice(4))
}
catch {
// ignore
}
}
else {
const decoded = decodeFromUrl(payload)
if (decoded)
input.value = decoded
}
}
}
catch {
// ignore
}
}
onMounted(() => {
if (process.client) {
restoreFromUrl()
shareUrl.value = window.location.href
}
})
watch(() => renderMode.value, (mode: string) => {
if (mode === 'pre') {
setCustomComponents({ code_block: PreCodeNode })
}
else if (mode === 'markdown') {
setCustomComponents({ code_block: MarkdownCodeBlockNode })
}
else {
setCustomComponents({ code_block: CodeBlockNode })
}
}, { immediate: true })
watch(mathEnabled, (enabled) => {
if (enabled)
enableKatex()
else
disableKatex()
}, { immediate: true })
watch(mermaidEnabled, (enabled) => {
if (enabled)
enableMermaid()
else
disableMermaid()
}, { immediate: true })
// 流式渲染函数
let streamTimer: number | null = null
function startStreamRender() {
if (isStreaming.value) {
// 如果正在流式渲染,停止它
stopStreamRender()
return
}
// 重置流式内容
streamContent.value = ''
isStreaming.value = true
let currentIndex = 0
const fullText = input.value
const streamStep = () => {
if (currentIndex >= fullText.length) {
// 完成流式渲染
stopStreamRender()
return
}
// 每次截取指定数量的字符
const nextIndex = Math.min(currentIndex + streamSpeed.value, fullText.length)
streamContent.value = fullText.slice(0, nextIndex)
currentIndex = nextIndex
// 继续下一次渲染,使用用户设置的时间间隔
streamTimer = window.setTimeout(streamStep, streamInterval.value)
}
streamStep()
}
function stopStreamRender() {
if (streamTimer !== null) {
clearTimeout(streamTimer)
streamTimer = null
}
isStreaming.value = false
// 确保显示完整内容
if (streamContent.value && streamContent.value !== input.value)
streamContent.value = input.value
}
function toggleStreamSettings() {
showStreamSettings.value = !showStreamSettings.value
}
</script>
<template>
<div class="p-4 app-container h-full bg-gray-50 dark:bg-gray-900">
<div class="max-w-6xl mx-auto h-full overflow-hidden flex flex-col">
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-semibold">
Markdown 输入 & 实时渲染 (Nuxt)
</h2>
<div class="text-sm text-gray-500 flex items-center gap-3">
<span>左侧输入,右侧预览</span>
<button
class="px-2 py-1 rounded text-sm flex items-center gap-2"
:class="isStreaming ? 'bg-red-600 text-white' : 'bg-purple-600 text-white'"
@click="startStreamRender"
>
{{ isStreaming ? '停止流式渲染' : '流式渲染' }}
</button>
<button
class="px-2 py-1 bg-gray-500 text-white rounded text-sm"
:class="{ 'bg-gray-700': showStreamSettings }"
@click="toggleStreamSettings"
>
⚙️ 设置
</button>
<button :disabled="isWorking" class="px-2 py-1 bg-blue-600 text-white rounded text-sm flex items-center gap-2" @click="generateAndCopy">
生成并复制分享链接
</button>
<button class="bg-green-600 text-white rounded px-2 py-1 text-sm" @click="openIssueInNewTab">
打开 Issue
</button>
</div>
</div>
<!-- 设置面板:流式渲染 + 渲染配置 -->
<div v-if="showStreamSettings" class="mb-4 p-4 bg-white dark:bg-gray-800 rounded border border-purple-300 dark:border-purple-700 shadow-md space-y-4">
<div>
<h3 class="text-sm font-semibold mb-3 text-gray-800 dark:text-gray-200">
流式渲染设置
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
每次截取字符数: <span class="text-purple-600 dark:text-purple-400 font-semibold">{{ streamSpeed }}</span>
</label>
<input
v-model.number="streamSpeed"
type="range"
min="1"
max="100"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>1 (慢)</span>
<span>100 (快)</span>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-700 dark:text-gray-300 mb-1">
更新间隔(毫秒): <span class="text-purple-600 dark:text-purple-400 font-semibold">{{ streamInterval }}ms</span>
</label>
<input
v-model.number="streamInterval"
type="range"
min="10"
max="500"
step="10"
class="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer dark:bg-gray-700"
>
<div class="flex justify-between text-xs text-gray-500 mt-1">
<span>10ms (快)</span>
<span>500ms (慢)</span>
</div>
</div>
</div>
<div class="mt-3 p-2 bg-purple-50 dark:bg-purple-900/20 rounded text-xs text-gray-600 dark:text-gray-400">
💡 提示:字符数越大或间隔越小,渲染速度越快
</div>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<h3 class="text-sm font-semibold mb-3 text-gray-800 dark:text-gray-200">
渲染配置(用于调试不同模式)
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div class="space-y-2">
<div>
<label class="block font-medium mb-1 text-gray-700 dark:text-gray-300">代码块模式</label>
<select
v-model="renderMode"
class="w-full rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 px-2 py-1"
>
<option value="monaco">
Monaco 编辑器
</option>
<option value="markdown">
MarkdownCodeBlock (stream-markdown)
</option>
<option value="pre">
纯 PreCodeNode
</option>
</select>
</div>
<div class="flex items-center gap-2">
<input id="toggle-code-stream" v-model="codeBlockStream" type="checkbox" class="rounded border-gray-300 dark:border-gray-600">
<label for="toggle-code-stream" class="cursor-pointer">代码块流式渲染</label>
</div>
</div>
<div class="space-y-2">
<div class="flex items-center gap-2">
<input id="toggle-viewport" v-model="viewportPriority" type="checkbox" class="rounded border-gray-300 dark:border-gray-600">
<label for="toggle-viewport" class="cursor-pointer">启用 viewportPriority</label>
</div>
<div class="flex items-center gap-2">
<input id="toggle-batch" v-model="batchRendering" type="checkbox" class="rounded border border-gray-300 dark:border-gray-600">
<label for="toggle-batch" class="cursor-pointer">启用批量渲染 (batchRendering)</label>
</div>
<div class="flex items-center gap-2">
<input id="toggle-typewriter" v-model="typewriter" type="checkbox" class="rounded border-gray-300 dark:border-gray-600">
<label for="toggle-typewriter" class="cursor-pointer">启用打字机过渡 (typewriter)</label>
</div>
<div class="flex items-center gap-2">
<input id="toggle-math" v-model="mathEnabled" type="checkbox" class="rounded border-gray-300 dark:border-gray-600">
<label for="toggle-math" class="cursor-pointer">启用数学 (KaTeX)</label>
</div>
<div class="flex items-center gap-2">
<input id="toggle-debug-parse" v-model="debugParse" type="checkbox" class="rounded border-gray-300 dark:border-gray-600">
<label for="toggle-debug-parse" class="cursor-pointer">调试解析树结构(console)</label>
</div>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 flex-1 overflow-hidden">
<div>
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200">输入</label>
<textarea v-model="input" rows="18" class="w-full p-3 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-gray-100 resize-none h-[calc(100%-2rem)]" @paste="handleEditorPaste" />
</div>
<div class="h-full overflow-hidden flex-col flex">
<label class="block mb-2 text-sm font-medium text-gray-700 dark:text-gray-200 shrink-0">
预览
<span v-if="streamContent" class="ml-2 text-xs text-purple-600 dark:text-purple-400">
(流式渲染模式 {{ isStreaming ? '- 渲染中...' : '- 已完成' }})
</span>
</label>
<div class="max-w-none p-3 rounded border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 min-h-[14rem] overflow-auto flex-1">
<ClientOnly>
<MarkdownRender
:content="streamContent || input"
:viewport-priority="viewportPriority"
:batch-rendering="batchRendering"
:typewriter="typewriter"
:code-block-stream="codeBlockStream"
:parse-options="{ debug: debugParse }"
/>
</ClientOnly>
</div>
<div class="mt-2 text-xs text-gray-500 break-words shrink-0">
<template v-if="tooLong">
<div class="mb-2 p-2 rounded bg-yellow-50 dark:bg-yellow-900 text-yellow-800 dark:text-yellow-200">
内容过长,无法嵌入到 URL。建议在 issue 中粘贴完整输入以便分享。
</div>
<div class="flex gap-2 items-center">
<button class="px-2 py-1 bg-gray-200 dark:bg-gray-700 text-sm rounded" @click="copyRawInput">
复制 Issue 链接
</button>
<button class="px-2 py-1 bg-blue-600 text-white text-sm rounded" @click="openIssueInNewTab">
打开 Issue
</button>
<span class="text-xs text-gray-500">或手动将输入粘贴到 GitHub Issue 中。</span>
</div>
</template>
</div>
<div v-if="notice" class="mt-2">
<div class="p-2 rounded" :class="[noticeType === 'success' ? 'bg-green-50 text-green-800 dark:bg-green-900 dark:text-green-200' : (noticeType === 'error' ? 'bg-red-50 text-red-800 dark:bg-red-900 dark:text-red-200' : 'bg-blue-50 text-blue-800 dark:bg-blue-900 dark:text-blue-200')]">
{{ notice }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.app-container {
transition: background-color 0.3s ease;
overflow: hidden;
}
/* Mermaid 块加载时的流光闪烁效果 */
:deep(.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);
}
}
/* 滑块样式优化 */
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: #9333ea;
cursor: pointer;
transition: all 0.2s;
}
input[type="range"]::-webkit-slider-thumb:hover {
background: #7c3aed;
transform: scale(1.2);
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: #9333ea;
cursor: pointer;
border: none;
transition: all 0.2s;
}
input[type="range"]::-moz-range-thumb:hover {
background: #7c3aed;
transform: scale(1.2);
}
</style>
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<style>
path { fill: #222; }
@media (prefers-color-scheme: dark) {
path { fill: #ffffff; }
}
</style>
<path d="M27.562 26L17.17 8.928l2.366-3.888L17.828 4L16 7.005L14.17 4l-1.708 1.04l2.366 3.888L4.438 26H2v2h28v-2zM16 10.85L25.22 26H17v-8h-2v8H6.78z" />
</svg>

After

Width:  |  Height:  |  Size: 347 B

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

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,3 @@
{
"extends": "./.nuxt/tsconfig.json"
}