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,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="icon" href="/favicon.svg" type="image/svg+xml">
<meta
name="description"
content="markstream-angular playground for streaming Markdown, Mermaid, KaTeX, and cross-framework rendering checks."
>
<title>markstream-angular Playground | Angular demo</title>
</head>
<body>
<playground-angular-root></playground-angular-root>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
@@ -0,0 +1,30 @@
{
"name": "playground-angular",
"type": "module",
"private": true,
"scripts": {
"dev": "vite dev --host 127.0.0.1 --port 4175",
"build": "vite build",
"preview": "vite preview --host 127.0.0.1 --port 4175"
},
"dependencies": {
"@angular/common": "^20.3.18",
"@angular/compiler": "^20.3.18",
"@angular/core": "^20.3.18",
"@angular/platform-browser": "^20.3.18",
"@antv/infographic": "^0.2.16",
"@terrastruct/d2": "^0.1.33",
"katex": "^0.16.44",
"markstream-angular": "workspace:*",
"mermaid": "^11.13.0",
"monaco-editor": "^0.52.2",
"rxjs": "^7.8.2",
"stream-monaco": "latest"
},
"devDependencies": {
"@types/node": "^18.19.130",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-monaco-editor-esm": "^2.0.2"
}
}
@@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
}
@@ -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 @@
declare module 'markstream-vue'
@@ -0,0 +1,266 @@
import type { OnDestroy, OnInit } from '@angular/core'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, computed, signal } from '@angular/core'
import { MarkstreamAngularComponent } from 'markstream-angular'
import { streamContent } from '../../playground/src/const/markdown'
import { TestPageComponent } from './test-page.component'
import { TestSandboxComponent } from './test-sandbox.component'
import { ThinkingNodeComponent } from './thinking-node.component'
const markdownSource = typeof streamContent === 'string' ? streamContent : String(streamContent || '')
const THINKING_TAGS = ['thinking'] as const
function normalizePath(pathname: string | null | undefined) {
const normalized = (pathname || '/').replace(/\/+$/, '')
return normalized || '/'
}
function clampDelay(value: number) {
if (!Number.isFinite(value))
return 16
return Math.min(200, Math.max(4, Math.round(value)))
}
function clampChunk(value: number) {
if (!Number.isFinite(value))
return 1
return Math.min(16, Math.max(1, Math.floor(value)))
}
function readNumericInput(event: Event, fallback: number) {
const value = Number((event.target as HTMLInputElement | null)?.value ?? fallback)
return Number.isFinite(value) ? value : fallback
}
@Component({
selector: 'playground-angular-root',
standalone: true,
imports: [CommonModule, MarkstreamAngularComponent, TestPageComponent, TestSandboxComponent],
template: `
<app-angular-test-sandbox *ngIf="isSandboxPage(); else nonSandbox" />
<ng-template #nonSandbox>
<app-angular-test-page
*ngIf="isTestPage(); else home"
(navigateHome)="goHome()"
/>
<ng-template #home>
<div class="page">
<header class="header">
<div class="header-main">
<div class="title">markstream-angular playground</div>
<button type="button" class="btn" (click)="goToTest()">
Open /test
</button>
</div>
<div class="sub">
Angular playground with streaming demo, standalone /test regression lab, and isolated /test-sandbox.
</div>
</header>
<div class="layout">
<section class="panel controls">
<h2>Stream controls</h2>
<div class="field">
<label for="delay">Delay (ms)</label>
<input
id="delay"
type="number"
min="4"
max="200"
[value]="delay()"
(input)="updateDelay($event)"
>
</div>
<div class="field">
<label for="chunk">Chunk size</label>
<input
id="chunk"
type="number"
min="1"
max="16"
[value]="chunkSize()"
(input)="updateChunkSize($event)"
>
</div>
<div class="actions">
<button type="button" class="btn" (click)="toggleStream()">
{{ running() ? 'Pause' : 'Resume' }}
</button>
<button type="button" class="btn" (click)="resetStream()">
Reset
</button>
<button type="button" class="btn ghost" (click)="fillAll()">
Render all
</button>
</div>
<div class="status">
<div class="progress">
<div class="bar" [style.width.%]="progress()"></div>
</div>
<div class="meta">
{{ content().length }} / {{ totalLength }} ({{ progress() }}%)
</div>
</div>
<p class="note">
首页保留主 demo;更完整的跨框架回归入口在 <code>/test</code>,隔离版本对照在 <code>/test-sandbox</code>。
</p>
</section>
<section class="panel preview">
<markstream-angular
[content]="content()"
[final]="isDone()"
[renderCodeBlocksAsPre]="false"
[codeBlockStream]="true"
[customHtmlTags]="thinkingTags"
[customComponents]="customComponents"
/>
</section>
</div>
</div>
</ng-template>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppComponent implements OnInit, OnDestroy {
readonly thinkingTags = THINKING_TAGS
readonly customComponents = {
thinking: ThinkingNodeComponent,
}
readonly totalLength = markdownSource.length
readonly currentPath = signal(typeof window === 'undefined' ? '/' : normalizePath(window.location.pathname))
readonly content = signal('')
readonly delay = signal(16)
readonly chunkSize = signal(2)
readonly running = signal(true)
readonly normalizedChunkSize = computed(() => clampChunk(this.chunkSize()))
readonly progress = computed(() => {
if (!this.totalLength)
return 0
return Math.min(100, Math.round((this.content().length / this.totalLength) * 100))
})
readonly isDone = computed(() => this.content().length >= this.totalLength)
readonly isTestPage = computed(() => this.currentPath() === '/test')
readonly isSandboxPage = computed(() => this.currentPath() === '/test-sandbox')
private timer: number | null = null
ngOnInit() {
if (typeof window !== 'undefined')
window.addEventListener('popstate', this.handlePopState)
if (!this.isTestPage())
this.startStream()
}
ngOnDestroy() {
if (typeof window !== 'undefined')
window.removeEventListener('popstate', this.handlePopState)
this.stopStream()
}
updateDelay(event: Event) {
this.delay.set(clampDelay(readNumericInput(event, this.delay())))
if (this.running() && !this.isTestPage())
this.restartStream()
}
updateChunkSize(event: Event) {
this.chunkSize.set(clampChunk(readNumericInput(event, this.chunkSize())))
}
goToTest() {
this.navigate('/test')
}
goHome() {
this.navigate('/')
}
toggleStream() {
if (this.running())
this.stopStream()
else
this.startStream()
}
resetStream() {
this.content.set('')
this.startStream()
}
fillAll() {
this.content.set(markdownSource)
this.stopStream()
}
private readonly handlePopState = () => {
this.syncPath()
if (this.isTestPage() || this.isSandboxPage())
this.stopStream()
else if (!this.content())
this.startStream()
}
private syncPath() {
if (typeof window === 'undefined')
return
this.currentPath.set(normalizePath(window.location.pathname))
}
private navigate(pathname: string) {
const nextPath = normalizePath(pathname)
if (typeof window !== 'undefined' && nextPath !== normalizePath(window.location.pathname))
window.history.pushState({}, '', nextPath)
this.currentPath.set(nextPath)
if (this.isTestPage() || this.isSandboxPage())
this.stopStream()
else if (!this.content() || this.content().length >= markdownSource.length)
this.resetStream()
}
private tick() {
if (this.isDone()) {
this.stopStream()
return
}
this.content.update((current) => {
const nextChunk = markdownSource.slice(current.length, current.length + this.normalizedChunkSize())
return current + nextChunk
})
}
private startStream() {
this.stopStream()
if (typeof window === 'undefined')
return
this.running.set(true)
this.timer = window.setInterval(() => this.tick(), this.delay())
}
private stopStream() {
if (this.timer != null && typeof window !== 'undefined') {
window.clearInterval(this.timer)
this.timer = null
}
this.running.set(false)
}
private restartStream() {
if (!this.running())
return
this.startStream()
}
}
@@ -0,0 +1,141 @@
body {
margin: 0;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
background: #f7f7fb;
color: #111827;
}
.page {
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 1.5rem;
padding: 1.5rem 2rem 2.5rem;
}
.header {
display: flex;
flex-direction: column;
gap: 0.35rem;
}
.header-main {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
}
.title {
font-size: 1.6rem;
font-weight: 700;
}
.sub {
color: #4b5563;
}
.layout {
display: grid;
grid-template-columns: minmax(240px, 320px) minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.panel {
background: #fff;
border-radius: 12px;
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.08);
padding: 1.25rem 1.5rem;
}
.controls h2 {
margin: 0 0 1rem;
font-size: 1.05rem;
}
.field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 0.9rem;
}
.field label {
font-size: 0.9rem;
color: #374151;
}
.field input {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 0.5rem 0.7rem;
font-size: 0.95rem;
}
.actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.btn {
border: none;
background: #111827;
color: #fff;
padding: 0.45rem 0.9rem;
border-radius: 8px;
font-size: 0.9rem;
cursor: pointer;
}
.btn.ghost {
background: #e5e7eb;
color: #111827;
}
.status {
margin-top: 1rem;
}
.progress {
width: 100%;
height: 8px;
background: #e5e7eb;
border-radius: 999px;
overflow: hidden;
}
.bar {
height: 100%;
background: linear-gradient(90deg, #3b82f6, #6366f1);
}
.meta {
margin-top: 0.5rem;
font-size: 0.85rem;
color: #6b7280;
}
.note {
margin-top: 0.9rem;
font-size: 0.85rem;
color: #6b7280;
}
.preview {
min-height: 70vh;
overflow: auto;
}
@media (max-width: 960px) {
.layout {
grid-template-columns: 1fr;
}
.header-main {
flex-direction: column;
align-items: flex-start;
}
}
@@ -0,0 +1,35 @@
function resolveVendorAssetUrl(pathname: string) {
if (typeof document !== 'undefined')
return new URL(pathname.replace(/^\/+/, ''), document.baseURI).href
return pathname
}
export async function bootstrapPlayground() {
const [
{ enableD2, setKaTeXWorker, setMermaidWorker },
{ provideZonelessChangeDetection },
{ bootstrapApplication },
{ AppComponent },
{ default: KatexWorker },
{ default: MermaidWorker },
] = await Promise.all([
import('markstream-angular'),
import('@angular/core'),
import('@angular/platform-browser'),
import('./app.component'),
import('../../packages/markstream-angular/src/workers/katexRenderer.worker?worker&inline'),
import('../../packages/markstream-angular/src/workers/mermaidParser.worker?worker&inline'),
])
const d2VendorUrl = resolveVendorAssetUrl('/vendor/d2-browser.js')
enableD2?.(() => import(/* @vite-ignore */ d2VendorUrl))
setKaTeXWorker?.(new KatexWorker())
setMermaidWorker?.(new MermaidWorker())
return bootstrapApplication(AppComponent, {
providers: [
provideZonelessChangeDetection(),
],
})
}
@@ -0,0 +1,14 @@
import 'katex/dist/katex.min.css'
import './app.css'
import './test-page.css'
import '../../packages/markstream-angular/src/index.css'
import '@angular/compiler'
async function main() {
const { bootstrapPlayground } = await import('./bootstrap-app')
return bootstrapPlayground()
}
main().catch((error) => {
console.error('[playground-angular] bootstrap failed', error)
})
@@ -0,0 +1,332 @@
import type { OnDestroy, OnInit } from '@angular/core'
import type { TestLabFrameworkId, TestLabSampleId } from '../../playground-shared/testLabFixtures'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, computed, EventEmitter, Output, signal } from '@angular/core'
import { MarkstreamAngularComponent } from 'markstream-angular'
import { resolveMarkdownTextareaPaste } from '../../playground-shared/markdownPaste'
import { TEST_LAB_FRAMEWORKS, TEST_LAB_SAMPLES } from '../../playground-shared/testLabFixtures'
import { decodeMarkdownHash, resolveFrameworkTestHref } from '../../playground-shared/testPageState'
import { ThinkingNodeComponent } from './thinking-node.component'
function clampInt(value: number, min: number, max: number, fallback: number) {
const normalized = Number.isFinite(value) ? Math.round(value) : fallback
return Math.min(max, Math.max(min, normalized))
}
function readTextInput(event: Event, fallback = '') {
return (event.target as HTMLInputElement | HTMLTextAreaElement | null)?.value ?? fallback
}
@Component({
selector: 'app-angular-test-lab',
standalone: true,
imports: [CommonModule, MarkstreamAngularComponent],
template: `
<div class="test-lab">
<div class="test-lab__shell">
<section class="hero-panel">
<div class="hero-copy">
<span class="eyebrow">Angular Regression Lab</span>
<h1>markstream-angular /test</h1>
<p>
现在已经把 parser、增强型 code block、KaTeX、Mermaid、Infographic 和 D2 拉进同一套回归驾驶舱。
</p>
</div>
<div class="hero-metrics">
<div class="metric-card">
<span>字符数</span>
<strong>{{ charCount() }}</strong>
</div>
<div class="metric-card">
<span>行数</span>
<strong>{{ lineCount() }}</strong>
</div>
<div class="metric-card">
<span>预览进度</span>
<strong>{{ progress() }}%</strong>
</div>
</div>
<div class="framework-switcher">
<a
*ngFor="let framework of frameworkCards"
class="framework-chip"
[class.framework-chip--current]="framework.id === currentFramework"
[href]="frameworkHref(framework.id)"
>
<span class="framework-chip__label">{{ framework.label }}</span>
<span class="framework-chip__note">{{ framework.note }}</span>
</a>
</div>
</section>
<div class="lab-layout">
<aside class="panel-card sidebar-card">
<div class="panel-head">
<div>
<h2>样例切换</h2>
<p>选一段输入,直接开始回归。</p>
</div>
<span class="mini-pill">{{ activeSample().title }}</span>
</div>
<div class="sample-list">
<button
*ngFor="let sample of sampleCards"
type="button"
class="sample-card"
[class.sample-card--active]="sample.id === selectedSampleId()"
(click)="applySample(sample.id)"
>
<strong>{{ sample.title }}</strong>
<span>{{ sample.summary }}</span>
</button>
</div>
<div class="panel-head panel-head--spaced">
<div>
<h2>流式控制</h2>
<p>看 Angular baseline 在 streaming 下是否稳定。</p>
</div>
</div>
<div class="control-grid">
<label class="input-card">
<span>每次追加字符</span>
<input
type="number"
min="1"
max="80"
[value]="streamSpeed()"
(input)="updateStreamSpeed($event)"
>
</label>
<label class="input-card">
<span>更新时间间隔 (ms)</span>
<input
type="number"
min="8"
max="300"
[value]="streamInterval()"
(input)="updateStreamInterval($event)"
>
</label>
</div>
<div class="button-grid">
<button type="button" class="btn btn--primary" (click)="toggleStreamRender()">
{{ isStreaming() ? '停止流式渲染' : '开始流式渲染' }}
</button>
<button type="button" class="btn" (click)="resetEditor()">
重置样例
</button>
<button type="button" class="btn" (click)="clearEditor()">
清空输入
</button>
<button type="button" class="btn" (click)="navigateHome.emit()">
返回主 demo
</button>
</div>
</aside>
<section class="workspace-grid">
<article class="workspace-card">
<header class="workspace-card__head">
<div>
<h2>Markdown 输入</h2>
<p>把复现内容直接贴进来。</p>
</div>
</header>
<textarea
class="editor-textarea"
spellcheck="false"
placeholder="在这里粘贴 markdown..."
[value]="input()"
(input)="updateInput($event)"
(paste)="handleEditorPaste($event)"
></textarea>
<footer class="workspace-card__foot">
<span>{{ charCount() }} chars</span>
<span>{{ lineCount() }} lines</span>
</footer>
</article>
<article class="workspace-card">
<header class="workspace-card__head">
<div>
<h2>实时预览</h2>
<p>{{ isStreaming() ? 'Streaming 中' : '已显示完整输入' }}</p>
</div>
<span class="mini-pill">{{ progress() }}%</span>
</header>
<div class="preview-surface">
<markstream-angular
[content]="previewContent()"
[final]="!isStreaming()"
[codeBlockStream]="true"
[customHtmlTags]="thinkingTags"
[customComponents]="customComponents"
/>
</div>
<footer class="workspace-card__foot">
<span>{{ previewContent().length }} / {{ input().length || 0 }}</span>
<span>Angular renderer</span>
</footer>
</article>
</section>
</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TestLabComponent implements OnInit, OnDestroy {
@Output() readonly navigateHome = new EventEmitter<void>()
readonly currentFramework: TestLabFrameworkId = 'angular'
readonly thinkingTags = ['thinking'] as const
readonly customComponents = {
thinking: ThinkingNodeComponent,
}
readonly frameworkCards = TEST_LAB_FRAMEWORKS
readonly sampleCards = TEST_LAB_SAMPLES
readonly selectedSampleId = signal<TestLabSampleId>('baseline')
readonly input = signal(TEST_LAB_SAMPLES[0].content)
readonly streamContent = signal('')
readonly isStreaming = signal(false)
readonly streamSpeed = signal(4)
readonly streamInterval = signal(24)
readonly activeSample = computed(() => this.sampleCards.find(sample => sample.id === this.selectedSampleId()) ?? this.sampleCards[0])
readonly previewContent = computed(() => this.isStreaming() ? this.streamContent() : this.input())
readonly progress = computed(() => {
if (!this.input().length)
return 0
return Math.min(100, Math.round((this.previewContent().length / this.input().length) * 100))
})
readonly charCount = computed(() => this.input().length)
readonly lineCount = computed(() => this.input() ? this.input().split('\n').length : 0)
private timer: number | null = null
ngOnInit() {
if (typeof window === 'undefined')
return
const restored = decodeMarkdownHash(window.location.hash || '')
if (restored)
this.input.set(restored)
}
ngOnDestroy() {
this.stopStreamRender()
}
applySample(id: TestLabSampleId) {
const sample = this.sampleCards.find(item => item.id === id)
if (!sample)
return
this.stopStreamRender()
this.selectedSampleId.set(sample.id)
this.input.set(sample.content)
}
updateInput(event: Event) {
this.stopStreamRender()
this.input.set(readTextInput(event, this.input()))
}
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
this.stopStreamRender()
this.input.set(next.nextValue)
}
updateStreamSpeed(event: Event) {
this.streamSpeed.set(clampInt(Number(readTextInput(event, String(this.streamSpeed()))), 1, 80, 4))
}
updateStreamInterval(event: Event) {
this.streamInterval.set(clampInt(Number(readTextInput(event, String(this.streamInterval()))), 8, 300, 24))
}
toggleStreamRender() {
if (this.isStreaming()) {
this.stopStreamRender()
return
}
this.startStreamRender()
}
resetEditor() {
this.applySample(this.selectedSampleId())
}
clearEditor() {
this.stopStreamRender()
this.input.set('')
}
frameworkHref(id: TestLabFrameworkId) {
const framework = this.frameworkCards.find(item => item.id === id)
if (!framework)
return '/test'
return resolveFrameworkTestHref(
framework,
this.currentFramework,
this.input(),
typeof window !== 'undefined'
? { hostname: window.location.hostname, protocol: window.location.protocol }
: undefined,
)
}
private startStreamRender() {
this.streamSpeed.set(clampInt(this.streamSpeed(), 1, 80, 4))
this.streamInterval.set(clampInt(this.streamInterval(), 8, 300, 24))
this.streamContent.set('')
this.isStreaming.set(true)
this.scheduleNextChunk()
}
private scheduleNextChunk() {
if (!this.isStreaming() || typeof window === 'undefined')
return
const nextLength = Math.min(this.streamContent().length + this.streamSpeed(), this.input().length)
this.streamContent.set(this.input().slice(0, nextLength))
if (nextLength >= this.input().length) {
this.stopStreamRender()
return
}
this.timer = window.setTimeout(() => this.scheduleNextChunk(), this.streamInterval())
}
private stopStreamRender() {
if (this.timer != null && typeof window !== 'undefined') {
window.clearTimeout(this.timer)
this.timer = null
}
this.isStreaming.set(false)
}
}
@@ -0,0 +1,299 @@
.test-lab {
min-height: 100vh;
padding: 24px 16px 36px;
background:
radial-gradient(circle at top left, rgba(56, 189, 248, 0.12), transparent 30%),
radial-gradient(circle at 85% 15%, rgba(59, 130, 246, 0.12), transparent 26%),
linear-gradient(180deg, #f8fbff 0%, #f2f6fb 100%);
color: #10203a;
}
.test-lab__shell {
max-width: 1400px;
margin: 0 auto;
display: grid;
gap: 18px;
}
.hero-panel,
.panel-card,
.workspace-card {
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 24px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 24px 64px rgba(15, 23, 42, 0.12);
}
.hero-panel {
padding: 24px;
display: grid;
gap: 18px;
}
.eyebrow {
display: inline-flex;
width: fit-content;
padding: 7px 11px;
border-radius: 999px;
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8;
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.hero-copy h1 {
margin: 12px 0 10px;
font-size: clamp(2rem, 4vw, 3rem);
line-height: 0.96;
}
.hero-copy p,
.panel-head p,
.workspace-card__head p,
.workspace-card__foot,
.sample-card span {
margin: 0;
color: #5b6f88;
line-height: 1.6;
}
.hero-metrics {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.metric-card,
.sample-card,
.input-card,
.framework-chip {
border-radius: 18px;
border: 1px solid rgba(15, 23, 42, 0.07);
background: rgba(255, 255, 255, 0.82);
}
.metric-card {
padding: 14px 16px;
}
.metric-card span {
display: block;
color: #5b6f88;
font-size: 0.82rem;
margin-bottom: 8px;
}
.metric-card strong {
font-size: 1.3rem;
}
.framework-switcher {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
}
.framework-chip {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
color: inherit;
text-decoration: none;
}
.framework-chip--current {
border-color: rgba(37, 99, 235, 0.24);
background: linear-gradient(135deg, rgba(37, 99, 235, 0.12), rgba(255, 255, 255, 0.94));
}
.framework-chip__label {
font-weight: 700;
}
.framework-chip__note {
color: #5b6f88;
font-size: 0.88rem;
}
.lab-layout {
display: grid;
grid-template-columns: 330px minmax(0, 1fr);
gap: 18px;
}
.panel-card {
padding: 18px;
}
.panel-head,
.workspace-card__head,
.workspace-card__foot {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.panel-head--spaced {
margin-top: 18px;
}
.panel-head h2,
.workspace-card__head h2 {
margin: 0;
font-size: 1.05rem;
}
.mini-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 7px 11px;
border-radius: 999px;
background: rgba(15, 23, 42, 0.05);
color: #5b6f88;
font-size: 0.78rem;
font-weight: 700;
}
.sample-list,
.button-grid,
.workspace-grid {
display: grid;
gap: 12px;
}
.sample-list {
margin-top: 14px;
}
.sample-card {
width: 100%;
padding: 13px 15px;
text-align: left;
cursor: pointer;
}
.sample-card strong {
display: block;
margin-bottom: 6px;
}
.sample-card--active {
border-color: rgba(37, 99, 235, 0.24);
background: rgba(37, 99, 235, 0.08);
}
.control-grid {
display: grid;
gap: 10px;
margin-top: 14px;
}
.input-card {
display: grid;
gap: 8px;
padding: 12px 14px;
}
.input-card span {
color: #5b6f88;
font-size: 0.86rem;
}
.input-card input {
border: 0;
border-radius: 12px;
padding: 10px 12px;
background: rgba(15, 23, 42, 0.05);
color: #10203a;
font: inherit;
}
.button-grid {
margin-top: 14px;
}
.testlab-btn {
border: 0;
border-radius: 16px;
padding: 12px 14px;
background: rgba(15, 23, 42, 0.06);
color: #10203a;
font: inherit;
cursor: pointer;
}
.testlab-btn--primary {
background: linear-gradient(135deg, #1d4ed8, #2563eb);
color: #fff;
}
.workspace-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-card {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-height: 720px;
}
.workspace-card__head,
.workspace-card__foot {
padding: 18px;
border-bottom: 1px solid rgba(15, 23, 42, 0.06);
}
.workspace-card__foot {
border-top: 1px solid rgba(15, 23, 42, 0.06);
border-bottom: 0;
}
.editor-textarea {
width: 100%;
min-height: 520px;
border: 0;
resize: none;
padding: 20px 18px;
background: linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.98));
color: #0f172a;
font:
500 0.94rem/1.7 "IBM Plex Mono", "SFMono-Regular", Consolas, monospace;
}
.editor-textarea:focus {
outline: none;
}
.preview-surface {
min-height: 520px;
padding: 20px 18px;
overflow: auto;
}
@media (max-width: 1120px) {
.lab-layout,
.workspace-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 760px) {
.hero-metrics,
.framework-switcher {
grid-template-columns: 1fr;
}
.workspace-card {
min-height: 620px;
}
.editor-textarea,
.preview-surface {
min-height: 400px;
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,417 @@
import type { AfterViewInit, ElementRef, OnDestroy } from '@angular/core'
import type { SandboxSelection } from '../../playground-shared/versionSandbox'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, computed, signal, ViewChild } from '@angular/core'
import { decodeMarkdownHash } from '../../playground-shared/testPageState'
import { parseSandboxSelection } from '../../playground-shared/versionSandbox'
import {
TEST_SANDBOX_KATEX_VERSION,
TEST_SANDBOX_MERMAID_VERSION,
testSandboxFrameworks,
} from './testSandboxConfig'
type SandboxStatus = 'idle' | 'loading' | 'ready' | 'error'
function setHeadLink(id: string, href: string | null) {
const existing = document.head.querySelector<HTMLLinkElement>(`link[data-sandbox-style="${id}"]`)
if (!href) {
existing?.remove()
return
}
if (existing) {
if (existing.href !== href)
existing.href = href
return
}
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = href
link.dataset.sandboxStyle = id
document.head.appendChild(link)
}
function encodeNpmVersion(version: string) {
return encodeURIComponent(version.trim() || 'latest')
}
function createEsmPackageUrl(packageName: string, version: string, deps: string[] = []) {
const query = new URLSearchParams()
if (deps.length)
query.set('deps', deps.join(','))
const suffix = query.toString()
return `https://esm.sh/${packageName}@${encodeNpmVersion(version)}${suffix ? `?${suffix}` : ''}`
}
function createEsmPackageSubpathUrl(
packageName: string,
version: string,
subpath: string,
deps: string[] = [],
) {
const base = createEsmPackageUrl(packageName, version, deps)
const [prefix, query] = base.split('?')
return `${prefix}/${subpath}${query ? `?${query}` : ''}`
}
function createJsDelivrCssUrl(packageName: string, version: string) {
return `https://cdn.jsdelivr.net/npm/${packageName}@${encodeNpmVersion(version)}/dist/index.css`
}
function applySandboxStyles(current: SandboxSelection) {
setHeadLink(
'renderer',
current.source === 'npm'
? createJsDelivrCssUrl(current.framework.packageName, current.version)
: null,
)
setHeadLink(
'katex',
`https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/katex.min.css`,
)
}
async function importRemote<T = any>(url: string): Promise<T> {
return await import(/* @vite-ignore */ url) as T
}
function setupCommonRendererRuntime(rendererModule: Record<string, any>) {
rendererModule.enableKatex?.()
rendererModule.enableMermaid?.()
const katexHandle = rendererModule.createKaTeXWorkerFromCDN?.({
mode: 'classic',
katexUrl: `https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/katex.min.js`,
mhchemUrl: `https://cdn.jsdelivr.net/npm/katex@${TEST_SANDBOX_KATEX_VERSION}/dist/contrib/mhchem.min.js`,
})
if (katexHandle?.worker)
rendererModule.setKaTeXWorker?.(katexHandle.worker)
const mermaidHandle = rendererModule.createMermaidWorkerFromCDN?.({
mode: 'module',
workerOptions: { type: 'module' },
mermaidUrl: `https://cdn.jsdelivr.net/npm/mermaid@${TEST_SANDBOX_MERMAID_VERSION}/dist/mermaid.esm.min.mjs`,
})
if (mermaidHandle?.worker)
rendererModule.setMermaidWorker?.(mermaidHandle.worker)
return () => {
rendererModule.clearKaTeXWorker?.()
rendererModule.clearMermaidWorker?.()
katexHandle?.dispose?.()
mermaidHandle?.dispose?.()
}
}
async function mountVue3Sandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
const rendererModule = current.source === 'workspace'
? await import('markstream-vue')
: await importRemote(createEsmPackageUrl(
current.framework.packageName,
current.version,
[`vue@${current.framework.runtimeVersion}`],
))
const vueModule = current.source === 'workspace'
? null
: await importRemote<any>(createEsmPackageUrl('vue', current.framework.runtimeVersion))
const teardownRuntime = setupCommonRendererRuntime(rendererModule)
const MarkdownRender = rendererModule.default || rendererModule.MarkdownRender
const createApp = current.source === 'workspace'
? vueModule?.createApp ?? (await import('vue')).createApp
: (vueModule?.createApp || vueModule?.default?.createApp)
const h = current.source === 'workspace'
? vueModule?.h ?? (await import('vue')).h
: (vueModule?.h || vueModule?.default?.h)
if (!MarkdownRender || !createApp || !h)
throw new Error('无法找到 Vue 3 渲染器入口。')
const host = document.createElement('div')
host.className = 'sandbox-canvas__host'
mountPoint.replaceChildren(host)
const app = createApp({
render() {
return h(MarkdownRender, {
content,
batchRendering: false,
typewriter: false,
viewportPriority: false,
})
},
})
app.mount(host)
return () => {
app.unmount()
teardownRuntime()
}
}
async function mountVue2Sandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
const [vueModule, rendererModule] = await Promise.all([
importRemote<any>(createEsmPackageUrl('vue', current.framework.runtimeVersion)),
importRemote<any>(createEsmPackageUrl(
current.framework.packageName,
current.version,
[
`vue@${current.framework.runtimeVersion}`,
'@vue/composition-api@1.7.2',
],
)),
])
const Vue2 = vueModule.default || vueModule
const MarkdownRender = rendererModule.default || rendererModule.MarkdownRender
const teardownRuntime = setupCommonRendererRuntime(rendererModule)
if (!Vue2 || !MarkdownRender)
throw new Error('无法找到 Vue 2 渲染器入口。')
const host = document.createElement('div')
host.className = 'sandbox-canvas__host'
mountPoint.replaceChildren(host)
const app = new Vue2({
render(createElement: (component: any, data?: any) => any) {
return createElement(MarkdownRender, {
props: {
content,
batchRendering: false,
typewriter: false,
viewportPriority: false,
},
})
},
})
app.$mount(host)
return () => {
app.$destroy()
teardownRuntime()
}
}
async function mountReactSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
const runtimeVersion = current.framework.runtimeVersion
const [reactModule, reactDomModule, rendererModule] = await Promise.all([
importRemote<any>(createEsmPackageUrl('react', runtimeVersion)),
importRemote<any>(createEsmPackageSubpathUrl(
'react-dom',
runtimeVersion,
'client',
[`react@${runtimeVersion}`],
)),
importRemote<any>(createEsmPackageUrl(
current.framework.packageName,
current.version,
[`react@${runtimeVersion}`, `react-dom@${runtimeVersion}`],
)),
])
const React = reactModule.default || reactModule
const ReactDOMClient = reactDomModule.default || reactDomModule
const MarkdownRender = rendererModule.default || rendererModule.NodeRenderer
if (!React?.createElement || !ReactDOMClient?.createRoot || !MarkdownRender)
throw new Error('无法找到 React 渲染器入口。')
const host = document.createElement('div')
host.className = 'sandbox-canvas__host'
mountPoint.replaceChildren(host)
const root = ReactDOMClient.createRoot(host)
root.render(
React.createElement(MarkdownRender, {
content,
batchRendering: false,
typewriter: false,
viewportPriority: false,
}),
)
return () => {
root.unmount()
}
}
async function mountAngularSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
const runtimeVersion = current.framework.runtimeVersion
const rendererModule = current.source === 'workspace'
? await import('markstream-angular')
: await importRemote<any>(createEsmPackageUrl(
current.framework.packageName,
current.version,
[`@angular/common@${runtimeVersion}`, `@angular/core@${runtimeVersion}`],
))
const renderMarkdownToHtml = rendererModule.renderMarkdownToHtml
const enhanceRenderedHtml = rendererModule.enhanceRenderedHtml
if (typeof renderMarkdownToHtml !== 'function')
throw new Error('无法找到 Angular 渲染器入口。')
const host = document.createElement('div')
host.className = 'sandbox-canvas__host'
const shell = document.createElement('div')
shell.className = 'markstream-angular markdown-renderer'
shell.innerHTML = renderMarkdownToHtml({
content,
final: true,
allowHtml: true,
})
const enhancementHandle = await enhanceRenderedHtml?.(shell, { final: true })
host.replaceChildren(shell)
mountPoint.replaceChildren(host)
return () => {
enhancementHandle?.dispose?.()
mountPoint.replaceChildren()
}
}
async function mountSandbox(current: SandboxSelection, content: string, mountPoint: HTMLElement) {
if (current.frameworkId === 'vue3')
return await mountVue3Sandbox(current, content, mountPoint)
if (current.frameworkId === 'vue2')
return await mountVue2Sandbox(current, content, mountPoint)
if (current.frameworkId === 'angular')
return await mountAngularSandbox(current, content, mountPoint)
return await mountReactSandbox(current, content, mountPoint)
}
@Component({
selector: 'app-angular-test-sandbox',
standalone: true,
imports: [CommonModule],
template: `
<div class="sandbox-page">
<header class="sandbox-hero">
<div>
<span class="sandbox-eyebrow">Version sandbox</span>
<h1>Isolated Renderer</h1>
<p>当前 iframe 正在独立加载对应 framework 与包版本,用于和左侧 workspace 预览做对照。</p>
</div>
<div class="sandbox-summary">
<div class="sandbox-summary__item">
<span>选择</span>
<strong>{{ selectionSummary() }}</strong>
</div>
<div class="sandbox-summary__item">
<span>状态</span>
<strong>{{ statusLabel() }}</strong>
</div>
<div class="sandbox-summary__item">
<span>字符数</span>
<strong>{{ markdown().length }}</strong>
</div>
</div>
</header>
<div *ngIf="status() === 'error'" class="sandbox-banner sandbox-banner--error">
<strong>沙箱加载失败。</strong>
<span>{{ errorMessage() || '请检查版本号是否存在,或稍后重试。' }}</span>
</div>
<div *ngIf="status() !== 'error' && selection().source === 'workspace' && !selection().framework.supportsWorkspace" class="sandbox-banner sandbox-banner--warning">
当前框架不支持在这个 iframe 中直接跑 workspace 源码,已自动回退到 npm 包模式。
</div>
<div *ngIf="status() === 'loading'" class="sandbox-banner sandbox-banner--info">
正在加载隔离渲染器与对应版本资源...
</div>
<section class="sandbox-canvas">
<div #canvasRef class="sandbox-canvas__mount"></div>
</section>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class TestSandboxComponent implements AfterViewInit, OnDestroy {
@ViewChild('canvasRef', { static: true }) private canvasRef?: ElementRef<HTMLElement>
readonly status = signal<SandboxStatus>('idle')
readonly selection = signal<SandboxSelection>(parseSandboxSelection(
typeof window === 'undefined' ? '' : window.location.search,
testSandboxFrameworks,
))
readonly markdown = signal(typeof window === 'undefined' ? '' : (decodeMarkdownHash(window.location.hash || '') ?? ''))
readonly errorMessage = signal('')
readonly statusLabel = computed(() => {
if (this.status() === 'loading')
return '加载中'
if (this.status() === 'error')
return '加载失败'
if (this.status() === 'ready')
return 'Ready'
return '待命'
})
readonly selectionSummary = computed(() => {
const current = this.selection()
return `${current.framework.label} / ${current.source === 'workspace' ? 'workspace' : 'npm'} / ${current.version}`
})
private cleanupMountedRuntime: (() => void) | null = null
private renderToken = 0
ngAfterViewInit() {
void this.renderSandbox()
if (typeof window !== 'undefined')
window.addEventListener('hashchange', this.handleHashChange)
}
ngOnDestroy() {
if (typeof window !== 'undefined')
window.removeEventListener('hashchange', this.handleHashChange)
this.cleanupRuntime()
}
private readonly handleHashChange = () => {
void this.renderSandbox()
}
private cleanupRuntime() {
this.cleanupMountedRuntime?.()
this.cleanupMountedRuntime = null
if (this.canvasRef?.nativeElement)
this.canvasRef.nativeElement.innerHTML = ''
}
private async renderSandbox() {
const mountPoint = this.canvasRef?.nativeElement
if (!mountPoint)
return
this.renderToken += 1
const activeToken = this.renderToken
this.cleanupRuntime()
this.selection.set(parseSandboxSelection(window.location.search, testSandboxFrameworks))
this.markdown.set(decodeMarkdownHash(window.location.hash || '') ?? '')
this.errorMessage.set('')
this.status.set('loading')
applySandboxStyles(this.selection())
try {
const teardown = await mountSandbox(this.selection(), this.markdown(), mountPoint)
if (activeToken !== this.renderToken) {
teardown()
return
}
this.cleanupMountedRuntime = teardown
this.status.set('ready')
}
catch (error) {
console.error('[test-sandbox] failed to render sandbox', error)
this.cleanupRuntime()
this.status.set('error')
this.errorMessage.set(error instanceof Error ? error.message : String(error))
}
}
}
@@ -0,0 +1,6 @@
export {
getTestSandboxFramework,
TEST_SANDBOX_KATEX_VERSION,
TEST_SANDBOX_MERMAID_VERSION,
testSandboxFrameworks,
} from '../../playground/src/testSandboxConfig'
@@ -0,0 +1,303 @@
import type { AngularRenderContext } from 'markstream-angular'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { NodeRenderer } from 'markstream-angular'
@Component({
selector: 'app-angular-thinking-node',
standalone: true,
imports: [CommonModule, NodeRenderer],
template: `
<div class="thinking-node">
<div class="thinking-node__icon-shell" aria-hidden="true">
<div class="thinking-node__icon">
<svg class="thinking-node__icon-svg" viewBox="0 0 24 24" fill="none">
<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="thinking-node__body">
<div class="thinking-node__meta">
<strong>Thinking</strong>
<span class="thinking-node__role">(assistant)</span>
<span class="thinking-dots" [class.visible]="node?.loading" [class.hidden]="!node?.loading" aria-hidden="true">
<span class="dot dot-1"></span>
<span class="dot dot-2"></span>
<span class="dot dot-3"></span>
</span>
</div>
<div class="thinking-node__content">
<span *ngIf="node?.loading" class="thinking-node__sr-only" aria-live="polite">Thinking...</span>
<div
class="thinking-node__content-shell"
[class.thinking-node__content-shell--loading]="node?.loading"
[class.thinking-node__content-shell--ready]="!node?.loading"
>
<markstream-angular
[content]="resolvedContent"
[final]="resolvedFinal"
[customId]="context?.customId"
[isDark]="context?.isDark"
[typewriter]="resolvedTypewriter"
[showTooltips]="context?.showTooltips"
[allowHtml]="context?.allowHtml"
[codeBlockStream]="context?.codeBlockStream"
[renderCodeBlocksAsPre]="context?.renderCodeBlocksAsPre"
[codeBlockProps]="context?.codeBlockProps"
[mermaidProps]="context?.mermaidProps"
[d2Props]="context?.d2Props"
[infographicProps]="context?.infographicProps"
[themes]="context?.codeBlockThemes?.themes"
[codeBlockDarkTheme]="context?.codeBlockThemes?.darkTheme"
[codeBlockLightTheme]="context?.codeBlockThemes?.lightTheme"
[codeBlockMonacoOptions]="context?.codeBlockThemes?.monacoOptions"
[codeBlockMinWidth]="context?.codeBlockThemes?.minWidth"
[codeBlockMaxWidth]="context?.codeBlockThemes?.maxWidth"
[customHtmlTags]="resolvedCustomHtmlTags"
[customComponents]="context?.customComponents"
[parseOptions]="context?.parseOptions"
[customMarkdownIt]="context?.customMarkdownIt"
[indexKey]="nestedPrefix"
[viewportPriority]="false"
[deferNodesUntilVisible]="false"
[batchRendering]="false"
[maxLiveNodes]="0"
></markstream-angular>
</div>
</div>
</div>
</div>
`,
styles: [`
:host {
display: block;
width: 100%;
}
.thinking-node {
margin: 1rem 0;
display: flex;
gap: 0.75rem;
border-left: 4px solid #60a5fa;
border-radius: 0.75rem;
background: #eff6ff;
padding: 1rem;
color: #0f172a;
}
:host-context(.dark) .thinking-node {
background: rgba(30, 64, 175, 0.2);
color: #e6f0ff;
}
.thinking-node__icon-shell {
flex-shrink: 0;
margin-top: 0.25rem;
}
.thinking-node__icon {
display: flex;
height: 2.25rem;
width: 2.25rem;
align-items: center;
justify-content: center;
border-radius: 9999px;
background: #bfdbfe;
color: #1d4ed8;
}
:host-context(.dark) .thinking-node__icon {
background: #1d4ed8;
color: #dbeafe;
}
.thinking-node__icon-svg {
height: 1.25rem;
width: 1.25rem;
}
.thinking-node__body {
min-width: 0;
flex: 1;
}
.thinking-node__meta {
display: flex;
align-items: baseline;
gap: 0.75rem;
font-size: 0.875rem;
}
.thinking-node__role {
font-size: 0.75rem;
color: #64748b;
}
:host-context(.dark) .thinking-node__role {
color: #cbd5e1;
}
.thinking-node__content {
margin-top: 0.25rem;
font-size: 0.875rem;
line-height: 1.65;
}
.thinking-node__content-shell {
min-height: 1.25rem;
width: 100%;
}
.thinking-node__content-shell--loading,
.thinking-node__content-shell--ready {
animation: thinking-content-fade 160ms ease both;
}
.thinking-node__content-shell--loading {
opacity: 0.92;
}
.thinking-node__content-shell--ready {
opacity: 1;
}
.thinking-node__content-shell :where(markstream-angular) {
display: block;
width: 100%;
}
.thinking-node__content-shell :where(.markstream-angular) {
content-visibility: visible;
contain: content;
contain-intrinsic-size: 0 0;
}
.thinking-dots {
display: inline-flex;
height: 12px;
width: 36px;
align-items: center;
justify-content: flex-start;
gap: 6px;
opacity: 0;
transition: opacity 160ms linear, transform 160ms linear;
}
.thinking-dots .dot {
height: 6px;
width: 6px;
transform: translateY(0);
border-radius: 9999px;
background: #1e3a8a;
opacity: 0.25;
}
:host-context(.dark) .thinking-dots .dot {
background: #bfdbfe;
opacity: 0.28;
}
.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;
}
.thinking-node__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;
}
@keyframes think-bounce {
0%, 80%, 100% {
transform: translateY(0);
opacity: 0.25;
}
40% {
transform: translateY(-6px);
opacity: 1;
}
}
@keyframes thinking-content-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
`],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ThinkingNodeComponent {
@Input({ required: true }) node!: Record<string, any>
@Input() context?: AngularRenderContext
@Input() indexKey?: string
@Input() typewriter?: boolean
private readonly fallbackThinkingTags = ['thinking']
get resolvedFinal() {
if (typeof this.node?.loading === 'boolean')
return !this.node.loading
return this.context?.final
}
get resolvedCustomHtmlTags() {
return this.context?.customHtmlTags?.length
? this.context.customHtmlTags
: this.fallbackThinkingTags
}
get nestedPrefix() {
return this.indexKey ? `${this.indexKey}-thinking` : 'thinking'
}
get resolvedContent() {
return typeof this.node?.content === 'string' ? this.node.content : ''
}
get resolvedTypewriter() {
if (typeof this.typewriter === 'boolean')
return this.typewriter
if (typeof this.context?.typewriter === 'boolean')
return this.context.typewriter
return true
}
}
@@ -0,0 +1,48 @@
function extractThinkingAttrs(rawContent: string) {
const match = rawContent.match(/<thinking([^>]*)>/i)
if (!match)
return []
const attrString = match[1] || ''
const attrs: Array<{ name: string, value: string | boolean }> = []
const attrRegex = /([^\s=]+)(?:="([^"]*)")?/g
let nextMatch: RegExpExecArray | null
while ((nextMatch = attrRegex.exec(attrString)) !== null) {
const attrName = nextMatch[1]
const attrValue = nextMatch[2] || true
attrs.push({ name: attrName, value: attrValue })
}
return attrs
}
function stripThinkingWrapper(rawContent: string) {
return rawContent
.replace(/<thinking[^>]*>/i, '')
.replace(/<\/thinking>\s*$/i, '')
}
export const THINKING_PARSE_OPTIONS = {
preTransformTokens(tokens: any[]) {
return tokens.map((token) => {
if (token?.type !== 'inline' || typeof token.content !== 'string' || !token.content.includes('<thinking'))
return token
const children = Array.isArray(token.children) ? token.children : []
token.children = children.map((child) => {
if (child?.type !== 'html_block' || child?.tag !== 'thinking')
return child
return {
type: 'thinking',
loading: child.loading,
attrs: extractThinkingAttrs(String(child.content || '')),
content: stripThinkingWrapper(String(child.content || '')),
}
})
return token
})
},
} as const
@@ -0,0 +1,4 @@
const d2BundledStub = {}
export default d2BundledStub
export const D2 = d2BundledStub
@@ -0,0 +1,4 @@
import '@angular/compiler'
export * from '../../../packages/markstream-angular/src/index'
export { default } from '../../../packages/markstream-angular/src/index'
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["DOM", "ES2022"],
"useDefineForClassFields": false,
"experimentalDecorators": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"types": ["vite/client"],
"strict": false,
"skipLibCheck": true
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
@@ -0,0 +1,153 @@
import type { Plugin, PluginOption } from 'vite'
import fs from 'node:fs'
import path from 'node:path'
import Vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vite'
import monacoEditorPlugin from 'vite-plugin-monaco-editor-esm'
const D2_VENDOR_PUBLIC_PATH = '/vendor/d2-browser.js'
const D2_VENDOR_FILE_NAME = 'vendor/d2-browser.js'
const d2BrowserEntry = path.resolve(__dirname, '../node_modules/@terrastruct/d2/dist/browser/index.js')
function normalizeId(id: string) {
return id.split(path.sep).join('/')
}
function d2VendorPlugin(): Plugin {
const vendorSource = fs.readFileSync(d2BrowserEntry)
return {
name: 'playground-angular-d2-vendor',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const pathname = req.url?.split('?')[0]
if (pathname !== D2_VENDOR_PUBLIC_PATH) {
next()
return
}
res.setHeader('Content-Type', 'application/javascript; charset=utf-8')
res.end(vendorSource)
})
},
generateBundle() {
this.emitFile({
type: 'asset',
fileName: D2_VENDOR_FILE_NAME,
source: vendorSource,
})
},
}
}
export default defineConfig({
base: './',
publicDir: path.resolve(__dirname, '../playground/public'),
worker: {
format: 'es',
},
server: {
port: 4175,
fs: {
allow: [path.resolve(__dirname, '..')],
},
},
resolve: {
alias: [
{
find: /^markstream-angular$/,
replacement: path.resolve(__dirname, './src/vendor/markstream-angular-jit.ts'),
},
{
find: /^markstream-vue$/,
replacement: path.resolve(__dirname, '../src/exports.ts'),
},
{
find: /^stream-markdown-parser$/,
replacement: path.resolve(__dirname, '../packages/markdown-parser/src/index.ts'),
},
{
find: /^stream-markdown-parser\//,
replacement: `${path.resolve(__dirname, '../packages/markdown-parser/src')}/`,
},
{
find: /^@terrastruct\/d2$/,
replacement: path.resolve(__dirname, './src/vendor/d2-bundled-stub.ts'),
},
],
},
css: {
postcss: path.resolve(__dirname, './postcss.config.cjs'),
},
optimizeDeps: {
include: [
'katex',
'katex/contrib/mhchem',
'katex/dist/contrib/mhchem',
'mermaid',
],
exclude: ['stream-monaco'],
},
plugins: [
Vue(),
d2VendorPlugin(),
monacoEditorPlugin({
languageWorkers: [
'editorWorkerService',
'typescript',
'css',
'html',
'json',
],
customDistPath(_root, buildOutDir) {
return path.resolve(buildOutDir, 'monacoeditorwork')
},
}) as unknown as PluginOption,
],
build: {
// The Angular playground still ships Monaco as an optional local asset,
// so a higher warning budget keeps the build signal focused on true
// regressions instead of the expected editor payload.
chunkSizeWarningLimit: 2500,
rollupOptions: {
output: {
manualChunks(id) {
const normalized = normalizeId(id)
if (normalized.includes('/node_modules/@angular/compiler/'))
return 'angular-compiler'
if (normalized.includes('/node_modules/@angular/core/'))
return 'angular-core'
if (normalized.includes('/node_modules/@angular/common/'))
return 'angular-common'
if (normalized.includes('/node_modules/@angular/platform-browser/'))
return 'angular-platform-browser'
if (normalized.includes('/node_modules/rxjs/'))
return 'angular-rxjs'
if (normalized.includes('/node_modules/monaco-editor/'))
return 'monaco-editor'
if (normalized.includes('/node_modules/stream-monaco/'))
return 'stream-monaco'
if (normalized.includes('/playground-angular/src/vendor/markstream-angular-jit.ts'))
return 'markstream-angular'
if (normalized.includes('/packages/markstream-angular/src/'))
return 'markstream-angular'
if (normalized.includes('/packages/markdown-parser/src/'))
return 'markdown-parser'
if (normalized.includes('/playground-angular/src/test-lab.component.ts'))
return 'angular-test-lab'
},
},
},
},
})