docs: update Chinese documentation and add Vue components

- Update AI capability dictionary by removing redundant mention of Baidu's model
- Add new Vue components for context engineering visualization (IntroProblemReasonSolution, MemoryPalaceDemo, MemoryPalaceActionDemo, KVCacheDemo, LostInMiddleDemo)
- Register new components in theme index.js
- Enhance audio introduction with new interactive demos (AudioQuickStartDemo, MelSpectrogramDemo, TTSPipelineDemo, VoiceCloningDemo, ASRvsTTSDemo, AudioTokenizationDemo, EmotionControlDemo)
- Improve existing context engineering demos with Chinese localization and better tokenization
- Fix Japanese documentation layout by properly closing NavGrid components
This commit is contained in:
sanbuphy
2026-02-03 19:41:14 +08:00
parent e5b1c6cc88
commit 084ebed417
30 changed files with 11563 additions and 2126 deletions
@@ -0,0 +1,789 @@
<!--
ASRvsTTSDemo.vue
ASR TTS 双向转换演示组件
用途
展示语音识别(ASR)和语音合成(TTS)的互逆过程
-->
<template>
<div class="asr-tts-demo">
<div class="header">
<div class="title">🔄 ASR TTS语音的双向转换</div>
<div class="subtitle">
探索语音识别和语音合成的互逆过程
</div>
</div>
<div class="conversion-flow">
<!-- ASR 区域 -->
<div class="flow-section">
<div class="section-header">
<span class="section-icon">🎙</span>
<div>
<div class="section-name">ASR 语音识别</div>
<div class="section-desc">音频 文本</div>
</div>
</div>
<div class="demo-box">
<div class="input-area">
<button
class="record-btn"
:class="{ recording: isRecording }"
@click="toggleRecording"
>
<span class="record-icon">{{ isRecording ? '⏹' : '🎤' }}</span>
<span>{{ isRecording ? '停止录音' : '开始录音' }}</span>
</button>
<div class="or-text"></div>
<button class="upload-audio-btn" @click="uploadAudio">
📁 上传音频
</button>
</div>
<div v-if="recordedAudio" class="audio-preview">
<canvas ref="inputWaveform" width="300" height="60"></canvas>
</div>
<button
class="process-btn"
:disabled="!recordedAudio || isProcessingASR"
@click="processASR"
>
<span v-if="isProcessingASR" class="spinner"></span>
<span v-else>🔍 识别语音</span>
</button>
<div v-if="asrResult" class="result-box">
<div class="result-label">识别结果</div>
<div class="result-text">{{ asrResult }}</div>
<div class="result-meta">
<span>置信度: {{ asrConfidence }}%</span>
<span>耗时: {{ asrTime }}ms</span>
</div>
</div>
</div>
</div>
<!-- 中间转换 -->
<div class="flow-arrow">
<div class="arrow-line"></div>
<div class="arrow-btns">
<button
class="arrow-btn"
:class="{ active: direction === 'asr' }"
@click="direction = 'asr'"
>
ASR
</button>
<button
class="arrow-btn"
:class="{ active: direction === 'tts' }"
@click="direction = 'tts'"
>
TTS
</button>
</div>
</div>
<!-- TTS 区域 -->
<div class="flow-section">
<div class="section-header">
<span class="section-icon">🔊</span>
<div>
<div class="section-name">TTS 语音合成</div>
<div class="section-desc">文本 音频</div>
</div>
</div>
<div class="demo-box">
<div class="input-area">
<textarea
v-model="ttsInput"
placeholder="输入要合成的文本..."
rows="3"
></textarea>
</div>
<div class="voice-select">
<label>选择声音:</label>
<div class="voice-options">
<button
v-for="voice in voices"
:key="voice.id"
class="voice-btn"
:class="{ active: selectedVoice === voice.id }"
@click="selectedVoice = voice.id"
>
{{ voice.icon }} {{ voice.name }}
</button>
</div>
</div>
<button
class="process-btn tts"
:disabled="!ttsInput.trim() || isProcessingTTS"
@click="processTTS"
>
<span v-if="isProcessingTTS" class="spinner"></span>
<span v-else>🗣 合成语音</span>
</button>
<div v-if="ttsResult" class="result-box audio-result">
<div class="result-label">合成结果</div>
<canvas ref="outputWaveform" width="300" height="60"></canvas>
<div class="audio-controls">
<button class="play-btn" @click="playResult">
{{ playing ? '' : '' }}
</button>
<div class="progress-bar">
<div class="progress" :style="{ width: playProgress + '%' }"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="comparison-section">
<div class="comp-title">📊 ASR vs TTS 对比</div>
<div class="comp-grid">
<div class="comp-card">
<div class="comp-icon">🎙</div>
<div class="comp-name">ASR</div>
<div class="comp-items">
<div class="comp-item">
<span class="label">输入:</span>
<span>音频波形</span>
</div>
<div class="comp-item">
<span class="label">输出:</span>
<span>文本序列</span>
</div>
<div class="comp-item">
<span class="label">难点:</span>
<span>噪声口音同音词</span>
</div>
</div>
</div>
<div class="comp-card">
<div class="comp-icon">🔊</div>
<div class="comp-name">TTS</div>
<div class="comp-items">
<div class="comp-item">
<span class="label">输入:</span>
<span>文本序列</span>
</div>
<div class="comp-item">
<span class="label">输出:</span>
<span>音频波形</span>
</div>
<div class="comp-item">
<span class="label">难点:</span>
<span>韵律情感自然度</span>
</div>
</div>
</div>
</div>
</div>
<div class="pipeline-comparison">
<div class="pipe-title">🔀 架构对比</div>
<div class="pipeline-diagram">
<div class="pipeline asr-pipe">
<div class="pipe-label">ASR Pipeline</div>
<div class="pipe-flow">
<div class="pipe-step">音频</div>
<span></span>
<div class="pipe-step">特征</div>
<span></span>
<div class="pipe-step">Encoder</div>
<span></span>
<div class="pipe-step">Decoder</div>
<span></span>
<div class="pipe-step output">文本</div>
</div>
</div>
<div class="pipeline tts-pipe">
<div class="pipe-label">TTS Pipeline</div>
<div class="pipe-flow">
<div class="pipe-step">文本</div>
<span></span>
<div class="pipe-step">Encoder</div>
<span></span>
<div class="pipe-step">Decoder</div>
<span></span>
<div class="pipe-step">声码器</div>
<span></span>
<div class="pipe-step output">音频</div>
</div>
</div>
</div>
</div>
<div class="info-box">
<span class="icon">💡</span>
<p>
<strong>互逆关系</strong>
ASR TTS 是语音技术的两个核心方向互为逆过程
ASR 将连续的音频信号转换为离散的文本TTS 则将离散的文本转换为连续的音频信号
两者都依赖于声学模型和语言模型
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
const direction = ref('asr')
const isRecording = ref(false)
const recordedAudio = ref(false)
const isProcessingASR = ref(false)
const asrResult = ref('')
const asrConfidence = ref(0)
const asrTime = ref(0)
const ttsInput = ref('')
const selectedVoice = ref('default')
const isProcessingTTS = ref(false)
const ttsResult = ref(false)
const playing = ref(false)
const playProgress = ref(0)
const voices = [
{ id: 'default', name: '默认', icon: '🎙️' },
{ id: 'male', name: '男声', icon: '👨' },
{ id: 'female', name: '女声', icon: '👩' },
{ id: 'child', name: '童声', icon: '🧒' }
]
const inputWaveform = ref(null)
const outputWaveform = ref(null)
const toggleRecording = () => {
isRecording.value = !isRecording.value
if (!isRecording.value) {
recordedAudio.value = true
drawWaveform(inputWaveform.value)
}
}
const uploadAudio = () => {
recordedAudio.value = true
setTimeout(() => drawWaveform(inputWaveform.value), 100)
}
const drawWaveform = (canvas) => {
if (!canvas) return
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
ctx.strokeStyle = '#409eff'
ctx.lineWidth = 2
ctx.beginPath()
for (let x = 0; x < w; x += 2) {
const y = h / 2 + Math.sin(x * 0.1) * 20 + (Math.random() - 0.5) * 10
if (x === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
}
const processASR = () => {
isProcessingASR.value = true
asrResult.value = ''
setTimeout(() => {
isProcessingASR.value = false
asrResult.value = '这是一段示例语音识别结果,展示了 ASR 的工作效果。'
asrConfidence.value = 94
asrTime.value = 320
ttsInput.value = asrResult.value
}, 1500)
}
const processTTS = () => {
isProcessingTTS.value = true
ttsResult.value = false
setTimeout(() => {
isProcessingTTS.value = false
ttsResult.value = true
setTimeout(() => drawWaveform(outputWaveform.value), 100)
}, 1500)
}
const playResult = () => {
playing.value = !playing.value
if (playing.value) {
playProgress.value = 0
const interval = setInterval(() => {
playProgress.value += 2
if (playProgress.value >= 100) {
playing.value = false
playProgress.value = 0
clearInterval(interval)
}
}, 100)
}
}
onMounted(() => {
if (recordedAudio.value) drawWaveform(inputWaveform.value)
})
</script>
<style scoped>
.asr-tts-demo {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 24px;
margin: 24px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
text-align: center;
margin-bottom: 24px;
}
.title {
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
background: linear-gradient(120deg, #409eff, #67c23a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 14px;
color: var(--vp-c-text-2);
}
.conversion-flow {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 20px;
margin-bottom: 24px;
}
.flow-section {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
}
.section-header {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 16px;
}
.section-icon {
font-size: 32px;
}
.section-name {
font-weight: 600;
}
.section-desc {
font-size: 12px;
color: var(--vp-c-text-3);
}
.demo-box {
display: flex;
flex-direction: column;
gap: 12px;
}
.input-area {
display: flex;
flex-direction: column;
gap: 8px;
}
.record-btn {
padding: 16px;
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 14px;
transition: all 0.2s;
}
.record-btn:hover {
border-color: #f56c6c;
}
.record-btn.recording {
background: #f56c6c;
color: white;
border-color: #f56c6c;
animation: pulse 1.5s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.7; }
}
.record-icon {
font-size: 20px;
}
.or-text {
text-align: center;
font-size: 12px;
color: var(--vp-c-text-3);
}
.upload-audio-btn {
padding: 12px;
background: var(--vp-c-bg-soft);
border: 1px dashed var(--vp-c-divider);
border-radius: 8px;
cursor: pointer;
color: var(--vp-c-text-2);
}
.audio-preview {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 12px;
}
.audio-preview canvas {
width: 100%;
height: auto;
}
.process-btn {
padding: 12px;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
font-weight: 500;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.process-btn.tts {
background: #67c23a;
}
.process-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.result-box {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
border: 1px solid var(--vp-c-divider);
}
.result-label {
font-size: 12px;
color: var(--vp-c-text-3);
margin-bottom: 8px;
}
.result-text {
font-size: 14px;
line-height: 1.5;
}
.result-meta {
display: flex;
gap: 16px;
margin-top: 12px;
font-size: 12px;
color: var(--vp-c-text-3);
}
textarea {
width: 100%;
padding: 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
font-size: 14px;
resize: vertical;
}
.voice-select {
display: flex;
flex-direction: column;
gap: 8px;
}
.voice-select label {
font-size: 12px;
color: var(--vp-c-text-3);
}
.voice-options {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.voice-btn {
padding: 8px 12px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.voice-btn.active {
background: #67c23a;
color: white;
border-color: #67c23a;
}
.audio-result canvas {
width: 100%;
height: auto;
margin-bottom: 12px;
}
.audio-controls {
display: flex;
align-items: center;
gap: 12px;
}
.play-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: #67c23a;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.progress-bar {
flex: 1;
height: 6px;
background: var(--vp-c-bg);
border-radius: 3px;
overflow: hidden;
}
.progress {
height: 100%;
background: #67c23a;
transition: width 0.1s;
}
.flow-arrow {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
}
.arrow-line {
width: 2px;
height: 100px;
background: var(--vp-c-divider);
}
.arrow-btns {
display: flex;
flex-direction: column;
gap: 8px;
}
.arrow-btn {
padding: 8px 16px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 20px;
cursor: pointer;
font-size: 12px;
}
.arrow-btn.active {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.comparison-section {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.comp-title {
font-weight: 600;
margin-bottom: 16px;
text-align: center;
}
.comp-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.comp-card {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
text-align: center;
}
.comp-icon {
font-size: 32px;
margin-bottom: 8px;
}
.comp-name {
font-weight: 600;
margin-bottom: 12px;
}
.comp-items {
display: flex;
flex-direction: column;
gap: 8px;
text-align: left;
}
.comp-item {
font-size: 13px;
}
.comp-item .label {
color: var(--vp-c-text-3);
}
.pipeline-comparison {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.pipe-title {
font-weight: 600;
margin-bottom: 16px;
text-align: center;
}
.pipeline-diagram {
display: flex;
flex-direction: column;
gap: 20px;
}
.pipeline {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
}
.pipe-label {
font-size: 12px;
color: var(--vp-c-text-3);
margin-bottom: 12px;
}
.pipe-flow {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: center;
}
.pipe-step {
padding: 8px 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
font-size: 12px;
}
.pipe-step.output {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.info-box {
display: flex;
gap: 12px;
padding: 16px;
background: var(--vp-c-bg-mute);
border-radius: 8px;
font-size: 13px;
line-height: 1.6;
}
.info-box .icon {
font-size: 18px;
flex-shrink: 0;
}
@media (max-width: 768px) {
.conversion-flow {
grid-template-columns: 1fr;
}
.flow-arrow {
flex-direction: row;
}
.arrow-line {
width: 100px;
height: 2px;
}
.arrow-btns {
flex-direction: row;
}
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,318 +1,687 @@
<!--
AudioTokenizationDemo.vue
音频 Tokenization 演示组件
用途
展示音频如何通过神经编解码器( EnCodecSoundStream)被压缩成离散的 Token
交互功能
- 音频压缩/解压流程
- 不同码率对比
- Token 可视化
- 重建质量评估
-->
<template>
<div class="tokenization-demo">
<div class="audio-tokenization-demo">
<el-card shadow="never">
<div class="controls">
<el-button type="primary" @click="playDemo" :loading="isPlaying">
<el-icon><VideoPlay /></el-icon> 演示处理流程
</el-button>
</div>
<el-steps
:active="activeStep"
align-center
finish-status="success"
class="steps"
>
<el-step title="音频信号" description="连续波形" />
<el-step title="切片 (Chunking)" description="20ms/帧" />
<el-step title="量化 (Quantization)" description="查字典" />
<el-step title="Token 序列" description="离散数字" />
</el-steps>
<div class="stage-display">
<!-- Stage 0: Audio -->
<div v-if="activeStep === 0" class="stage-content audio-stage">
<div class="waveform-viz">
<div
class="wave-bar"
v-for="n in 20"
:key="n"
:style="{
height: 30 + Math.random() * 50 + '%',
animationDelay: n * 0.1 + 's'
}"
></div>
</div>
<div class="stage-desc">原始的连续模拟信号或高采样率数字信号</div>
<template #header>
<div class="header-title">
<el-icon><Grid /></el-icon>
<span>🎵 音频 Tokenization神经编解码器</span>
</div>
</template>
<!-- Stage 1: Chunks -->
<div v-if="activeStep === 1" class="stage-content chunks-stage">
<div class="chunks-container">
<div class="chunk-item" v-for="n in 5" :key="n">
<span class="chunk-label">Frame {{ n }}</span>
</div>
</div>
<div class="stage-desc">
将音频切分为固定长度的小片段例如 20ms
</div>
</div>
<!-- Stage 2: Codebook -->
<div v-if="activeStep === 2" class="stage-content codebook-stage">
<div class="codebook-grid">
<div
class="codebook-entry"
v-for="n in 9"
:key="n"
:class="{ highlight: n === currentMatch }"
>
{{ 1024 + n * 50 }}
</div>
</div>
<div class="stage-desc">
在预训练的"声音字典"中寻找最接近的特征向量
</div>
</div>
<!-- Stage 3: Tokens -->
<div v-if="activeStep === 3" class="stage-content token-stage">
<div class="token-list">
<el-tag
v-for="(token, index) in tokens"
:key="index"
effect="dark"
size="large"
class="token-tag"
>
{{ token }}
</el-tag>
</div>
<div class="stage-desc">最终转换为 GPT 可以理解的数字序列</div>
</div>
</div>
<el-divider />
<div class="comparison-box">
<el-row :gutter="20">
<el-col :span="12">
<div class="compare-card">
<div class="compare-title">文本 GPT</div>
<div class="compare-content">
<el-tag type="info"></el-tag>
<el-tag type="info"></el-tag>
<el-tag type="info"></el-tag>
<el-tag type="info"></el-tag>
<div class="demo-content">
<!-- 流程图 -->
<div class="codec-flow">
<div class="flow-section encode">
<div class="section-title">🔽 编码器 (Encoder)</div>
<div class="flow-steps">
<div class="codec-step">
<div class="step-visual">
<canvas ref="originalWaveformCanvas" width="150" height="60" />
</div>
<div class="step-label">原始波形</div>
<div class="step-meta">24kHz, 16-bit</div>
</div>
<el-icon class="flow-arrow"><ArrowRight /></el-icon>
<div class="codec-step">
<div class="step-visual">
<div class="cnn-layers">
<div class="cnn-layer" v-for="i in 4" :key="i" :style="{ opacity: 0.3 + i * 0.2 }">
Conv {{ i }}
</div>
</div>
</div>
<div class="step-label">CNN 下采样</div>
<div class="step-meta">降维 320x</div>
</div>
<el-icon class="flow-arrow"><ArrowRight /></el-icon>
<div class="codec-step">
<div class="step-visual">
<div class="vq-codebook">
<div class="codebook-grid">
<div
v-for="i in 16"
:key="i"
class="codebook-cell"
:class="{ active: i <= 4 }"
/>
</div>
</div>
</div>
<div class="step-label">VQ 量化</div>
<div class="step-meta">离散 Token</div>
</div>
</div>
</el-col>
<el-col :span="12">
<div class="compare-card highlight-border">
<div class="compare-title">音频 GPT</div>
<div class="compare-content">
<el-tag type="warning">1024</el-tag>
<el-tag type="warning">5678</el-tag>
<el-tag type="warning">2340</el-tag>
<el-tag type="warning">8901</el-tag>
</div>
<div class="flow-divider">
<div class="divider-line"></div>
<div class="divider-label">压缩后: ~1.5 kbps</div>
<div class="divider-line"></div>
</div>
<div class="flow-section decode">
<div class="section-title">🔼 解码器 (Decoder)</div>
<div class="flow-steps reverse">
<div class="codec-step">
<div class="step-visual">
<div class="token-sequence">
<span
v-for="(token, i) in [42, 128, 7, 255, 33, 91]"
:key="i"
class="token"
:style="{ background: `hsl(${token}, 70%, 50%)` }"
>
{{ token }}
</span>
</div>
</div>
<div class="step-label">离散 Token</div>
<div class="step-meta">Codebook 索引</div>
</div>
<el-icon class="flow-arrow"><ArrowRight /></el-icon>
<div class="codec-step">
<div class="step-visual">
<div class="cnn-layers">
<div class="cnn-layer" v-for="i in 4" :key="i" :style="{ opacity: 1 - i * 0.15 }">
ConvT {{ 5 - i }}
</div>
</div>
</div>
<div class="step-label">转置卷积</div>
<div class="step-meta">上采样</div>
</div>
<el-icon class="flow-arrow"><ArrowRight /></el-icon>
<div class="codec-step">
<div class="step-visual">
<canvas ref="reconstructedWaveformCanvas" width="150" height="60" />
</div>
<div class="step-label">重建波形</div>
<div class="step-meta">24kHz</div>
</div>
</div>
</el-col>
</el-row>
</div>
</div>
<!-- 码率对比 -->
<div class="bitrate-comparison">
<div class="comparison-title">📊 不同码率对比</div>
<div class="bitrate-cards">
<div
v-for="config in bitrateConfigs"
:key="config.name"
class="bitrate-card"
:class="{ active: selectedBitrate === config.name }"
@click="selectedBitrate = config.name"
>
<div class="bitrate-value">{{ config.bitrate }}</div>
<div class="bitrate-name">{{ config.name }}</div>
<div class="bitrate-detail">
<div class="detail-item">
<span class="label">采样率:</span>
<span>{{ config.sampleRate }}</span>
</div>
<div class="detail-item">
<span class="label">帧率:</span>
<span>{{ config.frameRate }}</span>
</div>
<div class="detail-item">
<span class="label">码本大小:</span>
<span>{{ config.codebookSize }}</span>
</div>
</div>
<el-rate
v-model="config.quality"
disabled
show-score
text-color="#ff9900"
/>
</div>
</div>
</div>
<!-- Token 可视化 -->
<div class="token-visualization">
<div class="viz-title">🔢 Token 序列可视化</div>
<div class="token-display">
<div class="token-ruler">
<span v-for="i in 20" :key="i" class="ruler-mark">{{ i * 0.1 }}s</span>
</div>
<div class="token-stream">
<div
v-for="(token, i) in tokenSequence"
:key="i"
class="token-block"
:style="{
background: `hsl(${token % 360}, 70%, ${50 + (token % 20)}%)`,
height: `${20 + (token % 30)}px`
}"
:title="`Token: ${token}`"
/>
</div>
</div>
<div class="token-legend">
<span class="legend-item">
<span class="legend-color" style="background: #409eff"></span>
低频成分
</span>
<span class="legend-item">
<span class="legend-color" style="background: #67c23a"></span>
中频成分
</span>
<span class="legend-item">
<span class="legend-color" style="background: #e6a23c"></span>
高频成分
</span>
</div>
</div>
<!-- 应用场景 -->
<div class="applications">
<div class="apps-title">🎯 为什么需要音频 Tokenization</div>
<div class="apps-grid">
<div class="app-card">
<div class="app-icon">🚀</div>
<div class="app-title">高效传输</div>
<div class="app-desc">
将音频压缩到 ~1.5 kbps比原始音频小 256 适合网络传输
</div>
</div>
<div class="app-card">
<div class="app-icon">🧠</div>
<div class="app-title">语言模型友好</div>
<div class="app-desc">
离散 Token 可以被 LLM 直接处理实现文本到音频的统一建模
</div>
</div>
<div class="app-card">
<div class="app-icon">🎵</div>
<div class="app-title">音乐生成</div>
<div class="app-desc">
MusicGenAudioLDM 等模型使用音频 Token 生成音乐和音效
</div>
</div>
<div class="app-card">
<div class="app-icon">🗣</div>
<div class="app-title">语音合成</div>
<div class="app-desc">
VALL-ESoundStorm TTS 模型直接生成音频 Token
</div>
</div>
</div>
</div>
</div>
<el-alert
title="为什么要做 Tokenization?"
type="warning"
:closable="false"
description="因为 GPT 本质上是一个'预测下一个数字'的机器。只有把连续的声音变成离散的数字,才能用 GPT 来生成音频。"
show-icon
/>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>神经音频编解码器</strong>
EnCodec (Meta)SoundStream (Google)SNAC 等模型使用 VQ-VAE 架构将音频压缩成离散 Token这些 Token 可以被语言模型处理实现高质量的音频生成和压缩
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { VideoPlay } from '@element-plus/icons-vue'
import { ref, onMounted } from 'vue'
import { Grid, ArrowRight } from '@element-plus/icons-vue'
const activeStep = ref(0)
const isPlaying = ref(false)
const currentMatch = ref(0)
const tokens = [1024, 5678, 2340, 8901, 3342]
const selectedBitrate = ref('EnCodec-24k')
const originalWaveformCanvas = ref(null)
const reconstructedWaveformCanvas = ref(null)
const playDemo = async () => {
if (isPlaying.value) return
isPlaying.value = true
activeStep.value = 0
// Step 0 -> 1
await wait(1000)
activeStep.value = 1
// Step 1 -> 2
await wait(1500)
activeStep.value = 2
// Simulate codebook matching
for (let i = 0; i < 5; i++) {
currentMatch.value = Math.floor(Math.random() * 9) + 1
await wait(200)
const bitrateConfigs = [
{
name: 'EnCodec-24k',
bitrate: '1.5 kbps',
sampleRate: '24 kHz',
frameRate: '75 Hz',
codebookSize: '1024',
quality: 4
},
{
name: 'EnCodec-48k',
bitrate: '3.0 kbps',
sampleRate: '48 kHz',
frameRate: '75 Hz',
codebookSize: '1024',
quality: 5
},
{
name: 'SoundStream',
bitrate: '6.0 kbps',
sampleRate: '16 kHz',
frameRate: '50 Hz',
codebookSize: '1024',
quality: 4.5
},
{
name: 'SNAC',
bitrate: '0.98 kbps',
sampleRate: '24 kHz',
frameRate: '43 Hz',
codebookSize: '4096',
quality: 4
}
currentMatch.value = 0
]
// Step 2 -> 3
activeStep.value = 3
// 生成模拟 Token 序列
const tokenSequence = Array.from({ length: 50 }, () => Math.floor(Math.random() * 1024))
isPlaying.value = false
// 绘制波形
const drawWaveform = (canvas, isNoisy = false) => {
if (!canvas) return
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
ctx.clearRect(0, 0, width, height)
ctx.strokeStyle = '#409eff'
ctx.lineWidth = 1.5
ctx.beginPath()
for (let x = 0; x < width; x++) {
const t = x / width
let y = height / 2
// 基础波形
y += Math.sin(t * Math.PI * 8) * 15
y += Math.sin(t * Math.PI * 16) * 10
// 添加噪声(重建版本)
if (isNoisy) {
y += (Math.random() - 0.5) * 8
}
if (x === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.stroke()
// 中心线
ctx.strokeStyle = '#e0e0e0'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, height / 2)
ctx.lineTo(width, height / 2)
ctx.stroke()
}
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
onMounted(() => {
drawWaveform(originalWaveformCanvas.value, false)
drawWaveform(reconstructedWaveformCanvas.value, true)
})
</script>
<style scoped>
.tokenization-demo {
margin: 20px 0;
.audio-tokenization-demo {
margin: 1rem 0;
}
.controls {
text-align: center;
margin-bottom: 20px;
}
.steps {
margin-bottom: 30px;
}
.stage-display {
background: var(--el-fill-color-light);
border-radius: 8px;
padding: 30px;
min-height: 200px;
.header-title {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 600;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.stage-content {
.codec-flow {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.flow-section {
margin-bottom: 16px;
}
.section-title {
font-weight: 500;
margin-bottom: 16px;
color: var(--vp-c-brand);
}
.flow-steps {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.flow-steps.reverse {
flex-direction: row-reverse;
}
.codec-step {
text-align: center;
min-width: 120px;
}
.step-visual {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 12px;
margin-bottom: 8px;
min-height: 80px;
display: flex;
align-items: center;
justify-content: center;
}
.step-visual canvas {
width: 100%;
height: auto;
}
.stage-desc {
margin-top: 15px;
color: var(--el-text-color-secondary);
font-size: 0.9em;
.step-label {
font-weight: 500;
font-size: 0.875rem;
}
/* Audio Stage */
.waveform-viz {
height: 80px;
.step-meta {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.flow-arrow {
color: var(--vp-c-text-3);
}
.cnn-layers {
display: flex;
align-items: center;
justify-content: center;
gap: 3px;
flex-direction: column;
gap: 4px;
}
.wave-bar {
width: 6px;
background: var(--el-color-primary);
border-radius: 3px;
animation: wave 1s ease-in-out infinite;
}
@keyframes wave {
0%,
100% {
height: 30%;
opacity: 0.5;
}
50% {
height: 100%;
opacity: 1;
}
}
/* Chunks Stage */
.chunks-container {
display: flex;
gap: 5px;
justify-content: center;
}
.chunk-item {
width: 60px;
height: 60px;
background: var(--el-color-primary-light-8);
border: 1px solid var(--el-color-primary);
.cnn-layer {
background: #409eff;
color: white;
padding: 4px 8px;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.7rem;
}
.chunk-label {
font-size: 10px;
color: var(--el-color-primary);
.vq-codebook {
padding: 8px;
}
/* Codebook Stage */
.codebook-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
max-width: 300px;
margin: 0 auto;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.codebook-entry {
padding: 10px;
background: var(--el-bg-color);
border: 1px solid var(--el-border-color);
.codebook-cell {
width: 16px;
height: 16px;
background: #e0e0e0;
border-radius: 2px;
}
.codebook-cell.active {
background: #67c23a;
}
.token-sequence {
display: flex;
gap: 4px;
flex-wrap: wrap;
max-width: 120px;
}
.token {
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
transition: all 0.3s;
}
.codebook-entry.highlight {
background: var(--el-color-warning);
font-size: 0.7rem;
color: white;
transform: scale(1.1);
border-color: var(--el-color-warning);
}
/* Token Stage */
.token-list {
display: flex;
gap: 10px;
justify-content: center;
flex-wrap: wrap;
}
.token-tag {
font-family: monospace;
font-weight: bold;
}
.comparison-box {
margin-top: 20px;
margin-bottom: 20px;
}
.compare-card {
background: var(--el-bg-color-page);
padding: 15px;
border-radius: 8px;
text-align: center;
border: 1px solid transparent;
}
.highlight-border {
border-color: var(--el-color-warning);
background: var(--el-color-warning-light-9);
}
.compare-title {
font-weight: bold;
margin-bottom: 10px;
font-size: 0.9em;
}
.compare-content {
.flow-divider {
display: flex;
align-items: center;
gap: 16px;
margin: 16px 0;
}
.divider-line {
flex: 1;
height: 1px;
background: var(--vp-c-divider);
}
.divider-label {
font-size: 0.875rem;
color: var(--vp-c-text-3);
white-space: nowrap;
}
.bitrate-comparison {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.comparison-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.bitrate-cards {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.bitrate-card {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.bitrate-card:hover {
border-color: var(--vp-c-brand);
}
.bitrate-card.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.bitrate-value {
font-size: 1.5rem;
font-weight: 600;
color: var(--vp-c-brand);
margin-bottom: 4px;
}
.bitrate-name {
font-weight: 500;
margin-bottom: 12px;
}
.bitrate-detail {
font-size: 0.75rem;
color: var(--vp-c-text-3);
margin-bottom: 12px;
}
.detail-item {
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.detail-item .label {
color: var(--vp-c-text-2);
}
.token-visualization {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.viz-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.token-display {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px;
overflow-x: auto;
}
.token-ruler {
display: flex;
gap: 8px;
margin-bottom: 8px;
font-size: 0.7rem;
color: var(--vp-c-text-3);
}
.ruler-mark {
min-width: 30px;
}
.token-stream {
display: flex;
gap: 2px;
align-items: flex-end;
height: 60px;
}
.token-block {
flex: 1;
min-width: 8px;
border-radius: 2px;
transition: all 0.2s;
}
.token-block:hover {
transform: scaleY(1.2);
z-index: 1;
}
.token-legend {
display: flex;
gap: 5px;
justify-content: center;
flex-wrap: wrap;
gap: 24px;
margin-top: 16px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.875rem;
}
.legend-color {
width: 16px;
height: 16px;
border-radius: 4px;
}
.applications {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.apps-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.apps-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.app-card {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.app-icon {
font-size: 2rem;
margin-bottom: 8px;
}
.app-title {
font-weight: 600;
margin-bottom: 8px;
}
.app-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
line-height: 1.5;
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
@media (max-width: 640px) {
.flow-steps {
flex-direction: column;
}
.flow-steps.reverse {
flex-direction: column;
}
.flow-arrow {
transform: rotate(90deg);
}
}
</style>
@@ -0,0 +1,533 @@
<!--
EmotionControlDemo.vue
情感控制演示组件
用途
展示如何在 TTS 中控制情感语速语调等风格特征
交互功能
- 情感选择器
- 语速和音调滑块
- 实时预览
- 情感向量可视化
-->
<template>
<div class="emotion-control-demo">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><MagicStick /></el-icon>
<span>🎭 情感与风格控制</span>
</div>
</template>
<div class="demo-content">
<!-- 情感选择 -->
<div class="emotion-selector">
<div class="selector-title">选择情感风格</div>
<div class="emotion-grid">
<div
v-for="emotion in emotions"
:key="emotion.id"
class="emotion-card"
:class="{ active: selectedEmotion === emotion.id }"
@click="selectEmotion(emotion.id)"
>
<div class="emotion-emoji">{{ emotion.emoji }}</div>
<div class="emotion-name">{{ emotion.name }}</div>
<div class="emotion-desc">{{ emotion.description }}</div>
</div>
</div>
</div>
<!-- 情感向量可视化 -->
<div class="emotion-embedding">
<div class="embedding-title">情感向量空间 (Emotion Embedding)</div>
<canvas
ref="emotionCanvas"
width="400"
height="200"
class="emotion-canvas"
/>
<div class="embedding-legend">
<span
v-for="emotion in emotions"
:key="emotion.id"
class="legend-item"
>
<span
class="legend-dot"
:style="{ background: emotion.color }"
/>
{{ emotion.name }}
</span>
</div>
</div>
<!-- 参数控制 -->
<div class="parameter-controls">
<div class="control-title">🎚 细粒度控制</div>
<div class="controls-grid">
<div class="control-item">
<div class="control-label">
<span>语速</span>
<el-tag size="small">{{ speed }}x</el-tag>
</div>
<el-slider v-model="speed" :min="0.5" :max="2" :step="0.1" />
<div class="control-hint">
<span></span>
<span>正常</span>
<span></span>
</div>
</div>
<div class="control-item">
<div class="control-label">
<span>音调</span>
<el-tag size="small">{{ pitch > 0 ? '+' : '' }}{{ pitch }}</el-tag>
</div>
<el-slider v-model="pitch" :min="-10" :max="10" :step="1" />
<div class="control-hint">
<span></span>
<span>正常</span>
<span></span>
</div>
</div>
<div class="control-item">
<div class="control-label">
<span>音量动态</span>
<el-tag size="small">{{ energy }}%</el-tag>
</div>
<el-slider v-model="energy" :min="50" :max="150" :step="5" />
<div class="control-hint">
<span>柔和</span>
<span>适中</span>
<span>激昂</span>
</div>
</div>
<div class="control-item">
<div class="control-label">
<span>停顿控制</span>
<el-tag size="small">{{ pause }}ms</el-tag>
</div>
<el-slider v-model="pause" :min="0" :max="500" :step="50" />
<div class="control-hint">
<span>紧凑</span>
<span>自然</span>
<span>舒缓</span>
</div>
</div>
</div>
</div>
<!-- 文本输入和预览 -->
<div class="preview-section">
<div class="preview-title">🎙 预览合成</div>
<el-input
v-model="previewText"
type="textarea"
:rows="2"
placeholder="输入要合成的文本..."
class="preview-input"
/>
<div class="preview-actions">
<el-button type="primary" @click="synthesize">
<el-icon><VideoPlay /></el-icon>
合成预览
</el-button>
<el-button @click="resetParameters">
<el-icon><RefreshRight /></el-icon>
重置参数
</el-button>
</div>
</div>
<!-- 技术说明 -->
<div class="tech-explanation">
<el-collapse>
<el-collapse-item title="🔬 情感控制原理">
<div class="tech-content">
<h4>全局风格 Token (Global Style Token)</h4>
<p>
GST (Global Style Token) 是一种从参考音频中提取风格特征的方法模型学习将情感语速语调等风格信息编码成一组 Token
在推理时可以通过选择或插值这些 Token 来控制合成风格
</p>
<h4>参考音频编码</h4>
<p>
用户提供一段带有目标情感的参考音频编码器提取其风格特征向量这个向量作为条件输入到 TTS 模型
指导生成相似风格的语音
</p>
<h4>细粒度控制</h4>
<p>
现代 TTS 模型 CosyVoiceF5-TTS支持细粒度的风格控制包括
</p>
<ul>
<li><strong>速度控制</strong>调整音频播放速度而不改变音调</li>
<li><strong>音调控制</strong>改变基频 (F0) 曲线</li>
<li><strong>能量控制</strong>调整音量包络</li>
<li><strong>停顿控制</strong>调整句间和短语间的停顿长度</li>
</ul>
</div>
</el-collapse-item>
</el-collapse>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>情感控制</strong>
现代 TTS 系统不仅能合成自然的语音还能精确控制情感语速语调等风格特征这使得 AI 配音可以适应不同的应用场景从平静的客服对话到激昂的演讲
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { MagicStick, VideoPlay, RefreshRight } from '@element-plus/icons-vue'
const emotions = [
{ id: 'neutral', name: '中性', emoji: '😐', description: '平稳自然', color: '#909399' },
{ id: 'happy', name: '开心', emoji: '😊', description: '轻快愉悦', color: '#67c23a' },
{ id: 'sad', name: '悲伤', emoji: '😢', description: '低沉缓慢', color: '#409eff' },
{ id: 'angry', name: '愤怒', emoji: '😠', description: '激昂有力', color: '#f56c6c' },
{ id: 'excited', name: '兴奋', emoji: '🤩', description: '热情高涨', color: '#e6a23c' },
{ id: 'calm', name: '平静', emoji: '😌', description: '舒缓放松', color: '#13c2c2' }
]
const selectedEmotion = ref('neutral')
const speed = ref(1.0)
const pitch = ref(0)
const energy = ref(100)
const pause = ref(150)
const previewText = ref('这是一段带有情感控制的语音合成演示。')
const emotionCanvas = ref(null)
const selectEmotion = (id) => {
selectedEmotion.value = id
drawEmotionEmbedding()
}
const resetParameters = () => {
speed.value = 1.0
pitch.value = 0
energy.value = 100
pause.value = 150
selectedEmotion.value = 'neutral'
drawEmotionEmbedding()
}
const synthesize = () => {
// 模拟合成
console.log('Synthesizing with:', {
emotion: selectedEmotion.value,
speed: speed.value,
pitch: pitch.value,
energy: energy.value,
pause: pause.value
})
}
// 绘制情感向量空间
const drawEmotionEmbedding = () => {
const canvas = emotionCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
ctx.clearRect(0, 0, width, height)
// 绘制坐标轴
ctx.strokeStyle = '#e0e0e0'
ctx.lineWidth = 1
// X轴 (Valence: 消极 -> 积极)
ctx.beginPath()
ctx.moveTo(40, height / 2)
ctx.lineTo(width - 20, height / 2)
ctx.stroke()
// Y轴 (Arousal: 平静 -> 兴奋)
ctx.beginPath()
ctx.moveTo(width / 2, height - 30)
ctx.lineTo(width / 2, 20)
ctx.stroke()
// 轴标签
ctx.fillStyle = '#666'
ctx.font = '12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText('Valence (消极 → 积极)', width / 2, height - 10)
ctx.save()
ctx.translate(15, height / 2)
ctx.rotate(-Math.PI / 2)
ctx.fillText('Arousal (平静 → 兴奋)', 0, 0)
ctx.restore()
// 情感位置
const emotionPositions = {
neutral: { x: 0.5, y: 0.5 },
happy: { x: 0.8, y: 0.7 },
sad: { x: 0.2, y: 0.3 },
angry: { x: 0.3, y: 0.9 },
excited: { x: 0.9, y: 0.9 },
calm: { x: 0.6, y: 0.2 }
}
// 绘制所有情感点
emotions.forEach(emotion => {
const pos = emotionPositions[emotion.id]
const x = 50 + pos.x * (width - 80)
const y = height - 40 - pos.y * (height - 60)
// 绘制点
ctx.beginPath()
ctx.arc(x, y, emotion.id === selectedEmotion.value ? 12 : 8, 0, Math.PI * 2)
ctx.fillStyle = emotion.color
ctx.fill()
// 选中效果
if (emotion.id === selectedEmotion.value) {
ctx.strokeStyle = emotion.color
ctx.lineWidth = 2
ctx.beginPath()
ctx.arc(x, y, 18, 0, Math.PI * 2)
ctx.stroke()
}
// 标签
ctx.fillStyle = '#333'
ctx.font = emotion.id === selectedEmotion.value ? 'bold 12px sans-serif' : '12px sans-serif'
ctx.textAlign = 'center'
ctx.fillText(emotion.name, x, y + 25)
})
}
onMounted(drawEmotionEmbedding)
watch(selectedEmotion, drawEmotionEmbedding)
</script>
<style scoped>
.emotion-control-demo {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.emotion-selector {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.selector-title {
font-weight: 500;
margin-bottom: 16px;
}
.emotion-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 12px;
}
.emotion-card {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.emotion-card:hover {
border-color: var(--vp-c-brand);
transform: translateY(-2px);
}
.emotion-card.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.emotion-emoji {
font-size: 2rem;
margin-bottom: 8px;
}
.emotion-name {
font-weight: 600;
margin-bottom: 4px;
}
.emotion-desc {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.emotion-embedding {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.embedding-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.emotion-canvas {
width: 100%;
height: auto;
max-height: 200px;
background: var(--vp-c-bg);
border-radius: 8px;
}
.embedding-legend {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 16px;
margin-top: 16px;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
}
.legend-dot {
width: 12px;
height: 12px;
border-radius: 50%;
}
.parameter-controls {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.control-title {
font-weight: 500;
margin-bottom: 16px;
}
.controls-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 24px;
}
.control-item {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px;
}
.control-label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.control-hint {
display: flex;
justify-content: space-between;
margin-top: 8px;
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.preview-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.preview-title {
font-weight: 500;
margin-bottom: 16px;
}
.preview-input {
margin-bottom: 16px;
}
.preview-actions {
display: flex;
gap: 12px;
}
.tech-explanation {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.tech-content h4 {
margin: 16px 0 8px 0;
color: var(--vp-c-brand);
}
.tech-content h4:first-child {
margin-top: 0;
}
.tech-content p {
margin: 0 0 12px 0;
line-height: 1.6;
color: var(--vp-c-text-2);
}
.tech-content ul {
margin: 0;
padding-left: 20px;
color: var(--vp-c-text-2);
}
.tech-content li {
margin-bottom: 8px;
line-height: 1.5;
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
</style>
@@ -0,0 +1,567 @@
<!--
MelSpectrogramDemo.vue
梅尔频谱图交互演示组件
用途
让用户直观理解音频如何从波形转换为梅尔频谱图以及梅尔刻度的原理
交互功能
- 选择不同音频类型语音/音乐/噪声
- 实时查看波形和频谱对比
- 调整 FFT 参数观察变化
- 理解梅尔刻度 vs 线性刻度
-->
<template>
<div class="mel-spec-demo">
<div class="header">
<div class="title">📊 梅尔频谱AI 如何"看懂"声音</div>
<div class="subtitle">
声音是波 AI 看到的是频谱图探索波形如何变成 AI 能理解的"图像"
</div>
</div>
<div class="control-panel">
<div class="audio-types">
<button
v-for="type in audioTypes"
:key="type.id"
@click="selectType(type.id)"
class="type-btn"
:class="{ active: selectedType === type.id }"
>
<span class="type-icon">{{ type.icon }}</span>
<span>{{ type.name }}</span>
</button>
</div>
<div class="param-controls">
<div class="param">
<label>FFT 窗口</label>
<input
type="range"
v-model="fftSize"
min="256"
max="2048"
step="256"
/>
<span class="value">{{ fftSize }}</span>
</div>
<div class="param">
<label>梅尔滤波器</label>
<input
type="range"
v-model="melBins"
min="20"
max="128"
step="4"
/>
<span class="value">{{ melBins }}</span>
</div>
</div>
</div>
<div class="visualization">
<!-- 波形图 -->
<div class="viz-section">
<div class="viz-header">
<span class="viz-title">🔊 波形 (时域)</span>
<span class="viz-desc">原始音频振幅随时间变化</span>
</div>
<canvas ref="waveformCanvas" width="600" height="100"></canvas>
</div>
<div class="transform-arrow">
<span>STFT 变换</span>
<span class="arrow"></span>
</div>
<!-- 频谱对比 -->
<div class="spec-comparison">
<div class="viz-section">
<div class="viz-header">
<span class="viz-title">📈 线性频谱</span>
<span class="viz-tag">高频分辨率低</span>
</div>
<canvas ref="linearCanvas" width="280" height="150"></canvas>
</div>
<div class="vs">VS</div>
<div class="viz-section highlight">
<div class="viz-header">
<span class="viz-title">🎯 梅尔频谱</span>
<span class="viz-tag success">符合人耳感知</span>
</div>
<canvas ref="melCanvas" width="280" height="150"></canvas>
</div>
</div>
</div>
<div class="explanation">
<div class="exp-title">🎧 为什么用梅尔刻度</div>
<div class="exp-content">
<div class="exp-item">
<div class="exp-visual">
<div class="freq-bars human">
<div class="bar" style="height: 80%"></div>
<div class="bar" style="height: 60%"></div>
<div class="bar" style="height: 40%"></div>
<div class="bar" style="height: 20%"></div>
</div>
</div>
<div class="exp-text">
<strong>人耳感知</strong><br>
100Hz200Hz 10000Hz10100Hz 感知差异相同
</div>
</div>
<div class="exp-item">
<div class="exp-visual">
<div class="freq-bars linear">
<div class="bar" style="height: 10%"></div>
<div class="bar" style="height: 20%"></div>
<div class="bar" style="height: 70%"></div>
<div class="bar" style="height: 90%"></div>
</div>
</div>
<div class="exp-text">
<strong>线性刻度</strong><br>
等距频率间隔不符合人耳感知
</div>
</div>
</div>
</div>
<div class="info-box">
<span class="icon">💡</span>
<p>
<strong>梅尔频谱原理</strong>
梅尔刻度模拟了人耳对频率的非线性感知人耳对低频变化更敏感对高频变化较迟钝
梅尔频谱将频率映射到梅尔刻度使 AI 更关注人耳敏感的部分
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
const audioTypes = [
{ id: 'speech', name: '语音', icon: '🗣️' },
{ id: 'music', name: '音乐', icon: '🎵' },
{ id: 'noise', name: '噪声', icon: '📢' }
]
const selectedType = ref('speech')
const fftSize = ref(1024)
const melBins = ref(80)
const waveformCanvas = ref(null)
const linearCanvas = ref(null)
const melCanvas = ref(null)
const selectType = (type) => {
selectedType.value = type
}
// 生成波形数据
const generateWaveform = (type) => {
const samples = 600
const data = []
for (let i = 0; i < samples; i++) {
let value = 0
const t = i / samples
if (type === 'speech') {
value = Math.sin(t * 20 * Math.PI) * 0.3 +
Math.sin(t * 50 * Math.PI) * 0.2 +
Math.sin(t * 120 * Math.PI) * 0.15 +
(Math.random() - 0.5) * 0.1
} else if (type === 'music') {
value = Math.sin(t * 10 * Math.PI) * 0.4 +
Math.sin(t * 25 * Math.PI) * 0.3 +
Math.sin(t * 40 * Math.PI) * 0.2
} else {
value = (Math.random() - 0.5) * 0.8
}
data.push(value)
}
return data
}
// 绘制波形
const drawWaveform = () => {
const canvas = waveformCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
ctx.clearRect(0, 0, width, height)
const data = generateWaveform(selectedType.value)
const centerY = height / 2
ctx.strokeStyle = '#409eff'
ctx.lineWidth = 2
ctx.beginPath()
for (let i = 0; i < data.length; i++) {
const x = (i / data.length) * width
const y = centerY + data[i] * height * 0.4
if (i === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
// 中心线
ctx.strokeStyle = '#e0e0e0'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(0, centerY)
ctx.lineTo(width, centerY)
ctx.stroke()
}
// 生成频谱数据
const generateSpectrogram = (isMel = false) => {
const timeBins = 60
const freqBins = isMel ? melBins.value : 80
const data = []
for (let t = 0; t < timeBins; t++) {
const frame = []
for (let f = 0; f < freqBins; f++) {
let value = 0
const normalizedF = f / freqBins
if (selectedType.value === 'speech') {
const formant1 = Math.exp(-Math.pow(normalizedF - 0.1, 2) / 0.01)
const formant2 = Math.exp(-Math.pow(normalizedF - 0.3, 2) / 0.02)
value = (formant1 + formant2 * 0.7) * (0.8 + Math.random() * 0.2)
} else if (selectedType.value === 'music') {
value = Math.sin(normalizedF * Math.PI * 3) * 0.5 + 0.5
value *= (0.7 + Math.random() * 0.3)
} else {
value = Math.random() * 0.5
}
if (isMel) {
value *= (1 - normalizedF * 0.3)
}
frame.push(value)
}
data.push(frame)
}
return data
}
// 绘制频谱图
const drawSpectrogram = (canvas, data) => {
if (!canvas) return
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
ctx.clearRect(0, 0, width, height)
const cellWidth = width / data.length
const cellHeight = height / data[0].length
for (let t = 0; t < data.length; t++) {
for (let f = 0; f < data[t].length; f++) {
const value = data[t][f]
const intensity = Math.floor(value * 255)
const r = intensity
const g = Math.floor(intensity * 0.6)
const b = Math.floor(intensity * 0.2)
ctx.fillStyle = `rgb(${r}, ${g}, ${b})`
ctx.fillRect(
t * cellWidth,
height - (f + 1) * cellHeight,
cellWidth + 1,
cellHeight + 1
)
}
}
}
const updateVisualization = () => {
drawWaveform()
drawSpectrogram(linearCanvas.value, generateSpectrogram(false))
drawSpectrogram(melCanvas.value, generateSpectrogram(true))
}
onMounted(updateVisualization)
watch([selectedType, fftSize, melBins], updateVisualization)
</script>
<style scoped>
.mel-spec-demo {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 24px;
margin: 24px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
text-align: center;
margin-bottom: 24px;
}
.title {
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
background: linear-gradient(120deg, #409eff, #67c23a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 14px;
color: var(--vp-c-text-2);
}
.control-panel {
display: flex;
flex-wrap: wrap;
gap: 20px;
margin-bottom: 24px;
padding: 16px;
background: var(--vp-c-bg);
border-radius: 8px;
}
.audio-types {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.type-btn {
padding: 10px 16px;
border: 1px solid var(--vp-c-divider);
border-radius: 20px;
background: var(--vp-c-bg);
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
transition: all 0.2s;
}
.type-btn:hover {
border-color: var(--vp-c-brand);
}
.type-btn.active {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.param-controls {
display: flex;
gap: 20px;
flex-wrap: wrap;
flex: 1;
justify-content: flex-end;
}
.param {
display: flex;
align-items: center;
gap: 8px;
}
.param label {
font-size: 12px;
color: var(--vp-c-text-2);
}
.param input[type="range"] {
width: 100px;
}
.param .value {
font-size: 12px;
font-family: monospace;
min-width: 40px;
}
.visualization {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.viz-section {
margin-bottom: 16px;
}
.viz-section.highlight {
border: 2px solid #67c23a;
border-radius: 8px;
padding: 12px;
}
.viz-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.viz-title {
font-weight: 600;
font-size: 14px;
}
.viz-desc {
font-size: 12px;
color: var(--vp-c-text-3);
}
.viz-tag {
font-size: 11px;
padding: 4px 8px;
background: #e6a23c33;
color: #e6a23c;
border-radius: 4px;
}
.viz-tag.success {
background: #67c23a33;
color: #67c23a;
}
.viz-section canvas {
width: 100%;
height: auto;
background: #f5f5f5;
border-radius: 6px;
}
.transform-arrow {
text-align: center;
padding: 12px;
color: var(--vp-c-text-3);
font-size: 13px;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
}
.transform-arrow .arrow {
font-size: 20px;
}
.spec-comparison {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
align-items: center;
}
.vs {
font-weight: 600;
color: var(--vp-c-text-3);
}
.explanation {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.exp-title {
font-weight: 600;
margin-bottom: 16px;
text-align: center;
}
.exp-content {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 24px;
}
.exp-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.freq-bars {
display: flex;
align-items: flex-end;
gap: 8px;
height: 80px;
padding: 10px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.freq-bars .bar {
width: 30px;
border-radius: 4px 4px 0 0;
}
.freq-bars.human .bar {
background: linear-gradient(to top, #409eff, #67c23a);
}
.freq-bars.linear .bar {
background: linear-gradient(to top, #e6a23c, #f56c6c);
}
.exp-text {
font-size: 13px;
line-height: 1.5;
color: var(--vp-c-text-2);
}
.info-box {
display: flex;
gap: 12px;
padding: 16px;
background: var(--vp-c-bg-mute);
border-radius: 8px;
font-size: 13px;
line-height: 1.6;
}
.info-box .icon {
font-size: 18px;
flex-shrink: 0;
}
@media (max-width: 640px) {
.spec-comparison {
grid-template-columns: 1fr;
}
.vs {
transform: rotate(90deg);
}
}
</style>
@@ -0,0 +1,588 @@
<!--
TTSPipelineDemo.vue
TTS 流程演示组件
用途
展示文本转语音的完整流程对比不同架构自回归/非自回归/流匹配
-->
<template>
<div class="tts-pipeline-demo">
<div class="header">
<div class="title">🔄 TTS 架构演进从慢到快</div>
<div class="subtitle">
探索文本如何变成语音以及不同架构的优劣对比
</div>
</div>
<div class="arch-selector">
<button
v-for="arch in architectures"
:key="arch.id"
@click="selectArch(arch.id)"
class="arch-btn"
:class="{ active: selectedArch === arch.id }"
>
<span class="arch-icon">{{ arch.icon }}</span>
<span class="arch-name">{{ arch.name }}</span>
<span class="arch-tag" :class="arch.tagClass">{{ arch.tag }}</span>
</button>
</div>
<div class="pipeline-flow">
<div
v-for="(stage, index) in currentStages"
:key="stage.id"
class="stage"
:class="{ active: activeStage === index }"
@click="activeStage = index"
>
<div class="stage-num">{{ index + 1 }}</div>
<div class="stage-content">
<div class="stage-icon">{{ stage.icon }}</div>
<div class="stage-name">{{ stage.name }}</div>
<div class="stage-desc">{{ stage.shortDesc }}</div>
</div>
<div v-if="index < currentStages.length - 1" class="stage-arrow"></div>
</div>
</div>
<div class="stage-detail" v-if="currentStage">
<div class="detail-header">
<span class="detail-icon">{{ currentStage.icon }}</span>
<div>
<div class="detail-name">{{ currentStage.name }}</div>
<div class="detail-desc">{{ currentStage.description }}</div>
</div>
</div>
<div class="detail-canvas">
<canvas ref="detailCanvas" width="500" height="150"></canvas>
</div>
<div class="detail-meta">
<div class="meta-item">
<span class="label">输入:</span>
<span>{{ currentStage.input }}</span>
</div>
<div class="meta-item">
<span class="label">输出:</span>
<span>{{ currentStage.output }}</span>
</div>
<div class="meta-item">
<span class="label">技术:</span>
<span>{{ currentStage.tech }}</span>
</div>
</div>
</div>
<div class="comparison-table">
<div class="table-title">📊 架构对比</div>
<div class="table">
<div class="table-header">
<div class="cell">特性</div>
<div class="cell">自回归</div>
<div class="cell">非自回归</div>
<div class="cell">流匹配</div>
</div>
<div
v-for="row in comparisonRows"
:key="row.feature"
class="table-row"
>
<div class="cell feature">{{ row.feature }}</div>
<div class="cell" :class="{ highlight: selectedArch === 'ar' }">{{ row.ar }}</div>
<div class="cell" :class="{ highlight: selectedArch === 'nar' }">{{ row.nar }}</div>
<div class="cell" :class="{ highlight: selectedArch === 'flow' }">{{ row.flow }}</div>
</div>
</div>
</div>
<div class="models-section">
<div class="models-title">🏆 代表模型</div>
<div class="models-grid">
<div
v-for="model in models"
:key="model.name"
class="model-card"
:class="{ active: model.arch === selectedArch }"
>
<div class="model-name">{{ model.name }}</div>
<span class="model-tag" :class="model.tagClass">{{ model.type }}</span>
<div class="model-desc">{{ model.desc }}</div>
</div>
</div>
</div>
<div class="info-box">
<span class="icon">💡</span>
<p>
<strong>TTS 演进趋势</strong>
从早期的自回归模型 Tacotron到非自回归 FastSpeech再到最新的流匹配模型 F5-TTS
TTS 技术正在向更快更稳定更高质量的方向发展
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
const architectures = [
{ id: 'ar', name: '自回归', icon: '📝', tag: 'AR', tagClass: 'primary' },
{ id: 'nar', name: '非自回归', icon: '⚡', tag: 'NAR', tagClass: 'success' },
{ id: 'flow', name: '流匹配', icon: '🌊', tag: 'Flow', tagClass: 'warning' }
]
const pipelineStages = {
ar: [
{ id: 'text', name: '文本处理', icon: '📝', shortDesc: '分词 & 音素', description: '将输入文本转换为音素序列', input: '原始文本', output: '音素序列', tech: 'G2P' },
{ id: 'encoder', name: '文本编码', icon: '🔢', shortDesc: '提取特征', description: '使用 Encoder 编码文本', input: '音素序列', output: '文本特征', tech: 'Transformer' },
{ id: 'decoder', name: '自回归解码', icon: '🎯', shortDesc: '逐帧生成', description: '逐个时间步生成梅尔频谱', input: '文本特征', output: '梅尔频谱', tech: 'AR Decoder' },
{ id: 'vocoder', name: '声码器', icon: '🔊', shortDesc: '频谱转波形', description: '将频谱转换为音频波形', input: '梅尔频谱', output: '音频波形', tech: 'HiFi-GAN' }
],
nar: [
{ id: 'text', name: '文本处理', icon: '📝', shortDesc: '分词 & 音素', description: '将输入文本转换为音素序列', input: '原始文本', output: '音素序列', tech: 'G2P' },
{ id: 'duration', name: '时长预测', icon: '⏱️', shortDesc: '预测时长', description: '预测每个音素的帧数', input: '音素序列', output: '时长信息', tech: 'Duration Predictor' },
{ id: 'decoder', name: '并行解码', icon: '⚡', shortDesc: '一次性生成', description: '并行生成完整梅尔频谱', input: '文本特征', output: '梅尔频谱', tech: 'Non-AR Transformer' },
{ id: 'vocoder', name: '声码器', icon: '🔊', shortDesc: '频谱转波形', description: '将频谱转换为音频波形', input: '梅尔频谱', output: '音频波形', tech: 'HiFi-GAN' }
],
flow: [
{ id: 'text', name: '文本处理', icon: '📝', shortDesc: '分词 & 音素', description: '将输入文本转换为音素序列', input: '原始文本', output: '音素序列', tech: 'G2P' },
{ id: 'embedding', name: '文本嵌入', icon: '🔢', shortDesc: '特征提取', description: '将音素转换为向量', input: '音素序列', output: '文本嵌入', tech: 'DiT' },
{ id: 'flow', name: '流匹配', icon: '🌊', shortDesc: '最优传输', description: '使用流匹配生成频谱', input: '文本嵌入', output: '梅尔频谱', tech: 'Flow Matching' },
{ id: 'vocoder', name: '声码器', icon: '🔊', shortDesc: '频谱转波形', description: '将频谱转换为音频波形', input: '梅尔频谱', output: '音频波形', tech: 'Vocoder' }
]
}
const comparisonRows = [
{ feature: '生成速度', ar: '慢', nar: '快', flow: '很快' },
{ feature: '音质', ar: '高', nar: '中高', flow: '高' },
{ feature: '稳定性', ar: '中', nar: '高', flow: '高' },
{ feature: '可控性', ar: '中', nar: '高', flow: '高' }
]
const models = [
{ name: 'Tacotron 2', arch: 'ar', type: 'AR', tagClass: 'primary', desc: '经典 AR 模型,音质优秀' },
{ name: 'FastSpeech 2', arch: 'nar', type: 'NAR', tagClass: 'success', desc: '并行生成,速度快' },
{ name: 'F5-TTS', arch: 'flow', type: 'Flow', tagClass: 'warning', desc: '最新 SOTA10 步生成' },
{ name: 'CosyVoice', arch: 'flow', type: 'Flow', tagClass: 'warning', desc: '阿里开源,支持多语言' }
]
const selectedArch = ref('flow')
const activeStage = ref(0)
const detailCanvas = ref(null)
const currentStages = computed(() => pipelineStages[selectedArch.value])
const currentStage = computed(() => currentStages.value[activeStage.value])
const selectArch = (id) => {
selectedArch.value = id
activeStage.value = 0
}
const drawVisualization = () => {
const canvas = detailCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
const stage = currentStage.value
if (!stage) return
// 根据阶段绘制不同的可视化
if (stage.id === 'text') {
// 文本到音素
ctx.font = '16px sans-serif'
ctx.fillStyle = '#333'
ctx.fillText('"Hello"', 50, h/2)
ctx.strokeStyle = '#409eff'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(120, h/2)
ctx.lineTo(200, h/2)
ctx.stroke()
const phonemes = ['h', 'ə', 'l', 'oʊ']
let x = 220
phonemes.forEach((p, i) => {
ctx.fillStyle = `hsl(${200 + i * 30}, 70%, 50%)`
ctx.fillRect(x, h/2 - 15, 30, 30)
ctx.fillStyle = '#fff'
ctx.fillText(p, x + 8, h/2 + 5)
x += 40
})
} else if (stage.id === 'decoder' && selectedArch.value === 'ar') {
// 自回归解码
for (let i = 0; i < 5; i++) {
const x = 80 + i * 80
for (let j = 0; j < 8; j++) {
const barH = Math.random() * 40 + 10
ctx.fillStyle = `rgba(64, 158, 255, ${0.5 + i * 0.1})`
ctx.fillRect(x + j * 8, h - 50 - barH, 6, barH)
}
if (i < 4) {
ctx.strokeStyle = '#ccc'
ctx.beginPath()
ctx.moveTo(x + 70, h/2)
ctx.lineTo(x + 80, h/2)
ctx.stroke()
}
}
ctx.fillStyle = '#666'
ctx.fillText('逐个时间步生成', 50, 30)
} else if (stage.id === 'flow') {
// 流匹配
ctx.strokeStyle = '#409eff'
ctx.lineWidth = 3
ctx.beginPath()
ctx.moveTo(50, h - 50)
for (let t = 0; t <= 1; t += 0.02) {
const x = 50 + t * 400
const y = h - 50 - t * (h - 100) + Math.sin(t * Math.PI * 4) * 20
ctx.lineTo(x, y)
}
ctx.stroke()
const steps = [0, 0.25, 0.5, 0.75, 1]
steps.forEach((t, i) => {
const x = 50 + t * 400
const y = h - 50 - t * (h - 100) + Math.sin(t * Math.PI * 4) * 20
ctx.fillStyle = '#e6a23c'
ctx.beginPath()
ctx.arc(x, y, 6, 0, Math.PI * 2)
ctx.fill()
})
}
}
onMounted(drawVisualization)
watch([selectedArch, activeStage], drawVisualization)
</script>
<style scoped>
.tts-pipeline-demo {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 24px;
margin: 24px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
text-align: center;
margin-bottom: 24px;
}
.title {
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
background: linear-gradient(120deg, #409eff, #67c23a);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 14px;
color: var(--vp-c-text-2);
}
.arch-selector {
display: flex;
gap: 12px;
margin-bottom: 24px;
flex-wrap: wrap;
justify-content: center;
}
.arch-btn {
padding: 12px 20px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg);
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
transition: all 0.2s;
}
.arch-btn:hover {
border-color: var(--vp-c-brand);
}
.arch-btn.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.arch-icon {
font-size: 20px;
}
.arch-name {
font-weight: 500;
}
.arch-tag {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
}
.arch-tag.primary { background: #409eff33; color: #409eff; }
.arch-tag.success { background: #67c23a33; color: #67c23a; }
.arch-tag.warning { background: #e6a23c33; color: #e6a23c; }
.pipeline-flow {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
padding: 20px;
background: var(--vp-c-bg);
border-radius: 8px;
margin-bottom: 20px;
}
.stage {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.stage-content {
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 12px 16px;
text-align: center;
transition: all 0.2s;
min-width: 100px;
}
.stage:hover .stage-content,
.stage.active .stage-content {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.stage-num {
width: 24px;
height: 24px;
background: var(--vp-c-brand);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.stage-icon {
font-size: 24px;
margin-bottom: 4px;
}
.stage-name {
font-weight: 500;
font-size: 13px;
}
.stage-desc {
font-size: 11px;
color: var(--vp-c-text-3);
}
.stage-arrow {
color: var(--vp-c-text-3);
font-size: 20px;
}
.stage-detail {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.detail-header {
display: flex;
gap: 12px;
margin-bottom: 16px;
}
.detail-icon {
font-size: 32px;
}
.detail-name {
font-weight: 600;
margin-bottom: 4px;
}
.detail-desc {
font-size: 13px;
color: var(--vp-c-text-2);
}
.detail-canvas {
background: var(--vp-c-bg-soft);
border-radius: 8px;
margin-bottom: 16px;
}
.detail-canvas canvas {
width: 100%;
height: auto;
}
.detail-meta {
display: flex;
gap: 24px;
flex-wrap: wrap;
}
.meta-item {
font-size: 13px;
}
.meta-item .label {
color: var(--vp-c-text-3);
margin-right: 4px;
}
.comparison-table {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.table-title {
font-weight: 600;
margin-bottom: 16px;
text-align: center;
}
.table {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.table-header,
.table-row {
display: grid;
grid-template-columns: 1fr 1fr 1fr 1fr;
background: var(--vp-c-bg);
}
.table-header {
font-weight: 600;
background: var(--vp-c-bg-mute);
}
.cell {
padding: 12px;
text-align: center;
font-size: 13px;
}
.cell.feature {
text-align: left;
font-weight: 500;
}
.cell.highlight {
background: rgba(64, 158, 255, 0.1);
color: var(--vp-c-brand);
font-weight: 500;
}
.models-section {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.models-title {
font-weight: 600;
margin-bottom: 16px;
text-align: center;
}
.models-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.model-card {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
text-align: center;
border: 2px solid transparent;
transition: all 0.2s;
}
.model-card.active {
border-color: var(--vp-c-brand);
}
.model-name {
font-weight: 600;
margin-bottom: 8px;
}
.model-tag {
font-size: 10px;
padding: 2px 8px;
border-radius: 4px;
display: inline-block;
margin-bottom: 8px;
}
.model-tag.primary { background: #409eff33; color: #409eff; }
.model-tag.success { background: #67c23a33; color: #67c23a; }
.model-tag.warning { background: #e6a23c33; color: #e6a23c; }
.model-desc {
font-size: 12px;
color: var(--vp-c-text-3);
}
.info-box {
display: flex;
gap: 12px;
padding: 16px;
background: var(--vp-c-bg-mute);
border-radius: 8px;
font-size: 13px;
line-height: 1.6;
}
.info-box .icon {
font-size: 18px;
flex-shrink: 0;
}
@media (max-width: 640px) {
.pipeline-flow {
flex-direction: column;
}
.stage-arrow {
transform: rotate(90deg);
}
}
</style>
@@ -0,0 +1,723 @@
<!--
VoiceCloningDemo.vue
声音克隆交互演示组件
用途
演示零样本声音克隆的原理和流程
-->
<template>
<div class="voice-clone-demo">
<div class="header">
<div class="title">🎭 声音克隆 AI 模仿任何人</div>
<div class="subtitle">
只需几秒钟的参考音频AI 就能学会任何人的声音
</div>
</div>
<div class="mode-tabs">
<button
v-for="mode in modes"
:key="mode.id"
@click="selectMode(mode.id)"
class="mode-btn"
:class="{ active: selectedMode === mode.id }"
>
<span class="mode-icon">{{ mode.icon }}</span>
<span>{{ mode.name }}</span>
</button>
</div>
<div class="demo-area">
<!-- 参考音频 -->
<div class="section">
<div class="section-title">
<span class="num">1</span>
提供参考音频
</div>
<div class="audio-grid">
<div
v-for="ref in references"
:key="ref.id"
class="audio-card"
:class="{ selected: selectedRef === ref.id }"
@click="selectRef(ref.id)"
>
<div class="audio-avatar">{{ ref.avatar }}</div>
<div class="audio-name">{{ ref.name }}</div>
<div class="audio-desc">{{ ref.desc }}</div>
<button class="play-btn" @click.stop="playRef(ref.id)">
{{ playingRef === ref.id ? '' : '' }}
</button>
</div>
</div>
<div class="or-divider"></div>
<button class="upload-btn" @click="uploadRef">
📤 上传自己的音频
</button>
</div>
<!-- 处理过程 -->
<div class="section process-section">
<div class="section-title">
<span class="num">2</span>
AI 学习声音特征
</div>
<div class="process-flow">
<div
v-for="(step, index) in processSteps"
:key="step.id"
class="process-step"
:class="{ active: currentStep >= index }"
>
<div class="step-icon">{{ step.icon }}</div>
<div class="step-name">{{ step.name }}</div>
<div v-if="index < processSteps.length - 1" class="step-arrow"></div>
</div>
</div>
<div class="feature-viz" v-if="currentStep >= 2">
<canvas ref="featureCanvas" width="400" height="100"></canvas>
<div class="viz-label">提取的声音特征向量</div>
</div>
</div>
<!-- 生成结果 -->
<div class="section">
<div class="section-title">
<span class="num">3</span>
输入文本生成语音
</div>
<div class="text-input">
<textarea
v-model="inputText"
placeholder="输入要合成的文本..."
rows="3"
></textarea>
<button
class="generate-btn"
:disabled="!canGenerate"
@click="generate"
>
<span v-if="isGenerating" class="spinner"></span>
<span v-else>🎙 生成语音</span>
</button>
</div>
<div v-if="generatedAudio" class="result-area">
<div class="result-header">
<span class="result-icon">🎵</span>
<span>生成结果</span>
<span class="similarity">相似度: {{ similarity }}%</span>
</div>
<div class="waveform-mini">
<canvas ref="resultCanvas" width="400" height="60"></canvas>
</div>
<div class="result-actions">
<button class="action-btn" @click="playResult">
{{ playingResult ? ' 暂停' : ' 播放' }}
</button>
<button class="action-btn secondary" @click="download">
下载
</button>
</div>
</div>
</div>
</div>
<div class="tips-section">
<div class="tips-title">💡 声音克隆小贴士</div>
<div class="tips-grid">
<div class="tip-card">
<div class="tip-icon"></div>
<div class="tip-text">
<strong>参考音频时长</strong>
<p>3-10 秒即可质量比时长更重要</p>
</div>
</div>
<div class="tip-card">
<div class="tip-icon">🔇</div>
<div class="tip-text">
<strong>环境要求</strong>
<p>安静环境避免背景噪音</p>
</div>
</div>
<div class="tip-card">
<div class="tip-icon">🗣</div>
<div class="tip-text">
<strong>内容选择</strong>
<p>包含多种音调和语速效果更好</p>
</div>
</div>
</div>
</div>
<div class="info-box">
<span class="icon">🔬</span>
<p>
<strong>技术原理</strong>
声音克隆通过提取参考音频的音色语调和说话风格特征构建说话人嵌入向量
生成时TTS 模型结合文本内容和说话人嵌入合成与参考声音相似的语音
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
const modes = [
{ id: 'zeroshot', name: '零样本克隆', icon: '🎯' },
{ id: 'fewshot', name: '少样本克隆', icon: '📚' },
{ id: 'crosslingual', name: '跨语言克隆', icon: '🌍' }
]
const references = [
{ id: 'male1', name: '男声 A', avatar: '👨', desc: '低沉磁性' },
{ id: 'female1', name: '女声 B', avatar: '👩', desc: '温柔甜美' },
{ id: 'child', name: '童声', avatar: '🧒', desc: '活泼可爱' },
{ id: 'elder', name: '老人', avatar: '👴', desc: '沧桑稳重' }
]
const processSteps = [
{ id: 'load', name: '加载音频', icon: '📂' },
{ id: 'encode', name: '编码特征', icon: '🔢' },
{ id: 'extract', name: '提取音色', icon: '🎨' },
{ id: 'embed', name: '构建嵌入', icon: '💎' }
]
const selectedMode = ref('zeroshot')
const selectedRef = ref(null)
const currentStep = ref(0)
const inputText = ref('')
const isGenerating = ref(false)
const generatedAudio = ref(false)
const similarity = ref(0)
const playingRef = ref(null)
const playingResult = ref(false)
const featureCanvas = ref(null)
const resultCanvas = ref(null)
const canGenerate = computed(() => {
return selectedRef.value && inputText.value.trim().length > 0 && !isGenerating.value
})
const selectMode = (id) => {
selectedMode.value = id
resetDemo()
}
const selectRef = (id) => {
selectedRef.value = id
currentStep.value = 0
simulateProcess()
}
const playRef = (id) => {
playingRef.value = playingRef.value === id ? null : id
}
const uploadRef = () => {
alert('模拟:打开文件选择器')
}
const simulateProcess = () => {
currentStep.value = 0
const interval = setInterval(() => {
currentStep.value++
if (currentStep.value >= processSteps.length) {
clearInterval(interval)
drawFeatures()
}
}, 500)
}
const drawFeatures = () => {
const canvas = featureCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
// 绘制特征向量可视化
const features = 20
const barW = (w - 40) / features
for (let i = 0; i < features; i++) {
const value = Math.random() * 0.8 + 0.2
const barH = value * (h - 40)
const hue = 200 + value * 60
ctx.fillStyle = `hsl(${hue}, 70%, 50%)`
ctx.fillRect(20 + i * barW, h - 20 - barH, barW - 2, barH)
}
}
const generate = () => {
isGenerating.value = true
generatedAudio.value = false
setTimeout(() => {
isGenerating.value = false
generatedAudio.value = true
similarity.value = Math.floor(Math.random() * 15) + 85
drawResultWaveform()
}, 2000)
}
const drawResultWaveform = () => {
const canvas = resultCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
ctx.strokeStyle = '#409eff'
ctx.lineWidth = 2
ctx.beginPath()
for (let x = 0; x < w; x += 2) {
const y = h / 2 + Math.sin(x * 0.1) * 20 * Math.random()
if (x === 0) ctx.moveTo(x, y)
else ctx.lineTo(x, y)
}
ctx.stroke()
}
const playResult = () => {
playingResult.value = !playingResult.value
}
const download = () => {
alert('模拟:下载音频文件')
}
const resetDemo = () => {
selectedRef.value = null
currentStep.value = 0
inputText.value = ''
generatedAudio.value = false
similarity.value = 0
}
onMounted(() => {
if (featureCanvas.value) drawFeatures()
})
</script>
<style scoped>
.voice-clone-demo {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 24px;
margin: 24px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
text-align: center;
margin-bottom: 24px;
}
.title {
font-size: 18px;
font-weight: 700;
margin-bottom: 8px;
background: linear-gradient(120deg, #409eff, #e6a23c);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 14px;
color: var(--vp-c-text-2);
}
.mode-tabs {
display: flex;
gap: 12px;
margin-bottom: 24px;
justify-content: center;
}
.mode-btn {
padding: 10px 20px;
border: 1px solid var(--vp-c-divider);
border-radius: 20px;
background: var(--vp-c-bg);
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.2s;
}
.mode-btn:hover {
border-color: var(--vp-c-brand);
}
.mode-btn.active {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.demo-area {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.section {
margin-bottom: 24px;
}
.section:last-child {
margin-bottom: 0;
}
.section-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
margin-bottom: 16px;
}
.section-title .num {
width: 24px;
height: 24px;
background: var(--vp-c-brand);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
.audio-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.audio-card {
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
position: relative;
}
.audio-card:hover {
border-color: var(--vp-c-brand);
}
.audio-card.selected {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.audio-avatar {
font-size: 32px;
margin-bottom: 8px;
}
.audio-name {
font-weight: 500;
font-size: 13px;
margin-bottom: 4px;
}
.audio-desc {
font-size: 11px;
color: var(--vp-c-text-3);
}
.play-btn {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
background: var(--vp-c-brand);
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.or-divider {
text-align: center;
color: var(--vp-c-text-3);
margin: 12px 0;
font-size: 13px;
}
.upload-btn {
width: 100%;
padding: 12px;
border: 2px dashed var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
cursor: pointer;
color: var(--vp-c-text-2);
transition: all 0.2s;
}
.upload-btn:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.process-flow {
display: flex;
justify-content: center;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.process-step {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
opacity: 0.5;
transition: all 0.3s;
}
.process-step.active {
opacity: 1;
background: var(--vp-c-brand);
color: white;
}
.step-icon {
font-size: 20px;
}
.step-name {
font-size: 13px;
font-weight: 500;
}
.step-arrow {
color: var(--vp-c-text-3);
}
.feature-viz {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.feature-viz canvas {
width: 100%;
height: auto;
}
.viz-label {
font-size: 12px;
color: var(--vp-c-text-3);
margin-top: 8px;
}
.text-input textarea {
width: 100%;
padding: 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
font-size: 14px;
resize: vertical;
margin-bottom: 12px;
}
.generate-btn {
width: 100%;
padding: 14px;
background: linear-gradient(120deg, #409eff, #67c23a);
color: white;
border: none;
border-radius: 8px;
font-size: 15px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.generate-btn:hover:not(:disabled) {
opacity: 0.9;
}
.generate-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255,255,255,0.3);
border-top-color: white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.result-area {
margin-top: 16px;
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
border: 2px solid #67c23a;
}
.result-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
}
.result-icon {
font-size: 20px;
}
.similarity {
margin-left: auto;
font-size: 12px;
padding: 4px 8px;
background: #67c23a33;
color: #67c23a;
border-radius: 4px;
}
.waveform-mini {
background: var(--vp-c-bg);
border-radius: 4px;
margin-bottom: 12px;
}
.waveform-mini canvas {
width: 100%;
height: auto;
}
.result-actions {
display: flex;
gap: 8px;
}
.action-btn {
flex: 1;
padding: 10px;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.action-btn.secondary {
background: var(--vp-c-bg-mute);
color: var(--vp-c-text-1);
}
.tips-section {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 20px;
margin-bottom: 20px;
}
.tips-title {
font-weight: 600;
margin-bottom: 16px;
text-align: center;
}
.tips-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.tip-card {
display: flex;
gap: 12px;
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.tip-icon {
font-size: 24px;
}
.tip-text strong {
font-size: 13px;
display: block;
margin-bottom: 4px;
}
.tip-text p {
font-size: 12px;
color: var(--vp-c-text-3);
margin: 0;
}
.info-box {
display: flex;
gap: 12px;
padding: 16px;
background: var(--vp-c-bg-mute);
border-radius: 8px;
font-size: 13px;
line-height: 1.6;
}
.info-box .icon {
font-size: 18px;
flex-shrink: 0;
}
@media (max-width: 640px) {
.mode-tabs {
flex-direction: column;
}
.process-flow {
flex-direction: column;
}
.step-arrow {
transform: rotate(90deg);
}
}
</style>
@@ -1,531 +1,197 @@
<!--
* Component: AgentContextFlow.vue
* Description: Visualizes the data flow in an agentic system, showing how context is built, used, and updated during interactions.
* Features:
* - Step-by-step visualization of the Agent Loop (Input -> Context -> Decision -> Action -> Observation -> Update)
* - Animation of data flowing between components
* - Metrics display for context usage and cache hits
-->
<script setup>
import { ref, computed, onUnmounted } from 'vue'
import { ref, computed } from 'vue'
const steps = [
{ id: 'input', label: '用户输入', icon: '👤', desc: '用户提出问题或指令' },
{ id: 'context', label: '构建上下文', icon: '📚', desc: '检索历史消息与相关知识' },
{ id: 'reasoning', label: '模型推理', icon: '🧠', desc: 'LLM 分析意图并规划行动' },
{ id: 'action', label: '工具调用', icon: '🔧', desc: '执行外部工具或 API' },
{ id: 'observation', label: '观察结果', icon: '👁️', desc: '获取工具执行的返回结果' },
{ id: 'update', label: '更新上下文', icon: '📝', desc: '将结果追加到记忆中' }
]
const round = ref(1)
const minRound = 1
const maxRound = 5
const currentStepIndex = ref(-1)
const isAutoPlaying = ref(false)
const iteration = ref(1)
const contextTokens = ref(120)
const cacheHitRate = ref(0)
const autoPlayInterval = ref(null)
const contextTokens = computed(() => 120 + (round.value - 1) * 80)
// Simulation data
const currentScenario = computed(() => {
const scenarios = [
{ input: "查询北京天气", action: "WeatherAPI.get('Beijing')", result: "晴, 25°C", response: "北京今天晴,气温25度。" },
{ input: "计算 123 * 456", action: "Calculator.mul(123, 456)", result: "56088", response: "结果是 56088。" },
{ input: "搜索最新的 AI 新闻", action: "Search.query('AI news')", result: "Found 5 articles...", response: "最近的 AI 新闻包括..." }
]
return scenarios[(iteration.value - 1) % scenarios.length]
const cacheHitRate = computed(() =>
round.value === 1 ? 0 : Math.min(80, (round.value - 1) * 20)
)
const baseCostPerRound = 0.025
const currentCost = computed(() => {
const rate = cacheHitRate.value / 100
const cost = baseCostPerRound * (1 - rate * 0.9)
return cost.toFixed(4)
})
const currentStep = computed(() => {
if (currentStepIndex.value === -1) return null
return steps[currentStepIndex.value]
const savedPercent = computed(() => {
const cost = Number(currentCost.value)
const saved = ((baseCostPerRound - cost) / baseCostPerRound) * 100
return saved.toFixed(1)
})
const progress = computed(() => {
if (currentStepIndex.value === -1) return 0
return ((currentStepIndex.value + 1) / steps.length) * 100
})
const nextStep = () => {
if (currentStepIndex.value < steps.length - 1) {
currentStepIndex.value++
// Update metrics based on step
if (steps[currentStepIndex.value].id === 'context') {
contextTokens.value += 50
} else if (steps[currentStepIndex.value].id === 'update') {
contextTokens.value += 30
cacheHitRate.value = Math.min(95, cacheHitRate.value + 15)
}
} else {
// Loop finished, prepare next iteration
currentStepIndex.value = -1
iteration.value++
setTimeout(() => {
if (isAutoPlaying.value) nextStep()
}, 500)
}
const increaseRound = () => {
if (round.value < maxRound) round.value += 1
}
const toggleAutoPlay = () => {
isAutoPlaying.value = !isAutoPlaying.value
if (isAutoPlaying.value) {
if (currentStepIndex.value === steps.length - 1) {
currentStepIndex.value = -1
iteration.value++
}
runAutoPlay()
} else {
clearTimeout(autoPlayInterval.value)
}
const decreaseRound = () => {
if (round.value > minRound) round.value -= 1
}
const runAutoPlay = () => {
if (!isAutoPlaying.value) return
nextStep()
// Determine delay based on current step
const delay = currentStepIndex.value === -1 ? 500 : 1500
autoPlayInterval.value = setTimeout(() => {
runAutoPlay()
}, delay)
}
const reset = () => {
isAutoPlaying.value = false
clearTimeout(autoPlayInterval.value)
currentStepIndex.value = -1
iteration.value = 1
contextTokens.value = 120
cacheHitRate.value = 0
}
onUnmounted(() => {
clearTimeout(autoPlayInterval.value)
})
</script>
<template>
<div class="agent-context-flow">
<!-- Control Panel -->
<div class="control-panel">
<div class="controls-left">
<button
class="action-btn primary"
@click="toggleAutoPlay"
>
{{ isAutoPlaying ? '⏸ 暂停' : '▶ 自动运行' }}
</button>
<button
class="action-btn secondary"
@click="nextStep"
:disabled="isAutoPlaying || currentStepIndex === steps.length - 1"
>
下一步
</button>
<button
class="action-btn text"
@click="reset"
>
重置
</button>
<div class="agent-context-intro">
<div class="header">
<h3>三个关键数字轮次上下文长度缓存命中率</h3>
<p>拖动轮次看看这三个数字是怎么一起变化的</p>
</div>
<div class="round-control">
<button class="round-btn" @click="decreaseRound" :disabled="round === minRound">
-
</button>
<div class="round-text">
当前假设我们已经聊到了
<strong> {{ round }} </strong>拖动右侧滑块看看聊多几轮之后黑板会写满到什么程度背课文本比例会涨到多高
</div>
<div class="status-indicator">
<span class="status-dot" :class="{ active: isAutoPlaying }"></span>
{{ isAutoPlaying ? '运行中' : '等待中' }}
<input
class="round-slider"
type="range"
:min="minRound"
:max="maxRound"
v-model.number="round"
/>
<button class="round-btn" @click="increaseRound" :disabled="round === maxRound">
+
</button>
</div>
<div class="metrics-row">
<div class="metric-card">
<div class="metric-label">聊了几轮</div>
<div class="metric-value"> {{ round }} </div>
<div class="metric-desc">对话轮次</div>
</div>
<div class="metric-card">
<div class="metric-label">记了多少字</div>
<div class="metric-value">{{ contextTokens }}</div>
<div class="metric-desc">大致对应 token </div>
</div>
<div class="metric-card">
<div class="metric-label">背课文本比例</div>
<div class="metric-value">{{ cacheHitRate }}%</div>
<div class="metric-desc">前缀复用比例</div>
</div>
<div class="metric-card">
<div class="metric-label">这轮大概多少钱</div>
<div class="metric-value">${{ currentCost }}</div>
<div class="metric-desc">比不做优化便宜了 {{ savedPercent }}%</div>
</div>
</div>
<!-- Visualization Area -->
<div class="visualization-area">
<!-- Central Flow Diagram -->
<div class="flow-container">
<div
v-for="(step, index) in steps"
:key="step.id"
class="flow-step"
:class="{
active: index === currentStepIndex,
completed: index < currentStepIndex,
pending: index > currentStepIndex
}"
>
<div class="step-connector" v-if="index > 0"></div>
<div class="step-node">
<div class="step-icon">{{ step.icon }}</div>
<div class="step-label">{{ step.label }}</div>
</div>
</div>
</div>
<!-- Detail View -->
<div class="detail-view">
<transition name="fade" mode="out-in">
<div v-if="currentStep" :key="currentStep.id" class="step-detail">
<div class="detail-header">
<h3>{{ currentStep.icon }} {{ currentStep.label }}</h3>
<p>{{ currentStep.desc }}</p>
</div>
<div class="detail-content">
<div class="scenario-info" v-if="currentStep.id === 'input'">
<strong>输入:</strong> {{ currentScenario.input }}
</div>
<div class="scenario-info" v-else-if="currentStep.id === 'action'">
<strong>执行:</strong> <code>{{ currentScenario.action }}</code>
</div>
<div class="scenario-info" v-else-if="currentStep.id === 'observation'">
<strong>结果:</strong> {{ currentScenario.result }}
</div>
<div class="scenario-info" v-else-if="currentStep.id === 'update'">
上下文已更新准备下一轮对话
</div>
<div class="scenario-info" v-else>
正在处理...
</div>
</div>
</div>
<div v-else class="step-detail placeholder">
<div class="empty-state">
<span class="empty-icon">👋</span>
<p>点击"自动运行""下一步"开始 Agent 流程演示</p>
</div>
</div>
</transition>
</div>
</div>
<!-- Metrics/Info Section -->
<div class="metrics-panel">
<div class="metric-item">
<div class="metric-label">迭代轮次</div>
<div class="metric-value">#{{ iteration }}</div>
</div>
<div class="metric-item">
<div class="metric-label">上下文长度</div>
<div class="metric-value">{{ contextTokens }} tokens</div>
</div>
<div class="metric-item">
<div class="metric-label">KV 缓存命中</div>
<div class="metric-value highlight">{{ cacheHitRate }}%</div>
</div>
<div class="progress-bar-container">
<div class="progress-bar" :style="{ width: `${progress}%` }"></div>
</div>
<div class="summary-line">
参考基准一轮完全不做优化大约 {{ baseCostPerRound.toFixed(4) }} 美元
在当前轮次下通过复用前缀这轮的成本约为 {{ currentCost }} 美元
</div>
</div>
</template>
<style scoped>
.agent-context-flow {
.agent-context-intro {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
overflow: hidden;
margin: 1rem 0;
display: flex;
flex-direction: column;
}
/* Control Panel */
.control-panel {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background-color: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
margin: 1rem 0;
font-family: var(--vp-font-family-mono);
}
.controls-left {
.header {
margin-bottom: 0.75rem;
}
.header h3 {
margin: 0 0 0.25rem;
font-size: 1rem;
}
.header p {
margin: 0;
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.round-control {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.75rem;
font-size: 0.85rem;
}
.action-btn {
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-size: 0.9rem;
.round-btn {
padding: 0.2rem 0.6rem;
border-radius: 4px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
font-size: 0.85rem;
}
.action-btn.primary {
background-color: var(--vp-c-brand);
color: white;
}
.action-btn.primary:hover {
background-color: var(--vp-c-brand-dark);
}
.action-btn.secondary {
background-color: var(--vp-c-bg-mute);
color: var(--vp-c-text-1);
border-color: var(--vp-c-divider);
}
.action-btn.secondary:hover:not(:disabled) {
background-color: var(--vp-c-bg-soft);
border-color: var(--vp-c-brand);
}
.action-btn.secondary:disabled {
opacity: 0.5;
.round-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.action-btn.text {
background: none;
color: var(--vp-c-text-2);
}
.action-btn.text:hover {
color: var(--vp-c-text-1);
}
.status-indicator {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--vp-c-divider);
transition: background-color 0.3s;
}
.status-dot.active {
background-color: var(--vp-c-green);
box-shadow: 0 0 4px var(--vp-c-green);
}
/* Visualization Area */
.visualization-area {
padding: 2rem 1rem;
background-color: var(--vp-c-bg-alt);
min-height: 300px;
display: flex;
flex-direction: column;
gap: 2rem;
align-items: center;
}
.flow-container {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 0.5rem;
width: 100%;
max-width: 800px;
}
.flow-step {
display: flex;
align-items: center;
position: relative;
.round-text {
flex: 1;
min-width: 80px;
}
.step-node {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
z-index: 2;
width: 100%;
}
.step-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.step-label {
font-size: 0.75rem;
color: var(--vp-c-text-2);
font-weight: 500;
transition: color 0.3s;
text-align: center;
}
.step-connector {
position: absolute;
top: 20px;
left: -50%;
width: 100%;
height: 2px;
background-color: var(--vp-c-divider);
z-index: 1;
transform: translateY(-50%);
transition: background-color 0.5s ease;
}
/* Active State */
.flow-step.active .step-icon {
background-color: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
transform: scale(1.1);
box-shadow: 0 0 10px var(--vp-c-brand-dimm);
}
.flow-step.active .step-label {
color: var(--vp-c-brand);
font-weight: bold;
}
/* Completed State */
.flow-step.completed .step-icon {
background-color: var(--vp-c-brand-dimm);
border-color: var(--vp-c-brand);
color: var(--vp-c-brand-dark);
}
.flow-step.completed .step-connector {
background-color: var(--vp-c-brand);
}
/* Detail View */
.detail-view {
width: 100%;
max-width: 500px;
min-height: 120px;
background-color: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
position: relative;
}
.detail-header h3 {
margin: 0;
font-size: 1.1rem;
color: var(--vp-c-text-1);
display: flex;
align-items: center;
gap: 0.5rem;
}
.detail-header p {
margin: 0.25rem 0 0.75rem;
font-size: 0.9rem;
color: var(--vp-c-text-2);
}
.detail-content {
padding-top: 0.75rem;
border-top: 1px solid var(--vp-c-divider);
.round-slider {
width: 120px;
}
.scenario-info code {
background-color: var(--vp-c-bg-mute);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-family: var(--vp-font-mono);
font-size: 0.85rem;
}
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--vp-c-text-3);
text-align: center;
padding: 1rem;
}
.empty-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
/* Transitions */
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease, transform 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(10px);
}
/* Metrics Panel */
.metrics-panel {
.metrics-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
padding: 1rem;
background-color: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-divider);
position: relative;
grid-template-columns: repeat(4, 1fr);
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.metric-item {
.metric-card {
padding: 0.75rem;
border-radius: 6px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
text-align: center;
}
.metric-label {
font-size: 0.8rem;
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-bottom: 0.2rem;
margin-bottom: 0.25rem;
}
.metric-value {
font-size: 1.1rem;
font-weight: bold;
font-size: 1.4rem;
font-weight: 600;
color: var(--vp-c-text-1);
font-family: var(--vp-font-mono);
}
.metric-value.highlight {
color: var(--vp-c-brand);
margin-bottom: 0.25rem;
}
.progress-bar-container {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 3px;
background-color: transparent;
.metric-desc {
font-size: 0.75rem;
color: var(--vp-c-text-2);
}
.progress-bar {
height: 100%;
background-color: var(--vp-c-brand);
transition: width 0.3s linear;
.summary-line {
font-size: 0.8rem;
color: var(--vp-c-text-2);
padding: 0.6rem 0.75rem;
border-radius: 6px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
}
@media (max-width: 640px) {
.flow-container {
flex-direction: column;
gap: 1rem;
}
.step-connector {
width: 2px;
height: 20px;
top: -20px;
left: 50%;
transform: translateX(-50%);
}
.flow-step {
width: 100%;
min-width: unset;
}
.controls-left span {
display: none;
@media (max-width: 768px) {
.metrics-row {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
@@ -1,10 +1,6 @@
<!--
* Component: ContextCompressionDemo.vue
* Description: Demonstrates various context compression techniques to save tokens.
* Features:
* - Strategies: Summarization, Extraction, Structured Format (JSON)
* - Real-time comparison of original vs compressed text
* - Metrics display (Token count, Compression Ratio)
* Description: Demonstrates various context compression techniques with a clear vertical flow.
-->
<script setup>
@@ -15,9 +11,9 @@ const originalText = ref(
)
const strategies = [
{ id: 'summary', label: '📝 摘要生成', desc: '保留大意,缩减篇幅' },
{ id: 'extract', label: '🔑 关键词提取', desc: '提取核心要点' },
{ id: 'json', label: '⚙️ 结构化数据', desc: '转换为 JSON 格式' }
{ id: 'summary', label: '📝 摘要生成', desc: '保留大意' },
{ id: 'extract', label: '🔑 关键词', desc: '提要点' },
{ id: 'json', label: '⚙️ 结构化', desc: '转 JSON' }
]
const currentMode = ref('')
@@ -39,7 +35,7 @@ const compress = async (mode) => {
compressedText.value = ''
// Simulate API delay
await new Promise(r => setTimeout(r, 600))
await new Promise(r => setTimeout(r, 800))
if (mode === 'summary') {
compressedText.value = '上下文工程旨在优化 LLM 提示词以解决上下文窗口限制。主要技术包括摘要生成(浓缩关键信息)、RAG(按需检索相关片段)以及结构化数据转换(提高信息密度)。'
@@ -59,8 +55,9 @@ const compress = async (mode) => {
<template>
<div class="context-compression-demo">
<!-- Control Panel -->
<div class="control-panel">
<!-- 1. Strategy Selection -->
<div class="section control-panel">
<div class="section-label">1. 选择压缩策略</div>
<div class="strategy-group">
<button
v-for="s in strategies"
@@ -68,7 +65,6 @@ const compress = async (mode) => {
class="strategy-btn"
:class="{ active: currentMode === s.id }"
@click="compress(s.id)"
:title="s.desc"
>
<div class="btn-label">{{ s.label }}</div>
<div class="btn-desc">{{ s.desc }}</div>
@@ -76,92 +72,102 @@ const compress = async (mode) => {
</div>
</div>
<!-- Main Comparison Area -->
<div class="comparison-area">
<!-- Original -->
<div class="text-column original">
<div class="column-header">
<span class="badge">原始文本</span>
<span class="meta">{{ originalTokens }} Tokens</span>
</div>
<textarea
v-model="originalText"
class="text-content"
placeholder="在此输入长文本..."
></textarea>
<!-- 2. Input Area -->
<div class="section input-area">
<div class="section-header">
<span class="label">原始文本 (Original)</span>
<span class="token-count">{{ originalTokens }} tokens</span>
</div>
<textarea
v-model="originalText"
class="text-content original-input"
placeholder="在此输入长文本..."
></textarea>
</div>
<!-- Arrow -->
<div class="process-arrow">
<div class="arrow-icon" :class="{ compressing: isCompressing }">
{{ isCompressing ? '⚙️' : '➡️' }}
<!-- Connector / Process -->
<div class="flow-connector">
<div class="line"></div>
<div class="process-icon" :class="{ spinning: isCompressing }">
{{ isCompressing ? '⚙️' : '⬇️' }}
</div>
<div class="badge-container" v-if="compressedText && !isCompressing">
<span class="ratio-badge">-{{ compressionRatio }}%</span>
</div>
</div>
<!-- 3. Output Area -->
<div class="section output-area" :class="{ 'has-result': compressedText }">
<div class="section-header">
<span class="label">压缩后 (Compressed)</span>
<span class="token-count" v-if="compressedText">{{ compressedTokens }} tokens</span>
</div>
<div class="text-content result-box">
<div v-if="isCompressing" class="loading-state">
<span class="spinner"></span> 正在压缩...
</div>
<div class="ratio-badge" v-if="compressedText && !isCompressing">
-{{ compressionRatio }}%
<pre v-else-if="compressedText">{{ compressedText }}</pre>
<div v-else class="placeholder">
请点击上方按钮开始压缩
</div>
</div>
<!-- Compressed -->
<div class="text-column compressed">
<div class="column-header">
<span class="badge success">压缩后</span>
<span class="meta" v-if="compressedText">{{ compressedTokens }} Tokens</span>
<!-- Mini Metrics (Inside Output Area) -->
<div class="mini-metrics" v-if="compressedText && !isCompressing">
<div class="metric-item">
<span class="metric-label">节省空间</span>
<span class="metric-val highlight">{{ compressionRatio }}%</span>
</div>
<div class="text-content result-box" :class="{ empty: !compressedText }">
<div v-if="isCompressing" class="loading-state">
<span class="spinner"></span> 压缩中...
</div>
<pre v-else-if="compressedText">{{ compressedText }}</pre>
<div v-else class="placeholder">
请选择一种压缩策略
<br>
<small>点击上方按钮开始</small>
</div>
<div class="metric-bar">
<div class="bar-fill" :style="{ width: (100 - compressionRatio) + '%' }"></div>
</div>
</div>
</div>
<!-- Metrics Bar -->
<div class="metrics-bar" v-if="compressedText && !isCompressing">
<div class="progress-bg">
<div class="progress-fill" :style="{ width: (100 - compressionRatio) + '%' }"></div>
<div class="progress-label">占用空间: {{ 100 - compressionRatio }}%</div>
</div>
<div class="saved-label">节省了 {{ compressionRatio }}% Token</div>
</div>
</div>
</template>
<style scoped>
.context-compression-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
border-radius: 12px;
background-color: var(--vp-c-bg-soft);
overflow: hidden;
margin: 1rem 0;
max-width: 600px;
margin: 1.5rem auto;
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 0;
}
.control-panel {
.section {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
background-color: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
transition: all 0.3s ease;
}
.section-label {
font-size: 0.85rem;
font-weight: 600;
color: var(--vp-c-text-2);
margin-bottom: 0.8rem;
text-transform: uppercase;
}
/* Control Panel */
.strategy-group {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.strategy-btn {
flex: 1;
min-width: 140px;
padding: 0.8rem;
padding: 0.6rem 0.4rem;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
border-radius: 6px;
background-color: var(--vp-c-bg-alt);
cursor: pointer;
transition: all 0.2s;
@@ -177,176 +183,165 @@ const compress = async (mode) => {
border-color: var(--vp-c-brand);
background-color: var(--vp-c-brand-dimm);
color: var(--vp-c-brand-dark);
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
}
.btn-label {
font-weight: 600;
font-size: 0.95rem;
font-size: 0.85rem;
margin-bottom: 0.2rem;
color: var(--vp-c-text-1);
}
.btn-desc {
font-size: 0.75rem;
font-size: 0.7rem;
color: var(--vp-c-text-2);
}
/* Comparison Area */
.comparison-area {
display: flex;
padding: 1.5rem;
gap: 1rem;
align-items: stretch;
min-height: 250px;
}
.text-column {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.column-header {
/* Text Areas */
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.6rem;
font-size: 0.85rem;
}
.badge {
background-color: var(--vp-c-bg-mute);
padding: 0.2rem 0.5rem;
border-radius: 4px;
color: var(--vp-c-text-2);
.label {
font-weight: 600;
}
.badge.success {
background-color: var(--vp-c-green-dimm);
color: var(--vp-c-green-dark);
color: var(--vp-c-text-1);
}
.meta {
color: var(--vp-c-text-2);
.token-count {
font-family: var(--vp-font-mono);
color: var(--vp-c-text-2);
font-size: 0.75rem;
background: var(--vp-c-bg-mute);
padding: 2px 6px;
border-radius: 4px;
}
.text-content {
flex: 1;
width: 100%;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background-color: var(--vp-c-bg);
font-size: 0.9rem;
line-height: 1.6;
resize: none;
background-color: var(--vp-c-bg-alt);
font-size: 0.85rem;
line-height: 1.5;
color: var(--vp-c-text-1);
font-family: var(--vp-font-mono);
}
.text-content:focus {
.original-input {
min-height: 100px;
padding: 0.75rem;
resize: vertical;
}
.original-input:focus {
border-color: var(--vp-c-brand);
outline: none;
}
.result-box {
background-color: var(--vp-c-bg-alt);
overflow-y: auto;
position: relative;
min-height: 100px;
padding: 0.75rem;
overflow-x: auto;
background-color: #f6f8fa; /* Light code bg style */
}
.result-box.empty {
display: flex;
align-items: center;
justify-content: center;
.dark .result-box {
background-color: #161b22;
}
.placeholder {
color: var(--vp-c-text-3);
text-align: center;
}
/* Process Arrow */
.process-arrow {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.5rem;
width: 50px;
justify-content: center;
height: 100%;
min-height: 80px;
font-size: 0.85rem;
}
.arrow-icon {
font-size: 1.5rem;
transition: transform 0.5s;
/* Connector */
.flow-connector {
position: relative;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.arrow-icon.compressing {
.line {
position: absolute;
height: 100%;
width: 2px;
background: var(--vp-c-divider);
}
.process-icon {
z-index: 1;
background: var(--vp-c-bg-soft);
padding: 4px;
font-size: 1.2rem;
color: var(--vp-c-text-2);
}
.spinning {
animation: spin 1s linear infinite;
}
.ratio-badge {
font-size: 0.75rem;
font-weight: bold;
color: var(--vp-c-green);
background-color: var(--vp-c-green-dimm);
padding: 0.2rem 0.4rem;
border-radius: 10px;
}
/* Metrics Bar */
.metrics-bar {
padding: 1rem;
background-color: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 1rem;
}
.progress-bg {
flex: 1;
height: 20px;
background-color: var(--vp-c-bg-mute);
border-radius: 10px;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
}
.progress-fill {
height: 100%;
background-color: var(--vp-c-brand);
transition: width 0.5s ease;
}
.progress-label {
.badge-container {
position: absolute;
left: 10px;
right: 20px;
top: 50%;
transform: translateY(-50%);
}
.ratio-badge {
background: var(--vp-c-green-dimm);
color: var(--vp-c-green-dark);
font-size: 0.75rem;
color: white;
text-shadow: 0 0 2px rgba(0,0,0,0.5);
font-weight: bold;
padding: 2px 8px;
border-radius: 10px;
}
/* Metrics */
.mini-metrics {
margin-top: 1rem;
border-top: 1px solid var(--vp-c-divider);
padding-top: 0.8rem;
}
.metric-item {
display: flex;
justify-content: space-between;
margin-bottom: 0.4rem;
font-size: 0.8rem;
}
.highlight {
color: var(--vp-c-green);
font-weight: bold;
}
.saved-label {
font-size: 0.9rem;
font-weight: bold;
color: var(--vp-c-green);
.metric-bar {
height: 6px;
background: var(--vp-c-bg-mute);
border-radius: 3px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--vp-c-brand);
transition: width 0.5s ease;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.comparison-area {
flex-direction: column;
}
.process-arrow {
width: 100%;
flex-direction: row;
height: 40px;
}
}
</style>
@@ -18,12 +18,12 @@
<div class="stat-group">
<div class="stat-item">
<span class="value" :class="{ error: isOverflow }">{{ usedTokens }}</span>
<span class="label">Used Tokens</span>
<span class="label">已经写了多少个 token</span>
</div>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="value">{{ maxTokens }}</span>
<span class="label">Limit</span>
<span class="label">黑板最多能写几个 token</span>
</div>
</div>
@@ -45,7 +45,7 @@
<div class="window-frame" :class="{ overflow: isOverflow }">
<div class="window-header">
<span class="icon">🧠</span>
<span>Model Context Window</span>
<span>模型能看到的小黑板上下文窗口</span>
</div>
<div class="token-stream">
@@ -63,23 +63,23 @@
<div v-if="isOverflow" class="overflow-indicator">
<div class="overflow-line"></div>
<span class="overflow-text"> Context Limit Reached (Truncated)</span>
<span class="overflow-text"> 达到上下文上限 (已截断)</span>
</div>
</div>
</div>
<div class="input-section">
<div class="input-header">
<label>Input Text / 输入内容</label>
<label>输入内容看黑板怎么被一点点写满</label>
<div class="actions">
<button class="action-btn" @click="fillLorem(30)">+ Short</button>
<button class="action-btn" @click="fillLorem(120)">+ Overflow</button>
<button class="action-btn outline" @click="clear">Clear</button>
<button class="action-btn" @click="fillLorem(10)">填一段短文本</button>
<button class="action-btn" @click="fillLorem(60)">一下子塞满黑板</button>
<button class="action-btn outline" @click="clear">清空</button>
</div>
</div>
<textarea
v-model="inputText"
placeholder="Type here to see how tokens fill up the memory..."
placeholder="在这里输入几句话,看看小黑板是怎么逐渐被写满的..."
rows="4"
></textarea>
</div>
@@ -87,9 +87,9 @@
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>Note:</strong>
Context Window 是模型的短期记忆就像黑板只有么大写满了就必须擦掉旧的才能写新的
一旦溢出模型不仅会"忘记"前面的内容甚至可能无法处理新的请求
<strong>说明</strong>
上下文窗口可以理解成模型的小黑板黑板只有么大写满了就必须擦掉旧的才能写新的
一旦溢出最早写的那部分内容就会被擦掉模型会完全看不见它们
</p>
</div>
</div>
@@ -99,16 +99,18 @@
import { ref, computed } from 'vue'
const maxTokens = 100
const inputText = ref('Context engineering is the art of managing information.')
const inputText = ref('上下文工程(Context Engineering)是指优化提供给大语言模型(LLM)的提示词。')
// Simple mock tokenizer: split by space for demonstration
// In reality, tokens are subwords, but space-split is good enough for concept
const tokenizedText = computed(() => {
if (!inputText.value) return []
return inputText.value
.trim()
.split(/\s+/)
.filter((t) => t)
// Improved tokenizer:
// 1. Matches continuous English words/numbers ([a-zA-Z0-9]+)
// 2. OR matches any other single character (including Chinese, punctuation)
// This provides a better visual approximation for mixed Chinese/English text
const matches = inputText.value.match(/[a-zA-Z0-9]+|./g) || []
return matches.filter(t => t.trim().length > 0)
})
const usedTokens = computed(() => tokenizedText.value.length)
@@ -128,9 +130,8 @@ const getTokenClass = (index) => {
const fillLorem = (count) => {
const words = [
'lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur',
'adipiscing', 'elit', 'sed', 'do', 'eiusmod', 'tempor',
'incididunt', 'ut', 'labore', 'et', 'dolore', 'magna', 'aliqua'
'人工智能', '深度学习', '神经网络', '大模型', 'Transformer', '注意力机制',
'上下文窗口', 'Token', 'Embedding', '微调', '预训练', '推理', '生成', 'RAG'
]
const newText = Array.from({ length: count }, () => words[Math.floor(Math.random() * words.length)]).join(' ')
inputText.value = newText
@@ -146,7 +147,7 @@ const clear = () => {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
padding: 1.5rem;
padding: 1rem;
margin: 1rem 0;
font-family: var(--vp-font-family-mono);
}
@@ -154,10 +155,10 @@ const clear = () => {
.control-panel {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
gap: 1rem;
margin-bottom: 1rem;
background: var(--vp-c-bg);
padding: 1rem;
padding: 0.75rem;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
}
@@ -175,7 +176,7 @@ const clear = () => {
}
.stat-item .value {
font-size: 1.5rem;
font-size: 1.2rem;
font-weight: bold;
line-height: 1;
}
@@ -223,14 +224,14 @@ const clear = () => {
}
.visualization-area {
margin-bottom: 1.5rem;
margin-bottom: 1rem;
}
.window-frame {
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg);
min-height: 120px;
min-height: 100px;
position: relative;
transition: border-color 0.3s;
overflow: hidden;
@@ -242,8 +243,8 @@ const clear = () => {
.window-header {
background: var(--vp-c-bg-alt);
padding: 0.5rem 1rem;
font-size: 0.9rem;
padding: 0.25rem 0.75rem;
font-size: 0.85rem;
font-weight: bold;
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
@@ -252,11 +253,11 @@ const clear = () => {
}
.token-stream {
padding: 1rem;
padding: 0.5rem;
display: flex;
flex-wrap: wrap;
gap: 4px;
max-height: 200px;
gap: 2px;
max-height: 150px;
overflow-y: auto;
}
@@ -0,0 +1,70 @@
<template>
<div class="intro-prs">
<div class="prs-item">
<div class="prs-title">问题</div>
<ul>
<li><strong>上下文难以保持一致</strong>对话一长前后语义容易脱节</li>
<li><strong>关键事实容易丢失</strong>早期给出的信息在后续轮次中难以被准确引用</li>
<li><strong>调用成本持续上升</strong>每一轮都要重新处理大量历史内容</li>
</ul>
</div>
<div class="prs-item">
<div class="prs-title">可能的成因</div>
<ul>
<li><strong>视野仅限当前调用</strong>模型只能依赖这一轮提供的上下文</li>
<li><strong>信息缺乏结构化组织</strong>重要信息与次要细节混在一起难以形成稳定记忆</li>
<li><strong>历史内容反复计算</strong>大量固定前缀在多轮对话中被一遍遍重新处理</li>
</ul>
</div>
<div class="prs-item">
<div class="prs-title">带来的影响</div>
<ul>
<li><strong>回答质量不稳定</strong>对话越长模型越难保持一致性和可追溯性</li>
<li><strong>成本难以预估</strong>每轮上下文大小高度波动调用费用不可控</li>
<li><strong>难以工程化落地</strong>缺乏明确的上下文管理策略系统在生产环境中难以维护与扩展</li>
</ul>
</div>
</div>
</template>
<style scoped>
.intro-prs {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.75rem;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
padding: 0.75rem;
font-size: 0.82rem;
}
.prs-item {
background: var(--vp-c-bg);
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
padding: 0.75rem;
}
.prs-title {
font-weight: 600;
margin-bottom: 0.4rem;
}
ul {
margin: 0;
padding-left: 1.1rem;
font-size: 0.82rem;
color: var(--vp-c-text-2);
}
li + li {
margin-top: 0.25rem;
}
@media (max-width: 768px) {
.intro-prs {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,402 @@
<template>
<div class="kv-cache-demo">
<div class="control-panel">
<div class="control-group">
<label class="toggle-switch">
<input type="checkbox" v-model="isCacheEnabled" :disabled="isProcessing">
<span class="slider"></span>
</label>
<span class="label">开启背课文加速前缀复用 / KV Cache</span>
</div>
<button
class="action-btn"
:disabled="isProcessing"
@click="sendRequest"
>
{{ isProcessing ? '生成中...' : '发送新请求' }}
</button>
</div>
<div class="visualization-area">
<div class="memory-blocks">
<!-- System Prompt Block -->
<div
class="memory-block system"
:class="{ 'cached': isCacheEnabled && hasCache, 'processing': processingStep === 'system' }"
>
<div class="block-header">
<span class="icon"></span>
<span>固定开场白System Prompt</span>
<span class="badge" v-if="isCacheEnabled && hasCache">已背过</span>
</div>
<div class="block-content">
你是一个乐于助人的 AI 助手... 大约 500 token
</div>
<div class="process-indicator" v-if="processingStep === 'system'">
计算中...
</div>
</div>
<!-- History Block -->
<div
class="memory-block history"
:class="{ 'processing': processingStep === 'history' }"
>
<div class="block-header">
<span class="icon">💬</span>
<span>最近几轮聊天记录</span>
</div>
<div class="block-content">
用户你好... 大约 200 token
</div>
<div class="process-indicator" v-if="processingStep === 'history'">
计算中...
</div>
</div>
<!-- New Query Block -->
<div
class="memory-block query"
:class="{ 'processing': processingStep === 'query' }"
>
<div class="block-header">
<span class="icon"></span>
<span>这一次的新问题</span>
</div>
<div class="block-content">
{{ currentQuery }} 大约 50 token
</div>
<div class="process-indicator" v-if="processingStep === 'query'">
计算中...
</div>
</div>
</div>
</div>
<div class="metrics-panel">
<div class="metric-card">
<div class="metric-value">{{ metrics.ttft }}ms</div>
<div class="metric-label">开口速度首字延迟 TTFT</div>
<div class="metric-diff" :class="{ 'good': metrics.savedTime > 0 }" v-if="metrics.savedTime > 0">
节省 {{ metrics.savedTime }}ms
</div>
</div>
<div class="metric-card">
<div class="metric-value">{{ metrics.processedTokens }}</div>
<div class="metric-label">这次一共算了多少个 token</div>
</div>
<div class="metric-card">
<div class="metric-value">{{ metrics.cost }}</div>
<div class="metric-label">大致算力消耗越少越省钱</div>
</div>
</div>
<div class="info-box">
<p v-if="isCacheEnabled">
<span class="icon"></span>
<strong>命中时在干嘛</strong>前面的固定开场白不再重复计算直接用上一次背过的结果所以又快又省
</p>
<p v-else>
<span class="icon">🐌</span>
<strong>没开缓存时</strong>每次都要从头把所有 token 重新算一遍注意力就像每次都从第一页开始重读课文又慢又费钱
</p>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
const isCacheEnabled = ref(false)
const hasCache = ref(false)
const isProcessing = ref(false)
const processingStep = ref('') // 'system', 'history', 'query'
const currentQuery = ref('帮我写一段 Python 代码')
const metrics = reactive({
ttft: 0,
processedTokens: 0,
cost: 0,
savedTime: 0
})
const sendRequest = async () => {
if (isProcessing.value) return
isProcessing.value = true
// Reset metrics display
metrics.ttft = 0
metrics.processedTokens = 0
metrics.cost = 0
metrics.savedTime = 0
const systemTokens = 500
const historyTokens = 200
const queryTokens = 50
// Step 1: System Prompt
processingStep.value = 'system'
const systemDelay = (isCacheEnabled.value && hasCache.value) ? 100 : 800
await new Promise(r => setTimeout(r, systemDelay))
// Step 2: Chat History
processingStep.value = 'history'
await new Promise(r => setTimeout(r, 400))
// Step 3: New Query
processingStep.value = 'query'
await new Promise(r => setTimeout(r, 200))
// Calculate final metrics
processingStep.value = ''
isProcessing.value = false
// Logic:
// Without Cache: Process all (500 + 200 + 50) = 750 tokens
// With Cache: Process only (200 + 50) = 250 tokens (System is reused)
if (isCacheEnabled.value && hasCache.value) {
metrics.ttft = 150 // Fast
metrics.processedTokens = historyTokens + queryTokens
metrics.cost = 3 // Low cost
metrics.savedTime = 650
} else {
metrics.ttft = 800 // Slow
metrics.processedTokens = systemTokens + historyTokens + queryTokens
metrics.cost = 10 // High cost
// First run with cache enabled establishes the cache
if (isCacheEnabled.value) {
hasCache.value = true
}
}
// Update query for next run to simulate conversation
currentQuery.value = currentQuery.value === '帮我写一段 Python 代码'
? '这段代码怎么运行?'
: '帮我写一段 Python 代码'
}
</script>
<style scoped>
.kv-cache-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
font-family: var(--vp-font-family-mono);
}
.control-panel {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
background: var(--vp-c-bg);
padding: 0.75rem;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
}
.control-group {
display: flex;
align-items: center;
gap: 0.75rem;
}
/* Toggle Switch */
.toggle-switch {
position: relative;
display: inline-block;
width: 40px;
height: 20px;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
}
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
transition: .4s;
border-radius: 20px;
}
.slider:before {
position: absolute;
content: "";
height: 16px;
width: 16px;
left: 1px;
bottom: 1px;
background-color: var(--vp-c-text-2);
transition: .4s;
border-radius: 50%;
}
input:checked + .slider {
background-color: var(--vp-c-brand);
border-color: var(--vp-c-brand);
}
input:checked + .slider:before {
transform: translateX(20px);
background-color: white;
}
.action-btn {
padding: 0.4rem 0.8rem;
background-color: var(--vp-c-brand);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-weight: bold;
transition: opacity 0.2s;
font-size: 0.9rem;
}
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.visualization-area {
margin-bottom: 1rem;
}
.memory-blocks {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.memory-block {
border: 2px solid var(--vp-c-divider);
border-radius: 6px;
padding: 0.75rem;
background: var(--vp-c-bg);
position: relative;
transition: all 0.3s ease;
}
.memory-block.system { border-left: 4px solid var(--vp-c-green-1); }
.memory-block.history { border-left: 4px solid var(--vp-c-yellow-1); }
.memory-block.query { border-left: 4px solid var(--vp-c-brand-1); }
.memory-block.cached {
background: rgba(16, 185, 129, 0.1);
border-color: var(--vp-c-green-1);
}
.memory-block.processing {
box-shadow: 0 0 10px var(--vp-c-brand);
border-color: var(--vp-c-brand);
transform: scale(1.01);
}
.block-header {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: bold;
margin-bottom: 0.5rem;
font-size: 0.9rem;
}
.block-content {
color: var(--vp-c-text-2);
font-size: 0.85rem;
}
.badge {
background: var(--vp-c-green-1);
color: white;
padding: 1px 6px;
border-radius: 4px;
font-size: 0.7rem;
margin-left: auto;
}
.process-indicator {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
font-size: 0.8rem;
color: var(--vp-c-brand);
font-weight: bold;
animation: pulse 1s infinite;
}
@keyframes pulse {
0% { opacity: 0.5; }
50% { opacity: 1; }
100% { opacity: 0.5; }
}
.metrics-panel {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
.metric-card {
background: var(--vp-c-bg-alt);
padding: 1rem;
border-radius: 6px;
text-align: center;
position: relative;
}
.metric-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--vp-c-text-1);
}
.metric-label {
font-size: 0.8rem;
color: var(--vp-c-text-2);
margin-top: 0.25rem;
}
.metric-diff {
position: absolute;
top: -10px;
right: -10px;
background: var(--vp-c-brand);
color: white;
padding: 2px 6px;
border-radius: 10px;
font-size: 0.7rem;
font-weight: bold;
}
.metric-diff.good {
background: var(--vp-c-green-1);
}
.info-box {
background-color: var(--vp-c-bg-alt);
padding: 1rem;
border-radius: 6px;
font-size: 0.9rem;
color: var(--vp-c-text-2);
}
.info-box .icon {
margin-right: 0.5rem;
}
</style>
@@ -0,0 +1,283 @@
<template>
<div class="lost-in-middle-demo">
<div class="control-panel">
<div class="control-group">
<label>关键信息大概在整段话的哪个位置{{ needlePosition }}%</label>
<input
type="range"
v-model.number="needlePosition"
min="0"
max="100"
step="1"
class="slider-input"
>
</div>
</div>
<div class="visualization-area">
<!-- Context Window Bar -->
<div class="context-bar">
<div class="context-label start">Start (System)</div>
<div class="context-label end">End (Query)</div>
<!-- Attention Heatmap Background -->
<div class="attention-heatmap"></div>
<!-- Needle Marker -->
<div
class="needle-marker"
:style="{ left: `${needlePosition}%` }"
>
<div class="needle-icon">📍</div>
<div class="needle-tooltip">关键事实</div>
</div>
</div>
<!-- Probability Curve Chart -->
<div class="chart-container">
<svg viewBox="0 0 100 60" preserveAspectRatio="none" class="chart-svg">
<!-- U-Curve Path -->
<path
d="M 0 5 Q 50 55 100 5"
fill="none"
stroke="var(--vp-c-divider)"
stroke-width="2"
stroke-dasharray="4"
/>
<!-- Active Dot -->
<circle
:cx="needlePosition"
:cy="60 - (retrievalProb * 0.5 + 5)"
r="3"
fill="var(--vp-c-brand)"
/>
</svg>
<div class="chart-label y-axis">被记住的概率</div>
<div class="chart-label x-axis">在上下文里的位置</div>
</div>
</div>
<div class="metrics-panel">
<div class="metric-card">
<div class="metric-value" :class="getScoreClass(retrievalProb)">
{{ retrievalProb.toFixed(1) }}%
</div>
<div class="metric-label">检索成功率</div>
</div>
<div class="metric-card">
<div class="metric-value">{{ positionLabel }}</div>
<div class="metric-label">位置描述</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">🔍</span>
<strong>实验观察</strong>当关键信息藏在整段话的<strong>中间位置</strong>模型最容易漏看掉Lost in the Middle
<br>
最靠谱的做法把重要指令放在<strong>最前面的 System Prompt</strong>或者<strong>最后的用户问题里</strong>
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const needlePosition = ref(50) // 0 to 100
// Parabolic curve calculation: Vertex at (50, 40), passing through (0, 95) and (100, 95)
// y = a(x-h)^2 + k
// a = 0.022
const retrievalProb = computed(() => {
const x = needlePosition.value
const prob = 0.022 * Math.pow(x - 50, 2) + 40
return Math.min(99.9, Math.max(0, prob))
})
const positionLabel = computed(() => {
const p = needlePosition.value
if (p < 20) return '偏开头'
if (p > 80) return '偏结尾'
return '中间区域(最危险)'
})
const getScoreClass = (score) => {
if (score > 85) return 'text-success'
if (score > 60) return 'text-warning'
return 'text-danger'
}
</script>
<style scoped>
.lost-in-middle-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
font-family: var(--vp-font-family-mono);
}
.control-panel {
margin-bottom: 1rem;
background: var(--vp-c-bg);
padding: 0.75rem;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
}
.control-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.control-group label {
font-weight: bold;
font-size: 0.85rem;
}
.slider-input {
width: 100%;
accent-color: var(--vp-c-brand);
}
.visualization-area {
margin-bottom: 1rem;
position: relative;
}
.context-bar {
height: 40px;
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
position: relative;
margin-bottom: 0.75rem;
background: var(--vp-c-bg);
overflow: visible; /* Allow needle to stick out */
}
.attention-heatmap {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 6px;
background: linear-gradient(
90deg,
rgba(16, 185, 129, 0.2) 0%,
rgba(239, 68, 68, 0.1) 50%,
rgba(16, 185, 129, 0.2) 100%
);
opacity: 0.6;
}
.context-label {
position: absolute;
top: -18px;
font-size: 0.7rem;
color: var(--vp-c-text-2);
}
.context-label.start { left: 0; }
.context-label.end { right: 0; }
.needle-marker {
position: absolute;
top: 50%;
transform: translate(-50%, -50%);
z-index: 10;
cursor: grab;
transition: left 0.1s ease;
}
.needle-icon {
font-size: 1.25rem;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.2));
}
.needle-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: var(--vp-c-text-1);
color: var(--vp-c-bg);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.7rem;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
.needle-marker:hover .needle-tooltip {
opacity: 1;
}
.chart-container {
height: 60px;
position: relative;
border-bottom: 1px solid var(--vp-c-divider);
margin-top: 0.75rem;
}
.chart-svg {
width: 100%;
height: 100%;
overflow: visible;
}
.chart-label {
position: absolute;
font-size: 0.7rem;
color: var(--vp-c-text-3);
}
.chart-label.y-axis { top: 0; left: 0; }
.chart-label.x-axis { bottom: -1rem; width: 100%; text-align: center; }
.metrics-panel {
display: flex;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.metric-card {
flex: 1;
background: var(--vp-c-bg-alt);
padding: 0.75rem;
border-radius: 6px;
text-align: center;
}
.metric-value {
font-size: 1.25rem;
font-weight: bold;
}
.metric-label {
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-top: 0.2rem;
}
.text-success { color: var(--vp-c-success-1); }
.text-warning { color: var(--vp-c-warning-1); }
.text-danger { color: var(--vp-c-danger-1); }
.info-box {
background-color: var(--vp-c-bg-alt);
padding: 0.75rem;
border-radius: 6px;
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
.info-box .icon {
margin-right: 0.5rem;
}
</style>
@@ -0,0 +1,521 @@
<!--
* Component: MemoryPalaceActionDemo.vue
* Description: Interactive simulation of the "Memory Palace" in action.
* Features:
* - Scenario selection (Coding vs Support)
* - Chat interface simulation
* - Real-time visualization of the 4 context layers
* - Step-by-step walkthrough of the context construction process
-->
<script setup>
import { ref, computed, nextTick } from 'vue'
const scenarios = {
coding: {
name: '👨‍💻 代码助手场景',
steps: [
{
user: '帮我写一个 Python 贪吃蛇游戏',
action: '初始化',
layers: {
base: 'System: 你是资深 Python 工程师...',
task: 'Task: 编写贪吃蛇游戏,使用 Pygame 库...',
chat: [],
rag: []
},
desc: '初始化:装载地基(System)和任务(Task)。此时 Layer 1 & 2 建立。'
},
{
user: null,
ai_thinking: '需要查询 Pygame 的最新初始化代码...',
action: '检索',
layers: {
base: 'System: 你是资深 Python 工程师...',
task: 'Task: 编写贪吃蛇游戏,使用 Pygame 库...',
chat: [],
rag: ['Docs: Pygame.init() usage...', 'Docs: Game loop pattern...']
},
desc: '思考与检索:发现需要知识补充,临时调取 RAG 资料到 Layer 4。'
},
{
user: null,
ai: '好的,这是一个基于 Pygame 的贪吃蛇基础代码...',
action: '生成',
layers: {
base: 'System: 你是资深 Python 工程师...',
task: 'Task: 编写贪吃蛇游戏,使用 Pygame 库...',
chat: ['User: 写贪吃蛇', 'AI: [Code Block]'],
rag: [] // RAG cleared after generation to save space
},
desc: '生成回答:RAG 资料用完即扔(节省空间),对话写入 Layer 3 (Chat)。'
},
{
user: '蛇移动得太快了,怎么调慢点?',
action: '追问',
layers: {
base: 'System: 你是资深 Python 工程师...',
task: 'Task: 编写贪吃蛇游戏,使用 Pygame 库...',
chat: ['User: 写贪吃蛇', 'AI: [Code Block]', 'User: 调慢点'],
rag: []
},
desc: '用户追问:新对话追加到 Layer 3。Layer 1 & 2 保持不变(0成本)。'
},
{
user: null,
ai: '你可以调整 clock.tick(15) 中的数值...',
action: '回复',
layers: {
base: 'System: 你是资深 Python 工程师...',
task: 'Task: 编写贪吃蛇游戏,使用 Pygame 库...',
chat: ['User: 写贪吃蛇', 'AI: [Code Block]', 'User: 调慢点', 'AI: 调整 tick 值...'],
rag: []
},
desc: '持续对话:Layer 3 增长。如果太长,最上面的对话会被挤出去(滑动窗口)。'
}
]
},
support: {
name: '👩‍💼 客服助手场景',
steps: [
{
user: '我的订单发货了吗?单号 12345',
action: '接收',
layers: {
base: 'System: 你是电商客服,语气温柔...',
task: 'Task: 处理订单查询请求...',
chat: [],
rag: []
},
desc: '接收消息:加载地基(System)。'
},
{
user: null,
ai_thinking: '查询订单系统 API...',
action: '工具调用',
layers: {
base: 'System: 你是电商客服,语气温柔...',
task: 'Task: 处理订单查询请求...',
chat: ['User: 查单号 12345'],
rag: ['API_Result: {id:12345, status:"shipped", loc:"Beijing"}']
},
desc: '调用工具/RAG:获取实时订单状态,放入 Layer 4。'
},
{
user: null,
ai: '亲,查到了哦!您的包裹已经在北京中转了。',
action: '回复',
layers: {
base: 'System: 你是电商客服,语气温柔...',
task: 'Task: 处理订单查询请求...',
chat: ['User: 查单号 12345', 'AI: 在北京中转'],
rag: []
},
desc: '完成回复:Layer 4 清空,对话保留在 Layer 3。'
}
]
}
}
const currentScenarioKey = ref('coding')
const currentStepIndex = ref(0)
const currentScenario = computed(() => scenarios[currentScenarioKey.value])
const currentStep = computed(() => currentScenario.value.steps[currentStepIndex.value])
const isLastStep = computed(() => currentStepIndex.value === currentScenario.value.steps.length - 1)
const setScenario = (key) => {
currentScenarioKey.value = key
currentStepIndex.value = 0
}
const nextStep = () => {
if (!isLastStep.value) {
currentStepIndex.value++
} else {
currentStepIndex.value = 0
}
}
const prevStep = () => {
if (currentStepIndex.value > 0) {
currentStepIndex.value--
}
}
// Visual helpers
const getLayerStyle = (layerId) => {
const isActive = (layer) => {
// Logic to highlight active layer based on step action could go here
// For now, simple static colors
return true
}
return {}
}
</script>
<template>
<div class="action-demo">
<!-- Scenario Selector -->
<div class="scenario-tabs">
<button
v-for="(s, key) in scenarios"
:key="key"
class="tab-btn"
:class="{ active: currentScenarioKey === key }"
@click="setScenario(key)"
>
{{ s.name }}
</button>
</div>
<div class="demo-grid">
<!-- Left: Chat Simulator -->
<div class="chat-panel">
<div class="panel-header">📱 用户视角 (Chat)</div>
<div class="chat-window">
<div v-for="(msg, idx) in currentStep.layers.chat" :key="idx" class="chat-bubble" :class="msg.startsWith('User') ? 'user' : 'ai'">
{{ msg.split(': ')[1] || msg }}
</div>
<div v-if="currentStep.user && !currentStep.layers.chat.some(m => m.includes(currentStep.user))" class="chat-bubble user pending">
{{ currentStep.user }}...
</div>
<div v-if="currentStep.ai_thinking" class="chat-bubble thinking">
💭 {{ currentStep.ai_thinking }}
</div>
</div>
<div class="controls">
<div class="step-info">步骤 {{ currentStepIndex + 1 }} / {{ currentScenario.steps.length }}</div>
<div class="btn-group">
<button class="nav-btn" @click="prevStep" :disabled="currentStepIndex === 0"> 上一步</button>
<button class="nav-btn primary" @click="nextStep">
{{ isLastStep ? '🔄 重新演示' : '下一步 ' }}
</button>
</div>
</div>
</div>
<!-- Right: Memory Palace Internals -->
<div class="palace-panel">
<div class="panel-header">🧠 AI 视角 (Context Construction)</div>
<div class="context-visualizer">
<!-- Layer 1: Base -->
<div class="layer-box base">
<div class="layer-label">
<span class="icon">🏛</span>
<span class="title">Layer 1: 地基 (System)</span>
<span class="badge">KV Cached</span>
</div>
<div class="layer-content">{{ currentStep.layers.base }}</div>
</div>
<!-- Layer 2: Task -->
<div class="layer-box task">
<div class="layer-label">
<span class="icon">📌</span>
<span class="title">Layer 2: 支柱 (Task)</span>
<span class="badge">Pinned</span>
</div>
<div class="layer-content">{{ currentStep.layers.task }}</div>
</div>
<!-- Layer 3: Chat -->
<div class="layer-box chat">
<div class="layer-label">
<span class="icon">💬</span>
<span class="title">Layer 3: 客厅 (Chat)</span>
<span class="badge">Sliding</span>
</div>
<div class="layer-content">
<div v-for="(m, i) in currentStep.layers.chat" :key="i" class="mini-line">{{ m }}</div>
<div v-if="currentStep.layers.chat.length === 0" class="empty-hint">(暂无对话历史)</div>
</div>
</div>
<!-- Layer 4: RAG -->
<div class="layer-box rag" :class="{ active: currentStep.layers.rag.length > 0 }">
<div class="layer-label">
<span class="icon">📚</span>
<span class="title">Layer 4: 图书馆 (RAG)</span>
<span class="badge ephemeral">Temp</span>
</div>
<div class="layer-content">
<div v-for="(r, i) in currentStep.layers.rag" :key="i" class="rag-item">{{ r }}</div>
<div v-if="currentStep.layers.rag.length === 0" class="empty-hint">(当前无需检索)</div>
</div>
</div>
</div>
<!-- Explanation Footer -->
<div class="step-desc">
<strong>💡 这一步发生了什么</strong>
{{ currentStep.desc }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.action-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
margin: 1.5rem 0;
overflow: hidden;
font-size: 14px;
}
.scenario-tabs {
display: flex;
background: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
}
.tab-btn {
flex: 1;
padding: 10px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
border-bottom: 2px solid transparent;
}
.tab-btn:hover {
background: var(--vp-c-bg-alt);
}
.tab-btn.active {
color: var(--vp-c-brand);
border-bottom-color: var(--vp-c-brand);
background: var(--vp-c-bg-soft);
}
.demo-grid {
display: grid;
grid-template-columns: 1fr 1.2fr;
min-height: 400px;
}
@media (max-width: 768px) {
.demo-grid {
grid-template-columns: 1fr;
}
}
/* Chat Panel */
.chat-panel {
border-right: 1px solid var(--vp-c-divider);
display: flex;
flex-direction: column;
background: var(--vp-c-bg);
}
.panel-header {
padding: 10px;
font-weight: bold;
background: var(--vp-c-bg-alt);
border-bottom: 1px solid var(--vp-c-divider);
text-align: center;
font-size: 0.9em;
}
.chat-window {
flex: 1;
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
background: #f9f9f9;
}
.dark .chat-window {
background: #1e1e20;
}
.chat-bubble {
max-width: 85%;
padding: 8px 12px;
border-radius: 12px;
font-size: 0.9em;
line-height: 1.4;
}
.chat-bubble.user {
align-self: flex-end;
background: var(--vp-c-brand);
color: white;
border-bottom-right-radius: 2px;
}
.chat-bubble.ai {
align-self: flex-start;
background: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
border-bottom-left-radius: 2px;
}
.chat-bubble.thinking {
align-self: center;
background: transparent;
color: var(--vp-c-text-2);
font-style: italic;
font-size: 0.85em;
border: 1px dashed var(--vp-c-divider);
}
.chat-bubble.pending {
opacity: 0.6;
}
.controls {
padding: 15px;
border-top: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
}
.step-info {
text-align: center;
font-size: 0.8em;
color: var(--vp-c-text-2);
margin-bottom: 8px;
}
.btn-group {
display: flex;
gap: 10px;
}
.nav-btn {
flex: 1;
padding: 6px 12px;
border-radius: 4px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg-alt);
font-size: 0.9em;
cursor: pointer;
}
.nav-btn:hover:not(:disabled) {
background: var(--vp-c-bg-soft);
}
.nav-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.nav-btn.primary {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.nav-btn.primary:hover {
background: var(--vp-c-brand-dark);
}
/* Palace Panel */
.palace-panel {
display: flex;
flex-direction: column;
background: var(--vp-c-bg-soft);
}
.context-visualizer {
flex: 1;
padding: 15px;
display: flex;
flex-direction: column;
gap: 8px;
overflow-y: auto;
}
.layer-box {
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
padding: 8px;
transition: all 0.3s;
}
.layer-label {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 6px;
font-size: 0.85em;
}
.title {
font-weight: bold;
}
.badge {
margin-left: auto;
font-size: 0.7em;
padding: 2px 6px;
border-radius: 4px;
background: var(--vp-c-divider);
color: var(--vp-c-text-2);
}
.badge.ephemeral {
background: #e74c3c;
color: white;
}
.layer-content {
font-family: var(--vp-font-mono);
font-size: 0.8em;
color: var(--vp-c-text-2);
background: var(--vp-c-bg-alt);
padding: 6px;
border-radius: 4px;
white-space: pre-wrap;
max-height: 80px;
overflow-y: auto;
}
.mini-line {
margin-bottom: 2px;
border-bottom: 1px solid var(--vp-c-divider);
padding-bottom: 2px;
}
.rag-item {
color: #27ae60;
margin-bottom: 2px;
}
.empty-hint {
color: var(--vp-c-text-3);
font-style: italic;
font-size: 0.8em;
}
/* Layer specific styling */
.base .layer-label { color: var(--vp-c-brand); }
.base .badge { background: var(--vp-c-brand); color: white; }
.task .layer-label { color: #8e44ad; }
.task .badge { background: #8e44ad; color: white; }
.chat .layer-label { color: #e67e22; }
.rag { border-style: dashed; opacity: 0.6; }
.rag.active { opacity: 1; border-color: #27ae60; background: rgba(39, 174, 96, 0.05); }
.rag .layer-label { color: #27ae60; }
.step-desc {
padding: 12px;
background: #fff9c4;
color: #555;
font-size: 0.9em;
border-top: 1px solid #e0e0e0;
line-height: 1.4;
}
.dark .step-desc {
background: #333322;
color: #ddd;
border-top-color: #444;
}
</style>
@@ -0,0 +1,337 @@
<!--
* Component: MemoryPalaceDemo.vue
* Description: Visualizes the "Memory Palace" 4-layer context structure.
* Features:
* - Step-by-step assembly of the context layers
* - Visual distinction between Static (Cached) and Dynamic parts
* - Explains the purpose of each layer
-->
<script setup>
import { ref, computed } from 'vue'
const currentStep = ref(0)
const steps = [
{
id: 'base',
title: '第一层:地基 (System)',
desc: '系统设定、身份、原则',
detail: '✅ 永远不变,利用 KV Cache 实现 0 成本背诵',
color: 'var(--vp-c-brand)',
icon: '🏛️'
},
{
id: 'task',
title: '第二层:支柱 (Task)',
desc: '当前任务目标、用户画像',
detail: '📌 任务期内“钉死”,保证方向不偏',
color: '#8e44ad',
icon: '📌'
},
{
id: 'chat',
title: '第三层:客厅 (Chat)',
desc: '最近 5-10 轮对话',
detail: '🔄 滑动窗口,旧的自动腾出空间',
color: '#e67e22',
icon: '💬'
},
{
id: 'rag',
title: '第四层:图书馆 (RAG)',
desc: '按需检索的知识',
detail: '📚 不占脑子,用时再查,无限扩展',
color: '#27ae60',
icon: '🔍'
}
]
const nextStep = () => {
if (currentStep.value < 4) {
currentStep.value++
} else {
currentStep.value = 0
}
}
const isComplete = computed(() => currentStep.value === 4)
</script>
<template>
<div class="memory-palace-demo">
<!-- Visual Area -->
<div class="palace-container">
<div class="palace-stack">
<!-- Layer 4: RAG (Top/Side) -->
<div
class="layer-block rag-layer"
:class="{ visible: currentStep >= 4 }"
>
<div class="layer-content">
<span class="icon">{{ steps[3].icon }}</span>
<div class="text">
<div class="layer-title">{{ steps[3].title }}</div>
<div class="layer-desc">{{ steps[3].desc }}</div>
</div>
</div>
<div class="layer-detail" v-if="currentStep >= 4">{{ steps[3].detail }}</div>
</div>
<!-- Layer 3: Chat -->
<div
class="layer-block chat-layer"
:class="{ visible: currentStep >= 3 }"
>
<div class="layer-content">
<span class="icon">{{ steps[2].icon }}</span>
<div class="text">
<div class="layer-title">{{ steps[2].title }}</div>
<div class="layer-desc">{{ steps[2].desc }}</div>
</div>
</div>
<div class="layer-detail" v-if="currentStep >= 3">{{ steps[2].detail }}</div>
</div>
<!-- Layer 2: Task -->
<div
class="layer-block task-layer"
:class="{ visible: currentStep >= 2 }"
>
<div class="layer-content">
<span class="icon">{{ steps[1].icon }}</span>
<div class="text">
<div class="layer-title">{{ steps[1].title }}</div>
<div class="layer-desc">{{ steps[1].desc }}</div>
</div>
</div>
<div class="layer-detail" v-if="currentStep >= 2">{{ steps[1].detail }}</div>
</div>
<!-- Layer 1: Base -->
<div
class="layer-block base-layer"
:class="{ visible: currentStep >= 1 }"
>
<div class="layer-content">
<span class="icon">{{ steps[0].icon }}</span>
<div class="text">
<div class="layer-title">{{ steps[0].title }}</div>
<div class="layer-desc">{{ steps[0].desc }}</div>
</div>
</div>
<div class="layer-detail" v-if="currentStep >= 1">{{ steps[0].detail }}</div>
</div>
<!-- Empty State Placeholder -->
<div class="empty-placeholder" v-if="currentStep === 0">
🚧 空地点击下方按钮开始建造记忆宫殿
</div>
</div>
</div>
<!-- Control Area -->
<div class="control-area">
<div class="step-indicator">
当前进度: {{ currentStep }}/4
</div>
<button class="build-btn" @click="nextStep" :class="{ 'reset-mode': isComplete }">
{{ isComplete ? '🔄 重置重建' : (currentStep === 0 ? '🏗️ 开始建造' : ' 添加下一层') }}
</button>
</div>
<!-- Explanation Box -->
<div class="explanation-box" v-if="currentStep > 0">
<div class="exp-title">为什么这样设计</div>
<div class="exp-content" v-if="currentStep === 1">
**地基最稳** System Prompt 放在最前面利用 KV Cache 机制 AI "背下来"后续请求**速度快且免费**
</div>
<div class="exp-content" v-if="currentStep === 2">
**目标明确**无论聊得多嗨任务目标写一个 Python 爬虫必须**钉死**防止 AI 聊偏了
</div>
<div class="exp-content" v-if="currentStep === 3">
**保持鲜活**最近的对话最重要用滑动窗口保留**旧的自动忘掉**给新信息腾地方
</div>
<div class="exp-content" v-if="currentStep === 4">
**无限外脑**遇到不懂的不要瞎编图书馆查资料**用完即走**不占宝贵的脑容量
</div>
</div>
</div>
</template>
<style scoped>
.memory-palace-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
background-color: var(--vp-c-bg-soft);
margin: 1.5rem 0;
overflow: hidden;
}
.palace-container {
padding: 2rem;
min-height: 320px;
display: flex;
align-items: flex-end; /* Stack from bottom */
justify-content: center;
background: linear-gradient(to top, var(--vp-c-bg-alt), var(--vp-c-bg));
}
.palace-stack {
width: 100%;
max-width: 400px;
display: flex;
flex-direction: column-reverse; /* Stack from bottom */
gap: 8px;
position: relative;
}
.layer-block {
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
opacity: 0;
transform: translateY(20px) scale(0.95);
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.layer-block.visible {
opacity: 1;
transform: translateY(0) scale(1);
}
/* Layer Specific Styles */
.base-layer {
border-color: var(--vp-c-brand);
border-bottom-width: 6px; /* Heavy foundation */
background: var(--vp-c-brand-dimm);
}
.task-layer {
border-color: #8e44ad;
background: rgba(142, 68, 173, 0.1);
margin: 0 10px; /* Slightly narrower */
}
.chat-layer {
border-color: #e67e22;
background: rgba(230, 126, 34, 0.1);
margin: 0 20px; /* Narrower */
}
.rag-layer {
border-color: #27ae60;
border-style: dashed;
background: rgba(39, 174, 96, 0.1);
margin: 0 30px; /* Narrowest */
}
.layer-content {
display: flex;
align-items: center;
gap: 1rem;
}
.icon {
font-size: 1.5rem;
}
.layer-title {
font-weight: bold;
font-size: 0.95rem;
color: var(--vp-c-text-1);
}
.layer-desc {
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.layer-detail {
font-size: 0.75rem;
background: rgba(255,255,255,0.5);
padding: 4px 8px;
border-radius: 4px;
color: var(--vp-c-text-1);
display: inline-block;
align-self: flex-start;
}
.dark .layer-detail {
background: rgba(0,0,0,0.3);
}
.empty-placeholder {
text-align: center;
color: var(--vp-c-text-3);
padding: 2rem;
border: 2px dashed var(--vp-c-divider);
border-radius: 8px;
}
/* Controls */
.control-area {
padding: 1rem;
background: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-divider);
display: flex;
justify-content: space-between;
align-items: center;
}
.step-indicator {
font-family: var(--vp-font-mono);
font-size: 0.9rem;
color: var(--vp-c-text-2);
}
.build-btn {
background: var(--vp-c-brand);
color: white;
padding: 0.6rem 1.2rem;
border-radius: 6px;
font-weight: 600;
transition: all 0.2s;
}
.build-btn:hover {
background: var(--vp-c-brand-dark);
transform: translateY(-1px);
}
.build-btn.reset-mode {
background: var(--vp-c-text-3);
}
/* Explanation */
.explanation-box {
padding: 1rem;
background: var(--vp-c-bg-alt);
border-top: 1px solid var(--vp-c-divider);
}
.exp-title {
font-size: 0.8rem;
font-weight: bold;
color: var(--vp-c-text-2);
margin-bottom: 0.5rem;
text-transform: uppercase;
}
.exp-content {
font-size: 0.9rem;
line-height: 1.5;
color: var(--vp-c-text-1);
animation: fadeIn 0.3s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
</style>
@@ -1,11 +1,6 @@
<!--
* Component: RAGSimulationDemo.vue
* Description: Demonstrates the Retrieval-Augmented Generation (RAG) process.
* Features:
* - Interactive search simulation
* - Visual representation of Vector DB and Document retrieval
* - Step-by-step animation of the RAG pipeline
* - Visualization of context augmentation
* Description: Demonstrates the Retrieval-Augmented Generation (RAG) process with a vertical, intuitive flow.
-->
<script setup>
@@ -14,65 +9,48 @@ import { ref, computed } from 'vue'
const query = ref('如何重置密码?')
const lastQuery = ref('')
const isSearching = ref(false)
const currentStep = ref(0)
const searchTime = ref(0)
const currentStep = ref(0) // 0: Idle, 1: Searching/Scanning, 2: Retrieved/Assembling, 3: Done
const documents = ref([
{
id: 1,
title: '密码重置指南',
content: '用户可以通过点击设置页面的"忘记密码"链接来重置密码。系统会发送验证邮件。',
vector: [0.12, 0.88, 0.05],
score: 0
},
{
id: 2,
title: '定价策略',
content: '基础版每月 $10,专业版每月 $29。企业版需要联系销售团队获取报价。',
vector: [0.85, 0.15, 0.10],
score: 0
},
{
id: 3,
title: 'API 文档',
content: '所有 API 请求都需要在 Header 中包含 Bearer Token 进行身份验证。',
vector: [0.30, 0.20, 0.95],
score: 0
},
{
id: 4,
title: '账户安全',
content: '为了账户安全,建议开启双重认证 (2FA)。定期修改密码也是好习惯。',
vector: [0.15, 0.85, 0.12],
score: 0
}
])
const steps = [
{ id: 1, label: 'Embedding', desc: '将问题转换为向量' },
{ id: 2, label: 'Similarity Search', desc: '计算向量相似度' },
{ id: 3, label: 'Retrieval', desc: '提取 Top-K 相关文档' },
{ id: 4, label: 'Augmentation', desc: '注入上下文窗口' }
]
const retrievedDocs = computed(() => {
return documents.value
.filter(doc => doc.score > 0.7)
.filter(doc => doc.score > 0.6)
.sort((a, b) => b.score - a.score)
})
const calculateSimilarity = (q, docVector) => {
// Mock similarity calculation based on keywords for demo purposes
// In reality, this would be a vector dot product
if (q.includes('密码') || q.includes('安全')) {
if (docVector[1] > 0.8) return 0.92 + (Math.random() * 0.05)
if (docVector[0] > 0.8) return 0.15
return 0.4 + (Math.random() * 0.1)
}
if (q.includes('价格') || q.includes('多少钱')) {
if (docVector[0] > 0.8) return 0.95
return 0.1
}
const calculateSimilarity = (q, docContent) => {
// Simple keyword matching simulation
if (q.includes('密码') && (docContent.includes('密码') || docContent.includes('安全'))) return 0.95
if (q.includes('价格') && docContent.includes('')) return 0.9
if (q.includes('API') && docContent.includes('API')) return 0.9
// Random noise for non-matches
return Math.random() * 0.3
}
@@ -82,470 +60,387 @@ const search = async () => {
isSearching.value = true
lastQuery.value = query.value
currentStep.value = 1
searchTime.value = 0
// Reset scores
documents.value.forEach(d => d.score = 0)
// Step 1: Embedding (Simulated)
await new Promise(r => setTimeout(r, 800))
currentStep.value = 2
// Step 2: Search
const startTime = performance.now()
documents.value.forEach(doc => {
doc.score = calculateSimilarity(query.value, doc.vector)
})
await new Promise(r => setTimeout(r, 800))
searchTime.value = Math.round(performance.now() - startTime) + 45 // Add base latency
currentStep.value = 3
// Step 3: Retrieval
await new Promise(r => setTimeout(r, 800))
currentStep.value = 4
// Step 1: Simulate Scanning (1.5s)
await new Promise(r => setTimeout(r, 600))
// Calculate scores
documents.value.forEach(doc => {
doc.score = calculateSimilarity(query.value, doc.content + doc.title)
})
await new Promise(r => setTimeout(r, 800)) // Wait for scan animation to finish visual impact
currentStep.value = 2 // Transition to retrieval
// Step 2: Assemble Context (1s)
await new Promise(r => setTimeout(r, 1000))
currentStep.value = 3 // Done
// Step 4: Complete
await new Promise(r => setTimeout(r, 800))
isSearching.value = false
}
</script>
<template>
<div class="rag-simulation-demo">
<!-- Control Panel -->
<div class="control-panel">
<div class="search-bar">
<div class="rag-demo">
<!-- Step 1: User Input -->
<div class="step-section input-section">
<div class="step-label">
<span class="step-num">1</span>
<span class="step-text">用户提问 (User Query)</span>
</div>
<div class="search-box">
<input
v-model="query"
type="text"
placeholder="输入问题 (例如: 怎么重置密码?)"
placeholder="输入问题..."
@keyup.enter="search"
:disabled="isSearching"
/>
<button
class="search-btn"
class="action-btn"
@click="search"
:disabled="isSearching || !query"
>
{{ isSearching ? '检索中...' : '🔍 开始检索' }}
{{ isSearching ? '检索中...' : '🚀 开始检索' }}
</button>
</div>
<div class="step-indicator">
<div
v-for="s in steps"
:key="s.id"
class="step-dot"
:class="{ active: currentStep >= s.id, current: currentStep === s.id }"
:title="s.label"
></div>
</div>
</div>
<!-- Main Visualization -->
<div class="viz-container">
<!-- Arrow Connection -->
<div class="flow-arrow" :class="{ active: currentStep >= 1 }">
<div class="line"></div>
<div class="icon">🔍</div>
</div>
<!-- Step 2: Library Scanning -->
<div class="step-section library-section" :class="{ 'is-scanning': currentStep === 1 }">
<div class="step-label">
<span class="step-num">2</span>
<span class="step-text">图书馆检索 (Retrieval)</span>
<span class="status-badge" v-if="currentStep === 1">正在扫描...</span>
<span class="status-badge success" v-if="currentStep >= 2">命中 {{ retrievedDocs.length }} </span>
</div>
<!-- Left: Vector Database -->
<div class="panel vector-db" :class="{ dimmed: currentStep === 4 }">
<div class="panel-header">
<span class="icon">🗄</span> 向量数据库 (Knowledge Base)
</div>
<div class="doc-list">
<div
v-for="doc in documents"
:key="doc.id"
class="doc-card"
:class="{
'scanning': currentStep === 2,
'matched': doc.score > 0.7 && currentStep >= 3,
'rejected': doc.score <= 0.7 && currentStep >= 3
}"
:style="{ '--score': doc.score }"
>
<div class="doc-icon">📄</div>
<div class="doc-info">
<div class="doc-title">{{ doc.title }}</div>
<div class="doc-preview">{{ doc.content.substring(0, 20) }}...</div>
</div>
<div class="doc-score" v-if="currentStep >= 2 && doc.score > 0">
{{ (doc.score * 100).toFixed(0) }}%
</div>
<div class="vector-visual">
<span v-for="(v,i) in doc.vector" :key="i" :style="{ height: v * 10 + 'px' }"></span>
</div>
</div>
</div>
</div>
<!-- Center: Pipeline Visuals -->
<div class="pipeline-arrow">
<div class="arrow-line" :class="{ active: isSearching }"></div>
<div class="pipeline-status" v-if="currentStep > 0">
{{ steps[currentStep - 1]?.label }}
</div>
</div>
<!-- Right: Augmented Context -->
<div class="panel context-window" :class="{ active: currentStep === 4 }">
<div class="panel-header">
<span class="icon">🤖</span> 增强后的上下文 (Final Prompt)
</div>
<div class="prompt-content">
<div class="prompt-section system">
<span class="tag">System</span>
<p>你是一个帮助用户的 AI 助手请基于以下上下文回答用户的问题</p>
<div class="docs-grid">
<div
v-for="doc in documents"
:key="doc.id"
class="doc-card"
:class="{
'matched': doc.score > 0.6 && currentStep >= 2,
'ignored': doc.score <= 0.6 && currentStep >= 2
}"
>
<div class="doc-header">
<span class="doc-icon">📄</span>
<span class="doc-title">{{ doc.title }}</span>
<span class="doc-score" v-if="currentStep >= 2 && doc.score > 0.6">
{{ (doc.score * 100).toFixed(0) }}% 相关
</span>
</div>
<div class="doc-content">{{ doc.content }}</div>
<div class="prompt-section context" v-if="currentStep >= 3">
<span class="tag">Context (RAG)</span>
<div v-if="retrievedDocs.length > 0">
<div v-for="doc in retrievedDocs" :key="doc.id" class="retrieved-item">
<span class="bullet"></span> {{ doc.content }}
</div>
</div>
<div v-else class="empty-context">
(暂无相关文档)
</div>
</div>
<div class="prompt-section user" v-if="lastQuery">
<span class="tag">User</span>
<p>{{ lastQuery }}</p>
</div>
<div class="placeholder-text" v-else>
等待查询...
</div>
<!-- Visual effect for scanning -->
<div class="scan-line" v-if="currentStep === 1"></div>
</div>
</div>
</div>
<!-- Metrics Footer -->
<div class="metrics-footer">
<div class="metric">
<span class="label">检索耗时:</span>
<span class="value">{{ searchTime }} ms</span>
<!-- Arrow Connection -->
<div class="flow-arrow" :class="{ active: currentStep >= 2 }">
<div class="line"></div>
<div class="icon"> 复制粘贴</div>
</div>
<!-- Step 3: Context Assembly -->
<div class="step-section context-section" :class="{ active: currentStep >= 3 }">
<div class="step-label">
<span class="step-num">3</span>
<span class="step-text">最终上下文 (Final Prompt)</span>
</div>
<div class="metric">
<span class="label">命中数量:</span>
<span class="value">{{ retrievedDocs.length }} docs</span>
<div class="blackboard">
<div class="chalk-text system">
<span class="role-badge">SYSTEM</span>
你是一个专业的 AI 助手请基于下方检索到的资料回答用户的提问
</div>
<div class="retrieved-block" v-if="currentStep >= 2">
<div class="block-header">📚 检索到的资料 (Context)</div>
<div v-if="retrievedDocs.length > 0">
<div v-for="doc in retrievedDocs" :key="doc.id" class="retrieved-item">
{{ doc.content }}
</div>
</div>
<div v-else class="empty-state">
(未找到相关资料)
</div>
</div>
<div class="chalk-text user">
<span class="role-badge">USER</span>
{{ lastQuery || '等待提问...' }}
</div>
</div>
</div>
</div>
</template>
<style scoped>
.rag-simulation-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
overflow: hidden;
margin: 1rem 0;
.rag-demo {
display: flex;
flex-direction: column;
gap: 0;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 1.5rem;
max-width: 600px;
margin: 1rem auto;
}
.control-panel {
.step-section {
position: relative;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
background-color: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
transition: all 0.3s ease;
}
.search-bar {
.step-label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.8rem;
font-weight: 600;
font-size: 0.95rem;
color: var(--vp-c-text-1);
}
.step-num {
background: var(--vp-c-brand);
color: white;
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: bold;
}
/* Input Section */
.search-box {
display: flex;
gap: 0.5rem;
flex: 1;
min-width: 280px;
}
input {
flex: 1;
padding: 0.5rem 0.8rem;
padding: 0.6rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background-color: var(--vp-c-bg-alt);
color: var(--vp-c-text-1);
}
input:focus {
border-color: var(--vp-c-brand);
outline: none;
background: var(--vp-c-bg-alt);
font-size: 0.9rem;
}
.search-btn {
padding: 0.5rem 1rem;
background-color: var(--vp-c-brand);
.action-btn {
background: var(--vp-c-brand);
color: white;
padding: 0.5rem 1rem;
border-radius: 6px;
font-weight: 500;
transition: background-color 0.2s;
transition: opacity 0.2s;
}
.search-btn:hover:not(:disabled) {
background-color: var(--vp-c-brand-dark);
}
.search-btn:disabled {
.action-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.step-indicator {
display: flex;
gap: 0.4rem;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--vp-c-divider);
transition: all 0.3s;
}
.step-dot.active {
background-color: var(--vp-c-brand);
}
.step-dot.current {
transform: scale(1.4);
box-shadow: 0 0 4px var(--vp-c-brand);
}
/* Viz Container */
.viz-container {
display: flex;
padding: 1.5rem;
gap: 1rem;
background-color: var(--vp-c-bg-alt);
min-height: 350px;
align-items: stretch;
}
.panel {
flex: 1;
background-color: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
display: flex;
flex-direction: column;
transition: all 0.5s ease;
}
.panel.dimmed {
opacity: 0.6;
filter: grayscale(0.5);
}
.panel.active {
border-color: var(--vp-c-brand);
box-shadow: 0 0 15px rgba(var(--vp-c-brand-rgb), 0.1);
}
.panel-header {
padding: 0.8rem;
font-weight: 600;
font-size: 0.9rem;
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 0.5rem;
background-color: var(--vp-c-bg-soft);
}
.doc-list {
padding: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
overflow-y: auto;
max-height: 300px;
/* Library Section */
.docs-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 0.8rem;
}
.doc-card {
padding: 0.6rem;
background: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 0.6rem;
font-size: 0.8rem;
position: relative;
overflow: hidden;
transition: all 0.3s ease;
}
.doc-card.matched {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-dimm);
transform: scale(1.02);
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
}
.doc-card.ignored {
opacity: 0.4;
filter: grayscale(0.8);
}
.doc-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.4rem;
font-weight: 600;
}
.doc-score {
color: var(--vp-c-brand);
font-size: 0.75rem;
}
.doc-content {
color: var(--vp-c-text-2);
line-height: 1.4;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* Scanning Animation */
.scan-line {
position: absolute;
top: 0;
left: -100%;
width: 50%;
height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
animation: scan 1s infinite;
pointer-events: none;
}
@keyframes scan {
0% { left: -100%; }
100% { left: 200%; }
}
/* Context Section */
.blackboard {
background: #2b2b2b;
color: #e0e0e0;
padding: 1rem;
border-radius: 6px;
font-family: 'Menlo', 'Monaco', 'Courier New', monospace;
font-size: 0.85rem;
line-height: 1.5;
border: 2px solid #444;
}
.role-badge {
display: inline-block;
background: #444;
color: #aaa;
padding: 1px 4px;
border-radius: 3px;
font-size: 0.7rem;
margin-right: 6px;
vertical-align: middle;
}
.chalk-text {
margin-bottom: 0.8rem;
}
.retrieved-block {
background: rgba(255, 255, 255, 0.1);
border-left: 3px solid var(--vp-c-brand);
padding: 0.6rem;
margin: 0.5rem 0 1rem 0;
animation: slideIn 0.5s ease-out;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
.block-header {
color: var(--vp-c-brand);
font-weight: bold;
font-size: 0.75rem;
margin-bottom: 0.4rem;
text-transform: uppercase;
}
.retrieved-item {
margin-bottom: 0.4rem;
padding-left: 0.8rem;
position: relative;
}
.retrieved-item::before {
content: "•";
position: absolute;
left: 0;
color: #888;
}
/* Arrows */
.flow-arrow {
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
justify-content: center;
height: 40px;
color: var(--vp-c-divider);
position: relative;
transition: all 0.3s;
background-color: var(--vp-c-bg);
}
.doc-card.scanning {
animation: pulse 1s infinite;
border-color: var(--vp-c-brand-dimm);
}
.doc-card.matched {
border-color: var(--vp-c-green);
background-color: var(--vp-c-green-dimm);
transform: translateX(5px);
}
.doc-card.rejected {
opacity: 0.5;
.flow-arrow .line {
position: absolute;
height: 100%;
width: 2px;
background: var(--vp-c-divider);
z-index: 0;
}
.doc-icon {
.flow-arrow .icon {
background: var(--vp-c-bg-soft);
padding: 4px;
z-index: 1;
font-size: 1.2rem;
}
.doc-info {
flex: 1;
overflow: hidden;
.flow-arrow.active .line {
background: var(--vp-c-brand);
}
.doc-title {
font-weight: 600;
color: var(--vp-c-text-1);
.flow-arrow.active .icon {
animation: bounce 1s infinite;
}
.doc-preview {
color: var(--vp-c-text-2);
.status-badge {
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.doc-score {
font-family: var(--vp-font-mono);
font-weight: bold;
color: var(--vp-c-brand);
}
.vector-visual {
display: flex;
gap: 2px;
align-items: flex-end;
height: 15px;
width: 20px;
}
.vector-visual span {
width: 4px;
background-color: var(--vp-c-text-3);
border-radius: 1px;
}
/* Pipeline Arrow */
.pipeline-arrow {
width: 40px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
}
.arrow-line {
width: 100%;
height: 4px;
background-color: var(--vp-c-divider);
border-radius: 2px;
transition: all 0.3s;
}
.arrow-line.active {
background: linear-gradient(90deg, var(--vp-c-brand), var(--vp-c-brand-light));
background-size: 200% 100%;
animation: flow 1s linear infinite;
}
.pipeline-status {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--vp-c-brand);
color: white;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 0.7rem;
white-space: nowrap;
z-index: 10;
}
/* Context Window */
.prompt-content {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
font-family: var(--vp-font-mono);
font-size: 0.85rem;
overflow-y: auto;
}
.prompt-section {
background-color: var(--vp-c-bg-soft);
padding: 0.8rem;
border-radius: 6px;
border-left: 3px solid transparent;
}
.prompt-section.system {
border-left-color: var(--vp-c-yellow);
}
.prompt-section.context {
border-left-color: var(--vp-c-green);
background-color: rgba(var(--vp-c-green-rgb), 0.1);
}
.prompt-section.user {
border-left-color: var(--vp-c-brand);
}
.tag {
display: inline-block;
font-size: 0.7rem;
font-weight: bold;
text-transform: uppercase;
margin-bottom: 0.4rem;
padding: 2px 6px;
border-radius: 4px;
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-2);
}
.retrieved-item {
margin-top: 0.4rem;
color: var(--vp-c-text-1);
}
.empty-context {
color: var(--vp-c-text-3);
font-style: italic;
text-align: center;
}
.placeholder-text {
text-align: center;
color: var(--vp-c-text-3);
margin-top: 2rem;
.status-badge.success {
background: rgba(16, 185, 129, 0.1);
color: #10b981;
}
/* Metrics Footer */
.metrics-footer {
display: flex;
justify-content: space-around;
padding: 0.8rem;
background-color: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-divider);
font-size: 0.85rem;
}
.metric .label {
color: var(--vp-c-text-2);
margin-right: 0.5rem;
}
.metric .value {
font-weight: bold;
color: var(--vp-c-text-1);
}
@keyframes flow {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
@media (max-width: 768px) {
.viz-container {
flex-direction: column;
}
.pipeline-arrow {
width: 100%;
height: 40px;
flex-direction: row;
}
.arrow-line {
width: 4px;
height: 100%;
}
@keyframes bounce {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(3px); }
}
</style>
@@ -17,12 +17,12 @@
<div class="stat-group">
<div class="stat-item">
<span class="value">{{ totalMessages }}</span>
<span class="label">Total Messages</span>
<span class="label">现在一共记了几条</span>
</div>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="value">{{ maxSlots }}</span>
<span class="label">Window Capacity</span>
<span class="label">黑板最多能记几条</span>
</div>
</div>
<div class="usage-bar">
@@ -39,8 +39,8 @@
<div class="context-section pinned-section">
<div class="section-header">
<span class="icon">📌</span>
<span class="title">Pinned Context (Protected)</span>
<span class="count">{{ pinnedMessages.length }} items</span>
<span class="title">钉住区永远保留的重要信息</span>
<span class="count">当前 {{ pinnedMessages.length }} </span>
</div>
<div class="message-list">
<transition-group name="list">
@@ -56,10 +56,10 @@
class="pin-btn active"
@click="togglePin(msg)"
:disabled="msg.role === 'System'"
title="Unpin message"
title="取消钉住"
>
<span v-if="msg.role === 'System'">🔒 Fixed</span>
<span v-else>📌 Unpin</span>
<span v-if="msg.role === 'System'">🔒 系统信息固定在这</span>
<span v-else>📌 取消钉住</span>
</button>
</div>
<div class="card-content">{{ msg.content }}</div>
@@ -72,8 +72,8 @@
<div class="context-section scrolling-section">
<div class="section-header">
<span class="icon">📜</span>
<span class="title">Scrolling Context (FIFO)</span>
<span class="count">{{ scrollingMessages.length }} items</span>
<span class="title">会被挤走的普通对话先进先出</span>
<span class="count">当前 {{ scrollingMessages.length }} </span>
</div>
<div class="message-list">
<transition-group name="list">
@@ -85,15 +85,15 @@
>
<div class="card-header">
<span class="role-badge">{{ msg.role }}</span>
<button class="pin-btn" @click="togglePin(msg)" title="Pin message">
📌 Pin
<button class="pin-btn" @click="togglePin(msg)" title="把这条钉在黑板上">
📌 钉住这条
</button>
</div>
<div class="card-content">{{ msg.content }}</div>
</div>
</transition-group>
<div v-if="scrollingMessages.length === 0" class="empty-state">
No scrolling messages...
这里是普通对话区暂时还空着
</div>
</div>
</div>
@@ -104,24 +104,24 @@
<input
v-model="newMessage"
@keyup.enter="sendMessage"
placeholder="Add a new fact or message..."
placeholder="在这里输入一条新的信息,比如“我叫小明”"
/>
<button class="send-btn" @click="sendMessage" :disabled="!newMessage.trim()">
Add
添加到黑板
</button>
</div>
<div class="presets">
<button class="preset-btn" @click="addPreset('My name is Alice.')">User: My name is Alice</button>
<button class="preset-btn" @click="addPreset('The password is 1234.')">User: Password is 1234</button>
<button class="preset-btn" @click="addPreset('我的名字叫 Alice')">用户我的名字叫 Alice</button>
<button class="preset-btn" @click="addPreset('系统密码是 1234')">用户系统密码是 1234</button>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>Note:</strong>
"选择性保留" 解决了滑动窗口的遗忘问题
System Prompt 通常永久钉住用户也可以通过某些机制 RAG 记忆模块将重要信息如名字密码钉在窗口中防止被挤出
<strong>说明</strong>
选择性保留就是重要的就钉在黑板上普通的让它自己滑走
系统提示通常永久钉住用户的关键信息如名字账号重要偏好也可以通过记忆模块或 RAG 钉在这里避免被新对话挤掉
</p>
</div>
</div>
@@ -194,7 +194,7 @@ const togglePin = (msg) => {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
padding: 1.5rem;
padding: 1rem;
margin: 1rem 0;
font-family: var(--vp-font-family-mono);
}
@@ -202,10 +202,10 @@ const togglePin = (msg) => {
.control-panel {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
gap: 1rem;
margin-bottom: 1rem;
background: var(--vp-c-bg);
padding: 1rem;
padding: 0.75rem;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
}
@@ -259,8 +259,8 @@ const togglePin = (msg) => {
.visualization-area {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
gap: 0.75rem;
margin-bottom: 1rem;
}
.context-section {
@@ -276,7 +276,7 @@ const togglePin = (msg) => {
}
.section-header {
padding: 0.5rem 1rem;
padding: 0.4rem 0.8rem;
background: var(--vp-c-bg-alt);
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
@@ -298,15 +298,15 @@ const togglePin = (msg) => {
}
.message-list {
padding: 1rem;
min-height: 80px;
padding: 0.5rem;
min-height: 60px;
}
.message-card {
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.75rem;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: var(--vp-c-bg);
transition: all 0.3s ease;
}
@@ -327,11 +327,11 @@ const togglePin = (msg) => {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
margin-bottom: 0.25rem;
}
.role-badge {
font-size: 0.7rem;
font-size: 0.65rem;
text-transform: uppercase;
font-weight: bold;
padding: 2px 6px;
@@ -344,8 +344,8 @@ const togglePin = (msg) => {
background: transparent;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
padding: 2px 8px;
font-size: 0.75rem;
padding: 2px 6px;
font-size: 0.7rem;
cursor: pointer;
color: var(--vp-c-text-2);
transition: all 0.2s;
@@ -369,19 +369,19 @@ const togglePin = (msg) => {
}
.card-content {
font-size: 0.9rem;
line-height: 1.4;
font-size: 0.85rem;
line-height: 1.3;
}
.empty-state {
text-align: center;
color: var(--vp-c-text-3);
font-style: italic;
font-size: 0.85rem;
font-size: 0.8rem;
}
.input-section {
margin-bottom: 1rem;
margin-bottom: 0.75rem;
}
.input-group {
@@ -392,7 +392,7 @@ const togglePin = (msg) => {
input {
flex: 1;
padding: 0.75rem;
padding: 0.5rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
@@ -405,13 +405,14 @@ input:focus {
}
.send-btn {
padding: 0 1.5rem;
padding: 0 1rem;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 6px;
font-weight: bold;
cursor: pointer;
font-size: 0.9rem;
}
.send-btn:disabled {
@@ -442,10 +443,10 @@ input:focus {
.info-box {
background-color: var(--vp-c-bg-alt);
padding: 1rem;
padding: 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.5;
font-size: 0.85rem;
line-height: 1.4;
color: var(--vp-c-text-2);
}
@@ -15,15 +15,15 @@
<div class="sliding-window-demo">
<div class="control-panel">
<div class="info-stat">
<span class="label">Window Size / 窗口大小</span>
<span class="value">{{ windowSize }} Messages</span>
<span class="label">窗口里最多能记住几条对话</span>
<span class="value">最多 {{ windowSize }} </span>
</div>
<div class="actions">
<button class="action-btn" @click="autoPlay" :disabled="isAutoPlaying">
Auto Play
自动演示
</button>
<button class="action-btn outline" @click="reset">
Reset
重新开始
</button>
</div>
</div>
@@ -33,7 +33,7 @@
<!-- Forgotten / History Zone -->
<div class="zone history-zone">
<div class="zone-label">
<span class="icon">🗑</span> Forgotten (History)
<span class="icon">🗑</span> 已被遗忘的内容
</div>
<transition-group name="fade-list">
<div
@@ -50,21 +50,21 @@
</div>
</transition-group>
<div v-if="historyMessages.length === 0" class="empty-placeholder">
No history yet...
这里暂时还没有被挤出去的对话
</div>
</div>
<!-- Divider -->
<div class="window-divider">
<span> Out of Context</span>
<span> 窗口外模型已经看不到</span>
<div class="divider-line"></div>
<span> In Context</span>
<span> 窗口内模型还能看到</span>
</div>
<!-- Active Window Zone -->
<div class="zone active-zone">
<div class="zone-label">
<span class="icon">🖼</span> Active Context Window
<span class="icon">🖼</span> 当前还在记忆里的对话
</div>
<transition-group name="slide-list">
<div
@@ -81,7 +81,7 @@
</div>
</transition-group>
<div v-if="activeMessages.length === 0" class="empty-placeholder">
Start the conversation...
从这里开始聊天看看旧对话是怎么被挤出去
</div>
</div>
</div>
@@ -91,20 +91,20 @@
<input
v-model="newMessage"
@keyup.enter="sendMessage"
placeholder="Type a message..."
placeholder="在这里输入一条消息,然后点发送"
:disabled="isAutoPlaying"
/>
<button class="send-btn" @click="sendMessage" :disabled="!newMessage.trim() || isAutoPlaying">
Send
发送消息
</button>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>Note:</strong>
滑动窗口是最简单的记忆管理策略它保证了 Token 永远不会溢出但代价是"健忘"
一旦消息滑出窗口进入上方灰色区域模型就完全不知道它的存在
<strong>说明</strong>
滑动窗口是最简单的记忆管理方式新的进来旧的出去
好处是永远不会撑爆脑子代价就是一旦滑出窗口上面灰色区域模型就完全忘了它存在
</p>
</div>
</div>
@@ -151,25 +151,25 @@ const addMessage = (role, content) => {
const autoPlay = async () => {
isAutoPlaying.value = true
const script = [
"Hello there!",
"Hi! I'm an AI assistant.",
"What is your name?",
"I am Model GPT-X.",
"Do you remember my first message?",
"Yes, you said 'Hello there!'.",
"Tell me a joke.",
"Why did the chicken cross the road?",
"To get to the other side!",
"Haha, classic.",
"Wait, what was my name again?",
"I... I don't remember. It fell out of my context window!"
'你好,我是张三。',
'你好呀,我是你的 AI 助手。',
'我今天有点累,帮我记录一下待办吧。',
'没问题,你可以把待办一条条发给我。',
'第一件事:给客户发邮件。',
'好的,已经记下来了。',
'第二件事:晚上去买菜做饭。',
'收到,也帮你记住了。',
'第三件事:记得给女朋友买花。',
'这条也帮你写在“小黑板”上了。',
'现在还记得我第一句话说了什么吗?',
'呃……我只看得到窗口里的几条,最早那句已经被挤出去了。'
]
for (const line of script) {
if (!isAutoPlaying.value) break
const role = messages.value.length % 2 === 0 ? 'User' : 'AI'
addMessage(role, line)
await new Promise(r => setTimeout(r, 1500))
await new Promise((r) => setTimeout(r, 1500))
}
isAutoPlaying.value = false
}
@@ -186,7 +186,7 @@ const reset = () => {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
padding: 1.5rem;
padding: 1rem;
margin: 1rem 0;
font-family: var(--vp-font-family-mono);
}
@@ -195,9 +195,9 @@ const reset = () => {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
margin-bottom: 1rem;
background: var(--vp-c-bg);
padding: 1rem;
padding: 0.75rem;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
}
@@ -245,10 +245,10 @@ const reset = () => {
}
.visualization-area {
margin-bottom: 1.5rem;
margin-bottom: 1rem;
background: var(--vp-c-bg-alt);
border-radius: 8px;
padding: 1rem;
padding: 0.75rem;
border: 1px solid var(--vp-c-divider);
}
@@ -259,7 +259,7 @@ const reset = () => {
}
.zone {
padding: 1rem;
padding: 0.75rem;
border-radius: 6px;
transition: all 0.3s;
}
@@ -276,14 +276,14 @@ const reset = () => {
border: 2px solid var(--vp-c-brand);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin-top: 0.5rem;
min-height: 150px;
min-height: 100px;
}
.zone-label {
font-size: 0.8rem;
font-weight: bold;
color: var(--vp-c-text-2);
margin-bottom: 0.8rem;
margin-bottom: 0.5rem;
display: flex;
align-items: center;
gap: 0.5rem;
@@ -306,9 +306,9 @@ const reset = () => {
.message-bubble {
display: flex;
gap: 0.8rem;
margin-bottom: 0.8rem;
padding: 0.6rem;
gap: 0.5rem;
margin-bottom: 0.5rem;
padding: 0.5rem;
border-radius: 6px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
@@ -334,9 +334,9 @@ const reset = () => {
}
.avatar {
font-size: 1.2rem;
width: 2rem;
height: 2rem;
font-size: 1rem;
width: 1.5rem;
height: 1.5rem;
display: flex;
align-items: center;
justify-content: center;
@@ -347,37 +347,37 @@ const reset = () => {
.content {
display: flex;
flex-direction: column;
max-width: 80%;
max-width: 85%;
}
.role-name {
font-size: 0.7rem;
font-size: 0.65rem;
color: var(--vp-c-text-3);
margin-bottom: 0.2rem;
margin-bottom: 0.1rem;
}
.text {
font-size: 0.9rem;
line-height: 1.4;
font-size: 0.85rem;
line-height: 1.3;
}
.empty-placeholder {
text-align: center;
color: var(--vp-c-text-3);
font-style: italic;
padding: 1rem;
font-size: 0.9rem;
padding: 0.5rem;
font-size: 0.8rem;
}
.input-section {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
margin-bottom: 0.75rem;
}
input {
flex: 1;
padding: 0.75rem;
padding: 0.5rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
@@ -390,7 +390,7 @@ input:focus {
}
.send-btn {
padding: 0 1.5rem;
padding: 0 1rem;
background: var(--vp-c-brand);
color: white;
border: none;
@@ -398,6 +398,7 @@ input:focus {
font-weight: bold;
cursor: pointer;
transition: background 0.2s;
font-size: 0.9rem;
}
.send-btn:hover {
@@ -411,10 +412,10 @@ input:focus {
.info-box {
background-color: var(--vp-c-bg-alt);
padding: 1rem;
padding: 0.75rem;
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.5;
font-size: 0.85rem;
line-height: 1.4;
color: var(--vp-c-text-2);
}
@@ -0,0 +1,506 @@
<!--
BrowserRenderingDemo.vue
浏览器渲染演示 - 拆开包裹/装修房子类比
压缩版横向延展减少纵向高度
-->
<template>
<div class="unboxing-demo">
<!-- 紧凑头部标题+场景+步骤导航合并 -->
<div class="compact-header">
<div class="header-left">
<span class="title-icon">[包裹]</span>
<span class="header-title">拆开包裹代码如何变成画面</span>
</div>
<div class="header-steps">
<button
v-for="(step, index) in steps"
:key="step.id"
@click="goToStep(index)"
class="step-chip"
:class="{ active: currentStep === index, completed: currentStep > index }"
>
<span class="chip-num">{{ index + 1 }}</span>
<span class="chip-name">{{ step.shortName }}</span>
</button>
</div>
</div>
<!-- 横向主区域代码输入 | 处理可视化 | 屏幕输出 -->
<div class="main-stage">
<!-- 左侧代码输入 -->
<div class="stage-box input-box">
<div class="box-label">[代码包] 收到的代码</div>
<pre class="code-mini">&lt;div class="box"&gt;
&lt;h1&gt;Hello&lt;/h1&gt;
&lt;/div&gt;
&lt;style&gt;
.box { background: blue; }
&lt;/style&gt;</pre>
</div>
<!-- 中间处理过程 -->
<div class="stage-box process-box">
<div class="box-label">[{{ stepIcons[currentStep] }}] {{ steps[currentStep]?.name }}</div>
<!-- 步骤1: 解析HTML -->
<div v-if="currentStep === 0" class="process-content">
<div class="mini-tree">
<span class="tree-line">html</span>
<span class="tree-line indent"> body</span>
<span class="tree-line indent2 highlight"> div.box</span>
</div>
</div>
<!-- 步骤2: 解析CSS -->
<div v-else-if="currentStep === 1" class="process-content">
<div class="mini-css">
<span class="css-sel">.box</span> {<br/>
&nbsp;&nbsp;background: <span class="css-val">blue</span>;<br/>
}
</div>
</div>
<!-- 步骤3: 合并渲染树 -->
<div v-else-if="currentStep === 2" class="process-content">
<div class="mini-render">
<span class="render-tag">div.box</span>
<span class="render-arrow"></span>
<span class="render-style">蓝色背景</span>
</div>
</div>
<!-- 步骤4: 计算布局 -->
<div v-else-if="currentStep === 3" class="process-content">
<div class="mini-layout" :style="{ width: boxSize + 'px', height: boxSize + 'px' }">
{{ boxSize }}×{{ boxSize }}
</div>
</div>
<!-- 步骤5: 绘制像素 -->
<div v-else-if="currentStep === 4" class="process-content">
<div class="mini-layers">
<div class="layer-bar" :style="{ opacity: layerOp }">背景</div>
<div class="layer-bar" :style="{ opacity: layerOp }">文字</div>
</div>
</div>
<!-- 步骤6: 合成显示 -->
<div v-else class="process-content">
<div class="mini-final">
<strong>Hello</strong>
</div>
</div>
</div>
<!-- 右侧屏幕输出 -->
<div class="stage-box output-box">
<div class="box-label">[屏幕] 显示结果</div>
<div class="screen-mini">
<div class="browser-bar-mini">
<span></span><span></span><span></span>
</div>
<div class="screen-content" :class="{ styled: currentStep >= 4, final: currentStep >= 5 }">
<template v-if="currentStep >= 5">
<strong>Hello</strong>
</template>
<template v-else-if="currentStep >= 3">
内容
</template>
<template v-else>
...
</template>
</div>
</div>
</div>
</div>
<!-- 双栏对照说明 - 横向排列 -->
<div class="dual-bar">
<div class="bar-side">
<span class="bar-label">[生活] {{ steps[currentStep]?.analogy }}</span>
</div>
<div class="bar-divider"></div>
<div class="bar-side">
<span class="bar-label">[技术] {{ steps[currentStep]?.desc }}</span>
</div>
</div>
<!-- 控制按钮 -->
<div class="control-bar">
<button class="bar-btn" @click="prevStep" :disabled="currentStep <= 0">[上一步]</button>
<button class="bar-btn primary" @click="nextStep" :disabled="currentStep >= steps.length - 1">
{{ currentStep >= steps.length - 1 ? '[完成]' : '[下一步]' }}
</button>
<button class="bar-btn" @click="currentStep = 0">[重置]</button>
</div>
<!-- 完整流程 - 横向紧凑 -->
<div class="flow-bar" v-if="currentStep >= 5">
<div class="flow-items">
<span v-for="(step, i) in steps" :key="i" class="flow-item-mini" :class="{ highlight: i === 5 }">
[{{ stepIcons[i] }}] {{ step.shortName }}
</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, watch } from 'vue'
const currentStep = ref(0)
const boxSize = ref(0)
const layerOp = ref(0)
const stepIcons = ['清单', '图纸', '组装', '测量', '上色', '完成']
const steps = [
{
id: 'parse',
name: '解析 HTML',
shortName: '解析',
desc: '浏览器读取HTML代码,理解页面结构。',
analogy: '拆开包裹,先看清单。'
},
{
id: 'cssom',
name: '解析 CSS',
shortName: '样式',
desc: '浏览器读取CSS样式,知道每个元素的样子。',
analogy: '看装修设计图。'
},
{
id: 'render',
name: '合并渲染树',
shortName: '合并',
desc: '把HTML结构和CSS样式结合。',
analogy: '把结构图和设计图结合。'
},
{
id: 'layout',
name: '计算布局',
shortName: '布局',
desc: '计算每个元素在屏幕上的位置和大小。',
analogy: '丈量房间尺寸。'
},
{
id: 'paint',
name: '绘制像素',
shortName: '绘制',
desc: '把颜色、文字绘制到屏幕上。',
analogy: '刷漆、贴壁纸。'
},
{
id: 'composite',
name: '合成显示',
shortName: '显示',
desc: '把所有图层合成,最终显示。',
analogy: '装修完成!'
}
]
const goToStep = (step) => { currentStep.value = step }
const nextStep = () => { if (currentStep.value < steps.length - 1) currentStep.value++ }
const prevStep = () => { if (currentStep.value > 0) currentStep.value-- }
watch(currentStep, (newStep) => {
if (newStep === 3) { boxSize.value = 0; setTimeout(() => boxSize.value = 60, 100) }
if (newStep === 4) { layerOp.value = 0; setTimeout(() => layerOp.value = 1, 100) }
})
</script>
<style scoped>
.unboxing-demo {
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
border-radius: 12px;
padding: 16px;
margin: 16px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 紧凑头部 */
.compact-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--vp-c-divider);
flex-wrap: wrap;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.title-icon {
font-size: 12px;
color: var(--vp-c-brand);
font-weight: 600;
}
.header-title {
font-size: 15px;
font-weight: 700;
color: var(--vp-c-text-1);
}
.header-steps {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.step-chip {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 10px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
cursor: pointer;
font-size: 11px;
transition: all 0.2s;
}
.step-chip:hover { border-color: var(--vp-c-brand); }
.step-chip.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.step-chip.completed { border-color: #67c23a; color: #67c23a; }
.chip-num {
width: 14px;
height: 14px;
background: var(--vp-c-divider);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 9px;
}
.step-chip.active .chip-num { background: rgba(255,255,255,0.3); }
/* 横向主区域 */
.main-stage {
display: grid;
grid-template-columns: 1fr 1.5fr 1fr;
gap: 12px;
margin-bottom: 12px;
}
.stage-box {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 10px;
min-height: 100px;
}
.box-label {
font-size: 11px;
font-weight: 600;
color: var(--vp-c-text-2);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--vp-c-divider);
}
/* 代码输入 */
.code-mini {
font-family: monospace;
font-size: 10px;
line-height: 1.4;
color: var(--vp-c-text-2);
margin: 0;
white-space: pre-wrap;
}
/* 处理过程 */
.process-content {
display: flex;
align-items: center;
justify-content: center;
min-height: 70px;
}
.mini-tree {
font-family: monospace;
font-size: 10px;
line-height: 1.5;
}
.tree-line { display: block; }
.indent { margin-left: 12px; }
.indent2 { margin-left: 24px; }
.highlight { color: var(--vp-c-brand); font-weight: 600; }
.mini-css {
font-family: monospace;
font-size: 10px;
background: var(--vp-c-bg-soft);
padding: 8px 12px;
border-radius: 4px;
}
.css-sel { color: #e6a23c; }
.css-val { color: #409eff; }
.mini-render {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
}
.render-tag {
padding: 3px 8px;
background: var(--vp-c-brand);
color: white;
border-radius: 3px;
font-size: 10px;
}
.render-arrow { color: var(--vp-c-text-3); }
.render-style { color: var(--vp-c-text-2); }
.mini-layout {
background: var(--vp-c-brand);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 10px;
font-weight: 600;
transition: all 0.4s;
}
.mini-layers {
display: flex;
flex-direction: column;
gap: 4px;
width: 100%;
}
.layer-bar {
padding: 4px 8px;
background: var(--vp-c-bg-soft);
border-radius: 3px;
font-size: 10px;
text-align: center;
transition: opacity 0.4s;
}
.mini-final {
padding: 12px 20px;
background: linear-gradient(135deg, #409eff, #67c23a);
color: white;
border-radius: 4px;
font-size: 14px;
}
/* 屏幕输出 */
.screen-mini {
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
overflow: hidden;
}
.browser-bar-mini {
display: flex;
gap: 3px;
padding: 4px 6px;
background: var(--vp-c-divider);
}
.browser-bar-mini span {
width: 6px;
height: 6px;
border-radius: 50%;
background: #ccc;
}
.browser-bar-mini span:nth-child(1) { background: #ff5f57; }
.browser-bar-mini span:nth-child(2) { background: #febc2e; }
.browser-bar-mini span:nth-child(3) { background: #28c840; }
.screen-content {
padding: 16px;
text-align: center;
font-size: 11px;
color: var(--vp-c-text-3);
min-height: 50px;
display: flex;
align-items: center;
justify-content: center;
}
.screen-content.styled {
background: #409eff;
color: white;
}
.screen-content.final {
background: linear-gradient(135deg, #409eff, #67c23a);
}
/* 双栏对照 */
.dual-bar {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 8px;
align-items: center;
background: var(--vp-c-bg);
border-radius: 8px;
padding: 10px 12px;
margin-bottom: 10px;
}
.bar-side { text-align: center; }
.bar-label {
font-size: 11px;
color: var(--vp-c-text-2);
line-height: 1.4;
}
.bar-divider {
font-size: 12px;
color: var(--vp-c-brand);
font-weight: 700;
}
/* 控制按钮 */
.control-bar {
display: flex;
justify-content: center;
gap: 10px;
}
.bar-btn {
padding: 6px 16px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 14px;
cursor: pointer;
font-size: 11px;
color: var(--vp-c-text-2);
transition: all 0.2s;
}
.bar-btn:hover:not(:disabled) { border-color: var(--vp-c-brand); color: var(--vp-c-brand); }
.bar-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.bar-btn.primary { background: var(--vp-c-brand); border-color: var(--vp-c-brand); color: white; }
/* 流程条 */
.flow-bar {
margin-top: 10px;
padding-top: 10px;
border-top: 1px solid var(--vp-c-divider);
}
.flow-items {
display: flex;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
}
.flow-item-mini {
padding: 4px 10px;
background: var(--vp-c-bg);
border-radius: 12px;
font-size: 10px;
color: var(--vp-c-text-2);
}
.flow-item-mini.highlight {
background: linear-gradient(135deg, #67c23a, #85ce61);
color: white;
}
@media (max-width: 768px) {
.main-stage { grid-template-columns: 1fr; }
.compact-header { flex-direction: column; align-items: flex-start; }
.dual-bar { grid-template-columns: 1fr; gap: 4px; }
.bar-divider { display: none; }
}
</style>
@@ -0,0 +1,724 @@
<!--
DnsLookupDemo.vue
DNS查询演示 - 查地址簿 vs 真实DNS查询 双栏对照
用途
"查地址簿"的生活化比喻配合真实DNS查询过程
让0基础用户理解域名如何转换成IP地址
-->
<template>
<div class="dns-lookup-demo">
<!-- 标题区 -->
<div class="demo-header">
<div class="header-title">
<span class="title-icon">[地址簿]</span>
<span>查地址簿 vs DNS查询</span>
</div>
<div class="header-subtitle">生活比喻 技术实现 双栏对照</div>
</div>
<!-- 场景设置 -->
<div class="scenario-setup">
<div class="setup-text">
快递员要送包裹给 <strong>"{{ currentTarget.name }}"</strong>{{ currentTarget.domain }}
但他只知道名字不知道具体门牌号...
</div>
<div class="target-selector">
<span class="selector-label">换个目标</span>
<button
v-for="target in targets"
:key="target.name"
@click="selectTarget(target)"
class="target-chip"
:class="{ active: currentTarget.name === target.name }"
:disabled="isSearching"
>
{{ target.name }}
</button>
</div>
</div>
<!-- 开始查询按钮 -->
<div class="start-action" v-if="!isSearching && !showResult">
<button class="start-btn" @click="startSearch">
[查询] 开始查询地址
</button>
</div>
<!-- 双栏对照展示 -->
<div class="comparison-container" v-if="isSearching || showResult">
<!-- 左侧生活比喻查地址簿 -->
<div class="comparison-side analogy-side">
<div class="side-header">
<span class="side-icon">[生活]</span>
<span class="side-title">查地址簿流程</span>
</div>
<div class="analogy-flow">
<div
v-for="(level, index) in queryLevels"
:key="level.id"
class="flow-level"
:class="{
passed: currentStep > index,
current: currentStep === index,
pending: currentStep < index
}"
>
<div class="level-icon">{{ level.analogyIcon }}</div>
<div class="level-content">
<div class="level-name">{{ level.analogyName }}</div>
<div class="level-role">{{ level.analogyRole }}</div>
<div class="level-action" v-if="currentStep === index">
<span class="action-text">{{ level.analogyAction }}</span>
</div>
</div>
</div>
</div>
</div>
<!-- 中间连接指示 -->
<div class="connection-indicator">
<div class="indicator-line" v-for="i in 5" :key="i">
<span class="indicator-arrow"></span>
<span class="indicator-text">对应</span>
</div>
</div>
<!-- 右侧技术实现真实DNS -->
<div class="comparison-side tech-side">
<div class="side-header">
<span class="side-icon">[技术]</span>
<span class="side-title">DNS查询流程</span>
</div>
<div class="tech-flow">
<div
v-for="(level, index) in queryLevels"
:key="level.id"
class="flow-level"
:class="{
passed: currentStep > index,
current: currentStep === index,
pending: currentStep < index
}"
>
<div class="level-icon" :style="{ background: level.techColor }">{{ level.techIcon }}</div>
<div class="level-content">
<div class="level-name">{{ level.techName }}</div>
<div class="level-role">{{ level.techRole }}</div>
<div class="level-action" v-if="currentStep === index">
<code class="action-code">{{ level.techAction }}</code>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- 查询结果 -->
<div class="result-section" v-if="showResult">
<div class="result-card">
<div class="result-header">[成功] 查询成功</div>
<div class="result-body">
<div class="result-row">
<span class="result-label">域名名字</span>
<code class="result-value">{{ currentTarget.domain }}</code>
</div>
<div class="result-row">
<span class="result-label">IP地址门牌号</span>
<code class="result-value highlight">{{ currentTarget.ip }}</code>
</div>
</div>
</div>
<!-- 技术说明卡片 -->
<div class="tech-explanation">
<div class="explanation-header">
<span class="explanation-icon">[详解]</span>
<span>DNS查询技术详解</span>
</div>
<div class="explanation-body">
<div class="explanation-item">
<strong>[查询] 查询类型</strong>
<p><strong>递归查询</strong>浏览器只发一次请求本地DNS负责层层查询后返回结果像委托代理</p>
<p><strong>迭代查询</strong>每层只告诉下一层去哪查浏览器需要多次查询像自己跑腿</p>
</div>
<div class="explanation-item">
<strong>[缓存] 缓存机制</strong>
<p>查询结果会被缓存在浏览器操作系统路由器本地DNS服务器等多个层级下次直接返回大大加速访问</p>
</div>
<div class="explanation-item">
<strong>[] 根域名服务器</strong>
<p>全球只有13组根服务器字母A-M命名管理所有顶级域.com/.org/.cn等它们知道每个顶级域由谁管理</p>
</div>
</div>
</div>
<button class="reset-btn" @click="reset">[重置] 再查一次</button>
</div>
<!-- 层级说明未开始查询时显示 -->
<div class="levels-info" v-if="!isSearching && !showResult">
<div class="info-title">[对照] DNS查询层级对照表</div>
<div class="info-grid">
<div class="info-card" v-for="level in queryLevels" :key="level.id">
<div class="info-analogy">
<span class="info-icon">{{ level.analogyIcon }}</span>
<span class="info-name">{{ level.analogyName }}</span>
</div>
<div class="info-arrow"></div>
<div class="info-tech">
<span class="info-icon" :style="{ background: level.techColor }">{{ level.techIcon }}</span>
<span class="info-name">{{ level.techName }}</span>
</div>
<div class="info-desc">{{ level.description }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const targets = [
{ name: '百度', domain: 'baidu.com', ip: '110.242.68.66' },
{ name: '谷歌', domain: 'google.com', ip: '142.250.80.46' },
{ name: 'GitHub', domain: 'github.com', ip: '140.82.114.4' },
{ name: 'B站', domain: 'bilibili.com', ip: '120.92.78.57' }
]
const currentTarget = ref(targets[0])
const isSearching = ref(false)
const showResult = ref(false)
const currentStep = ref(0)
const queryLevels = [
{
id: 'browser',
analogyIcon: '自',
analogyName: '翻通讯录',
analogyRole: '快递员自己',
analogyAction: '先看看自己记没记过这个地址',
techIcon: '浏',
techName: '浏览器缓存',
techRole: '本地缓存',
techAction: '检查 DNS cache',
techColor: '#67c23a',
description: '浏览器会缓存最近访问过的域名,避免重复查询'
},
{
id: 'os',
analogyIcon: '本',
analogyName: '查记事本',
analogyRole: '自己的记录',
analogyAction: '看看之前有没有记过',
techIcon: '系',
techName: '操作系统缓存',
techRole: 'OS DNS Cache',
techAction: '检查 hosts 文件',
techColor: '#95d475',
description: '操作系统也有DNS缓存,hosts文件可手动指定域名映射'
},
{
id: 'recursive',
analogyIcon: '服',
analogyName: '社区服务中心',
analogyRole: '帮跑腿的人',
analogyAction: '帮用户查询,自己跑遍各部门',
techIcon: '递',
techName: '本地DNS服务器',
techRole: 'Recursive Resolver',
techAction: 'ISP DNS 查询',
techColor: '#409eff',
description: '通常由网络运营商提供,负责递归查询并缓存结果'
},
{
id: 'root',
analogyIcon: '根',
analogyName: '国务院',
analogyRole: '最高管理机构',
analogyAction: '.com 归谁管?去问它!',
techIcon: '根',
techName: '根域名服务器',
techRole: 'Root Server',
techAction: '返回 TLD 服务器地址',
techColor: '#e6a23c',
description: '全球13组,管理所有顶级域,知道.com/.cn等归谁管'
},
{
id: 'tld',
analogyIcon: '省',
analogyName: '省政府',
analogyRole: '省级管理机构',
analogyAction: 'baidu.com 归谁管?',
techIcon: '顶',
techName: '顶级域服务器',
techRole: 'TLD Server',
techAction: '返回权威DNS地址',
techColor: '#f56c6c',
description: '管理特定顶级域(如Verisign管理.com),知道具体域名归谁管'
},
{
id: 'auth',
analogyIcon: '户',
analogyName: '户籍系统',
analogyRole: '最终档案',
analogyAction: '查到具体门牌号了!',
techIcon: '权',
techName: '权威DNS服务器',
techRole: 'Authoritative DNS',
techAction: '返回 A 记录',
techColor: '#b37feb',
description: '域名所有者设置的DNS服务器,保存着域名到IP的最终映射'
}
]
const selectTarget = (target) => {
currentTarget.value = target
reset()
}
const startSearch = () => {
isSearching.value = true
showResult.value = false
currentStep.value = 0
//
const steps = [0, 1, 2, 3, 4, 5]
let i = 0
const nextStep = () => {
if (i < steps.length) {
currentStep.value = steps[i]
i++
setTimeout(nextStep, 800)
} else {
setTimeout(() => {
showResult.value = true
isSearching.value = false
}, 500)
}
}
nextStep()
}
const reset = () => {
isSearching.value = false
showResult.value = false
currentStep.value = 0
}
</script>
<style scoped>
.dns-lookup-demo {
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
border: 2px solid var(--vp-c-divider);
border-radius: 16px;
padding: 24px;
margin: 20px 0;
}
/* 头部 */
.demo-header {
text-align: center;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--vp-c-divider);
}
.header-title {
font-size: 18px;
font-weight: 700;
color: var(--vp-c-text-1);
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
}
.title-icon {
font-size: 14px;
color: var(--vp-c-brand);
font-weight: 600;
}
.header-subtitle {
font-size: 13px;
color: var(--vp-c-text-3);
margin-top: 4px;
}
/* 场景设置 */
.scenario-setup {
background: linear-gradient(135deg, rgba(64, 158, 255, 0.1), rgba(103, 194, 58, 0.1));
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.setup-text {
font-size: 14px;
color: var(--vp-c-text-1);
line-height: 1.6;
margin-bottom: 12px;
}
.setup-text strong {
color: var(--vp-c-brand);
}
.target-selector {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.selector-label {
font-size: 12px;
color: var(--vp-c-text-3);
}
.target-chip {
padding: 6px 12px;
background: white;
border: 1px solid var(--vp-c-divider);
border-radius: 16px;
cursor: pointer;
font-size: 12px;
color: var(--vp-c-text-2);
transition: all 0.2s;
}
.target-chip:hover:not(:disabled) {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.target-chip.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.target-chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* 开始按钮 */
.start-action {
text-align: center;
margin-bottom: 20px;
}
.start-btn {
padding: 12px 32px;
background: linear-gradient(135deg, var(--vp-c-brand), #67c23a);
color: white;
border: none;
border-radius: 24px;
cursor: pointer;
font-size: 15px;
font-weight: 600;
transition: all 0.3s;
}
.start-btn:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(64, 158, 255, 0.3);
}
/* 双栏对照容器 */
.comparison-container {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
margin-bottom: 20px;
}
/* 侧边栏 */
.comparison-side {
background: white;
border-radius: 12px;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
}
.side-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 16px;
font-weight: 600;
font-size: 14px;
color: white;
}
.analogy-side .side-header {
background: linear-gradient(90deg, #67c23a, #95d475);
}
.tech-side .side-header {
background: linear-gradient(90deg, #409eff, #79bbff);
}
.side-icon {
font-size: 12px;
font-weight: 600;
}
/* 流程展示 */
.analogy-flow, .tech-flow {
padding: 12px;
}
.flow-level {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
margin-bottom: 8px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
transition: all 0.3s;
opacity: 0.4;
}
.flow-level.passed {
opacity: 0.7;
border-color: #67c23a;
background: rgba(103, 194, 58, 0.1);
}
.flow-level.current {
opacity: 1;
border-color: var(--vp-c-brand);
background: rgba(64, 158, 255, 0.1);
transform: scale(1.02);
}
.level-icon {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
flex-shrink: 0;
}
.analogy-flow .level-icon {
background: #fff3e0;
color: #666;
}
.level-content {
flex: 1;
}
.level-name {
font-size: 13px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.level-role {
font-size: 11px;
color: var(--vp-c-text-3);
}
.level-action {
margin-top: 4px;
}
.action-text {
font-size: 11px;
color: var(--vp-c-brand);
background: white;
padding: 2px 8px;
border-radius: 10px;
}
.action-code {
font-size: 10px;
color: var(--vp-c-brand);
background: white;
padding: 2px 6px;
border-radius: 4px;
font-family: monospace;
}
/* 连接指示器 */
.connection-indicator {
display: flex;
flex-direction: column;
justify-content: space-around;
padding: 40px 0;
}
.indicator-line {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
}
.indicator-arrow {
font-size: 18px;
color: var(--vp-c-brand);
font-weight: bold;
}
.indicator-text {
font-size: 10px;
color: var(--vp-c-text-3);
writing-mode: vertical-rl;
}
/* 结果区域 */
.result-section {
animation: fadeIn 0.5s ease;
}
.result-card {
background: linear-gradient(135deg, #67c23a, #85ce61);
border-radius: 12px;
padding: 20px;
color: white;
margin-bottom: 16px;
}
.result-header {
font-size: 16px;
font-weight: 700;
margin-bottom: 12px;
}
.result-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
font-size: 14px;
}
.result-label {
opacity: 0.9;
}
.result-value {
font-family: monospace;
background: rgba(255,255,255,0.2);
padding: 4px 8px;
border-radius: 4px;
}
.result-value.highlight {
background: rgba(255,255,255,0.3);
font-weight: 600;
}
/* 技术说明 */
.tech-explanation {
background: white;
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
box-shadow: 0 2px 12px rgba(0,0,0,0.05);
}
.explanation-header {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 12px;
padding-bottom: 12px;
border-bottom: 1px solid var(--vp-c-divider);
}
.explanation-icon {
font-size: 12px;
font-weight: 600;
color: var(--vp-c-brand);
}
.explanation-item {
margin-bottom: 12px;
}
.explanation-item strong {
display: block;
font-size: 13px;
color: var(--vp-c-text-1);
margin-bottom: 4px;
}
.explanation-item p {
margin: 4px 0;
font-size: 12px;
color: var(--vp-c-text-2);
line-height: 1.5;
}
/* 重置按钮 */
.reset-btn {
display: block;
width: 100%;
padding: 10px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
cursor: pointer;
font-size: 13px;
color: var(--vp-c-text-2);
transition: all 0.2s;
}
.reset-btn:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
/* 层级信息 */
.levels-info {
margin-top: 20px;
}
.info-title {
font-size: 14px;
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 12px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.info-card {
background: white;
border-radius: 10px;
padding: 12px;
text-align: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.info-analogy, .info-tech {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.info-icon {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 600;
}
.info-analogy .info-icon {
background: #fff3e0;
color: #666;
}
.info-name {
font-size: 12px;
font-weight: 600;
}
.info-arrow {
font-size: 16px;
color: var(--vp-c-brand);
margin: 4px 0;
}
.info-desc {
font-size: 11px;
color: var(--vp-c-text-3);
margin-top: 8px;
line-height: 1.4;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* 响应式 */
@media (max-width: 768px) {
.comparison-container {
grid-template-columns: 1fr;
}
.connection-indicator {
flex-direction: row;
padding: 8px 0;
justify-content: center;
gap: 16px;
}
.indicator-text {
writing-mode: horizontal-tb;
}
}
</style>
@@ -0,0 +1,533 @@
<!--
HttpExchangeDemo.vue
HTTP请求响应演示 - 快递员送达对话类比
用途
"快递员和收件人对话"的生活化比喻让用户理解HTTP请求和响应的过程
把枯燥的HTTP协议变成直观的对话场景
-->
<template>
<div class="delivery-dialog-demo">
<!-- 标题 -->
<div class="dialog-header">
<span class="dialog-icon">[送达]</span>
<span class="dialog-title">送达对话请求与响应</span>
</div>
<!-- 场景选择 -->
<div class="scenario-selector">
<div class="selector-label">选择送达场景</div>
<div class="scenario-buttons">
<button
v-for="scene in scenarios"
:key="scene.id"
@click="selectScenario(scene)"
class="scenario-btn"
:class="{ active: currentScenario?.id === scene.id }"
:disabled="isDelivering"
>
<span class="btn-text">{{ scene.name }}</span>
</button>
</div>
</div>
<!-- 对话场景 -->
<div class="dialog-scene" v-if="currentScenario">
<div class="scene-background">
<!-- 快递员请求方 -->
<div class="character courier">
<div class="char-avatar"></div>
<div class="char-name">快递员浏览器</div>
</div>
<!-- 对话区域 -->
<div class="conversation-area">
<!-- 请求消息 -->
<div class="message request" :class="{ sent: step >= 1 }">
<div class="message-bubble">
<div class="bubble-header">
<span class="method-badge" :class="currentScenario.method.toLowerCase()">
{{ currentScenario.method }}
</span>
<span class="path-text">{{ currentScenario.path }}</span>
</div>
<div class="bubble-body">{{ currentScenario.requestText }}</div>
</div>
<div class="message-meta">请求</div>
</div>
<!-- 传输动画 -->
<div class="transit-animation" v-if="step === 2">
<div class="transit-line"></div>
<div class="transit-package"></div>
</div>
<!-- 响应消息 -->
<div class="message response" :class="{ sent: step >= 3 }">
<div class="message-meta">响应</div>
<div class="message-bubble" :class="currentScenario.statusType">
<div class="bubble-header">
<span class="status-badge" :class="currentScenario.statusType">
{{ currentScenario.status }}
</span>
<span class="status-text">{{ currentScenario.statusText }}</span>
</div>
<div class="bubble-body">{{ currentScenario.responseText }}</div>
</div>
</div>
</div>
<!-- 收件人响应方 -->
<div class="character recipient">
<div class="char-avatar"></div>
<div class="char-name">收件人服务器</div>
</div>
</div>
</div>
<!-- 控制按钮 -->
<div class="dialog-controls">
<button
class="control-btn primary"
@click="nextStep"
:disabled="isDelivering || step >= 3"
>
{{ step === 0 ? '[开始]' : step === 3 ? '对话完成' : '[下一步]' }}
</button>
<button class="control-btn" @click="reset" v-if="step > 0">
[重置]
</button>
</div>
<!-- 状态码说明 -->
<div class="status-legend">
<div class="legend-title">[对照] HTTP状态码速查</div>
<div class="legend-grid">
<div class="legend-item success">
<span class="status-dot"></span>
<span class="status-code">2xx</span>
<span class="status-meaning">成功送达</span>
</div>
<div class="legend-item redirect">
<span class="status-dot"></span>
<span class="status-code">3xx</span>
<span class="status-meaning">地址变更</span>
</div>
<div class="legend-item client-error">
<span class="status-dot"></span>
<span class="status-code">4xx</span>
<span class="status-meaning">请求有误</span>
</div>
<div class="legend-item server-error">
<span class="status-dot"></span>
<span class="status-code">5xx</span>
<span class="status-meaning">服务器问题</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const scenarios = [
{
id: 'success',
name: '正常送达',
method: 'GET',
path: '/index.html',
requestText: '您好,这是您的包裹,请签收!',
status: '200',
statusText: 'OK',
statusType: 'success',
responseText: '好的,收到了,谢谢!'
},
{
id: 'notfound',
name: '地址错误',
method: 'GET',
path: '/nopage',
requestText: '您好,送包裹到这个地方。',
status: '404',
statusText: 'Not Found',
statusType: 'client-error',
responseText: '这里没有这个人,您送错地方了。'
},
{
id: 'redirect',
name: '地址变更',
method: 'GET',
path: '/old-address',
requestText: '您好,送包裹到这个地址。',
status: '301',
statusText: 'Moved',
statusType: 'redirect',
responseText: '这里搬走了,请送到新地址。'
},
{
id: 'error',
name: '家中故障',
method: 'POST',
path: '/api/order',
requestText: '您好,我来送您订购的商品。',
status: '500',
statusText: 'Error',
statusType: 'server-error',
responseText: '抱歉,我们家系统出问题了,暂时无法接收。'
}
]
const currentScenario = ref(scenarios[0])
const step = ref(0)
const isDelivering = ref(false)
const selectScenario = (scenario) => {
currentScenario.value = scenario
reset()
}
const nextStep = () => {
if (step.value < 3) {
isDelivering.value = true
step.value++
if (step.value === 2) {
setTimeout(() => {
step.value++
isDelivering.value = false
}, 1000)
} else {
isDelivering.value = false
}
}
}
const reset = () => {
step.value = 0
isDelivering.value = false
}
</script>
<style scoped>
.delivery-dialog-demo {
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
border: 2px solid var(--vp-c-divider);
border-radius: 16px;
padding: 24px;
margin: 20px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 头部 */
.dialog-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--vp-c-divider);
}
.dialog-icon {
font-size: 14px;
color: var(--vp-c-brand);
font-weight: 600;
}
.dialog-title {
font-size: 17px;
font-weight: 700;
color: var(--vp-c-text-1);
}
/* 场景选择 */
.scenario-selector {
margin-bottom: 20px;
}
.selector-label {
font-size: 13px;
color: var(--vp-c-text-3);
margin-bottom: 10px;
}
.scenario-buttons {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.scenario-btn {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 20px;
cursor: pointer;
transition: all 0.3s;
}
.scenario-btn:hover:not(:disabled) {
border-color: var(--vp-c-brand);
transform: translateY(-2px);
}
.scenario-btn.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.scenario-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-text { font-size: 13px; }
/* 对话场景 */
.dialog-scene {
background: var(--vp-c-bg);
border-radius: 12px;
padding: 20px;
margin-bottom: 20px;
}
.scene-background {
display: flex;
align-items: center;
gap: 20px;
}
/* 角色 */
.character {
text-align: center;
flex-shrink: 0;
}
.char-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 600;
color: white;
margin-bottom: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.courier .char-avatar {
background: linear-gradient(135deg, #409eff, #67c23a);
}
.recipient .char-avatar {
background: linear-gradient(135deg, #e6a23c, #f56c6c);
}
.char-name {
font-size: 12px;
color: var(--vp-c-text-2);
font-weight: 500;
}
/* 对话区域 */
.conversation-area {
flex: 1;
display: flex;
flex-direction: column;
gap: 16px;
}
.message {
display: flex;
align-items: center;
gap: 12px;
opacity: 0.3;
transform: translateY(10px);
transition: all 0.4s;
}
.message.sent {
opacity: 1;
transform: translateY(0);
}
.message.request {
justify-content: flex-end;
}
.message.response {
justify-content: flex-start;
}
.message-bubble {
max-width: 280px;
padding: 14px;
border-radius: 12px;
background: var(--vp-c-bg-soft);
border: 2px solid var(--vp-c-divider);
}
.message.request .message-bubble {
background: #409eff;
border-color: #409eff;
color: white;
}
.message.response .message-bubble.success {
background: #67c23a;
border-color: #67c23a;
color: white;
}
.message.response .message-bubble.redirect {
background: #e6a23c;
border-color: #e6a23c;
color: white;
}
.message.response .message-bubble.client-error {
background: #f56c6c;
border-color: #f56c6c;
color: white;
}
.message.response .message-bubble.server-error {
background: #909399;
border-color: #909399;
color: white;
}
.bubble-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255,255,255,0.2);
}
.method-badge, .status-badge {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 700;
font-family: monospace;
background: rgba(255,255,255,0.2);
}
.path-text, .status-text {
font-size: 12px;
opacity: 0.9;
}
.bubble-body {
font-size: 13px;
line-height: 1.5;
}
.message-meta {
font-size: 11px;
color: var(--vp-c-text-3);
font-weight: 500;
}
/* 传输动画 */
.transit-animation {
display: flex;
align-items: center;
justify-content: center;
position: relative;
height: 40px;
}
.transit-line {
width: 100%;
height: 2px;
background: var(--vp-c-divider);
}
.transit-package {
position: absolute;
font-size: 20px;
font-weight: 600;
color: var(--vp-c-brand);
animation: deliver 1s ease-in-out;
}
@keyframes deliver {
0% { transform: translateX(-100px); }
50% { transform: translateX(0) scale(1.2); }
100% { transform: translateX(100px); }
}
/* 控制按钮 */
.dialog-controls {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 20px;
}
.control-btn {
padding: 12px 24px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 24px;
cursor: pointer;
font-size: 14px;
color: var(--vp-c-text-2);
transition: all 0.3s;
}
.control-btn:hover:not(:disabled) {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.control-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.control-btn.primary {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.control-btn.primary:hover:not(:disabled) {
background: #66b1ff;
}
/* 状态码说明 */
.status-legend {
padding: 16px;
background: var(--vp-c-bg);
border-radius: 12px;
}
.legend-title {
font-size: 14px;
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 12px;
}
.legend-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.status-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.legend-item.success .status-dot { background: #67c23a; }
.legend-item.redirect .status-dot { background: #e6a23c; }
.legend-item.client-error .status-dot { background: #f56c6c; }
.legend-item.server-error .status-dot { background: #909399; }
.status-code {
font-family: monospace;
font-size: 12px;
font-weight: 700;
color: var(--vp-c-text-1);
}
.status-meaning {
font-size: 12px;
color: var(--vp-c-text-2);
}
@media (max-width: 640px) {
.scene-background {
flex-direction: column;
}
.character {
order: -1;
}
.legend-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -0,0 +1,551 @@
<!--
TcpHandshakeDemo.vue
TCP三次握手演示 - 打电话确认类比
用途
"打电话确认对方是否在家"的生活化比喻让用户理解TCP三次握手的必要性
把枯燥的技术概念变成直观的对话过程
-->
<template>
<div class="phone-call-demo">
<!-- 标题 -->
<div class="call-header">
<span class="call-icon">[电话]</span>
<span class="call-title">打电话确认建立可靠连接</span>
</div>
<!-- 场景说明 -->
<div class="scenario-box">
<div class="scenario-text">
快递员到了<strong>"{{ targetAddress }}"</strong>附近但他需要确认收件人是否在家才能确保包裹能成功送达
</div>
</div>
<!-- 模式切换 -->
<div class="mode-toggle">
<button
v-for="mode in modes"
:key="mode.id"
@click="currentMode = mode.id"
class="mode-btn"
:class="{ active: currentMode === mode.id }"
>
{{ mode.label }}
</button>
</div>
<!-- 通话可视化 -->
<div class="call-visualization">
<!-- 左侧快递员客户端 -->
<div class="caller-side">
<div class="avatar-box client">
<div class="avatar"></div>
<div class="avatar-label">快递员你的电脑</div>
</div>
<div class="speech-bubble client" v-if="currentStep >= 1">
<div class="bubble-content">
{{ currentMode === 'simple' ? '喂,在家吗?我是快递员!' : 'SYN: 请求连接' }}
</div>
<div class="bubble-arrow"></div>
</div>
</div>
<!-- 中间信号传输 -->
<div class="signal-area">
<div class="signal-line">
<div
v-for="(signal, i) in signals"
:key="i"
class="signal-dot"
:class="[signal.type, { active: currentStep >= signal.step }]"
>
{{ signal.icon }}
</div>
</div>
<div class="connection-status" v-if="currentStep > 0">
<span class="status-text" :class="{ connected: currentStep >= 3 }">
{{ currentStep >= 3 ? '[已接通]' : '[连接中...]' }}
</span>
</div>
</div>
<!-- 右侧收件人服务器 -->
<div class="receiver-side">
<div class="speech-bubble server" v-if="currentStep >= 2">
<div class="bubble-arrow"></div>
<div class="bubble-content">
{{ currentMode === 'simple' ? '在的!我听到了,请说!' : 'SYN-ACK: 确认收到' }}
</div>
</div>
<div class="avatar-box server">
<div class="avatar"></div>
<div class="avatar-label">收件人服务器</div>
</div>
<div class="speech-bubble server final" v-if="currentStep >= 3">
<div class="bubble-arrow"></div>
<div class="bubble-content">
{{ currentMode === 'simple' ? '好的,开始说吧!' : 'ACK: 确认连接' }}
</div>
</div>
</div>
</div>
<!-- 步骤说明 -->
<div class="steps-explanation">
<div
v-for="(step, index) in steps"
:key="index"
class="step-card"
:class="{ active: currentStep === index + 1, completed: currentStep > index + 1 }"
@click="goToStep(index + 1)"
>
<div class="step-number">{{ index + 1 }}</div>
<div class="step-content">
<div class="step-title">{{ currentMode === 'simple' ? step.simpleTitle : step.techTitle }}</div>
<div class="step-desc">{{ currentMode === 'simple' ? step.simpleDesc : step.techDesc }}</div>
</div>
</div>
</div>
<!-- 控制按钮 -->
<div class="control-panel">
<button class="ctrl-btn" @click="prevStep" :disabled="currentStep <= 0">
[上一步]
</button>
<button
class="ctrl-btn primary"
@click="nextStep"
:disabled="currentStep >= 3"
>
{{ currentStep >= 3 ? '已接通' : '[下一步]' }}
</button>
<button class="ctrl-btn" @click="reset">
[重置]
</button>
</div>
<!-- 为什么是三次 -->
<div class="why-three-box" v-if="currentStep >= 3">
<div class="why-icon">[提示]</div>
<div class="why-content">
<strong>为什么是三次不是两次</strong>
<p>两次对话只能确认"你能发、对方能收"但对方不知道他的回复你有没有收到三次对话确保<strong>双方都能发双方都能收</strong>通信才是可靠的</p>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const currentMode = ref('simple')
const currentStep = ref(0)
const targetAddress = ref('北京市朝阳区XX小区')
const modes = [
{ id: 'simple', label: '生活语言' },
{ id: 'tech', label: '技术术语' }
]
const steps = [
{
simpleTitle: '快递员:喂,在家吗?',
simpleDesc: '快递员拨通电话,确认对方是否在家',
techTitle: '客户端发送 SYN',
techDesc: 'Synchronize: 请求建立连接,携带初始序列号'
},
{
simpleTitle: '收件人:在的,请说!',
simpleDesc: '收件人确认在家,也表示可以接收包裹',
techTitle: '服务器回复 SYN-ACK',
techDesc: 'Synchronize-Acknowledge: 确认收到,也请求连接'
},
{
simpleTitle: '快递员:好的,开始送!',
simpleDesc: '快递员确认对方已准备好,开始运送',
techTitle: '客户端回复 ACK',
techDesc: 'Acknowledge: 确认收到,连接建立成功'
}
]
const signals = [
{ type: 'syn', step: 1, icon: '发' },
{ type: 'synack', step: 2, icon: '收' },
{ type: 'ack', step: 3, icon: '通' }
]
const nextStep = () => {
if (currentStep.value < 3) {
currentStep.value++
}
}
const prevStep = () => {
if (currentStep.value > 0) {
currentStep.value--
}
}
const goToStep = (step) => {
currentStep.value = step
}
const reset = () => {
currentStep.value = 0
}
</script>
<style scoped>
.phone-call-demo {
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
border: 2px solid var(--vp-c-divider);
border-radius: 16px;
padding: 24px;
margin: 20px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 头部 */
.call-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--vp-c-divider);
}
.call-icon {
font-size: 14px;
color: var(--vp-c-brand);
font-weight: 600;
}
.call-title {
font-size: 17px;
font-weight: 700;
color: var(--vp-c-text-1);
}
/* 场景 */
.scenario-box {
background: linear-gradient(135deg, rgba(64, 158, 255, 0.1), rgba(103, 194, 58, 0.1));
border-radius: 12px;
padding: 16px;
margin-bottom: 16px;
}
.scenario-text {
font-size: 14px;
color: var(--vp-c-text-1);
line-height: 1.6;
}
.scenario-text strong {
color: var(--vp-c-brand);
}
/* 模式切换 */
.mode-toggle {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 20px;
}
.mode-btn {
padding: 8px 20px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 20px;
cursor: pointer;
font-size: 13px;
color: var(--vp-c-text-2);
transition: all 0.3s;
}
.mode-btn:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.mode-btn.active {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
/* 通话可视化 */
.call-visualization {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
padding: 20px;
background: var(--vp-c-bg);
border-radius: 12px;
margin-bottom: 20px;
}
.caller-side, .receiver-side {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
flex: 1;
}
.avatar-box {
text-align: center;
}
.avatar-box.client .avatar {
background: linear-gradient(135deg, #409eff, #67c23a);
}
.avatar-box.server .avatar {
background: linear-gradient(135deg, #e6a23c, #f56c6c);
}
.avatar {
width: 60px;
height: 60px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
font-weight: 600;
color: white;
margin-bottom: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.avatar-label {
font-size: 12px;
color: var(--vp-c-text-2);
font-weight: 500;
}
.speech-bubble {
position: relative;
max-width: 160px;
}
.speech-bubble.client {
align-self: flex-end;
}
.speech-bubble.server {
align-self: flex-start;
}
.bubble-content {
padding: 10px 14px;
border-radius: 12px;
font-size: 12px;
line-height: 1.5;
}
.speech-bubble.client .bubble-content {
background: #409eff;
color: white;
border-bottom-right-radius: 4px;
}
.speech-bubble.server .bubble-content {
background: #67c23a;
color: white;
border-bottom-left-radius: 4px;
}
/* 信号区域 */
.signal-area {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.signal-line {
display: flex;
flex-direction: column;
gap: 8px;
align-items: center;
}
.signal-dot {
width: 36px;
height: 36px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: 600;
color: white;
opacity: 0.2;
transform: scale(0.8);
transition: all 0.4s;
}
.signal-dot.active {
opacity: 1;
transform: scale(1);
animation: pulseSignal 1s infinite;
}
.signal-dot.syn { background: #409eff; }
.signal-dot.synack { background: #67c23a; }
.signal-dot.ack { background: #e6a23c; }
@keyframes pulseSignal {
0%, 100% { box-shadow: 0 0 0 0 rgba(64, 158, 255, 0.4); }
50% { box-shadow: 0 0 0 10px rgba(64, 158, 255, 0); }
}
.connection-status {
padding: 6px 12px;
background: var(--vp-c-bg-soft);
border-radius: 16px;
}
.status-text {
font-size: 12px;
color: var(--vp-c-text-3);
}
.status-text.connected {
color: #67c23a;
font-weight: 600;
}
/* 步骤说明 */
.steps-explanation {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.step-card {
flex: 1;
padding: 14px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s;
opacity: 0.5;
}
.step-card:hover {
opacity: 0.8;
transform: translateY(-2px);
}
.step-card.active {
opacity: 1;
border-color: var(--vp-c-brand);
background: rgba(64, 158, 255, 0.05);
}
.step-card.completed {
opacity: 0.8;
border-color: #67c23a;
}
.step-number {
width: 28px;
height: 28px;
background: var(--vp-c-divider);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 700;
margin-bottom: 10px;
}
.step-card.active .step-number {
background: var(--vp-c-brand);
color: white;
}
.step-card.completed .step-number {
background: #67c23a;
color: white;
}
.step-title {
font-size: 13px;
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 4px;
}
.step-desc {
font-size: 11px;
color: var(--vp-c-text-3);
line-height: 1.4;
}
/* 控制面板 */
.control-panel {
display: flex;
justify-content: center;
gap: 12px;
margin-bottom: 16px;
}
.ctrl-btn {
padding: 10px 20px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 20px;
cursor: pointer;
font-size: 13px;
color: var(--vp-c-text-2);
transition: all 0.3s;
}
.ctrl-btn:hover:not(:disabled) {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.ctrl-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ctrl-btn.primary {
background: var(--vp-c-brand);
border-color: var(--vp-c-brand);
color: white;
}
.ctrl-btn.primary:hover:not(:disabled) {
background: #66b1ff;
}
/* 为什么是三次 */
.why-three-box {
display: flex;
gap: 12px;
padding: 16px;
background: linear-gradient(135deg, rgba(103, 194, 58, 0.1), rgba(64, 158, 255, 0.1));
border-radius: 12px;
border-left: 4px solid #67c23a;
animation: slideIn 0.4s ease;
}
.why-icon {
font-size: 12px;
font-weight: 600;
color: var(--vp-c-brand);
}
.why-content {
flex: 1;
}
.why-content strong {
font-size: 14px;
color: var(--vp-c-text-1);
}
.why-content p {
margin: 8px 0 0;
font-size: 13px;
color: var(--vp-c-text-2);
line-height: 1.6;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 640px) {
.call-visualization {
flex-direction: column;
}
.signal-area {
flex-direction: row;
order: -1;
}
.signal-line {
flex-direction: row;
}
.steps-explanation {
flex-direction: column;
}
}
</style>
@@ -0,0 +1,517 @@
<!--
UrlParserDemo.vue
URL解析演示 - 交互式可视化组件
用途
URL 解析过程可视化通过颜色编码和分块展示
直观地展示 URL 的各个组成部分及其对应的技术含义和生活比喻
-->
<template>
<div class="url-parser-demo">
<!-- 头部控制区 -->
<div class="control-panel">
<div class="header-section">
<span class="icon">🔍</span>
<span class="title">URL 解析器</span>
</div>
<div class="examples-section">
<span class="label">试一试</span>
<div class="button-group">
<button
v-for="ex in examples"
:key="ex.name"
@click="useExample(ex)"
class="action-btn outline small"
:class="{ active: currentExample === ex.name }"
>
{{ ex.name }}
</button>
</div>
</div>
</div>
<!-- 输入区域 -->
<div class="input-section">
<div class="input-wrapper">
<input
v-model="urlInput"
type="text"
placeholder="输入或粘贴一个网址..."
@input="parseUrl"
class="url-input"
/>
<button class="clear-btn" @click="clear" v-if="urlInput"></button>
</div>
</div>
<!-- 可视化展示区 -->
<div class="visualization-area" v-if="parsed.protocol">
<div class="url-blocks">
<div
v-for="(field, key) in formFields"
:key="key"
v-show="shouldShowField(key)"
class="url-block"
:class="[key, { active: hovered === key }]"
:style="{ '--block-color': field.color, '--block-bg': hexToRgba(field.color, 0.15) }"
@mouseenter="hovered = key"
@mouseleave="hovered = null"
>
<span class="block-value">{{ getDisplayValue(key) }}</span>
<span class="block-label">{{ field.techName }}</span>
</div>
</div>
</div>
<!-- 详情说明卡片 -->
<div class="info-card" v-if="hovered && formFields[hovered]">
<div class="info-header" :style="{ borderLeftColor: formFields[hovered].color }">
<span class="info-title">{{ formFields[hovered].techLabel }} ({{ formFields[hovered].techName }})</span>
<span class="info-badge" :style="{ backgroundColor: formFields[hovered].color }">
{{ formFields[hovered].icon }}
</span>
</div>
<div class="info-content">
<div class="info-row">
<div class="info-label">技术含义</div>
<div class="info-value">
<strong>{{ formFields[hovered].techDesc }}</strong>
<div class="info-detail">{{ formFields[hovered].techDetail }}</div>
</div>
</div>
<div class="info-divider"></div>
<div class="info-row">
<div class="info-label">生活比喻</div>
<div class="info-value">
<strong>{{ formFields[hovered].analogyLabel }}</strong>
<div class="info-detail">{{ formFields[hovered].analogyDesc }}</div>
</div>
</div>
</div>
</div>
<!-- 空状态提示 -->
<div class="empty-state" v-else-if="!urlInput">
<p>👆 在上方输入网址查看它是由哪些部分组成的</p>
</div>
<div class="default-info" v-else>
<p>👆 鼠标悬停在上方色块上查看详细解释</p>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const urlInput = ref('')
const parsed = ref({})
const hovered = ref(null)
const currentExample = ref('')
const examples = [
{ name: '百度搜索', url: 'https://www.baidu.com/s?wd=hello' },
{ name: 'GitHub项目', url: 'https://github.com/vuejs/core' },
{ name: '带端口', url: 'http://localhost:8080/api/users' },
{ name: '带锚点', url: 'https://vuejs.org/guide/introduction.html#what-is-vue' }
]
const formFields = {
protocol: {
techName: 'Protocol',
techLabel: '协议',
color: '#f43f5e', // Red
icon: '规',
analogyLabel: '快递公司',
techDesc: '通信规则',
analogyDesc: '决定是用"顺丰"(HTTPS)还是"平邮"(HTTP)传输',
techDetail: 'https (加密) 或 http (明文)'
},
hostname: {
techName: 'Hostname',
techLabel: '域名',
color: '#3b82f6', // Blue
icon: '名',
analogyLabel: '收件人',
techDesc: '服务器地址',
analogyDesc: '如 "google.com",方便人类记忆的名字',
techDetail: '最终需要通过 DNS 解析为 IP 地址'
},
port: {
techName: 'Port',
techLabel: '端口',
color: '#f59e0b', // Amber
icon: '门',
analogyLabel: '门牌号',
techDesc: '服务入口',
analogyDesc: '如 ":8080",大楼里的具体房间号',
techDetail: '默认端口(80/443)通常会被浏览器省略'
},
pathname: {
techName: 'Path',
techLabel: '路径',
color: '#10b981', // Emerald
icon: '径',
analogyLabel: '具体位置',
techDesc: '资源路径',
analogyDesc: '如 "/files/doc.txt",文件柜的位置',
techDetail: '指向服务器上的具体资源'
},
search: {
techName: 'Query',
techLabel: '参数',
color: '#8b5cf6', // Violet
icon: '参',
analogyLabel: '备注',
techDesc: '查询参数',
analogyDesc: '如 "?q=hello",告诉对方具体要求',
techDetail: '键值对形式的附加数据'
},
hash: {
techName: 'Hash',
techLabel: '锚点',
color: '#ec4899', // Pink
icon: '锚',
analogyLabel: '页码',
techDesc: '页内定位',
analogyDesc: '如 "#section1",书的某一页',
techDetail: '浏览器滚动到指定位置,不会发送给服务器'
}
}
const hexToRgba = (hex, alpha) => {
const r = parseInt(hex.slice(1, 3), 16)
const g = parseInt(hex.slice(3, 5), 16)
const b = parseInt(hex.slice(5, 7), 16)
return `rgba(${r}, ${g}, ${b}, ${alpha})`
}
const shouldShowField = (key) => {
const val = parsed.value[key]
if (!val) return false
if (val === '无') return false
if (key === 'search' && (val === '' || val === '?')) return false
if (key === 'hash' && (val === '' || val === '#')) return false
return true
}
const getDisplayValue = (key) => {
let val = parsed.value[key]
if (key === 'protocol') return val + '://'
if (key === 'port') return ':' + val.replace('(默认)', '')
// simple formatting
return val
}
const useExample = (ex) => {
urlInput.value = ex.url
currentExample.value = ex.name
parseUrl()
}
const clear = () => {
urlInput.value = ''
parsed.value = {}
currentExample.value = ''
}
const parseUrl = () => {
if (!urlInput.value) {
parsed.value = {}
return
}
try {
let urlStr = urlInput.value.trim()
// Auto-add protocol if missing for better UX
if (!urlStr.match(/^https?:\/\//)) {
if (urlStr.startsWith('localhost')) {
urlStr = 'http://' + urlStr
} else {
urlStr = 'https://' + urlStr
}
}
const u = new URL(urlStr)
// Determine if port is explicit or default
let portDisplay = u.port
if (!portDisplay) {
// Just for display logic in the parser, we might not show it if it's default/hidden
// But let's show it if we want to be educational
// Actually, let's only show if it's in the string or we want to be explicit
// For visualizer, maybe better to show what's THERE.
// But to be educational, maybe show implied?
// Let's stick to what's in the URL object but handle defaults
// If the user typed it, u.port is set. If implied, it's empty string.
}
parsed.value = {
protocol: u.protocol.replace(':', ''),
hostname: u.hostname,
port: u.port, // Only show if explicit
pathname: u.pathname === '/' ? '/' : u.pathname,
search: u.search,
hash: u.hash
}
} catch (e) {
// simplistic fallback or error state could go here
// parsed.value = {}
}
}
</script>
<style scoped>
.url-parser-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
padding: 1rem;
margin: 1rem 0;
font-family: var(--vp-font-family-mono);
}
.control-panel {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
background: var(--vp-c-bg);
padding: 0.75rem;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
}
.header-section {
display: flex;
align-items: center;
gap: 0.5rem;
font-weight: bold;
color: var(--vp-c-text-1);
}
.examples-section {
display: flex;
align-items: center;
gap: 0.5rem;
}
.label {
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.button-group {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.action-btn {
padding: 0.2rem 0.6rem;
border-radius: 4px;
font-size: 0.75rem;
cursor: pointer;
border: 1px solid var(--vp-c-divider);
background: transparent;
color: var(--vp-c-text-2);
transition: all 0.2s;
}
.action-btn:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.action-btn.active {
background-color: var(--vp-c-brand-soft);
color: var(--vp-c-brand);
border-color: var(--vp-c-brand);
}
.input-section {
margin-bottom: 1.5rem;
}
.input-wrapper {
position: relative;
display: flex;
align-items: center;
}
.url-input {
width: 100%;
padding: 0.75rem 2.5rem 0.75rem 1rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
font-family: inherit;
font-size: 1rem;
transition: border-color 0.2s;
}
.url-input:focus {
outline: none;
border-color: var(--vp-c-brand);
}
.clear-btn {
position: absolute;
right: 0.75rem;
background: none;
border: none;
color: var(--vp-c-text-3);
cursor: pointer;
font-size: 1rem;
}
.clear-btn:hover {
color: var(--vp-c-text-1);
}
.visualization-area {
margin-bottom: 1rem;
overflow-x: auto;
padding-bottom: 0.5rem;
}
.url-blocks {
display: flex;
gap: 4px;
align-items: flex-start;
}
.url-block {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0.75rem;
border-radius: 6px;
background-color: var(--block-bg);
border: 1px solid var(--block-color);
cursor: help;
transition: all 0.2s;
min-width: fit-content;
}
.url-block:hover, .url-block.active {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
filter: brightness(1.05);
}
.block-value {
font-weight: bold;
color: var(--block-color);
font-size: 1rem;
white-space: nowrap;
}
.block-label {
font-size: 0.7rem;
text-transform: uppercase;
margin-top: 0.25rem;
opacity: 0.7;
color: var(--block-color);
}
.info-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
animation: slide-up 0.3s ease;
}
.info-header {
padding: 0.75rem 1rem;
background: var(--vp-c-bg-alt);
display: flex;
justify-content: space-between;
align-items: center;
border-left: 4px solid transparent;
}
.info-title {
font-weight: bold;
font-size: 0.95rem;
}
.info-badge {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 0.75rem;
font-weight: bold;
}
.info-content {
padding: 1rem;
display: grid;
grid-template-columns: 1fr 1px 1fr;
gap: 1rem;
}
.info-divider {
background: var(--vp-c-divider);
height: 100%;
}
.info-row {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-label {
font-size: 0.75rem;
color: var(--vp-c-text-2);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-value {
font-size: 0.9rem;
}
.info-detail {
font-size: 0.8rem;
color: var(--vp-c-text-2);
margin-top: 0.25rem;
}
.empty-state, .default-info {
text-align: center;
padding: 2rem;
color: var(--vp-c-text-2);
font-size: 0.9rem;
background: var(--vp-c-bg-alt);
border-radius: 6px;
border: 1px dashed var(--vp-c-divider);
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@media (max-width: 640px) {
.info-content {
grid-template-columns: 1fr;
}
.info-divider {
height: 1px;
width: 100%;
}
}
</style>
@@ -0,0 +1,914 @@
<!--
UrlToBrowserQuickStart.vue
网络快递之旅 - 全流程快速体验组件
用途
"寄快递"的故事主线让0基础用户在30秒内体验从输入URL到页面显示的完整过程
设计原则故事化可视化即时反馈
-->
<template>
<div class="delivery-journey">
<!-- 故事标题 -->
<div class="journey-header">
<span class="journey-icon">[包裹]</span>
<span class="journey-title">体验一次"网络快递"之旅</span>
</div>
<!-- 快递单填写 -->
<div class="delivery-form">
<div class="form-label">填写快递单输入网址</div>
<div class="address-input">
<span class="protocol-badge">https://</span>
<input
v-model="url"
type="text"
placeholder="比如:baidu.com"
@keyup.enter="startJourney"
:disabled="isRunning"
/>
<button class="send-btn" @click="startJourney" :disabled="!url || isRunning">
{{ isRunning ? '运送中...' : '寄出' }}
</button>
</div>
</div>
<!-- 快速选择 -->
<div class="quick-select" v-if="!isRunning && !showResult">
<span class="quick-hint">快速体验</span>
<button
v-for="u in quickUrls"
:key="u.domain"
@click="url = u.domain; startJourney()"
class="quick-btn"
:title="u.desc"
>
{{ u.domain }}
</button>
</div>
<!-- 双栏对照展示 -->
<div class="comparison-view" v-if="isRunning || showResult">
<div class="comparison-header">
<div class="side-label delivery-side">寄快递流程</div>
<div class="connection-hint">对应关系</div>
<div class="side-label network-side">网络访问流程</div>
</div>
<div class="comparison-steps">
<div
v-for="(step, index) in steps"
:key="step.id"
class="step-row"
:class="{
passed: currentStep > index,
current: currentStep === index,
waiting: currentStep < index
}"
>
<!-- 左侧快递流程 -->
<div class="step-delivery">
<div class="step-icon">{{ step.deliveryIcon }}</div>
<div class="step-content">
<div class="step-title">{{ step.deliveryTitle }}</div>
<div class="step-desc">{{ step.deliveryDesc }}</div>
</div>
</div>
<!-- 中间对应指示 -->
<div class="step-connector">
<div class="connector-line"></div>
<div class="connector-arrow"></div>
<div class="connector-label">{{ step.mappingLabel }}</div>
</div>
<!-- 右侧网络流程 -->
<div class="step-network">
<div class="step-icon">{{ step.networkIcon }}</div>
<div class="step-content">
<div class="step-title">{{ step.networkTitle }}</div>
<div class="step-desc">{{ step.networkDesc }}</div>
<div class="step-tech" v-if="currentStep >= index">{{ step.techDetail }}</div>
</div>
</div>
</div>
</div>
<!-- 当前步骤高亮提示 -->
<div class="current-step-hint" v-if="isRunning && steps[currentStep]">
<div class="hint-label">当前阶段</div>
<div class="hint-content">
<span class="hint-delivery">{{ steps[currentStep].deliveryTitle }}</span>
<span class="hint-equals">=</span>
<span class="hint-network">{{ steps[currentStep].networkTitle }}</span>
</div>
</div>
<!-- 进度条 -->
<div class="progress-track" v-if="isRunning">
<div class="progress-fill" :style="{ width: progress + '%' }"></div>
</div>
</div>
<!-- 送达结果 -->
<div class="delivery-result" v-if="showResult">
<div class="success-banner">
包裹送达耗时 {{ time }}ms
</div>
<!-- 网页预览 -->
<div class="page-preview">
<div class="browser-chrome">
<div class="chrome-dots">
<span></span><span></span><span></span>
</div>
<div class="chrome-address">{{ url }}</div>
</div>
<div class="page-content">
<div class="skeleton-line" style="width: 70%"></div>
<div class="skeleton-line" style="width: 50%"></div>
<div class="skeleton-img"></div>
<div class="skeleton-line" style="width: 80%"></div>
</div>
</div>
<button class="retry-btn" @click="reset">再寄一次</button>
</div>
<!-- 名词对照卡片默认显示 -->
<div class="glossary-cards" v-if="!isRunning && !showResult">
<div class="glossary-title">快递 vs 网络 名词对照</div>
<div class="cards-grid">
<div
v-for="item in glossary"
:key="item.delivery"
class="glossary-card"
@mouseenter="hoveredCard = item"
@mouseleave="hoveredCard = null"
>
<div class="card-delivery">
<span class="card-label">快递</span>
<span class="card-value">{{ item.delivery }}</span>
</div>
<div class="card-arrow"></div>
<div class="card-network">
<span class="card-label">网络</span>
<span class="card-value">{{ item.network }}</span>
</div>
<div class="card-explanation" v-if="hoveredCard === item">
{{ item.explanation }}
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const url = ref('')
const isRunning = ref(false)
const showResult = ref(false)
const currentStep = ref(0)
const progress = ref(0)
const time = ref(0)
const hoveredCard = ref(null)
const quickUrls = [
{ domain: 'baidu.com', desc: '百度搜索引擎' },
{ domain: 'github.com', desc: '代码托管平台' },
{ domain: 'vuejs.org', desc: 'Vue.js 官网' }
]
//
const steps = [
{
id: 'parse',
deliveryIcon: '写',
deliveryTitle: '填写快递单',
deliveryDesc: '写明收件人、地址、电话',
mappingLabel: '对应',
networkIcon: '输',
networkTitle: '输入网址',
networkDesc: '浏览器解析 URL',
techDetail: '协议 + 域名 + 路径 + 参数'
},
{
id: 'dns',
deliveryIcon: '查',
deliveryTitle: '查地址簿',
deliveryDesc: '姓名 → 门牌号',
mappingLabel: '对应',
networkIcon: 'DNS',
networkTitle: 'DNS 查询',
networkDesc: '域名 → IP 地址',
techDetail: 'google.com → 142.250.80.46'
},
{
id: 'tcp',
deliveryIcon: '电',
deliveryTitle: '打电话确认',
deliveryDesc: '"在家吗?能收件吗?"',
mappingLabel: '对应',
networkIcon: 'TCP',
networkTitle: 'TCP 三次握手',
networkDesc: '建立可靠连接',
techDetail: 'SYN → SYN-ACK → ACK'
},
{
id: 'http',
deliveryIcon: '送',
deliveryTitle: '快递员送货',
deliveryDesc: '把包裹送到对方手中',
mappingLabel: '对应',
networkIcon: 'HTTP',
networkTitle: 'HTTP 传输',
networkDesc: '请求网页数据',
techDetail: 'GET /index.html → 200 OK'
},
{
id: 'render',
deliveryIcon: '拆',
deliveryTitle: '拆开包裹',
deliveryDesc: '看到礼物内容',
mappingLabel: '对应',
networkIcon: '渲染',
networkTitle: '浏览器渲染',
networkDesc: '显示网页内容',
techDetail: 'HTML + CSS + JS → 像素'
}
]
//
const glossary = [
{
delivery: '快递单',
network: 'URL',
explanation: '网页的完整地址,包含去哪里找、找什么资源'
},
{
delivery: '收件人姓名',
network: '域名',
explanation: '服务器的名字,如 google.com,方便人类记忆'
},
{
delivery: '门牌号',
network: 'IP 地址',
explanation: '服务器的数字地址,如 142.250.80.46,计算机使用'
},
{
delivery: '查地址簿',
network: 'DNS 查询',
explanation: '把域名转换成 IP 地址的查询系统'
},
{
delivery: '打电话确认',
network: 'TCP 握手',
explanation: '确保双方在线且能正常通信的确认过程'
},
{
delivery: '快递员送货',
network: 'HTTP 请求',
explanation: '浏览器向服务器请求数据,服务器返回响应'
},
{
delivery: '拆开包裹',
network: '浏览器渲染',
explanation: '把代码转换成屏幕上看到的图文页面'
}
]
const startJourney = () => {
if (!url.value) return
isRunning.value = true
showResult.value = false
currentStep.value = 0
progress.value = 0
const startTime = Date.now()
let step = 0
const runStep = () => {
if (step >= steps.length) {
isRunning.value = false
showResult.value = true
time.value = Date.now() - startTime
return
}
currentStep.value = step
//
let p = step * 20
const interval = setInterval(() => {
p += 1
progress.value = p
if (p >= (step + 1) * 20) {
clearInterval(interval)
step++
setTimeout(runStep, 600)
}
}, 80)
}
runStep()
}
const reset = () => {
url.value = ''
isRunning.value = false
showResult.value = false
currentStep.value = 0
progress.value = 0
}
</script>
<style scoped>
.delivery-journey {
background: linear-gradient(135deg, var(--vp-c-bg-soft) 0%, var(--vp-c-bg) 100%);
border: 2px solid var(--vp-c-divider);
border-radius: 16px;
padding: 24px;
margin: 20px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
/* 头部 */
.journey-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 20px;
padding-bottom: 16px;
border-bottom: 1px solid var(--vp-c-divider);
}
.journey-icon {
font-size: 20px;
font-weight: bold;
color: var(--vp-c-brand);
}
.journey-title {
font-size: 18px;
font-weight: 700;
color: var(--vp-c-text-1);
}
/* 快递单 */
.delivery-form {
margin-bottom: 16px;
}
.form-label {
font-size: 14px;
color: var(--vp-c-text-2);
margin-bottom: 10px;
font-weight: 500;
}
.address-input {
display: flex;
align-items: center;
gap: 8px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 12px;
padding: 4px;
transition: border-color 0.3s;
}
.address-input:focus-within {
border-color: var(--vp-c-brand);
}
.protocol-badge {
padding: 8px 12px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
font-size: 13px;
color: var(--vp-c-text-3);
font-family: monospace;
}
.address-input input {
flex: 1;
border: none;
background: transparent;
font-size: 15px;
padding: 8px;
outline: none;
color: var(--vp-c-text-1);
}
.send-btn {
padding: 10px 20px;
background: linear-gradient(135deg, var(--vp-c-brand), #67c23a);
color: white;
border: none;
border-radius: 10px;
cursor: pointer;
font-size: 14px;
font-weight: 600;
transition: all 0.3s;
}
.send-btn:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(64, 158, 255, 0.3);
}
.send-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 快速选择 */
.quick-select {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 16px;
}
.quick-hint {
font-size: 13px;
color: var(--vp-c-text-3);
}
.quick-btn {
padding: 6px 14px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 20px;
cursor: pointer;
font-size: 13px;
color: var(--vp-c-text-2);
transition: all 0.2s;
}
.quick-btn:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
transform: translateY(-1px);
}
/* 双栏对照视图 */
.comparison-view {
margin-top: 20px;
background: var(--vp-c-bg);
border-radius: 12px;
padding: 20px;
}
.comparison-header {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 16px;
align-items: center;
margin-bottom: 20px;
padding-bottom: 12px;
border-bottom: 2px solid var(--vp-c-divider);
}
.side-label {
font-size: 14px;
font-weight: 600;
padding: 8px 16px;
border-radius: 8px;
text-align: center;
}
.delivery-side {
background: linear-gradient(135deg, #e6f7ff, #bae7ff);
color: #096dd9;
}
.network-side {
background: linear-gradient(135deg, #f6ffed, #d9f7be);
color: #389e0d;
}
.connection-hint {
font-size: 12px;
color: var(--vp-c-text-3);
padding: 4px 12px;
background: var(--vp-c-bg-soft);
border-radius: 12px;
}
/* 步骤行 */
.comparison-steps {
display: flex;
flex-direction: column;
gap: 12px;
}
.step-row {
display: grid;
grid-template-columns: 1fr auto 1fr;
gap: 12px;
align-items: stretch;
padding: 12px;
border-radius: 10px;
transition: all 0.3s;
opacity: 0.4;
}
.step-row.passed {
opacity: 0.7;
background: rgba(103, 194, 58, 0.05);
}
.step-row.current {
opacity: 1;
background: linear-gradient(135deg, rgba(64, 158, 255, 0.1), rgba(103, 194, 58, 0.1));
border: 2px solid var(--vp-c-brand);
transform: scale(1.02);
}
/* 左侧:快递流程 */
.step-delivery {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: linear-gradient(135deg, #e6f7ff, #f0f5ff);
border-radius: 10px;
border: 1px solid #91d5ff;
}
.step-delivery .step-icon {
width: 40px;
height: 40px;
background: #1890ff;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
font-weight: bold;
flex-shrink: 0;
}
.step-delivery .step-title {
font-size: 14px;
font-weight: 600;
color: #096dd9;
}
.step-delivery .step-desc {
font-size: 12px;
color: var(--vp-c-text-2);
margin-top: 4px;
}
/* 中间:连接器 */
.step-connector {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
min-width: 60px;
}
.connector-line {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: var(--vp-c-divider);
}
.connector-arrow {
width: 28px;
height: 28px;
background: var(--vp-c-brand);
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
z-index: 1;
}
.connector-label {
font-size: 10px;
color: var(--vp-c-text-3);
margin-top: 4px;
background: var(--vp-c-bg-soft);
padding: 2px 6px;
border-radius: 4px;
}
/* 右侧:网络流程 */
.step-network {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: linear-gradient(135deg, #f6ffed, #f0f9ff);
border-radius: 10px;
border: 1px solid #b7eb8f;
}
.step-network .step-icon {
width: 40px;
height: 40px;
background: #52c41a;
color: white;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: bold;
flex-shrink: 0;
}
.step-network .step-title {
font-size: 14px;
font-weight: 600;
color: #389e0d;
}
.step-network .step-desc {
font-size: 12px;
color: var(--vp-c-text-2);
margin-top: 4px;
}
.step-network .step-tech {
font-size: 11px;
color: var(--vp-c-brand);
margin-top: 6px;
padding: 4px 8px;
background: rgba(64, 158, 255, 0.1);
border-radius: 4px;
font-family: monospace;
}
/* 当前步骤提示 */
.current-step-hint {
margin-top: 20px;
padding: 16px;
background: linear-gradient(135deg, rgba(64, 158, 255, 0.1), rgba(103, 194, 58, 0.1));
border-radius: 10px;
text-align: center;
}
.hint-label {
font-size: 12px;
color: var(--vp-c-text-3);
margin-bottom: 8px;
}
.hint-content {
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
flex-wrap: wrap;
}
.hint-delivery {
font-size: 14px;
font-weight: 600;
color: #096dd9;
padding: 6px 12px;
background: #e6f7ff;
border-radius: 6px;
}
.hint-equals {
font-size: 16px;
font-weight: bold;
color: var(--vp-c-brand);
}
.hint-network {
font-size: 14px;
font-weight: 600;
color: #389e0d;
padding: 6px 12px;
background: #f6ffed;
border-radius: 6px;
}
/* 进度条 */
.progress-track {
height: 8px;
background: var(--vp-c-divider);
border-radius: 4px;
overflow: hidden;
margin-top: 16px;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, var(--vp-c-brand), #67c23a);
border-radius: 4px;
transition: width 0.1s linear;
}
/* 送达结果 */
.delivery-result {
margin-top: 20px;
text-align: center;
}
.success-banner {
font-size: 16px;
font-weight: 600;
color: #67c23a;
margin-bottom: 20px;
padding: 12px;
background: rgba(103, 194, 58, 0.1);
border-radius: 10px;
}
.page-preview {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
overflow: hidden;
margin-bottom: 20px;
max-width: 400px;
margin-left: auto;
margin-right: auto;
}
.browser-chrome {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
}
.chrome-dots {
display: flex;
gap: 6px;
}
.chrome-dots span {
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--vp-c-divider);
}
.chrome-dots span:nth-child(1) { background: #ff5f57; }
.chrome-dots span:nth-child(2) { background: #febc2e; }
.chrome-dots span:nth-child(3) { background: #28c840; }
.chrome-address {
flex: 1;
padding: 4px 10px;
background: var(--vp-c-bg);
border-radius: 4px;
font-size: 12px;
color: var(--vp-c-text-2);
text-align: left;
}
.page-content {
padding: 20px;
}
.skeleton-line {
height: 12px;
background: var(--vp-c-divider);
border-radius: 6px;
margin-bottom: 12px;
}
.skeleton-img {
width: 80px;
height: 60px;
background: linear-gradient(135deg, var(--vp-c-divider), var(--vp-c-bg-soft));
border-radius: 8px;
margin: 16px auto;
}
.retry-btn {
padding: 10px 24px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
border-radius: 24px;
cursor: pointer;
font-size: 14px;
color: var(--vp-c-text-2);
font-weight: 500;
transition: all 0.3s;
}
.retry-btn:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
/* 名词对照卡片 */
.glossary-cards {
margin-top: 24px;
padding-top: 20px;
border-top: 1px solid var(--vp-c-divider);
}
.glossary-title {
font-size: 14px;
font-weight: 600;
color: var(--vp-c-text-1);
margin-bottom: 16px;
text-align: center;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
}
.glossary-card {
position: relative;
padding: 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
cursor: pointer;
transition: all 0.3s;
}
.glossary-card:hover {
border-color: var(--vp-c-brand);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.card-delivery,
.card-network {
display: flex;
align-items: center;
gap: 8px;
}
.card-network {
margin-top: 8px;
}
.card-label {
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 500;
}
.card-delivery .card-label {
background: #e6f7ff;
color: #096dd9;
}
.card-network .card-label {
background: #f6ffed;
color: #389e0d;
}
.card-value {
font-size: 13px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.card-arrow {
text-align: center;
font-size: 14px;
color: var(--vp-c-text-3);
margin: 4px 0;
}
.card-explanation {
margin-top: 8px;
padding-top: 8px;
border-top: 1px dashed var(--vp-c-divider);
font-size: 11px;
color: var(--vp-c-text-2);
line-height: 1.5;
}
/* 响应式 */
@media (max-width: 768px) {
.comparison-header {
grid-template-columns: 1fr;
gap: 8px;
}
.connection-hint {
display: none;
}
.step-row {
grid-template-columns: 1fr;
gap: 8px;
}
.step-connector {
flex-direction: row;
min-height: 40px;
}
.connector-line {
position: static;
width: 100%;
height: 2px;
}
.cards-grid {
grid-template-columns: 1fr;
}
.address-input {
flex-wrap: wrap;
}
.protocol-badge {
display: none;
}
}
</style>
+10
View File
@@ -215,11 +215,16 @@ import TrainingProcessDemo from './components/appendix/prompt-engineering/Traini
// Context Engineering Components
import AgentContextFlow from './components/appendix/context-engineering/AgentContextFlow.vue'
import IntroProblemReasonSolution from './components/appendix/context-engineering/IntroProblemReasonSolution.vue'
import ContextWindowVisualizer from './components/appendix/context-engineering/ContextWindowVisualizer.vue'
import SlidingWindowDemo from './components/appendix/context-engineering/SlidingWindowDemo.vue'
import SelectiveContextDemo from './components/appendix/context-engineering/SelectiveContextDemo.vue'
import RAGSimulationDemo from './components/appendix/context-engineering/RAGSimulationDemo.vue'
import ContextCompressionDemo from './components/appendix/context-engineering/ContextCompressionDemo.vue'
import MemoryPalaceDemo from './components/appendix/context-engineering/MemoryPalaceDemo.vue'
import MemoryPalaceActionDemo from './components/appendix/context-engineering/MemoryPalaceActionDemo.vue'
import KVCacheDemo from './components/appendix/context-engineering/KVCacheDemo.vue'
import LostInMiddleDemo from './components/appendix/context-engineering/LostInMiddleDemo.vue'
// Agent Intro Components
import AgentWorkflowDemo from './components/appendix/agent-intro/AgentWorkflowDemo.vue'
@@ -499,11 +504,16 @@ export default {
// Context Engineering Components Registration
app.component('AgentContextFlow', AgentContextFlow)
app.component('IntroProblemReasonSolution', IntroProblemReasonSolution)
app.component('ContextWindowVisualizer', ContextWindowVisualizer)
app.component('SlidingWindowDemo', SlidingWindowDemo)
app.component('SelectiveContextDemo', SelectiveContextDemo)
app.component('RAGSimulationDemo', RAGSimulationDemo)
app.component('ContextCompressionDemo', ContextCompressionDemo)
app.component('MemoryPalaceDemo', MemoryPalaceDemo)
app.component('MemoryPalaceActionDemo', MemoryPalaceActionDemo)
app.component('KVCacheDemo', KVCacheDemo)
app.component('LostInMiddleDemo', LostInMiddleDemo)
// Agent Intro Components Registration
app.component('AgentWorkflowDemo', AgentWorkflowDemo)