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:
@@ -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 演示组件
|
||||
|
||||
用途:
|
||||
展示音频如何通过神经编解码器(如 EnCodec、SoundStream)被压缩成离散的 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">
|
||||
MusicGen、AudioLDM 等模型使用音频 Token 生成音乐和音效
|
||||
</div>
|
||||
</div>
|
||||
<div class="app-card">
|
||||
<div class="app-icon">🗣️</div>
|
||||
<div class="app-title">语音合成</div>
|
||||
<div class="app-desc">
|
||||
VALL-E、SoundStorm 等 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 模型(如 CosyVoice、F5-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>
|
||||
100Hz→200Hz 与 10000Hz→10100Hz 感知差异相同
|
||||
</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: '最新 SOTA,10 步生成' },
|
||||
{ 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>
|
||||
+131
-465
@@ -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>
|
||||
|
||||
+175
-180
@@ -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>
|
||||
|
||||
+33
-32
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+70
@@ -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>
|
||||
+521
@@ -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>
|
||||
+308
-413
@@ -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>
|
||||
|
||||
+44
-43
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
+56
-55
@@ -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"><div class="box">
|
||||
<h1>Hello</h1>
|
||||
</div>
|
||||
<style>
|
||||
.box { background: blue; }
|
||||
</style></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/>
|
||||
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>
|
||||
@@ -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)
|
||||
|
||||
+126
-152
@@ -9,170 +9,142 @@
|
||||
モダンなフロントエンド開発をマスターし、コンポーネントライブラリとデザインツールの使用方法を学ぶ:
|
||||
|
||||
<NavGrid>
|
||||
<a href="/ja-jp/stage-2/frontend/2.1-figma-mastergo/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🖼️</span>
|
||||
<span class="card-title">フロントエンド1</span>
|
||||
</div>
|
||||
<div class="card-desc">FigmaとMasterGo入門</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/frontend/2.2-ui-design/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">✨</span>
|
||||
<span class="card-title">フロントエンド2</span>
|
||||
</div>
|
||||
<div class="card-desc">初めてのモダンアプリ - UIデザイン</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/frontend/2.3-multi-product-ui/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">📐</span>
|
||||
<span class="card-title">フロントエンド3</span>
|
||||
</div>
|
||||
<div class="card-desc">UIデザインガイドラインとマルチプロダクト</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/frontend/2.4-hogwarts-portraits/chapter4-lets-build-hogwarts-portraits" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🧙</span>
|
||||
<span class="card-title">フロントエンド4</span>
|
||||
</div>
|
||||
<div class="card-desc">ホグワーツ肖像画を作ろう</div>
|
||||
</div>
|
||||
</a>
|
||||
</NavGrid>
|
||||
<div class="card-desc">Lovartで素材を作成</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/frontend/2.1-figma-mastergo/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🖼️</span>
|
||||
<span class="card-title">フロントエンド1</span>
|
||||
</div>
|
||||
<div class="card-desc">FigmaとMasterGo入門</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/frontend/2.2-ui-design/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">✨</span>
|
||||
<span class="card-title">フロントエンド2</span>
|
||||
</div>
|
||||
<div class="card-desc">初めてのモダンアプリ - UIデザイン</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/frontend/2.3-multi-product-ui/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">📐</span>
|
||||
<span class="card-title">フロントエンド3</span>
|
||||
</div>
|
||||
<div class="card-desc">UIデザインガイドラインとマルチプロダクト</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/frontend/2.4-hogwarts-portraits/chapter4-lets-build-hogwarts-portraits" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🧙</span>
|
||||
<span class="card-title">フロントエンド4</span>
|
||||
</div>
|
||||
<div class="card-desc">ホグワーツ肖像画を作ろう</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
### バックエンドとフルスタック
|
||||
|
||||
API設計、データベース管理、アプリケーションデプロイメント戦略を学ぶ:
|
||||
|
||||
<NavGrid>
|
||||
<a href="/ja-jp/stage-2/backend/2.2-database-supabase/chapter5/chapter5-from-database-to-supabase" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🗄️</span>
|
||||
<span class="card-title">バックエンド2</span>
|
||||
</div>
|
||||
<div class="card-desc">データベースからSupabaseへ</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/backend/2.3-ai-interface-code/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🤖</span>
|
||||
<span class="card-title">バックエンド3</span>
|
||||
</div>
|
||||
<div class="card-desc">AI支援インターフェースコードとドキュメント</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/backend/2.4-git-workflow/extra1/extra1-what-is-git-and-what-is-github" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🌿</span>
|
||||
<span class="card-title">バックエンド4</span>
|
||||
</div>
|
||||
<div class="card-desc">Gitワークフロー</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/backend/2.5-zeabur-deployment/extra6/extra6-zeabur-what-is-it-and-how-to-deploy-web-applications" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🚀</span>
|
||||
<span class="card-title">バックエンド5</span>
|
||||
</div>
|
||||
<div class="card-desc">Zeaburデプロイメント</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/backend/2.6-modern-cli/extra7/extra7-cli-ai-coding-tools-and-the-principles-of-test-driven-development" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">💻</span>
|
||||
<span class="card-title">バックエンド6</span>
|
||||
</div>
|
||||
<div class="card-desc">モダンCLI開発ツール</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/backend/2.7-stripe-payment/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">💳</span>
|
||||
<span class="card-title">バックエンド7</span>
|
||||
</div>
|
||||
<div class="card-desc">Stripe決済システムの統合</div>
|
||||
</div>
|
||||
</a>
|
||||
</NavGrid>
|
||||
<div class="card-desc">APIとは</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/backend/2.2-database-supabase/chapter5/chapter5-from-database-to-supabase" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🗄️</span>
|
||||
<span class="card-title">バックエンド2</span>
|
||||
</div>
|
||||
<div class="card-desc">データベースからSupabaseへ</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/backend/2.3-ai-interface-code/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🤖</span>
|
||||
<span class="card-title">バックエンド3</span>
|
||||
</div>
|
||||
<div class="card-desc">AI支援インターフェースコードとドキュメント</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/backend/2.4-git-workflow/extra1/extra1-what-is-git-and-what-is-github" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🌿</span>
|
||||
<span class="card-title">バックエンド4</span>
|
||||
</div>
|
||||
<div class="card-desc">Gitワークフロー</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/backend/2.5-zeabur-deployment/extra6/extra6-zeabur-what-is-it-and-how-to-deploy-web-applications" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🚀</span>
|
||||
<span class="card-title">バックエンド5</span>
|
||||
</div>
|
||||
<div class="card-desc">Zeaburデプロイメント</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/backend/2.6-modern-cli/extra7/extra7-cli-ai-coding-tools-and-the-principles-of-test-driven-development" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">💻</span>
|
||||
<span class="card-title">バックエンド6</span>
|
||||
</div>
|
||||
<div class="card-desc">モダンCLI開発ツール</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/backend/2.7-stripe-payment/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">💳</span>
|
||||
<span class="card-title">バックエンド7</span>
|
||||
</div>
|
||||
<div class="card-desc">Stripe決済システムの統合</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
### 課題
|
||||
|
||||
実践プロジェクトを通じてフルスタック開発スキルを固める:
|
||||
|
||||
<NavGrid>
|
||||
|
||||
</NavGrid>
|
||||
<div class="card-desc">初めてのモダンアプリ - フルスタック</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/assignments/2.2-modern-frontend-trae/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🎯</span>
|
||||
<span class="card-title">課題2</span>
|
||||
<a href="/ja-jp/stage-2/assignments/2.2-modern-frontend-trae/" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🎯</span>
|
||||
<span class="card-title">課題2</span>
|
||||
</div>
|
||||
<div class="card-desc">モダンフロントエンド + Trae</div>
|
||||
</div>
|
||||
<div class="card-desc">モダンフロントエンド + Trae</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</a>
|
||||
</NavGrid>
|
||||
|
||||
### AI機能拡張
|
||||
|
||||
<NavGrid>
|
||||
|
||||
</NavGrid>
|
||||
<div class="card-desc">Dify入門とナレッジベース統合</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a href="/ja-jp/stage-2/ai-capabilities/2.2-multimodal-api/extra3/extra3-ai-capability-starter-handbook" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🎭</span>
|
||||
<span class="card-title">AI 2</span>
|
||||
<a href="/ja-jp/stage-2/ai-capabilities/2.2-multimodal-api/extra3/extra3-ai-capability-starter-handbook" class="card-link">
|
||||
<div class="content-card">
|
||||
<div class="card-header">
|
||||
<span class="card-icon">🎭</span>
|
||||
<span class="card-title">AI 2</span>
|
||||
</div>
|
||||
<div class="card-desc">AI辞書クエリとマルチモーダルAPI</div>
|
||||
</div>
|
||||
<div class="card-desc">AI辞書クエリとマルチモーダルAPI</div>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
</a>
|
||||
</NavGrid>
|
||||
|
||||
## 対象者
|
||||
|
||||
@@ -200,42 +172,44 @@ API設計、データベース管理、アプリケーションデプロイメ
|
||||
.card-link {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
padding: 16px;
|
||||
padding: 20px;
|
||||
transition: all 0.3s ease;
|
||||
background: var(--vp-c-bg-soft);
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.content-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1);
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 20px;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-brand);
|
||||
font-size: 14px;
|
||||
font-size: 16px;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
> - OpenAI 系列:GPT-4、GPT-4.1、GPT-4o、GPT-5.1 等
|
||||
> - Google 系列:Gemini 1.5 Pro、Gemini 1.5 Flash 等
|
||||
> - Anthropic 系列:Claude 3.5 Sonnet、Claude 3.5 Haiku 等
|
||||
> - 国内模型:通义千问 Qwen 系列、文心一言 ERNIE Bot 系列、GLM/智谱清言、百度的文心大模型家族、腾讯混元、讯飞星火、月之暗面的 Kimi 背后的大模型等
|
||||
> - 国内模型:通义千问 Qwen 系列、文心一言 ERNIE Bot 系列、GLM/智谱清言、腾讯混元、讯飞星火、月之暗面的 Kimi 背后的大模型等
|
||||
>
|
||||
> 更偏视觉和视频方向的大模型和服务,包括:
|
||||
>
|
||||
|
||||
@@ -2,6 +2,16 @@
|
||||
|
||||
> 💡 **学习指南**:声音是空气的振动,也是情感的载体。本章节将带你了解 AI 如何"听懂"声音,又是如何像人一样"开口说话"甚至"作曲"的。从语音识别到音乐生成,探索音频 AI 的完整技术栈。
|
||||
|
||||
<script setup>
|
||||
import AudioQuickStartDemo from '../../.vitepress/theme/components/appendix/audio-intro/AudioQuickStartDemo.vue'
|
||||
import MelSpectrogramDemo from '../../.vitepress/theme/components/appendix/audio-intro/MelSpectrogramDemo.vue'
|
||||
import TTSPipelineDemo from '../../.vitepress/theme/components/appendix/audio-intro/TTSPipelineDemo.vue'
|
||||
import VoiceCloningDemo from '../../.vitepress/theme/components/appendix/audio-intro/VoiceCloningDemo.vue'
|
||||
import ASRvsTTSDemo from '../../.vitepress/theme/components/appendix/audio-intro/ASRvsTTSDemo.vue'
|
||||
import AudioTokenizationDemo from '../../.vitepress/theme/components/appendix/audio-intro/AudioTokenizationDemo.vue'
|
||||
import EmotionControlDemo from '../../.vitepress/theme/components/appendix/audio-intro/EmotionControlDemo.vue'
|
||||
</script>
|
||||
|
||||
## 0. 快速上手:如何让 AI 说话?
|
||||
|
||||
### 0.1 常见的 AI 音频工具
|
||||
@@ -38,6 +48,8 @@
|
||||
- **场景**:开车、做饭、运动时,打字不方便,但说话很容易。
|
||||
- **未来**:AI 助手将通过语音成为我们的自然伙伴。
|
||||
|
||||
<AudioQuickStartDemo />
|
||||
|
||||
## 1. 概念界定:音频的数字化 (Definition)
|
||||
|
||||
_很多人以为 AI 直接处理"声音",但实际上 AI 处理的是**数字化的音频信号**。_
|
||||
@@ -49,8 +61,6 @@ _很多人以为 AI 直接处理"声音",但实际上 AI 处理的是**数字
|
||||
- **传统信号处理**:处理原始波形(WAV 文件)。
|
||||
- **AI 音频模型**:处理更有意义的"中间表示"。
|
||||
|
||||
<AudioWaveformDemo />
|
||||
|
||||
本质上,音频 AI 是一个**从物理信号到语义表示**的转换过程:
|
||||
|
||||
- **物理层**:声波振动(模拟信号)
|
||||
@@ -96,7 +106,7 @@ _很多人以为 AI 直接处理"声音",但实际上 AI 处理的是**数字
|
||||
2. **生成**:用图像生成模型(如 CNN、Diffusion)生成频谱图。
|
||||
3. **还原**:通过**声码器 (Vocoder)** 将频谱图还原为音频波形。
|
||||
|
||||
<SpectrogramViz />
|
||||
<MelSpectrogramDemo />
|
||||
|
||||
**代表模型**:Tacotron 2, FastSpeech, F5-TTS
|
||||
|
||||
@@ -133,11 +143,102 @@ _很多人以为 AI 直接处理"声音",但实际上 AI 处理的是**数字
|
||||
|
||||
这让 AI 更关注人耳敏感的部分,忽略不重要的细节。
|
||||
|
||||
## 4. 生成机制:从 GPT 到 Flow (Generation Methods)
|
||||
## 4. TTS 流程全景 (TTS Pipeline)
|
||||
|
||||
文本转语音(TTS)是音频 AI 最核心的应用之一。让我们深入了解其完整流程。
|
||||
|
||||
<TTSPipelineDemo />
|
||||
|
||||
### 4.1 自回归 vs 非自回归
|
||||
|
||||
| 特性 | 自回归 (AR) | 非自回归 (NAR) | 流匹配 (Flow) |
|
||||
|------|------------|---------------|--------------|
|
||||
| 生成方式 | 逐个时间步 | 一次性生成 | 流匹配路径 |
|
||||
| 速度 | 慢 | 快 | 很快 |
|
||||
| 音质 | 高 | 中高 | 高 |
|
||||
| 代表模型 | Tacotron 2 | FastSpeech 2 | F5-TTS |
|
||||
|
||||
### 4.2 关键组件
|
||||
|
||||
1. **文本前端 (Text Frontend)**:将文本转换为音素序列,处理多音字、数字、缩写等。
|
||||
2. **声学模型 (Acoustic Model)**:将音素转换为声学特征(梅尔频谱)。
|
||||
3. **声码器 (Vocoder)**:将声学特征还原为音频波形。
|
||||
|
||||
## 5. ASR 与 TTS:语音的双向转换 (ASR vs TTS)
|
||||
|
||||
语音识别(ASR)和语音合成(TTS)是音频 AI 的两个核心方向,它们互为逆过程。
|
||||
|
||||
<ASRvsTTSDemo />
|
||||
|
||||
### 5.1 ASR:音频 → 文本
|
||||
|
||||
- **输入**:音频波形
|
||||
- **输出**:文本/Token
|
||||
- **核心任务**:模式识别、分类
|
||||
- **代表模型**:Whisper, Conformer
|
||||
|
||||
### 5.2 TTS:文本 → 音频
|
||||
|
||||
- **输入**:文本序列
|
||||
- **输出**:音频波形
|
||||
- **核心任务**:序列生成、回归
|
||||
- **代表模型**:F5-TTS, CosyVoice
|
||||
|
||||
### 5.3 联合应用
|
||||
|
||||
- **语音助手**:ASR → LLM → TTS
|
||||
- **实时翻译**:ASR → 翻译 → TTS
|
||||
- **字幕生成**:视频 → ASR → 字幕
|
||||
|
||||
## 6. 声音克隆:零样本能力的魔法 (Zero-Shot Voice Cloning)
|
||||
|
||||
早期的 TTS 需要几十小时的数据来训练一个声音。现在,我们只需要几秒钟。
|
||||
|
||||
<VoiceCloningDemo />
|
||||
|
||||
### 6.1 声音编码器 (Speaker Encoder)
|
||||
|
||||
声音编码器是一个神经网络,它的任务是:**把一段音频压缩成一个固定长度的向量(Embedding)**。
|
||||
|
||||
这个向量捕捉了声音的"身份":
|
||||
|
||||
- 音色(低沉 vs 清脆)
|
||||
- 声道特征(男声 vs 女声)
|
||||
- 说话风格(语速、停顿习惯)
|
||||
|
||||
### 6.2 零样本合成流程
|
||||
|
||||
有了声音编码器,我们就能实现"一句话克隆":
|
||||
|
||||
1. **提取声音特征**:参考音频 → 声音编码器 → 声音向量(如 256 维)
|
||||
2. **条件生成**:文本 + 声音向量 → TTS 模型 → 音频
|
||||
|
||||
这就是 ElevenLabs、CosyVoice 等工具的核心技术。
|
||||
|
||||
## 7. 情感与风格控制 (Emotion & Style Control)
|
||||
|
||||
现代 TTS 系统不仅能合成自然的语音,还能精确控制情感、语速、语调等风格特征。
|
||||
|
||||
<EmotionControlDemo />
|
||||
|
||||
### 7.1 全局风格 Token (GST)
|
||||
|
||||
GST (Global Style Token) 是一种从参考音频中提取风格特征的方法。模型学习将情感、语速、语调等风格信息编码成一组 Token,在推理时可以通过选择或插值这些 Token 来控制合成风格。
|
||||
|
||||
### 7.2 细粒度控制
|
||||
|
||||
现代 TTS 模型支持细粒度的风格控制:
|
||||
|
||||
- **速度控制**:调整音频播放速度而不改变音调
|
||||
- **音调控制**:改变基频 (F0) 曲线
|
||||
- **能量控制**:调整音量包络
|
||||
- **停顿控制**:调整句间和短语间的停顿长度
|
||||
|
||||
## 8. 生成机制演进 (Generation Evolution)
|
||||
|
||||
音频生成模型经历了从模仿人类到直接建模的演进。
|
||||
|
||||
### 4.1 Audio Language Model (如 VALL-E, AudioLM)
|
||||
### 8.1 Audio Language Model (如 VALL-E, AudioLM)
|
||||
|
||||
这一派的思想是:**把声音当语言学**。
|
||||
|
||||
@@ -145,8 +246,6 @@ _很多人以为 AI 直接处理"声音",但实际上 AI 处理的是**数字
|
||||
- **输入**:文本 Token + 音频 Token
|
||||
- **预测**:像成语接龙一样,根据前面的声音,预测下一个声音 Token。
|
||||
|
||||
<AutoregressiveAudioDemo />
|
||||
|
||||
**优点**:
|
||||
|
||||
- 能学到非常自然的韵律、停顿和情感
|
||||
@@ -157,7 +256,7 @@ _很多人以为 AI 直接处理"声音",但实际上 AI 处理的是**数字
|
||||
- 容易"胡言乱语"(重复、漏词)
|
||||
- 生成速度慢(必须逐个 Token 生成)
|
||||
|
||||
### 4.2 Flow Matching TTS (如 F5-TTS, CosyVoice, Matcha-TTS)
|
||||
### 8.2 Flow Matching TTS (如 F5-TTS, CosyVoice, Matcha-TTS)
|
||||
|
||||
这是目前最前沿的流派,结合了生成模型的最新进展。
|
||||
|
||||
@@ -173,36 +272,14 @@ _很多人以为 AI 直接处理"声音",但实际上 AI 处理的是**数字
|
||||
- **鲁棒性强**:不容易丢字漏字
|
||||
- **零样本克隆**:给一段几秒钟的参考音频,立马就能模仿它的音色和语调
|
||||
|
||||
## 5. 声音克隆:零样本能力的魔法 (Zero-Shot Voice Cloning)
|
||||
|
||||
早期的 TTS 需要几十小时的数据来训练一个声音。现在,我们只需要几秒钟。
|
||||
|
||||
### 5.1 声音编码器 (Speaker Encoder)
|
||||
|
||||
声音编码器是一个神经网络,它的任务是:**把一段音频压缩成一个固定长度的向量(Embedding)**。
|
||||
|
||||
这个向量捕捉了声音的"身份":
|
||||
|
||||
- 音色(低沉 vs 清脆)
|
||||
- 声道特征(男声 vs 女声)
|
||||
- 说话风格(语速、停顿习惯)
|
||||
|
||||
### 5.2 零样本合成流程
|
||||
|
||||
有了声音编码器,我们就能实现"一句话克隆":
|
||||
|
||||
1. **提取声音特征**:参考音频 → 声音编码器 → 声音向量(如 256 维)
|
||||
2. **条件生成**:文本 + 声音向量 → TTS 模型 → 音频
|
||||
|
||||
这就是 ElevenLabs、CosyVoice 等工具的核心技术。
|
||||
|
||||
## 6. 总结 (Summary)
|
||||
## 9. 总结 (Summary)
|
||||
|
||||
音频 AI 的进化,正在从"信号处理"走向"语义理解"。
|
||||
|
||||
- **Tokenization** 把声音变成了语言,让 GPT 能"开口说话"。
|
||||
- **Flow Matching** 把生成速度提升了数十倍,让实时语音合成成为可能。
|
||||
- **Speaker Encoder** 让声音克隆像换皮肤一样简单。
|
||||
- **Emotion Control** 让 AI 语音充满情感,适应各种场景。
|
||||
|
||||
未来的 AI(如 GPT-4o),将不再需要把声音转成文字再转回去,而是**直接在统一的多模态空间里理解声音的笑声、语气和情绪**。
|
||||
|
||||
@@ -218,3 +295,5 @@ _很多人以为 AI 直接处理"声音",但实际上 AI 处理的是**数字
|
||||
| **零样本克隆** | Zero-Shot Cloning | 只需几秒参考音频就能模仿任何声音。 |
|
||||
| **流匹配** | Flow Matching | 一种高效的生成方法,用于最新的 TTS 模型。 |
|
||||
| **声音编码器** | Speaker Encoder | 提取声音身份特征的神经网络。 |
|
||||
| **GST** | Global Style Token | 全局风格 Token,用于情感控制。 |
|
||||
| **神经编解码器**| Neural Codec | 将音频压缩为离散 Token 的模型。 |
|
||||
|
||||
@@ -1,165 +1,479 @@
|
||||
# 上下文工程入门 (Context Engineering)
|
||||
|
||||
> 💡 **学习指南**:如果说 Prompt Engineering 是教 AI "怎么说话",那么 Context Engineering 就是教 AI "怎么记事"。本章节将通过一系列交互式实验,带你深入理解 AI 的记忆机制,从基础的滑动窗口到高级的 RAG 系统,掌握让 AI "过目不忘"的核心技术。
|
||||
> 💡 **学习指南**:提示词工程解决的是“怎么把话说清楚”,上下文工程解决的是“让模型在合适的时刻看到合适的信息”。本章节会围绕一个问题展开:**在有限的上下文窗口里,如何既让模型懂你,又不把钱烧光?**
|
||||
|
||||
在开始之前,建议你先了解两个概念:
|
||||
在开始之前,建议你先补两块“基础砖”:
|
||||
|
||||
- **Token 是什么**:可以先阅读 [大语言模型入门](./llm-intro.md) 的「分词 & Token」部分。
|
||||
- **Prompt 是什么**:如果你还不熟悉 System / User / Assistant 的基本结构,可以先看 [提示词工程](./prompt-engineering/)。
|
||||
|
||||
<AgentContextFlow />
|
||||
---
|
||||
|
||||
## 0. 引言:金鱼与大象
|
||||
## 0. 引言:为什么聊着聊着,它就忘事,还越来越贵?
|
||||
|
||||
想象一下,你正在和两个人聊天:
|
||||
很多人在实际使用大模型时都会遇到类似的情况:
|
||||
|
||||
- **A (金鱼记忆)**:只能记住最后说的 3 句话。如果你问他"我刚才说了什么?",他可能会一脸茫然。
|
||||
- **B (大象记忆)**:能记住你们聊过的每一句话,甚至是你上个月提到的细节。
|
||||
- 聊到一半,模型突然“忘记”之前说过的关键条件;
|
||||
- 长对话里,前后回答自相矛盾,很难保持同一套设定;
|
||||
- 对话轮次一多,账单像打车计价一样不断往上走。
|
||||
|
||||
**上下文工程 (Context Engineering)** 的目标,就是通过技术手段,让 AI 从 "金鱼" 进化成 "大象"。
|
||||
直觉上,我们会以为是:**“这个模型记性不好”**。
|
||||
但大多数时候,问题并不在于模型“不会记”,而在于我们**没有设计好它能看到的上下文**。
|
||||
|
||||
更具体地说:你每次调用模型时,都会把一份「输入包」发给它,这份输入包通常由这些部分拼起来:
|
||||
<IntroProblemReasonSolution />
|
||||
|
||||
- **系统设定**(System Prompt):角色、规则、边界。
|
||||
- **对话历史**(Messages):你们之前聊过什么。
|
||||
- **工具结果**(Tool / Observation):Agent 调用外部工具拿到的新信息。
|
||||
- **检索片段**(RAG Context):从知识库临时取回的相关内容。
|
||||
|
||||
但这里有一个核心挑战:**AI 的"脑容量"(上下文窗口)是有限的**。我们不能把全世界的信息都塞进去。
|
||||
|
||||
我们需要解决五个核心问题:
|
||||
|
||||
1. **容量限制**:到底能装多少东西?
|
||||
2. **遗忘机制**:装满了怎么办?
|
||||
3. **智能保留**:如何只忘掉不重要的?
|
||||
4. **长期记忆**:怎么记住很久以前的事?
|
||||
5. **信息压缩**:怎么把书读薄?
|
||||
面对这些挑战,单纯依靠“写好提示词”已经捉襟见肘。我们需要一套更系统的工程方法,来在有限的窗口和预算内,让模型始终获得最关键的信息。这正是**上下文工程**试图解决的问题。
|
||||
|
||||
---
|
||||
|
||||
## 1. 第一步:理解瓶颈 (The Context Window)
|
||||
## 1. 什么是“上下文工程”?(定义 + 场景)
|
||||
|
||||
### 1.1 什么是上下文窗口?
|
||||
先给一个简短的工作定义,再看几个典型场景。
|
||||
|
||||
大语言模型 (LLM) 的记忆不是无限的。它有一个固定的**上下文窗口 (Context Window)**,就像一个只能写 1000 个字的黑板。一旦写满,要么擦掉旧的,要么停止写入。
|
||||
> 上下文工程,是一门为 LLM 构建和管理“信息环境”的工程方法,决定模型“看到什么、忽略什么、什么时候看到”,从而在有限的上下文窗口内稳定完成任务。
|
||||
|
||||
### 1.2 实验:Token 与容量
|
||||
你可以简单地把它理解成三件事:整理信息、控制窗口、管理成本。
|
||||
常见会用到它的场景包括:
|
||||
|
||||
在 AI 的世界里,计量的单位不是"字",而是 **Token**。(Token 的更完整解释可以回看 [大语言模型入门](./llm-intro.md)。)
|
||||
粗略地说,一个 Token 大约相当于 0.75 个英文单词,或 0.5-1 个汉字(会因内容而变化)。
|
||||
- 对话型 Agent 和客服机器人
|
||||
- 代码 / 文档助手
|
||||
- 多轮工具调用和长流程编排
|
||||
|
||||
试着在下面的模拟器中输入文字,看看它是如何填满上下文窗口的:
|
||||
接下来,我们就从一个真实团队的“血泪教训”出发,看看他们是怎么一点点从“只会写 Prompt”进化到“会做上下文工程”的。
|
||||
|
||||
---
|
||||
|
||||
## 2. 从"血泪教训"说起:Manus 团队踩过的坑
|
||||
|
||||
本章案例来自 **Manus**(一款通用 AI Agent)。
|
||||
与普通对话不同,Manus 需要自主规划并调用工具完成长任务(涉及几十甚至上百轮交互)。
|
||||
|
||||
这带来了核心矛盾:
|
||||
- **如果不记**:关键信息丢失,任务中断。
|
||||
- **全记**:成本和延迟爆炸,甚至超出窗口限制。
|
||||
|
||||
Manus 团队经历过多次架构重构,才明白一个道理:**上下文不能只靠“写”,而要靠“设计”。**
|
||||
|
||||
### 2.1 四次重构教会我们什么?
|
||||
|
||||
Manus 的联合创始人季逸超分享过他们的"踩坑史":
|
||||
|
||||
| 阶段 | 遇到的问题 | 当时的想法 | 结果 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **第一次** | AI 聊着聊着就忘事 | "多写点提示词就好了" | 越写越长,越写越贵 |
|
||||
| **第二次** | 重要信息总被挤掉 | "把重要的多复制几遍" | 文本更长,成本更高 |
|
||||
| **第三次** | 账单高得吓人 | "能不能复用之前的计算?" | 找到降低重复计算成本的方式 |
|
||||
| **第四次** | 长文档处理不了 | "能不能需要时再查?" | 建立“图书馆+按需检索”的方案 |
|
||||
|
||||
**核心领悟**:**不是记得越多越好,而是记得越巧越好**。
|
||||
|
||||
### 2.2 AI 的"记性"到底像什么?
|
||||
|
||||
**传统电脑内存** = **硬盘**:
|
||||
- 容量大:可以长期保存大量数据;
|
||||
- 价格低:存放一年成本较低;
|
||||
- 读写速度相对较慢,查找信息需要一定时间。
|
||||
|
||||
**AI 的上下文** = **小黑板**:
|
||||
- 读写快:模型可以在一次调用中直接看到全部上下文;
|
||||
- 容量有限:写满后不得不擦除旧内容;
|
||||
- 每写入一个 token 都会带来额外计算与费用。
|
||||
|
||||
**Manus 的经验**:**小黑板要用得省,用得巧,别用来存百科全书**。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第一步:认识成本 - 你的每一分钱花在哪?
|
||||
|
||||
### 3.1 为什么要先看成本?
|
||||
|
||||
让我们看看一次典型的 AI 对话,你的钱是怎么花的:
|
||||
|
||||
```
|
||||
💰 成本构成(一次对话):
|
||||
├─ 70% 重复看旧内容("刚才聊了什么?")
|
||||
├─ 20% 处理新内容("现在说什么?")
|
||||
└─ 10% 生成回复("怎么回答?")
|
||||
```
|
||||
|
||||
**惊人发现**:**70% 的钱花在让 AI 重新看你之前说过的话!**
|
||||
|
||||
### 3.2 什么是 KV Cache?(前缀复用)
|
||||
|
||||
在讨论价格之前,我们得先搞懂一个核心技术概念:**KV Cache(键值缓存)**。
|
||||
别被这个技术名词吓到,它其实就是 AI 的“短期记忆速查表”。
|
||||
|
||||
- **没有 KV Cache 时**:AI 每次都要像第一次看到这篇文章一样,从第一个字开始重新阅读、理解、计算。
|
||||
- **有了 KV Cache 时**:AI 会把看过的部分(Pre-fill)计算结果存下来。下次如果开头的内容没变,它就直接调取记忆,不用重新算了。
|
||||
|
||||
这就好比:
|
||||
> 你去考场考试。
|
||||
> **情况 A**:每次都要把整本教材从头读一遍,再开始答题。(慢、累、贵)
|
||||
> **情况 B**:教材内容你已经背滚瓜烂熟了(Cache),坐下直接答题。(快、轻松、便宜)
|
||||
|
||||
在云厂商的计费表里,**“背过的书”(Cache Hit)**通常比**“新看的书”(Cache Miss)**便宜 90% 以上。
|
||||
|
||||
### 3.3 "背课文" vs "现查现用"的价格差
|
||||
|
||||
以 Claude 为例:
|
||||
- **现查现用**(没缓存):$3.00 / 百万字
|
||||
- **背过再用**(有缓存):$0.30 / 百万字
|
||||
- **相差 10 倍**!
|
||||
|
||||
**Manus 的实践**:通过让 AI "背课文",他们把成本从 **$0.15 降到 $0.02**,**省了 87%**!
|
||||
|
||||
<ContextWindowVisualizer />
|
||||
|
||||
**关键点**:
|
||||
### 3.4 避坑指南:别让时间戳毁了你的“缓存”
|
||||
|
||||
- **溢出即丢失**:一旦超过红色警戒线,模型可能会报错,或者直接截断后面的内容。
|
||||
- **昂贵的记忆**:上下文越长,推理速度越慢,费用也越高。
|
||||
很多开发者习惯把“当前时间”写在 System Prompt 的第一句,觉得这样很严谨。
|
||||
**但这其实是上下文工程中最大的反模式之一。**
|
||||
|
||||
### 1.3 额外收益:前缀稳定与缓存命中
|
||||
想象一下:你背了一整本历史书(System Prompt),结果书的第一行写的是“现在的秒数”。
|
||||
如果这行字每秒都在变,那你上一秒背的所有内容,下一秒就全废了——你得从头再背一遍。
|
||||
|
||||
在 Agent 场景里,上下文通常是「系统设定 + 工具定义 + 历史消息 + 本轮新信息」的拼接。
|
||||
如果你能让这份输入包的**前半段尽量稳定**(比如系统提示、工具列表不要频繁变动),很多模型/服务会更容易复用缓存,从而降低延迟与成本。
|
||||
这就是**前缀复用(KV Cache)**的死穴:**只要开头变了,后面全都要重算。**
|
||||
|
||||
#### 错误示范:把动态信息放前面
|
||||
```text
|
||||
System: 现在是 2024-01-01 12:00:01。你是助手...
|
||||
(一分钟后)
|
||||
System: 现在是 2024-01-01 12:01:01。你是助手...
|
||||
```
|
||||
**后果**:虽然只变了几个字,但因为在开头,导致后续 99% 的固定内容无法复用缓存,每次请求都像第一次一样慢且贵。
|
||||
|
||||
#### 正确姿势:动静分离
|
||||
```text
|
||||
System: 你是助手... (这里放几千字的固定规则、知识库)
|
||||
User: (在这里通过工具调用或用户消息传入当前时间)
|
||||
```
|
||||
**好处**:前面的几千字规则永远不变,AI 只需要“背”一次。后续请求直接调用记忆,速度极快。
|
||||
|
||||
👇 **动手点点看**:
|
||||
点击下方的开关,开启**“背课文加速”**,然后多次点击“发送新请求”。
|
||||
观察一下:当第一块内容变成“已背过”时,**开口速度(TTFT)**会发生什么变化?
|
||||
|
||||
<KVCacheDemo />
|
||||
|
||||
---
|
||||
|
||||
## 2. 第二步:即时记忆 (Sliding Window)
|
||||
## 4. 第二步:滑动窗口 - 当"记性"变成"成本"
|
||||
|
||||
### 2.1 问题:聊久了就忘
|
||||
随着对话越来越长,最先遇到的问题就是:**窗口满了怎么办?**
|
||||
|
||||
当对话持续进行,Token 数量不断增加,最终会通过窗口限制。最简单的处理方式是**滑动窗口 (Sliding Window)**。
|
||||
### 4.1 为什么“先进先出”会出问题?
|
||||
|
||||
### 2.2 解决方案:先进先出 (FIFO)
|
||||
最简单的记忆管理是**滑动窗口(Sliding Window)**:**新的进来,旧的出去**。
|
||||
这听起来很公平,但在实际任务中却是个灾难。
|
||||
|
||||
就像一个滑动的相框,当新消息进来时,最旧的消息被"挤"出画面,被彻底遗忘。
|
||||
**场景重现**:
|
||||
```text
|
||||
对话记录:
|
||||
[1] 用户:我是张三,负责支付系统
|
||||
[2] 用户:项目用 Go 语言开发
|
||||
[3] 用户:数据库是 PostgreSQL
|
||||
...
|
||||
[20] 用户:帮我写个接口
|
||||
```
|
||||
**结果**:当聊到第 20 句时,第 1 句“我是张三”已经被挤出了窗口。AI 彻底忘了你是谁,也不知道你在负责什么系统。
|
||||
|
||||
**问题本质**:这种策略把**重要信息**(身份、技术栈)和**废话**(“好的”、“收到”)同等对待,一起被踢了出去。
|
||||
|
||||
### 4.2 "中间失忆症" - 为什么 AI 总看不到关键信息?
|
||||
|
||||
除了“忘得快”,AI 还有一个怪癖:**它也会“看漏”**。
|
||||
研究发现:**AI 对开头和结尾最敏感,中间最容易被忽略**。这就是著名的 **Lost in the Middle(中间迷失)**现象。
|
||||
|
||||
**U 型记忆曲线**:
|
||||
```text
|
||||
位置:开头 → 中间 → 结尾
|
||||
记忆: 高 → 低 → 高
|
||||
```
|
||||
|
||||
👇 **动手点点看**:
|
||||
1. 先试试**“滑动窗口”**:在下面的聊天框里多发几条消息,看看旧的对话是怎么被无情“挤出去”的。
|
||||
2. 再看看**“中间迷失”**:观察一下,当关键信息藏在整段话的中间位置时,检索成功率是不是最低的?
|
||||
|
||||
<SlidingWindowDemo />
|
||||
<LostInMiddleDemo />
|
||||
|
||||
**观察**:
|
||||
|
||||
- 当对话填满窗口后,最早的 `User` 消息变灰并消失。
|
||||
- **缺陷**:如果我在第一句话里告诉 AI "我的名字叫小明",几轮对话后,它就忘了我叫什么。
|
||||
**解决方案**:把关键信息放在**开头**(系统提示)或**结尾**(用户问题)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第三步:智能管理 (Selective Retention)
|
||||
## 5. 第三步:选择性保留 - 如何"钉"住关键信息?
|
||||
|
||||
### 3.1 问题:重要的事不能忘
|
||||
既然“先进先出”不靠谱,那我们该怎么办?
|
||||
Manus 的答案是:**建立“信息等级制度”**。
|
||||
|
||||
"滑动窗口"太笨了,它平等地对待每一句话。但有些信息(如你的名字、任务目标、系统设定)是**全局重要**的,无论对话多长都不能忘。
|
||||
### 5.1 为什么要给信息分等级?
|
||||
|
||||
### 3.2 解决方案:选择性保留 (Smart Context)
|
||||
不再平等对待每条信息,而是根据重要程度决定它们的去留:
|
||||
|
||||
我们需要一种机制,将关键信息**钉 (Pin)** 在窗口里,不受滑动影响。这通常通过 `System Prompt` 或动态注入来实现。
|
||||
| 等级 | 信息类型 | 待遇 | 成本影响 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **VIP** | 系统设定、用户身份 | **永远保留** | +15% 成本 |
|
||||
| **重要** | 当前任务目标 | **任务期内保留** | +10% 成本 |
|
||||
| **一般** | 普通对话历史 | **最近 5 轮保留** | 基准成本 |
|
||||
| **可弃** | 可检索的知识 | **用时再查** | -60% 成本 |
|
||||
|
||||
**核心思想**:**用 25% 的成本增加,换取 90% 的关键信息保留**。
|
||||
|
||||
### 5.2 "钉钉子"策略
|
||||
|
||||
你可以把上下文窗口想象成一面黑板:
|
||||
- **VIP 信息**:用钉子死死**钉在**黑板最上面(System Prompt)。
|
||||
- **重要信息**:用磁铁**吸在**黑板中间(Context Injection)。
|
||||
- **普通对话**:写在黑板下半部分,满了就擦掉旧的(Sliding Window)。
|
||||
|
||||
👇 **动手点点看**:
|
||||
试着在下面的演示里,把某条重要的对话“钉”住。
|
||||
观察一下:当你继续聊天时,被钉住的信息是不是一直都在,而没钉住的就被挤走了?
|
||||
|
||||
<SelectiveContextDemo />
|
||||
|
||||
**实验指南**:
|
||||
|
||||
1. 观察顶部的 **📌 Pinned** 区域,这里的信息永远不会被挤走。
|
||||
2. 在下方添加新消息,注意观察 **📜 Scrolling** 区域的变化。
|
||||
3. 尝试点击某条消息旁边的 📌 按钮,把它变成"永久记忆"。
|
||||
|
||||
**原理**:我们牺牲了一部分流动窗口的空间,换取了关键信息的持久性。
|
||||
|
||||
---
|
||||
|
||||
## 4. 第四步:长期记忆 (RAG & Vector DB)
|
||||
## 6. 第四步:RAG - 当"记性"需要"图书馆"
|
||||
|
||||
### 4.1 问题:如何记住一本书?
|
||||
有时候,我们要处理的信息太多了(比如几百页的技术文档),黑板根本写不下。这时候就需要外挂大脑——**RAG(检索增强生成)**。
|
||||
|
||||
如果我们需要 AI 记住几百页的技术文档,或者你过去一年的日记,即使是 "Pinned" 也装不下(窗口太贵且有限)。
|
||||
### 6.1 为什么“小黑板”不够用?
|
||||
|
||||
### 4.2 解决方案:外挂大脑 (RAG)
|
||||
Manus 面对百万字级的技术文档时,对比了两种做法:
|
||||
|
||||
**检索增强生成 (RAG, Retrieval-Augmented Generation)** 是目前的终极解决方案。我们不把所有记忆都塞进大脑,而是把它们写在"笔记本"(向量数据库)里。
|
||||
1. **全量写入**:所有内容一次性塞进上下文。
|
||||
* **后果**:黑板瞬间被占满,处理极慢,而且根据“中间迷失”理论,AI 根本记不住中间的内容。
|
||||
* **成本**:约 $50/次,等待 15 秒。
|
||||
2. **按需检索(RAG)**:先去图书馆(数据库)查,只把相关的几段话抄到黑板上。
|
||||
* **后果**:黑板很清爽,AI 聚焦于关键信息。
|
||||
* **成本**:约 $0.5/次,等待 2 秒。
|
||||
|
||||
当需要回答问题时,先去笔记本里**检索**相关的那一页,临时读入大脑。
|
||||
**省了 99% 的钱,87% 的时间!**
|
||||
|
||||
### 6.2 "查资料"的最佳实践
|
||||
|
||||
Manus 的经验总结:
|
||||
* **每本书撕成多大片?** 500-1000 字效果最好。
|
||||
* **一次查几本书?** 3-5 本,多了反而干扰。
|
||||
* **多相关的书才查?** 相似度 > 0.7,避免“硬凑”不相关的内容。
|
||||
|
||||
👇 **动手点点看**:
|
||||
在搜索框里输入问题(比如“如何重置密码”),看看系统是如何从一大堆文档里只找出最相关的那几条的。
|
||||
|
||||
<RAGSimulationDemo />
|
||||
|
||||
**流程解析**:
|
||||
|
||||
1. **Embedding**:将你的问题变成数学向量。
|
||||
2. **Search**:在数据库中寻找"长得像"(语义相似)的片段。
|
||||
3. **Retrieve**:只把那 2-3 个相关片段取出来。
|
||||
4. **Augment**:把这些片段和你的问题一起塞进 Prompt。
|
||||
|
||||
这样,AI 就能回答它从未"背过"的知识了。
|
||||
|
||||
---
|
||||
|
||||
## 5. 第五步:信息压缩 (Compression)
|
||||
## 7. 第五步:压缩 - 如何让"小黑板"写得更密?
|
||||
|
||||
### 5.1 问题:检索出来的内容还是太长
|
||||
如果信息都很重要,实在删不掉,又不想查资料怎么办?
|
||||
那就只能**把字写小点**——这就是**上下文压缩**。
|
||||
|
||||
有时候,即使检索出的相关片段也太长了。我们需要一种方法,在保留核心含义的前提下,减少 Token 消耗。
|
||||
### 7.1 什么时候需要"缩写"?
|
||||
* 检索回来的资料太厚(>2000 字)。
|
||||
* 对话历史太啰嗦(占了 >80% 黑板空间)。
|
||||
* 需要快速回答,不想让 AI 读长篇大论。
|
||||
|
||||
### 5.2 解决方案:上下文压缩
|
||||
### 7.2 "缩写"的三种境界
|
||||
|
||||
我们可以用更小的模型,或者专门的算法,对文本进行压缩。
|
||||
| 压缩方式 | 压缩率 | 保留什么 | 适用场景 | 省钱效果 |
|
||||
| :--- | :--- | :--- | :--- | :--- |
|
||||
| **总结式** | 70% | 主要意思 | 快速了解 | 省 30% |
|
||||
| **要点式** | 50% | 关键要点 | 结构化输出 | 省 50% |
|
||||
| **表格式** | 30% | 核心数据 | 程序处理 | 省 70% |
|
||||
|
||||
👇 **动手点点看**:
|
||||
选择不同的压缩策略,看看长篇大论是如何变短、变精炼的。
|
||||
|
||||
<ContextCompressionDemo />
|
||||
|
||||
**常见策略**:
|
||||
---
|
||||
|
||||
- **Summarize**:用自然语言总结大意。适合理解整体脉络。
|
||||
- **Extract Key Points**:提取要点列表。适合逻辑性强的内容。
|
||||
- **JSON Structure**:提取结构化数据。适合程序处理。
|
||||
## 8. 系统整合:打造 AI 的“记忆宫殿”
|
||||
|
||||
前面我们像搭积木一样,学习了各种独立的策略:
|
||||
* **KV Cache**:帮我们省钱(第 3 章)
|
||||
* **滑动窗口**:帮我们腾位置(第 4 章)
|
||||
* **分级保留**:帮我们留重点(第 5 章)
|
||||
* **RAG**:帮我们开外挂(第 6 章)
|
||||
|
||||
现在,是时候把这些积木搭成一座完整的城堡了——我们称之为 Manus 的**“记忆宫殿”**。
|
||||
|
||||
### 8.1 像盖房子一样组装上下文
|
||||
|
||||
不要把上下文看作一堆乱糟糟的文字,而要把它看作一座分层的建筑。每一层都有它独特的功能和“居住规则”。
|
||||
|
||||
👇 **动手点点看**:
|
||||
点击“开始建造”,看看我们是如何一层层把这座宫殿盖起来的。
|
||||
|
||||
<MemoryPalaceDemo />
|
||||
|
||||
### 8.2 为什么这样设计最强?
|
||||
|
||||
这座宫殿的设计哲学,其实就为了解决三个矛盾:
|
||||
|
||||
1. **地基(System Prompt)—— 解决“贵”的问题**
|
||||
* **矛盾**:系统设定(你是谁、规则是什么)最长,每次都要发。
|
||||
* **解法**:把它放在最底层,利用 **KV Cache** 技术,只要不改动,AI 就能“背诵全文”。后续几百轮对话,这部分的计算成本几乎为 **0**。
|
||||
|
||||
2. **支柱(Task Context)—— 解决“忘”的问题**
|
||||
* **矛盾**:对话一长,AI 容易忘了最初的任务目标(比如“写一个贪吃蛇游戏”)。
|
||||
* **解法**:利用**分级保留**策略,把任务目标“钉”在第二层。不管聊了多少轮,这层永远不删,确保 AI 不忘初心。
|
||||
|
||||
3. **顶层(Chat & RAG)—— 解决“乱”的问题**
|
||||
* **矛盾**:又有新对话,又有查到的资料,混在一起容易晕。
|
||||
* **解法**:
|
||||
* **客厅(对话)**:用**滑动窗口**管理,只留最近 5-10 句热乎的。
|
||||
* **图书馆(RAG)**:资料用完即走,不占地方。
|
||||
|
||||
### 8.3 实战效果
|
||||
|
||||
Manus 团队把这套架构搬上线后,效果立竿见影:
|
||||
|
||||
* **省钱了**:因为地基被“背”下来了,每轮对话的成本暴跌 **84%**。
|
||||
* **变快了**:AI 不用每次都从头读几千字,平均响应时间从 8 秒缩短到 **2 秒**。
|
||||
* **更准了**:关键信息被“钉”死,再也不会聊着聊着就忘了自己是干嘛的。
|
||||
|
||||
---
|
||||
|
||||
## 6. 总结:构建 AI 的记忆宫殿
|
||||
## 9. 实战模板:直接抄作业
|
||||
|
||||
上下文工程不仅仅是简单的"拼接字符串",它是一个精密的系统工程:
|
||||
为了让你更直观地理解这套机制是如何运作的,我们为你准备了**全链路模拟**。
|
||||
|
||||
| 阶段 | 核心技术 | 适用场景 | 类比 |
|
||||
| :----------- | :----------- | :----------------- | :------------------ |
|
||||
| **L1. 限制** | Token 计数 | 了解边界 | 黑板的大小 |
|
||||
| **L2. 短期** | 滑动窗口 | 日常闲聊 | 只能记 3 句话的金鱼 |
|
||||
| **L3. 关键** | 选择性保留 | 角色扮演、任务设定 | 手心写字的备忘录 |
|
||||
| **L4. 长期** | RAG / 向量库 | 知识库问答 | 随时查阅的图书馆 |
|
||||
| **L5. 优化** | 压缩 / 摘要 | 降低成本、提速 | 读书笔记 |
|
||||
请选择一个场景,点击“下一步”,看看从用户发问到 AI 回答的几秒钟内,**记忆宫殿**是如何动态调取、组装和清理上下文的。
|
||||
|
||||
掌握了这些,你就掌握了控制 AI "注意力"的钥匙。现在,去构建属于你的长记忆 AI 应用吧!
|
||||
<MemoryPalaceActionDemo />
|
||||
|
||||
### 📝 拿来即用的实战设计
|
||||
|
||||
如果你要设计一个类似 Manus 的系统,不要只盯着 Prompt 怎么写,更要关注**系统架构如何调度上下文**。
|
||||
|
||||
以下是两个经典场景的**系统设计蓝图**,包含了**提示词设计**和**代码逻辑(伪代码)**。
|
||||
|
||||
#### 场景 1:全栈工程师 Agent(长程记忆型)
|
||||
> **核心挑战**:任务周期长,容易忘了最初的需求和项目背景。
|
||||
> **解决策略**:System 层(身份)+ Task 层(钉死目标)+ Chat 层(滑动窗口)。
|
||||
|
||||
**1. 系统提示词 (Layer 1 & 2)**
|
||||
```markdown
|
||||
# Layer 1: 身份设定 (System Prompt) - 永远不变,利用 KV Cache
|
||||
你是一名资深的全栈工程师,精通 Python 和 Vue3。
|
||||
代码风格:
|
||||
- 变量命名严格遵守 PEP8
|
||||
- 关键逻辑必须包含注释
|
||||
- 优先使用项目已有的工具函数
|
||||
|
||||
# Layer 2: 任务锁定 (Task Context) - 任务期间不许删
|
||||
当前任务:重构支付模块 (payment_module)
|
||||
核心约束:
|
||||
1. 必须兼容旧版 API 接口 v1.0
|
||||
2. 数据库迁移脚本必须是幂等的
|
||||
3. 截止时间:本周五
|
||||
```
|
||||
|
||||
**2. 上下文组装逻辑 (Pseudo-Code)**
|
||||
```python
|
||||
def build_engineer_context(user_input, chat_history, task_info):
|
||||
context = []
|
||||
|
||||
# 1. 地基层:身份设定 (利用 KV Cache 缓存)
|
||||
# 这部分内容几百轮对话都不变,计算成本几乎为 0
|
||||
context.append(SYSTEM_PROMPT)
|
||||
|
||||
# 2. 支柱层:任务锁定 (Pinned)
|
||||
# 无论对话多长,这部分永远插入在 System 之后
|
||||
context.append(f"当前任务:{task_info}")
|
||||
|
||||
# 3. 检索层:代码片段 (RAG)
|
||||
# 根据用户的问题,去代码库里找相关的代码
|
||||
relevant_code = search_codebase(user_input)
|
||||
if relevant_code:
|
||||
context.append(f"参考代码:\n{relevant_code}")
|
||||
|
||||
# 4. 交互层:对话历史 (Sliding Window)
|
||||
# 只取最近 10 轮,避免撑爆上下文
|
||||
recent_chat = chat_history[-10:]
|
||||
context.extend(recent_chat)
|
||||
|
||||
# 5. 最新输入
|
||||
context.append(user_input)
|
||||
|
||||
return context
|
||||
```
|
||||
|
||||
#### 场景 2:智能客服 Agent(精准问答型)
|
||||
> **核心挑战**:成本敏感,且绝对不能胡说八道。
|
||||
> **解决策略**:System 层(强约束)+ RAG 层(动态注入)。
|
||||
|
||||
**1. 系统提示词 (Layer 1)**
|
||||
```markdown
|
||||
# Layer 1: 身份设定 (System Prompt)
|
||||
你是一名专业的电商客服专员。
|
||||
回复原则:
|
||||
1. 语气温柔、专业、简洁
|
||||
2. **绝对禁止**编造事实,只根据[参考资料]回答
|
||||
3. 如果资料里没有答案,请直接回答“非常抱歉,这个问题我需要转接人工客服”
|
||||
```
|
||||
|
||||
**2. 上下文组装逻辑 (Pseudo-Code)**
|
||||
```python
|
||||
def build_support_context(user_input):
|
||||
context = []
|
||||
|
||||
# 1. 地基层:身份设定
|
||||
context.append(SYSTEM_PROMPT)
|
||||
|
||||
# 2. 图书馆层:动态检索 (RAG)
|
||||
# 只有客服场景,RAG 才是主角,放在中间位置
|
||||
docs = vector_db.search(user_input, top_k=3)
|
||||
|
||||
context.append("【参考资料开始】")
|
||||
for doc in docs:
|
||||
context.append(doc.content)
|
||||
context.append("【参考资料结束】")
|
||||
|
||||
# 3. 交互层:极短的历史
|
||||
# 客服通常不需要太久远的记忆,保留最近 3 轮即可
|
||||
context.extend(get_recent_chat(limit=3))
|
||||
|
||||
context.append(user_input)
|
||||
|
||||
return context
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 名词对照表
|
||||
|
||||
| 英文术语 | 中文对照 | 解释 |
|
||||
| :--- | :--- | :--- |
|
||||
| **Context Window** | 上下文窗口 | 模型一次性能够处理的文本最大长度(包括输入和输出)。超出限制的内容会被截断或遗忘。 |
|
||||
| **Token** | 词元 | LLM 处理文本的最小单位。通常 1 个 Token 约等于 0.75 个英文单词或 0.5 个汉字。计费和窗口限制都以此为单位。 |
|
||||
| **KV Cache** | KV 缓存 | 一种推理加速技术,通过缓存已经计算过的注意力键值对,避免对重复前缀进行重复计算,显著降低延迟和成本。 |
|
||||
| **RAG** | 检索增强生成 | 在回答问题前,先从外部知识库检索相关信息,作为上下文提供给模型,以减少幻觉并扩展知识边界。 |
|
||||
| **Sliding Window** | 滑动窗口 | 最基础的上下文管理策略。保持窗口内 Token 数量恒定,当新内容进入时,自动移除最早的旧内容。 |
|
||||
| **Lost in Middle** | 中间迷失 | 大模型的一种局限性。研究表明,模型对长上下文开头和结尾的信息记忆最深,而容易忽略中间部分的信息。 |
|
||||
| **System Prompt** | 系统提示 | 位于对话最开始的指令,用于设定模型的身份、行为规范、回复风格和核心任务。 |
|
||||
| **Few-shot** | 少样本学习 | 在提示词中提供几个“问题-答案”的示例,帮助模型快速理解任务模式和输出格式。 |
|
||||
| **Chain of Thought** | 思维链 | 引导模型在给出最终答案前,先输出推理步骤。这种方法能显著提升模型解决复杂逻辑和数学问题的能力。 |
|
||||
| **Hallucination** | 幻觉 | 模型自信地生成看似合理但实际上错误或不存在的信息的现象。 |
|
||||
| **Embedding** | 向量化 | 将文本转换为高维数值向量的技术。语义相似的文本在向量空间中的距离更近,是语义搜索的基础。 |
|
||||
| **Vector DB** | 向量数据库 | 专门用于存储和检索向量数据的数据库。支持通过相似度搜索快速找到与查询最匹配的文档片段。 |
|
||||
| **Temperature** | 温度 | 控制模型输出随机性的超参数。数值越高(如 0.8)输出越多样、有创意;数值越低(如 0.2)输出越确定、严谨。 |
|
||||
| **TTFT** | 首字延迟 | Time to First Token,即从用户发送请求到模型输出第一个 Token 所花费的时间,是衡量交互体验的关键指标。 |
|
||||
|
||||
---
|
||||
|
||||
## 总结:上下文工程的本质
|
||||
|
||||
Manus 的四次重构告诉我们:
|
||||
|
||||
**从实践来看**:不是记得越多越好,而是记得越有结构、越有选择性越好。
|
||||
|
||||
**从成本视角看**:
|
||||
- 大部分浪费来自对固定前缀的重复计算,需要通过前缀稳定和缓存机制解决;
|
||||
- 重要信息被误删,往往源于“一视同仁”的滑动窗口,需要通过信息分级与钉住策略解决;
|
||||
- 面对超长文档和知识库时,仅依赖增大上下文窗口并不现实,必须结合检索与压缩机制。
|
||||
|
||||
目标是:在给定的模型与上下文上限下,让每一个 token 的投入都具备明确的用途。
|
||||
|
||||
@@ -1,142 +1,358 @@
|
||||
# 从 URL 输入到浏览器显示 (From URL to Browser)
|
||||
# 从 URL 到网页显示:一次网络"快递"之旅
|
||||
|
||||
> 💡 **学习指南**:本章节通过交互式演示,带你深入了解浏览器如何将一行网址变成丰富多彩的页面。我们将从输入 URL 开始,一步步拆解背后的网络请求、连接建立和页面渲染过程。
|
||||
<script setup>
|
||||
import UrlToBrowserQuickStart from '../../.vitepress/theme/components/appendix/url-to-browser/UrlToBrowserQuickStart.vue'
|
||||
import UrlParserDemo from '../../.vitepress/theme/components/appendix/url-to-browser/UrlParserDemo.vue'
|
||||
import DnsLookupDemo from '../../.vitepress/theme/components/appendix/url-to-browser/DnsLookupDemo.vue'
|
||||
import TcpHandshakeDemo from '../../.vitepress/theme/components/appendix/url-to-browser/TcpHandshakeDemo.vue'
|
||||
import HttpExchangeDemo from '../../.vitepress/theme/components/appendix/url-to-browser/HttpExchangeDemo.vue'
|
||||
import BrowserRenderingDemo from '../../.vitepress/theme/components/appendix/url-to-browser/BrowserRenderingDemo.vue'
|
||||
</script>
|
||||
|
||||
## 0. 全景图:一次神奇的旅行
|
||||
|
||||
当你在浏览器地址栏输入一个网址并按下回车,短短几秒钟内,背后发生了一系列复杂而精妙的过程。这就像是一次跨越万水千山的旅行。
|
||||
|
||||
它的核心任务只有一个:**将你想要的资源(网页)准确无误地从世界的另一端搬运到你的屏幕上**。
|
||||
|
||||
我们可以把这个过程分为五个关键阶段,点击下方的步骤来预览整个流程:
|
||||
|
||||
<UrlToBrowserDemo />
|
||||
> **学习指南**:本章节无需编程基础。我们将用**"寄快递"**的生活化比喻,配合**真实的技术过程**,带你一步步理解浏览器如何将一行网址变成丰富多彩的页面。
|
||||
|
||||
---
|
||||
|
||||
## 1. 第一步:寻址 (URL Parsing)
|
||||
## 0. 引言:当你按下回车键的那一刻
|
||||
|
||||
### 1.1 计算机的"导航地址"
|
||||
想象你要给远方的朋友寄一份礼物。你需要:
|
||||
1. **填写快递单**(写明地址、收件人)
|
||||
2. **快递公司查地址**(把"XX市XX区"转换成具体的门牌号)
|
||||
3. **打电话确认**(确保对方在家能收件)
|
||||
4. **快递员送达**(把包裹交给对方)
|
||||
5. **朋友拆开包裹**(看到礼物)
|
||||
|
||||
计算机需要一个精确的地址格式才能找到资源。这就是 **URL (统一资源定位符)** 的作用。它不仅告诉浏览器**去哪里**(域名),还告诉它**怎么去**(协议),以及**找什么**(路径)。
|
||||
**访问网页的过程和寄快递惊人地相似!**
|
||||
|
||||
试着在下方的模拟地址栏中输入不同的 URL,看看它是如何被拆解的:
|
||||
当你在浏览器输入 `google.com` 并按下回车,浏览器就是那个"快递员",它要完成一次从"你的电脑"到"远方服务器"再到"屏幕显示"的完整旅程。
|
||||
|
||||
<UrlToBrowserQuickStart />
|
||||
|
||||
---
|
||||
|
||||
## 1. 第一步:填写"快递单" —— URL 解析
|
||||
|
||||
### 生活比喻:填写快递单
|
||||
|
||||
假设你只在快递单上写"寄给张三",快递员肯定找不到人。你需要写清楚:
|
||||
- **用什么快递**(顺丰/中通)
|
||||
- **哪个城市**(北京市)
|
||||
- **具体地址**(朝阳区XX街道XX号)
|
||||
- **哪栋楼哪间房**(3号楼201)
|
||||
- **备注信息**(放快递柜/打电话)
|
||||
|
||||
### 真实过程:浏览器解析 URL
|
||||
|
||||
**URL(Uniform Resource Locator,统一资源定位符)**就是浏览器世界的"快递单格式"。当你在地址栏输入 `https://www.example.com:8080/path/page.html?id=123#section`,浏览器会立即拆解它:
|
||||
|
||||
| URL 部分 | 示例值 | 快递单类比 | 技术作用 |
|
||||
|----------|--------|-----------|----------|
|
||||
| **协议** `https://` | 安全超文本传输协议 | **快递公司**:顺丰(安全)vs 中通(普通) | 决定使用什么规则通信。`http` 是普通传输,`https` 是加密传输 |
|
||||
| **域名** `www.example.com` | 服务器的人类可读名字 | **收件人姓名**:张三 | 告诉浏览器要找哪台服务器。域名是为了让人记住,最终要转换成 IP 地址 |
|
||||
| **端口** `:8080` | 服务器的具体"门牌号" | **详细门牌号**:3号楼201(默认不写) | 服务器上可能有多个服务,端口指定访问哪一个。HTTP 默认 80,HTTPS 默认 443 |
|
||||
| **路径** `/path/page.html` | 服务器上的文件位置 | **房间里的抽屉**:衣柜第二层 | 指定服务器上的具体资源位置 |
|
||||
| **查询参数** `?id=123` | 附加信息 | **备注**:请轻拿轻放 | 传递给服务器的额外数据,如搜索关键词、页码等 |
|
||||
| **锚点** `#section` | 页面内的位置 | **书里的页码**:翻到第5页 | 页面加载后自动滚动到指定位置,不发送给服务器 |
|
||||
|
||||
<UrlParserDemo />
|
||||
|
||||
**关键部分解析**:
|
||||
|
||||
- **Protocol (协议)**:通常是 `https` (安全) 或 `http`。就像告诉司机是"坐飞机"还是"坐火车"。
|
||||
- **Host (域名)**:`www.example.com`。目的地的名字,方便人类记忆。
|
||||
- **Port (端口)**:服务器的"门牌号"。Web 服务默认是 80 (HTTP) 或 443 (HTTPS),通常省略不写。
|
||||
- **Path (路径)**:`/path/to/page`。资源在服务器文件系统中的具体位置。
|
||||
- **Query (参数)**:`?q=vue`。给服务器的附加指令,就像点餐时的备注"不要香菜"。
|
||||
> **关键理解**:URL 的存在是为了让**人类**能记住和输入。计算机最终需要的是 **IP 地址**(就像快递员最终需要的是门牌号,而不是"张三的家")。
|
||||
|
||||
---
|
||||
|
||||
## 2. 第二步:定位 (DNS Lookup)
|
||||
## 2. 第二步:查"地址簿" —— DNS 查询
|
||||
|
||||
### 2.1 互联网的"电话簿"
|
||||
### 生活比喻:查地址簿
|
||||
|
||||
虽然我们记住了 `google.com` 这样的域名,但计算机之间通信真正识别的是 **IP 地址**(如 `142.250.185.238`)。
|
||||
你告诉快递员"送到张三那里",但快递员怎么知道张三住哪?他需要查地址簿:
|
||||
1. 先翻**通讯录**(最近联系过的人)→ 浏览器缓存
|
||||
2. 没有的话问**社区服务中心**(他们知道各个小区归谁管)→ 本地 DNS 服务器
|
||||
3. 社区问**总管理处**(他们知道XX街道归哪个片区管)→ 根域名服务器
|
||||
4. 片区查**住户登记**(最终找到张三的门牌号)→ 权威域名服务器
|
||||
|
||||
**DNS (Domain Name System)** 就是互联网的"电话簿"或"导航系统"。当你输入域名时,浏览器需要先通过 DNS 找到它对应的 IP 地址。
|
||||
### 真实过程:DNS 分层查询
|
||||
|
||||
点击下方的 **"Go"** 按钮,观察 DNS 的**递归查询**过程:
|
||||
**DNS(Domain Name System,域名系统)**是互联网的"分布式地址簿查询系统"。由于全球有数十亿个域名,采用分层架构来分散查询压力:
|
||||
|
||||
```
|
||||
你(浏览器)
|
||||
↓ 问:google.com 的 IP 是多少?
|
||||
本地 DNS 服务器(你的网络运营商,如电信/联通)
|
||||
↓ 问:.com 归谁管?
|
||||
根域名服务器(全球13组根服务器,管理所有顶级域)
|
||||
↓ 告诉:去问 .com 的管理者
|
||||
顶级域服务器(Verisign 管理 .com)
|
||||
↓ 告诉:去问 google.com 的管理者
|
||||
权威域名服务器(Google 自己的 DNS 服务器)
|
||||
↓ 告诉:google.com 的 IP 是 142.250.80.46
|
||||
返回 IP 地址给浏览器
|
||||
```
|
||||
|
||||
**查询类型说明:**
|
||||
- **递归查询(Recursive Query)**:浏览器只发一次请求,本地 DNS 负责层层查询后返回结果
|
||||
- **迭代查询(Iterative Query)**:每一层只告诉下一层去哪查,浏览器需要多次查询
|
||||
- **缓存机制**:查询结果会被缓存,下次直接返回,大大加速访问
|
||||
|
||||
<DnsLookupDemo />
|
||||
|
||||
**查询流程解析**:
|
||||
|
||||
1. **浏览器缓存/Hosts**:先看看自己是否最近去过(缓存),或者本地小本本上有没有记(Hosts 文件)。
|
||||
2. **递归解析器 (Recursive Resolver)**:通常由你的 ISP (运营商) 提供。它像个尽职的跑腿员,负责帮你跑完剩下的路。
|
||||
3. **根域名服务器 (Root Server)**:它是 DNS 层级的顶端(`.`)。它不知道具体地址,但知道谁管 `.com`。
|
||||
4. **顶级域名服务器 (TLD Server)**:管理 `.com`、`.org` 等后缀的服务器。它会告诉你 `google.com` 归谁管。
|
||||
5. **权威域名服务器 (Authoritative Server)**:最终的管理者,它知道 `www.google.com` 的确切 IP 地址。
|
||||
> **为什么需要这么多层?** 想象一下如果全世界只有一个地址簿,几十亿人同时查,早就崩溃了。分层设计让每个层级只管理自己的"辖区",既高效又可靠。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第三步:连接 (TCP Handshake)
|
||||
## 3. 第三步:打电话确认 —— TCP 三次握手
|
||||
|
||||
### 3.1 建立可靠的通路
|
||||
### 生活比喻:打电话确认
|
||||
|
||||
拿到 IP 地址后,浏览器找到了服务器。但在传输数据之前,它们必须建立一条可靠的连接,确保双方都能"听得到"且"说得出"。
|
||||
假设快递员直接冲到张三家门口,结果:
|
||||
- 张三不在家 → 白跑一趟
|
||||
- 电话打不通 → 不知道送哪
|
||||
- 地址错了 → 送错地方
|
||||
|
||||
这就是著名的 **TCP 三次握手 (Three-Way Handshake)**。
|
||||
**所以在真正送包裹之前,必须先确认"对方能收到"**。
|
||||
|
||||
点击 **"Connect"** 亲自完成这次握手:
|
||||
### 真实过程:TCP 三次握手
|
||||
|
||||
**TCP(Transmission Control Protocol,传输控制协议)**是确保数据可靠传输的规则。在发送任何数据前,客户端和服务器必须通过"三次握手"建立可靠连接:
|
||||
|
||||
```
|
||||
客户端(你的浏览器) 服务器(网站)
|
||||
| |
|
||||
|--- SYN=1, seq=x ------------->| 第1次:我想连接你,我的初始序号是 x
|
||||
| |
|
||||
|<-- SYN=1, ACK=1, seq=y, ack=x+1 | 第2次:我也想连接你,我的初始序号是 y,期待收到 x+1
|
||||
| |
|
||||
|--- ACK=1, ack=y+1 ----------->| 第3次:确认,期待收到 y+1
|
||||
| |
|
||||
===== 连接建立,开始传输数据 =====
|
||||
```
|
||||
|
||||
**为什么是三次,不是两次?**
|
||||
|
||||
- **第一次(SYN)**:客户端证明自己能发送
|
||||
- **第二次(SYN-ACK)**:服务器证明自己能接收和发送
|
||||
- **第三次(ACK)**:客户端证明自己能接收
|
||||
|
||||
三次握手确保:**双方都能发、双方都能收** —— 四个条件都满足,才能可靠传输。
|
||||
|
||||
**TCP 还负责:**
|
||||
- **数据分包**:大数据拆成小数据包传输
|
||||
- **顺序重组**:确保数据包按正确顺序组装
|
||||
- **错误重传**:丢包后自动重新发送
|
||||
- **流量控制**:根据网络状况调整发送速度
|
||||
|
||||
<TcpHandshakeDemo />
|
||||
|
||||
**握手三部曲**:
|
||||
|
||||
1. **SYN** (Synchronize):客户端发送一个包,说"你好,我想和你建立连接,我的序列号是 X"。
|
||||
2. **SYN-ACK** (Synchronize-Acknowledge):服务器收到后回复,"好的,收到了 X。我也想和你建立连接,我的序列号是 Y"。
|
||||
3. **ACK** (Acknowledge):客户端最后回复,"好的,收到了 Y。那我们开始传输数据吧"。
|
||||
|
||||
> 🔒 **关于 HTTPS (TLS)**:
|
||||
> 如果使用 HTTPS,在 TCP 握手之后,还会进行 **TLS 握手**。双方会协商加密算法并交换证书,确保后续传输的数据像装在保险箱里一样安全。
|
||||
> **HTTPS 的额外步骤**:如果是 HTTPS(安全的网站),在 TCP 握手后还会进行 **TLS 握手**(1-RTT 或 2-RTT),双方交换加密密钥,确保之后的对话内容只有双方能看懂,就像用暗语通话。
|
||||
|
||||
---
|
||||
|
||||
## 4. 第四步:交流 (HTTP Exchange)
|
||||
## 4. 第四步:"快递员"和"收件人"的对话 —— HTTP 请求与响应
|
||||
|
||||
### 4.1 索取与交付
|
||||
### 生活比喻:快递员送达
|
||||
|
||||
连接建立好了,浏览器终于可以发出它的请求了:"请给我首页的 HTML 代码"。这就像在餐厅点餐。
|
||||
快递员敲门:"张三在吗?您的快递!"
|
||||
张三开门:"好的,给我吧。" 或者 "我没买东西啊,退回去吧。"
|
||||
|
||||
**HTTP (HyperText Transfer Protocol)** 定义了这种对话的格式。
|
||||
### 真实过程:HTTP 协议通信
|
||||
|
||||
在下方的模拟器中尝试发送不同的请求(GET/POST),观察网络日志:
|
||||
**HTTP(HyperText Transfer Protocol,超文本传输协议)**是浏览器和服务器之间的"对话规则"。TCP 连接建立后,浏览器发送 HTTP 请求:
|
||||
|
||||
**HTTP 请求示例:**
|
||||
```http
|
||||
GET /index.html HTTP/1.1 ← 请求方法 + 路径 + 协议版本
|
||||
Host: www.example.com ← 目标主机(支持虚拟主机,一台服务器可托管多个网站)
|
||||
User-Agent: Chrome/120.0 ← 客户端标识(服务器可据此返回适配内容)
|
||||
Accept: text/html,application/xhtml+xml ← 可接受的响应格式
|
||||
Accept-Language: zh-CN,zh;q=0.9 ← 偏好的语言
|
||||
Accept-Encoding: gzip, deflate ← 支持的压缩格式
|
||||
Connection: keep-alive ← 保持连接(复用 TCP 连接)
|
||||
Cookie: session_id=abc123 ← 身份凭证
|
||||
```
|
||||
|
||||
**常见 HTTP 方法:**
|
||||
- `GET`:获取资源(安全、幂等,可被缓存)
|
||||
- `POST`:提交数据(创建资源,如注册、登录)
|
||||
- `PUT`:更新资源(完整替换)
|
||||
- `PATCH`:部分更新资源
|
||||
- `DELETE`:删除资源
|
||||
- `HEAD`:获取响应头(不返回主体,用于检查资源是否存在)
|
||||
|
||||
**服务器返回 HTTP 响应:**
|
||||
```http
|
||||
HTTP/1.1 200 OK ← 协议版本 + 状态码 + 状态描述
|
||||
Date: Mon, 23 May 2025 12:00:00 GMT ← 服务器时间
|
||||
Content-Type: text/html; charset=UTF-8 ← 内容类型和编码
|
||||
Content-Length: 1234 ← 内容长度(字节)
|
||||
Cache-Control: max-age=3600 ← 缓存策略
|
||||
Set-Cookie: user_id=xyz789 ← 设置 Cookie
|
||||
|
||||
<!DOCTYPE html>... ← 响应体(网页内容)
|
||||
```
|
||||
|
||||
**HTTP 状态码分类:**
|
||||
|
||||
| 状态码 | 类别 | 含义 | 生活类比 |
|
||||
|--------|------|------|----------|
|
||||
| **200** | 成功 | 请求成功处理 | "给,这是你要的" |
|
||||
| **301/302** | 重定向 | 资源已移动 | "搬家了,去新地址取" |
|
||||
| **304** | 未修改 | 缓存仍有效 | "和上次一样,不用重新拿" |
|
||||
| **400** | 客户端错误 | 请求格式错误 | "你说的话我听不懂" |
|
||||
| **401** | 未授权 | 需要身份验证 | "请出示证件" |
|
||||
| **403** | 禁止访问 | 权限不足 | "你不准进" |
|
||||
| **404** | 未找到 | 资源不存在 | "没这个人/没这个东西" |
|
||||
| **500** | 服务器错误 | 服务器内部错误 | "我们这系统出故障了" |
|
||||
| **502** | 网关错误 | 上游服务器无响应 | "我们上级部门没回应" |
|
||||
| **503** | 服务不可用 | 服务器过载或维护 | "今天休息,不营业" |
|
||||
|
||||
<HttpExchangeDemo />
|
||||
|
||||
**对话过程**:
|
||||
|
||||
1. **请求 (Request)**:
|
||||
- **Method**:`GET`(获取)、`POST`(提交数据)等。
|
||||
- **Path**:我要什么资源。
|
||||
- **Headers**:我是谁(User-Agent)、我想要什么格式(Accept)等元数据。
|
||||
|
||||
2. **响应 (Response)**:
|
||||
- **Status Code**:`200 OK`(成功)、`404 Not Found`(没找到)、`500 Error`(服务器出错了)。
|
||||
- **Headers**:内容类型(Content-Type)、服务器信息等。
|
||||
- **Body**:具体的 HTML 代码、JSON 数据或图片二进制流。
|
||||
|
||||
---
|
||||
|
||||
## 5. 第五步:展示 (Browser Rendering)
|
||||
## 5. 第五步:拆开"包裹" —— 浏览器渲染
|
||||
|
||||
### 5.1 代码如何变成画面?
|
||||
### 生活比喻:拆开包裹看到礼物
|
||||
|
||||
浏览器收到的是一堆枯燥的 HTML 代码,它是如何变成我们在屏幕上看到的精美网页的呢?这个过程叫做**渲染 (Rendering)**。
|
||||
快递员把包裹交给张三,张三看到的是**包装盒**。他需要:
|
||||
1. **拆开包装**(去掉快递袋)→ 解析 HTML
|
||||
2. **查看说明书**(了解怎么用)→ 解析 CSS
|
||||
3. **组装零件**(按说明书拼装)→ 构建渲染树
|
||||
4. **摆放位置**(确定放哪里)→ 布局计算
|
||||
5. **最终呈现**(展示成品)→ 绘制到屏幕
|
||||
|
||||
浏览器就像一个精密的工厂,将原材料(HTML/CSS)加工成最终产品(屏幕上的像素)。
|
||||
### 真实过程:浏览器渲染引擎
|
||||
|
||||
点击下方的步骤,查看渲染流水线的每个阶段:
|
||||
浏览器收到的是 **HTML/CSS/JavaScript 代码**(枯燥的文本),但它要变成**像素画面**(精美的网页)。这个过程叫做**渲染(Rendering)**,由浏览器的**渲染引擎**(如 Chrome 的 Blink、Safari 的 WebKit)执行。
|
||||
|
||||
#### 步骤1:解析 HTML → 构建 DOM 树
|
||||
|
||||
浏览器读取 HTML 字节流,按编码(通常是 UTF-8)转换成字符,通过词法分析生成 Token,再解析成 DOM 节点,最终构建成**DOM(Document Object Model,文档对象模型)树**:
|
||||
|
||||
```html
|
||||
<!-- 原始 HTML -->
|
||||
<html>
|
||||
<body>
|
||||
<div class="header">标题</div>
|
||||
<div class="content">内容</div>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
```
|
||||
变成树形结构:
|
||||
Document
|
||||
│
|
||||
html
|
||||
│
|
||||
body
|
||||
/ \
|
||||
div div
|
||||
.header .content
|
||||
│ │
|
||||
"标题" "内容"
|
||||
```
|
||||
|
||||
**关键特性:**
|
||||
- **流式解析**:浏览器边下载边解析,不需要等整个 HTML 下载完
|
||||
- **遇到 script 标签**:会暂停解析,先下载并执行 JavaScript(除非加 `async` 或 `defer`)
|
||||
- **遇到 css 链接**:不会阻塞解析,但会阻塞渲染(需要等 CSS 下载完)
|
||||
|
||||
#### 步骤2:解析 CSS → 构建 CSSOM 树
|
||||
|
||||
浏览器同时解析 CSS(内联样式、`<style>` 标签、外部 `.css` 文件),构建**CSSOM(CSS Object Model,CSS 对象模型)树**:
|
||||
|
||||
```css
|
||||
.header { color: blue; font-size: 24px; }
|
||||
.content { margin: 20px; background: #f0f0f0; }
|
||||
```
|
||||
|
||||
CSSOM 树与 DOM 树结构类似,但存储的是样式规则。
|
||||
|
||||
**CSS 解析特点:**
|
||||
- **阻塞渲染**:CSS 会阻塞渲染,因为浏览器不知道元素长什么样就无法绘制
|
||||
- **层叠和继承**:CSS 的"C"就是 Cascading(层叠),遵循特定的优先级规则
|
||||
|
||||
#### 步骤3:合并 → 渲染树(Render Tree)
|
||||
|
||||
DOM 树 + CSSOM 树 = **渲染树**。渲染树只包含可见元素(`display: none` 的元素不会进入渲染树)。
|
||||
|
||||
```
|
||||
渲染树节点示例:
|
||||
- 节点类型:div
|
||||
- 样式:color: blue, font-size: 24px
|
||||
- 内容:"标题"
|
||||
```
|
||||
|
||||
#### 步骤4:布局(Layout / Reflow)
|
||||
|
||||
浏览器计算渲染树中每个节点的**精确位置和大小**:
|
||||
- 视口(viewport)多大?
|
||||
- 每个元素占多少空间?
|
||||
- 元素之间如何排列?
|
||||
|
||||
这个过程也叫**重排(Reflow)**,是性能开销较大的操作。
|
||||
|
||||
**影响布局的因素:**
|
||||
- 视口大小变化(窗口缩放)
|
||||
- 元素尺寸变化(内容增减)
|
||||
- 字体大小变化
|
||||
- CSS 布局属性变化(`width`、`height`、`margin` 等)
|
||||
|
||||
#### 步骤5:绘制(Paint)→ 合成(Composite)
|
||||
|
||||
**绘制阶段**:把渲染树的每个节点绘制成像素,填充到**图层(Layer)**中。
|
||||
|
||||
**合成阶段**:如果页面有多个图层(如 `position: fixed`、CSS 动画、3D 变换等),浏览器会把这些图层按正确顺序叠加,最终显示到屏幕上。
|
||||
|
||||
**GPU 加速**:现代浏览器会把某些图层交给 GPU 处理,实现流畅的动画效果。
|
||||
|
||||
<BrowserRenderingDemo />
|
||||
|
||||
**关键渲染路径 (Critical Rendering Path)**:
|
||||
|
||||
1. **构建 DOM 树**:解析 HTML,建立文档结构树(就像房屋的框架)。
|
||||
2. **构建渲染树 (Render Tree)**:结合 CSS 样式,计算出所有**可见**元素的样式规则。
|
||||
3. **布局 (Layout/Reflow)**:计算每个元素在屏幕上的确切坐标和大小(就像丈量尺寸)。
|
||||
4. **绘制 (Paint)**:填充像素,包括颜色、图片、边框等。
|
||||
5. **合成 (Composite)**:将不同的图层(Layer)在 GPU 中合成,最终显示在屏幕上。
|
||||
> **关键洞察**:渲染是"渐进式"的。浏览器不需要等所有内容都下载完才开始显示,而是收到一部分就渲染一部分。这就是为什么大网页会"慢慢加载出来"。
|
||||
|
||||
---
|
||||
|
||||
## 6. 总结
|
||||
## 6. 总结:一次完整的"网络快递"之旅
|
||||
|
||||
从 URL 输入到页面显示,这短短的几秒钟内,凝聚了计算机网络几十年的智慧结晶。
|
||||
让我们回顾整个旅程:
|
||||
|
||||
| 阶段 | 核心任务 | 关键技术 | 类比 |
|
||||
| :---------- | :------- | :-------- | :------------- |
|
||||
| **1. 寻址** | 解析目标 | URL | 确定目的地地址 |
|
||||
| **2. 定位** | 查找 IP | DNS | 查电话簿 |
|
||||
| **3. 连接** | 建立通路 | TCP/TLS | 打电话确认通畅 |
|
||||
| **4. 交流** | 交换数据 | HTTP | 点餐对话 |
|
||||
| **5. 展示** | 绘制页面 | Rendering | 装修房子 |
|
||||
| 阶段 | 技术术语 | 快递类比 | 核心任务 | 关键技术 |
|
||||
|------|----------|----------|----------|----------|
|
||||
| **1. 解析** | URL 解析 | 填写快递单 | 理解用户想访问哪里 | 协议、域名、端口、路径、参数 |
|
||||
| **2. 查询** | DNS 查询 | 查地址簿 | 把域名转换成 IP 地址 | 递归/迭代查询、缓存机制 |
|
||||
| **3. 连接** | TCP 握手 | 打电话确认 | 确保双方能可靠通信 | 三次握手、序列号、流量控制 |
|
||||
| **4. 对话** | HTTP 交换 | 快递员送达 | 请求和传输数据 | 请求方法、状态码、头部字段 |
|
||||
| **5. 展示** | 浏览器渲染 | 拆开包裹 | 把代码变成画面 | DOM、CSSOM、渲染树、布局、绘制 |
|
||||
|
||||
现在,当你再次按下回车键时,你已经看到了屏幕背后的那个忙碌而精彩的数字世界。
|
||||
**整个过程通常在几百毫秒内完成** —— 想想这有多么不可思议!
|
||||
|
||||
你的浏览器在不到1秒的时间里:
|
||||
- 解析了一个复杂的地址
|
||||
- 查询了分布在全球的 DNS 服务器
|
||||
- 和千里之外的服务器建立了可靠连接
|
||||
- 进行了一次完整的 HTTP 对话
|
||||
- 把枯燥的代码变成了精美的画面
|
||||
|
||||
这就是互联网的魅力:**复杂的技术,简单的体验**。
|
||||
|
||||
---
|
||||
|
||||
## 7. 名词速查表 (Glossary)
|
||||
|
||||
| 名词 | 全称 | 简单解释 |
|
||||
|------|------|----------|
|
||||
| **URL** | Uniform Resource Locator | **统一资源定位符**。网页的"地址",告诉浏览器去哪里找资源。 |
|
||||
| **DNS** | Domain Name System | **域名系统**。互联网的"电话簿",把人类可读的域名转换成机器可读的 IP 地址。 |
|
||||
| **IP 地址** | Internet Protocol Address | **互联网协议地址**。每台联网设备的唯一"门牌号",如 `192.168.1.1`。 |
|
||||
| **TCP** | Transmission Control Protocol | **传输控制协议**。确保数据可靠传输的"规则",通过三次握手建立连接。 |
|
||||
| **HTTP** | HyperText Transfer Protocol | **超文本传输协议**。浏览器和服务器"对话"的规则。 |
|
||||
| **HTTPS** | HTTP Secure | **安全的 HTTP**。在 HTTP 基础上加了加密(TLS/SSL),保护数据安全。 |
|
||||
| **HTML** | HyperText Markup Language | **超文本标记语言**。网页的"骨架",定义内容的结构。 |
|
||||
| **CSS** | Cascading Style Sheets | **层叠样式表**。网页的"皮肤",定义内容的外观。 |
|
||||
| **DOM** | Document Object Model | **文档对象模型**。浏览器把 HTML 转换成的树形结构,方便操作。 |
|
||||
| **CSSOM** | CSS Object Model | **CSS 对象模型**。浏览器把 CSS 转换成的树形结构。 |
|
||||
| **渲染** | Rendering | 浏览器把代码转换成屏幕像素的过程。 |
|
||||
| **RTT** | Round Trip Time | **往返时间**。数据包从发送到接收确认的时间,影响网页加载速度。 |
|
||||
|
||||
---
|
||||
|
||||
> **恭喜!** 现在当你再次在地址栏输入网址时,你已经能看到屏幕背后的那个忙碌而精彩的数字世界了。
|
||||
|
||||
Reference in New Issue
Block a user