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,118 @@
export interface ChunkInfo {
content: string
delay: number
index: number
}
export interface LocalStreamControl {
shouldPause?: () => boolean
signal?: AbortSignal
}
export type StreamSliceMode = 'pure-random' | 'boundary-aware'
export interface CreateLocalTextStreamOptions {
chunkSizeMin: number
chunkSizeMax: number
chunkDelayMin: number
chunkDelayMax: number
onChunk?: (chunk: ChunkInfo) => void
control?: LocalStreamControl
random?: () => number
sliceMode?: StreamSliceMode
}
const WORD_BOUNDARY = /[\s,.;:!?()[\]{}"'`<>/\-\\]/
function randomInt(min: number, max: number, random: () => number) {
return Math.floor((random() * ((max - min) + 1))) + min
}
function isBoundaryChar(char: string | undefined) {
return char ? WORD_BOUNDARY.test(char) : false
}
function snapChunkToBoundary(content: string, start: number, chunkSize: number) {
const preferredEnd = Math.min(content.length, start + chunkSize)
if (preferredEnd >= content.length)
return content.length - start
if (isBoundaryChar(content[preferredEnd - 1]) || isBoundaryChar(content[preferredEnd]))
return preferredEnd - start
const lookaheadEnd = Math.min(content.length, preferredEnd + 12)
for (let index = preferredEnd; index < lookaheadEnd; index++) {
if (isBoundaryChar(content[index]))
return (index + 1) - start
}
return preferredEnd - start
}
export function createLocalTextStream(
content: string,
options: CreateLocalTextStreamOptions,
) {
const encoder = new TextEncoder()
const random = options.random ?? Math.random
const control = options.control ?? {}
let currentPosition = 0
let chunkIndex = 0
const sleep = (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
const waitIfPaused = async () => {
while (control.shouldPause?.()) {
if (control.signal?.aborted)
return
await sleep(50)
}
}
return new ReadableStream<Uint8Array>({
async start(controller) {
while (currentPosition < content.length) {
if (control.signal?.aborted)
break
await waitIfPaused()
if (control.signal?.aborted)
break
const rawChunkSize = randomInt(options.chunkSizeMin, options.chunkSizeMax, random)
const chunkSize = options.sliceMode === 'boundary-aware'
? snapChunkToBoundary(content, currentPosition, rawChunkSize)
: rawChunkSize
const delay = randomInt(options.chunkDelayMin, options.chunkDelayMax, random)
const chunkContent = content.slice(currentPosition, currentPosition + chunkSize)
await sleep(delay)
if (control.signal?.aborted)
break
await waitIfPaused()
if (control.signal?.aborted)
break
controller.enqueue(encoder.encode(chunkContent))
options.onChunk?.({
index: chunkIndex++,
content: chunkContent,
delay,
})
currentPosition += chunkSize
}
try {
controller.close()
}
catch {
// The consumer may have already canceled the stream.
}
},
cancel() {
// No extra cleanup is required for the local simulation stream.
},
})
}
@@ -0,0 +1,2 @@
export const isDark = useDark()
export const toggleDark = useToggle(isDark)
@@ -0,0 +1 @@
export * from './dark'
@@ -0,0 +1,96 @@
export const CUSTOM_STREAM_PRESET_ID = 'custom' as const
export type StreamPresetId
= 'balanced'
| 'sse'
| 'websocket'
| 'proxy-buffered'
| 'weak-mobile'
| typeof CUSTOM_STREAM_PRESET_ID
export interface StreamPresetValues {
chunkDelayMin: number
chunkDelayMax: number
chunkSizeMin: number
chunkSizeMax: number
burstiness: number
}
export interface StreamPreset extends StreamPresetValues {
id: Exclude<StreamPresetId, typeof CUSTOM_STREAM_PRESET_ID>
label: string
description: string
descriptionZh: string
}
export const STREAM_PRESETS: StreamPreset[] = [
{
id: 'balanced',
label: 'Balanced',
description: 'Moderate jitter with short stalls and occasional bursts.',
descriptionZh: '中等抖动,偶尔停顿和 burst,适合大多数 LLM 对话流。',
chunkDelayMin: 14,
chunkDelayMax: 34,
chunkSizeMin: 2,
chunkSizeMax: 7,
burstiness: 35,
},
{
id: 'sse',
label: 'SSE',
description: 'Smaller chunks, frequent flushes, and visible network wobble.',
descriptionZh: '更小的 chunk、更频繁的 flush,能看到明显的网络抖动。',
chunkDelayMin: 18,
chunkDelayMax: 44,
chunkSizeMin: 1,
chunkSizeMax: 4,
burstiness: 18,
},
{
id: 'websocket',
label: 'WebSocket',
description: 'Smoother cadence with larger chunks and lower transport jitter.',
descriptionZh: '节奏更顺、chunk 更大、传输抖动更小,像直连 WebSocket。',
chunkDelayMin: 8,
chunkDelayMax: 20,
chunkSizeMin: 4,
chunkSizeMax: 8,
burstiness: 22,
},
{
id: 'proxy-buffered',
label: 'Proxy Buffered',
description: 'Silent gaps followed by buffered batches from an intermediate proxy.',
descriptionZh: '前面更安静,后面成批吐内容,模拟代理层缓冲后再转发。',
chunkDelayMin: 36,
chunkDelayMax: 160,
chunkSizeMin: 6,
chunkSizeMax: 16,
burstiness: 88,
},
{
id: 'weak-mobile',
label: 'Weak Mobile',
description: 'High latency, high jitter, and resume-after-stall behavior.',
descriptionZh: '高延迟、高抖动,偶尔停住再继续,像弱网移动端。',
chunkDelayMin: 42,
chunkDelayMax: 180,
chunkSizeMin: 1,
chunkSizeMax: 6,
burstiness: 68,
},
]
export function getStreamPreset(id: StreamPresetId | string | null | undefined) {
return STREAM_PRESETS.find(preset => preset.id === id) ?? null
}
export function findMatchingStreamPreset(values: StreamPresetValues) {
return STREAM_PRESETS.find(preset =>
preset.chunkDelayMin === values.chunkDelayMin
&& preset.chunkDelayMax === values.chunkDelayMax
&& preset.chunkSizeMin === values.chunkSizeMin
&& preset.chunkSizeMax === values.chunkSizeMax
&& preset.burstiness === values.burstiness,
) ?? null
}
@@ -0,0 +1,591 @@
import type { MaybeRefOrGetter } from 'vue'
import type { ChunkInfo, StreamSliceMode } from './createLocalTextStream'
import { computed, onBeforeUnmount, ref, toValue } from 'vue'
import { createLocalTextStream } from './createLocalTextStream'
export type StreamTransportMode = 'scheduler' | 'readable-stream'
export interface StreamSimulatorOptions {
source: MaybeRefOrGetter<string>
baseChunkSize?: MaybeRefOrGetter<number>
baseDelayMs?: MaybeRefOrGetter<number>
chunkSizeMin?: MaybeRefOrGetter<number>
chunkSizeMax?: MaybeRefOrGetter<number>
chunkDelayMin?: MaybeRefOrGetter<number>
chunkDelayMax?: MaybeRefOrGetter<number>
jitter?: MaybeRefOrGetter<number>
burstiness: MaybeRefOrGetter<number>
sliceMode?: MaybeRefOrGetter<StreamSliceMode>
transportMode?: MaybeRefOrGetter<StreamTransportMode>
random?: () => number
}
const WORD_BOUNDARY = /[\s,.;:!?()[\]{}"'`<>/\-\\]/
interface BurstState {
quickChunksRemaining: number
fastCadenceChunkPending: boolean
}
export function clampStreamControl(value: number, min: number, max: number, fallback: number) {
const normalized = Number.isFinite(value) ? value : fallback
return Math.min(max, Math.max(min, normalized))
}
export function normalizeStreamRange(
minValue: number,
maxValue: number,
lowerBound: number,
upperBound: number,
fallbackMin: number,
fallbackMax: number,
) {
const normalizedMin = Math.round(clampStreamControl(minValue, lowerBound, upperBound, fallbackMin))
const normalizedMax = Math.round(clampStreamControl(maxValue, lowerBound, upperBound, fallbackMax))
if (normalizedMin <= normalizedMax)
return { min: normalizedMin, max: normalizedMax }
return { min: normalizedMax, max: normalizedMin }
}
function normalizeRatio(value: number, fallback: number) {
return clampStreamControl(value, 0, 1, fallback)
}
function randomBetween(min: number, max: number, random: () => number) {
return min + ((max - min) * random())
}
function randomInt(min: number, max: number, random: () => number) {
return Math.floor(randomBetween(min, max + 1, random))
}
function hasExplicitRange(minRef: MaybeRefOrGetter<number> | undefined, maxRef: MaybeRefOrGetter<number> | undefined) {
return minRef !== undefined || maxRef !== undefined
}
function inferJitterFromRange(min: number, max: number) {
if (max <= min)
return 0
return normalizeRatio((max - min) / Math.max(1, max), 0)
}
function resolveSliceMode(sliceMode: MaybeRefOrGetter<StreamSliceMode> | undefined) {
return (sliceMode ? toValue(sliceMode) : 'boundary-aware') as StreamSliceMode
}
function isBoundaryChar(char: string | undefined) {
return char ? WORD_BOUNDARY.test(char) : false
}
function snapChunkToBoundary(source: string, start: number, desiredChunk: number) {
const preferredEnd = Math.min(source.length, start + desiredChunk)
if (preferredEnd >= source.length)
return source.length - start
if (isBoundaryChar(source[preferredEnd - 1]) || isBoundaryChar(source[preferredEnd]))
return preferredEnd - start
const lookaheadEnd = Math.min(source.length, preferredEnd + 12)
for (let index = preferredEnd; index < lookaheadEnd; index++) {
if (isBoundaryChar(source[index]))
return (index + 1) - start
}
return preferredEnd - start
}
function sampleChunkSize(
source: string,
start: number,
baseChunkSize: number,
jitter: number,
burstiness: number,
burstState: BurstState,
sliceMode: StreamSliceMode,
random: () => number,
) {
const remaining = source.length - start
if (remaining <= 0)
return 0
const useBurstChunkBias = burstState.fastCadenceChunkPending || burstState.quickChunksRemaining > 0
const smallChunkBias = jitter > 0.08 && random() < (jitter * 0.4)
const stableMin = Math.max(0.35, 1 - (jitter * 0.55))
const stableMax = 1 + (jitter * 1.45)
const sizeFactor = smallChunkBias
? randomBetween(Math.max(0.18, 1 - (jitter * 1.35)), Math.max(0.32, 0.78 - (jitter * 0.08)), random)
: randomBetween(stableMin, stableMax, random)
let chunkSize = Math.max(1, Math.round(baseChunkSize * sizeFactor))
if (useBurstChunkBias) {
const burstExtra = Math.max(1, Math.round(baseChunkSize * randomBetween(0.75, 2.2 + burstiness, random)))
chunkSize += burstExtra
}
else if (burstiness > 0.05 && random() < (burstiness * 0.22)) {
const burstExtra = Math.max(1, Math.round(baseChunkSize * randomBetween(1.2, 3.2 + (burstiness * 2), random)))
chunkSize += burstExtra
}
chunkSize = Math.min(remaining, chunkSize)
if (sliceMode === 'boundary-aware' && remaining > 6 && random() < 0.72)
chunkSize = Math.min(remaining, snapChunkToBoundary(source, start, chunkSize))
return Math.max(1, chunkSize)
}
function sampleChunkSizeFromRange(
source: string,
start: number,
chunkSizeMin: number,
chunkSizeMax: number,
burstState: BurstState,
sliceMode: StreamSliceMode,
random: () => number,
) {
const remaining = source.length - start
if (remaining <= 0)
return 0
const range = Math.max(0, chunkSizeMax - chunkSizeMin)
if (range === 0 && sliceMode === 'pure-random')
return Math.min(remaining, chunkSizeMin)
if (sliceMode === 'pure-random') {
const chunkSize = range === 0
? chunkSizeMin
: randomInt(chunkSizeMin, chunkSizeMax, random)
return Math.max(1, Math.min(remaining, chunkSize))
}
if (range === 0)
return Math.min(remaining, chunkSizeMin)
const useBurstChunkBias = burstState.fastCadenceChunkPending || burstState.quickChunksRemaining > 0
const useLowWindow = !useBurstChunkBias && random() < 0.22
let chunkSize = chunkSizeMax
if (useBurstChunkBias) {
const burstMin = Math.max(chunkSizeMin, chunkSizeMax - Math.max(1, Math.floor(range * 0.35)))
chunkSize = randomInt(burstMin, chunkSizeMax, random)
}
else if (useLowWindow) {
const lowMax = Math.min(chunkSizeMax, chunkSizeMin + Math.max(1, Math.floor(range * 0.4)))
chunkSize = randomInt(chunkSizeMin, lowMax, random)
}
else {
chunkSize = randomInt(chunkSizeMin, chunkSizeMax, random)
}
chunkSize = Math.min(remaining, chunkSize)
if (sliceMode === 'boundary-aware' && remaining > 6 && random() < 0.72)
chunkSize = Math.min(remaining, snapChunkToBoundary(source, start, chunkSize))
return Math.max(1, chunkSize)
}
function sampleDelayMs(
baseDelayMs: number,
jitter: number,
burstiness: number,
burstState: BurstState,
isFirstChunk: boolean,
random: () => number,
) {
const fastCadence = burstState.quickChunksRemaining > 0
let delayMs = baseDelayMs * randomBetween(
fastCadence ? 0.16 : Math.max(0.55, 1 - (jitter * 0.42)),
fastCadence ? 0.7 : 1 + (jitter * 2.05),
random,
)
if (isFirstChunk && (jitter > 0.05 || burstiness > 0.05))
delayMs += baseDelayMs * randomBetween(0.35, 1.8 + (jitter * 4.6), random)
if (!fastCadence && burstiness > 0.05 && random() < (burstiness * 0.24))
delayMs += baseDelayMs * randomBetween(2.5, 8 + (jitter * 6), random)
if (!fastCadence && burstiness > 0.08 && random() < (burstiness * 0.2))
burstState.quickChunksRemaining = randomInt(1, 2 + Math.round(burstiness * 4), random)
if (fastCadence)
burstState.fastCadenceChunkPending = true
return Math.round(clampStreamControl(delayMs, 8, 1800, baseDelayMs))
}
function sampleDelayMsFromRange(
chunkDelayMin: number,
chunkDelayMax: number,
burstiness: number,
burstState: BurstState,
isFirstChunk: boolean,
pureRandom: boolean,
random: () => number,
) {
const range = Math.max(0, chunkDelayMax - chunkDelayMin)
if (range === 0)
return chunkDelayMin
if (pureRandom)
return randomInt(chunkDelayMin, chunkDelayMax, random)
const fastCadence = burstState.quickChunksRemaining > 0
if (fastCadence) {
const quickMax = Math.min(chunkDelayMax, chunkDelayMin + Math.max(1, Math.floor(range * 0.3)))
burstState.fastCadenceChunkPending = true
return randomInt(chunkDelayMin, quickMax, random)
}
const shouldStall = burstiness > 0.05 && random() < (burstiness * 0.24)
const shouldBurst = burstiness > 0.08 && random() < (burstiness * 0.18)
let delayMs: number
if (isFirstChunk) {
const warmupMin = Math.min(chunkDelayMax, chunkDelayMin + Math.max(1, Math.floor(range * 0.45)))
delayMs = randomInt(warmupMin, chunkDelayMax, random)
}
else if (shouldStall) {
const stallMin = Math.max(chunkDelayMin, chunkDelayMax - Math.max(1, Math.floor(range * 0.35)))
delayMs = randomInt(stallMin, chunkDelayMax, random)
burstState.quickChunksRemaining = randomInt(1, 2 + Math.round(burstiness * 4), random)
}
else {
delayMs = randomInt(chunkDelayMin, chunkDelayMax, random)
if (shouldBurst)
burstState.quickChunksRemaining = randomInt(1, 2 + Math.round(burstiness * 4), random)
}
return delayMs
}
export function useStreamSimulator(options: StreamSimulatorOptions) {
const content = ref('')
const isStreaming = ref(false)
const isPaused = ref(false)
const lastDelayMs = ref(0)
const lastChunkSize = ref(0)
const chunkCount = ref(0)
const chunks = ref<ChunkInfo[]>([])
const burstState: BurstState = {
quickChunksRemaining: 0,
fastCadenceChunkPending: false,
}
const progress = computed(() => {
const source = toValue(options.source) || ''
if (!source.length)
return 0
return Math.min(100, Math.round((content.value.length / source.length) * 100))
})
const random = options.random ?? Math.random
let abortController: AbortController | null = null
let timer: ReturnType<typeof window.setTimeout> | null = null
function clearTimer() {
if (timer !== null) {
window.clearTimeout(timer)
timer = null
}
}
function stop() {
clearTimer()
abortController?.abort()
abortController = null
isStreaming.value = false
isPaused.value = false
}
function scheduleNext(isFirstChunk = false) {
const source = toValue(options.source) || ''
if (!isStreaming.value || isPaused.value)
return
if (content.value.length >= source.length) {
stop()
return
}
const burstiness = normalizeRatio(Number(toValue(options.burstiness)), 0.35)
const hasExplicitDelayRange = hasExplicitRange(options.chunkDelayMin, options.chunkDelayMax)
const pureRandom = resolveSliceMode(options.sliceMode) === 'pure-random'
const delayMs = hasExplicitDelayRange
? (() => {
const { min, max } = normalizeStreamRange(
Number(toValue(options.chunkDelayMin)),
Number(toValue(options.chunkDelayMax)),
8,
1800,
16,
40,
)
return sampleDelayMsFromRange(min, max, burstiness, burstState, isFirstChunk, pureRandom, random)
})()
: (() => {
const baseDelayMs = clampStreamControl(Number(toValue(options.baseDelayMs)), 8, 400, 24)
const jitter = normalizeRatio(Number(toValue(options.jitter)), 0.45)
return sampleDelayMs(baseDelayMs, jitter, burstiness, burstState, isFirstChunk, random)
})()
lastDelayMs.value = delayMs
timer = window.setTimeout(() => {
timer = null
pushNextChunk()
}, delayMs)
}
function pushNextChunk() {
const source = toValue(options.source) || ''
if (!isStreaming.value || isPaused.value)
return
if (!source.length) {
stop()
return
}
const start = content.value.length
if (start >= source.length) {
stop()
return
}
const burstiness = normalizeRatio(Number(toValue(options.burstiness)), 0.35)
const hasExplicitChunkRange = hasExplicitRange(options.chunkSizeMin, options.chunkSizeMax)
const sliceMode = resolveSliceMode(options.sliceMode)
const chunkSize = hasExplicitChunkRange
? (() => {
const { min, max } = normalizeStreamRange(
Number(toValue(options.chunkSizeMin)),
Number(toValue(options.chunkSizeMax)),
1,
160,
2,
6,
)
return sampleChunkSizeFromRange(source, start, min, max, burstState, sliceMode, random)
})()
: (() => {
const baseChunkSize = clampStreamControl(Number(toValue(options.baseChunkSize)), 1, 160, 4)
const hasExplicitDelayRange = hasExplicitRange(options.chunkDelayMin, options.chunkDelayMax)
const jitter = hasExplicitDelayRange
? (() => {
const { min, max } = normalizeStreamRange(
Number(toValue(options.chunkDelayMin)),
Number(toValue(options.chunkDelayMax)),
8,
1800,
16,
40,
)
return inferJitterFromRange(min, max)
})()
: normalizeRatio(Number(toValue(options.jitter)), 0.45)
return sampleChunkSize(source, start, baseChunkSize, jitter, burstiness, burstState, sliceMode, random)
})()
lastChunkSize.value = chunkSize
chunkCount.value += 1
const nextChunk = source.slice(start, start + chunkSize)
content.value = source.slice(0, start + chunkSize)
chunks.value.push({
index: chunkCount.value - 1,
content: nextChunk,
delay: lastDelayMs.value,
})
if (burstState.fastCadenceChunkPending) {
burstState.fastCadenceChunkPending = false
burstState.quickChunksRemaining = Math.max(0, burstState.quickChunksRemaining - 1)
}
if (content.value.length >= source.length) {
stop()
return
}
scheduleNext()
}
async function startReadableStream(reset = true) {
clearTimer()
abortController?.abort()
const controller = new AbortController()
abortController = controller
burstState.quickChunksRemaining = 0
burstState.fastCadenceChunkPending = false
if (reset)
content.value = ''
chunks.value = []
lastChunkSize.value = 0
lastDelayMs.value = 0
chunkCount.value = 0
isPaused.value = false
const source = toValue(options.source) || ''
if (!source.length) {
abortController = null
isStreaming.value = false
return
}
const { min: chunkSizeMin, max: chunkSizeMax } = normalizeStreamRange(
Number(toValue(options.chunkSizeMin)),
Number(toValue(options.chunkSizeMax)),
1,
160,
2,
6,
)
const { min: chunkDelayMin, max: chunkDelayMax } = normalizeStreamRange(
Number(toValue(options.chunkDelayMin)),
Number(toValue(options.chunkDelayMax)),
8,
1800,
16,
40,
)
const sliceMode = (toValue(options.sliceMode) ?? 'boundary-aware') as StreamSliceMode
isStreaming.value = true
try {
const stream = createLocalTextStream(source, {
chunkDelayMax,
chunkDelayMin,
chunkSizeMax,
chunkSizeMin,
random,
sliceMode,
control: {
shouldPause: () => isPaused.value,
signal: controller.signal,
},
onChunk: (chunk) => {
lastDelayMs.value = chunk.delay
lastChunkSize.value = chunk.content.length
chunkCount.value += 1
chunks.value.push(chunk)
},
})
const reader = stream.getReader()
const decoder = new TextDecoder()
let accumulated = reset ? '' : content.value
while (!controller.signal.aborted) {
const { done, value } = await reader.read()
if (done)
break
accumulated += decoder.decode(value, { stream: true })
content.value = accumulated
}
}
finally {
if (abortController === controller)
abortController = null
if (abortController === null || abortController === controller) {
isStreaming.value = false
isPaused.value = false
}
}
}
function start(reset = true) {
const transportMode = (toValue(options.transportMode) ?? 'scheduler') as StreamTransportMode
if (transportMode === 'readable-stream') {
void startReadableStream(reset)
return
}
const source = toValue(options.source) || ''
if (!source.length) {
lastChunkSize.value = 0
lastDelayMs.value = 0
isStreaming.value = false
isPaused.value = false
return
}
clearTimer()
abortController?.abort()
abortController = null
burstState.quickChunksRemaining = 0
burstState.fastCadenceChunkPending = false
if (reset)
content.value = ''
chunks.value = []
lastChunkSize.value = 0
lastDelayMs.value = 0
chunkCount.value = 0
isStreaming.value = true
isPaused.value = false
scheduleNext(true)
}
function pause() {
if (!isStreaming.value)
return
isPaused.value = true
clearTimer()
}
function resume() {
if (!isStreaming.value || !isPaused.value)
return
isPaused.value = false
const transportMode = (toValue(options.transportMode) ?? 'scheduler') as StreamTransportMode
if (transportMode === 'scheduler')
scheduleNext()
}
function togglePause() {
if (isPaused.value)
resume()
else
pause()
}
function reset() {
stop()
content.value = ''
chunks.value = []
chunkCount.value = 0
lastChunkSize.value = 0
lastDelayMs.value = 0
burstState.quickChunksRemaining = 0
burstState.fastCadenceChunkPending = false
}
onBeforeUnmount(() => {
stop()
})
return {
chunkCount,
content,
chunks,
isStreaming,
isPaused,
lastChunkSize,
lastDelayMs,
pause,
progress,
reset,
resume,
start,
stop,
togglePause,
}
}