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,346 @@
<script>
/* global __MARKSTREAM_VUE2_CLI_DEMO_MD__ */
import MarkdownRender, { setCustomComponents } from 'markstream-vue2'
import ThinkingNode from './components/ThinkingNode.vue'
const markdownSource = typeof __MARKSTREAM_VUE2_CLI_DEMO_MD__ === 'string'
? __MARKSTREAM_VUE2_CLI_DEMO_MD__
: String(__MARKSTREAM_VUE2_CLI_DEMO_MD__ || '')
const customHtmlTags = Object.freeze(['thinking'])
const codeBlockProps = Object.freeze({ isDark: true })
const probeMode = typeof window !== 'undefined'
&& typeof window.location !== 'undefined'
&& /(?:^|[?&])probe=1(?:&|$)/.test(window.location.search)
setCustomComponents('vue2-demo', { thinking: ThinkingNode })
export default {
name: 'Vue2CliPlayground',
components: {
MarkdownRender,
},
data() {
return {
content: '',
delay: 24,
chunkSize: 2,
timer: null,
running: true,
customHtmlTags,
codeBlockProps,
}
},
computed: {
totalLength() {
return markdownSource.length
},
progress() {
if (!this.totalLength)
return 0
return Math.min(100, Math.round((this.content.length / this.totalLength) * 100))
},
normalizedChunkSize() {
const n = Math.floor(Number(this.chunkSize) || 1)
return Math.max(1, n)
},
isDone() {
return this.content.length >= this.totalLength
},
},
watch: {
delay() {
if (this.running)
this.restartStream()
},
},
mounted() {
if (!probeMode)
this.startStream()
},
beforeUnmount() {
this.stopStream()
},
methods: {
scheduleNextTick(previousElapsed = 0) {
if (!this.running || this.isDone)
return
if (this.timer != null)
window.clearTimeout(this.timer)
const wait = Math.max(
0,
Math.max(Number(this.delay) || 0, Math.ceil(Number(previousElapsed) || 0)),
)
this.timer = window.setTimeout(() => {
this.timer = null
this.tick()
}, wait)
},
tick() {
if (!this.running)
return
if (this.isDone) {
this.stopStream()
return
}
const start = this.content.length
const nextChunk = markdownSource.slice(start, start + this.normalizedChunkSize)
const tickStartedAt = typeof performance !== 'undefined' ? performance.now() : Date.now()
this.content += nextChunk
this.$nextTick(() => {
const schedule = () => {
if (!this.running)
return
const now = typeof performance !== 'undefined' ? performance.now() : Date.now()
this.scheduleNextTick(now - tickStartedAt)
}
if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function')
window.requestAnimationFrame(() => schedule())
else
schedule()
})
},
startStream() {
this.stopStream()
this.running = true
this.scheduleNextTick()
},
stopStream() {
if (this.timer != null) {
window.clearTimeout(this.timer)
this.timer = null
}
this.running = false
},
toggleStream() {
if (this.running)
this.stopStream()
else
this.startStream()
},
restartStream() {
if (!this.running)
return
this.startStream()
},
resetStream() {
this.content = ''
this.startStream()
},
fillAll() {
this.content = markdownSource
this.stopStream()
},
},
}
</script>
<template>
<div class="page">
<header class="header">
<div class="title">
markstream-vue2 playground
</div>
<div class="sub">
Vue CLI 4 demo: streaming markdown into the renderer with CDN workers
</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" v-model.number="delay" type="number" min="4" max="200">
</div>
<div class="field">
<label for="chunk">Chunk size</label>
<input id="chunk" v-model.number="chunkSize" type="number" min="1" max="16">
</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 class="meta">
{{ content.length }} / {{ totalLength }} ({{ progress }}%)
</div>
</div>
<p class="note">
This Webpack 4 playground uses CDN workers for KaTeX and Mermaid. D2 remains optional and falls back to source mode here.
</p>
</section>
<section class="panel preview">
<MarkdownRender
:content="content"
:final="isDone"
:typewriter="false"
:code-block-stream="true"
code-block-dark-theme="vitesse-dark"
code-block-light-theme="vitesse-dark"
:code-block-props="codeBlockProps"
:viewport-priority="false"
:defer-nodes-until-visible="false"
:batch-rendering="false"
custom-id="vue2-demo"
:custom-html-tags="customHtmlTags"
/>
</section>
</div>
</div>
</template>
<style>
html,
body,
#app {
margin: 0;
min-height: 100%;
}
.page {
min-height: 100vh;
padding: 1.5rem 2rem 2.5rem;
background: #f7f7fb;
color: #111827;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
.header {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1.5rem;
}
.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;
line-height: 1.5;
color: #6b7280;
}
.preview {
min-height: 70vh;
overflow: auto;
}
@media (max-width: 960px) {
.page {
padding-left: 1rem;
padding-right: 1rem;
}
.layout {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,193 @@
<script>
import { NestedRenderer } from 'markstream-vue2'
export default {
name: 'ThinkingNode',
components: {
NestedRenderer,
},
props: {
node: {
type: Object,
required: true,
},
},
}
</script>
<template>
<div class="thinking-node">
<div class="thinking-node__icon-wrap">
<div class="thinking-node__icon">
<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="thinking-node__body">
<div class="thinking-node__header">
<strong class="thinking-node__title">Thinking</strong>
<span class="thinking-node__meta">(assistant)</span>
<span class="thinking-node__status" 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="thinking-node__content">
<span v-if="node.loading" class="thinking-node__sr-only" aria-live="polite">Thinking…</span>
<transition name="fade" mode="out-in">
<div :key="node.loading ? 'loading' : 'ready'" class="content-area">
<NestedRenderer
:node="node"
custom-id="vue2-demo"
:custom-html-tags="['thinking']"
:typewriter="false"
:batch-rendering="false"
:viewport-priority="false"
:defer-nodes-until-visible="false"
/>
</div>
</transition>
</div>
</div>
</div>
</template>
<style scoped>
.thinking-node {
display: flex;
align-items: flex-start;
gap: 0.75rem;
margin: 1rem 0;
padding: 1rem;
border-left: 4px solid #60a5fa;
border-radius: 0.375rem;
background: #eff6ff;
color: #0f172a;
}
.thinking-node__icon-wrap {
flex-shrink: 0;
margin-top: 0.25rem;
}
.thinking-node__icon {
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border-radius: 9999px;
background: #bfdbfe;
color: #1d4ed8;
}
.thinking-node__icon svg {
width: 1.25rem;
height: 1.25rem;
}
.thinking-node__body {
flex: 1 1 auto;
min-width: 0;
}
.thinking-node__header {
display: flex;
align-items: baseline;
gap: 0.75rem;
}
.thinking-node__title {
font-size: 0.875rem;
}
.thinking-node__meta {
font-size: 0.75rem;
color: #64748b;
}
.thinking-node__status {
margin-left: 0.5rem;
}
.thinking-node__content {
margin-top: 0.25rem;
font-size: 0.875rem;
line-height: 1.7;
color: #1e293b;
}
.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;
}
.thinking-dots {
display: inline-flex;
align-items: center;
gap: 6px;
width: 36px;
justify-content: flex-start;
height: 12px;
transition: opacity 160ms linear, transform 160ms linear;
opacity: 0;
}
.thinking-dots .dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: #1e3a8a;
opacity: 0.25;
transform: translateY(0);
}
.thinking-dots.visible { opacity: 1; }
.thinking-dots.hidden { opacity: 0; transform: translateY(0); }
.thinking-dots.visible .dot-1 { animation: think-bounce 1s infinite ease-in-out; animation-delay: 0s }
.thinking-dots.visible .dot-2 { animation: think-bounce 1s infinite ease-in-out; animation-delay: 0.12s }
.thinking-dots.visible .dot-3 { animation: think-bounce 1s infinite ease-in-out; animation-delay: 0.24s }
.dark .thinking-dots .dot { background: #bfdbfe; opacity: 0.28 }
@keyframes think-bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.25 }
40% { transform: translateY(-6px); opacity: 1 }
}
.content-area { min-height: 1.25rem; }
.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 }
@media (prefers-color-scheme: dark) {
.thinking-node {
background: rgba(30, 64, 175, 0.18);
color: #e6f0ff;
}
.thinking-node__icon {
background: #1d4ed8;
color: #dbeafe;
}
.thinking-node__meta,
.thinking-node__content {
color: #dbeafe;
}
.dark .thinking-dots .dot,
.thinking-node .thinking-dots .dot {
background: #bfdbfe;
opacity: 0.28;
}
}
</style>
@@ -0,0 +1,49 @@
import {
createKaTeXWorkerFromCDN,
createMermaidWorkerFromCDN,
MarkdownCodeBlockNode,
setCustomComponents,
setKaTeXWorker,
setMermaidWorker,
VueRendererMarkdown,
} from 'markstream-vue2'
import Vue from 'vue'
import App from './App.vue'
import './vue2-composition-api-shim'
import 'markstream-vue2/dist/index.css'
import 'katex/dist/katex.min.css'
Vue.config.productionTip = false
// In Vue CLI (Webpack 4), Monaco+Shiki integration is fragile and can fail with
// internal Monaco service errors or Shiki TextMate regex engine crashes.
// For this playground, use stream-markdown's Shiki renderer instead.
setCustomComponents({ code_block: MarkdownCodeBlockNode })
Vue.use(VueRendererMarkdown)
async function bootstrap() {
if (typeof window !== 'undefined') {
// Webpack 4 cannot handle Vite-style `?worker` imports. Use CDN workers instead.
const { worker: katexWorker } = createKaTeXWorkerFromCDN({
mode: 'classic',
katexUrl: 'https://cdn.jsdelivr.net/npm/katex@0.16.25/dist/katex.min.js',
mhchemUrl: 'https://cdn.jsdelivr.net/npm/katex@0.16.25/dist/contrib/mhchem.min.js',
})
if (katexWorker)
setKaTeXWorker(katexWorker)
const { worker: mermaidWorker } = createMermaidWorkerFromCDN({
mode: 'classic',
mermaidUrl: 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.min.js',
})
if (mermaidWorker)
setMermaidWorker(mermaidWorker)
}
new Vue({
render: h => h(App),
}).$mount('#app')
}
bootstrap()
@@ -0,0 +1,18 @@
import CompositionApi, * as CompositionApiExports from '@vue/composition-api'
import Vue from 'vue'
// Vue 2.6 needs the Composition API plugin to support `setup`.
// Also expose Composition API helpers on the Vue export because
// the built markstream-vue2 bundle imports them from "vue".
const compositionApiInstalled = Vue.__composition_api_installed__ || Vue.__compositionApiInstalled
if (!compositionApiInstalled) {
Vue.use(CompositionApi)
Vue.__compositionApiInstalled = true
}
Object.keys(CompositionApiExports).forEach((key) => {
if (key === 'default')
return
if (Vue[key] == null)
Vue[key] = CompositionApiExports[key]
})