feat(docs): add interactive demo components for technical appendices

Add placeholder Vue components for visualizing technical concepts across multiple domains including frontend routing, browser rendering, cache design, queue design, database principles, API design, cloud services, and backend evolution. These components provide interactive educational content for the documentation.

Update documentation structure to include new appendix sections and enhance existing content with visual components. Remove unused 'codex' dependency from package.json.
This commit is contained in:
sanbuphy
2026-02-06 03:34:50 +08:00
parent e8bba6f7c0
commit 7c70c37072
171 changed files with 69830 additions and 6689 deletions
@@ -1,333 +1,283 @@
<template>
<div class="diffusion-process-demo">
<el-card shadow="never">
<template #header>
<div class="header-controls">
<div class="mode-toggles">
<el-radio-group v-model="mode" size="large">
<el-radio-button label="forward">
<el-icon><TopRight /></el-icon> 加噪 (Forward)
</el-radio-button>
<el-radio-button label="reverse">
<el-icon><BottomLeft /></el-icon> 去噪 (Reverse)
</el-radio-button>
</el-radio-group>
</div>
<el-button type="primary" circle @click="togglePlay">
<el-icon v-if="isPlaying"><VideoPause /></el-icon>
<el-icon v-else><VideoPlay /></el-icon>
</el-button>
</div>
</template>
<div class="canvas-container">
<canvas
ref="canvasRef"
width="300"
height="300"
class="noise-canvas"
></canvas>
<div class="step-indicator">
<span class="step-text"
>Step: {{ currentStep }} / {{ totalSteps }}</span
>
<el-progress
:percentage="
mode === 'forward'
? (currentStep / totalSteps) * 100
: ((totalSteps - currentStep) / totalSteps) * 100
"
:status="mode === 'forward' ? 'exception' : 'success'"
:show-text="false"
:stroke-width="4"
/>
<div class="diffusion-magic">
<div class="magic-frame">
<!-- The Canvas -->
<div class="canvas-wrapper">
<canvas ref="canvasRef" width="300" height="300"></canvas>
<!-- Overlay Status -->
<div class="status-overlay" :class="{ visible: isProcessing }">
<div class="step-counter">Step {{ currentStep }} / {{ totalSteps }}</div>
<div class="step-desc">{{ stepDescription }}</div>
</div>
</div>
<div class="slider-control">
<el-slider
v-model="currentStep"
:min="0"
:max="totalSteps"
:format-tooltip="formatTooltip"
@input="draw"
/>
<div class="slider-labels">
<span>{{
mode === 'forward' ? '原图 (Original)' : '纯噪声 (Noise)'
}}</span>
<span>{{
mode === 'forward' ? '纯噪声 (Noise)' : '原图 (Original)'
}}</span>
</div>
<!-- Controls -->
<div class="controls">
<button class="magic-btn" @click="startDenoise" :disabled="isProcessing">
<span class="icon"></span>
{{ isProcessing ? '去噪中...' : '开始去噪 (Denoise)' }}
</button>
<button class="reset-btn" @click="reset" :disabled="isProcessing">
<span class="icon">🔄</span> 重置
</button>
</div>
</div>
<el-alert
:title="
mode === 'forward' ? '训练阶段:破坏数据' : '生成阶段:创造数据'
"
:type="mode === 'forward' ? 'warning' : 'success'"
:description="
mode === 'forward'
? 'AI 通过学习如何「一点点加噪」,掌握了噪声的规律。这就像教它把积木推倒。'
: 'AI 通过预测并减去噪声,从混沌中还原出图像。这就像它学会了把推倒的积木重新搭好。'
"
show-icon
:closable="false"
class="explanation-alert"
/>
</el-card>
<div class="info-bar">
<span class="icon">💡</span>
<span>
<strong>观察重点</strong>
注意看图像不是一下子变出来的而是像在雾气中慢慢显影这就是 Diffusion 的核心它在不断猜测噪声背后的真相
</span>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, watch, onUnmounted } from 'vue'
import {
VideoPlay,
VideoPause,
TopRight,
BottomLeft
} from '@element-plus/icons-vue'
import { ref, onMounted, computed } from 'vue'
const canvasRef = ref(null)
const mode = ref('reverse')
const isProcessing = ref(false)
const currentStep = ref(0)
const totalSteps = 100
const isPlaying = ref(false)
const totalSteps = 50
let animationFrame = null
let originalImage = null
// Load a base image (using a generated pattern to avoid external dependencies)
const loadBaseImage = () => {
const canvas = document.createElement('canvas')
canvas.width = 300
canvas.height = 300
const ctx = canvas.getContext('2d')
// Draw a simple landscape
// Sky
// Use a simple gradient pattern as the "Target Image" to avoid external assets
const drawTargetImage = (ctx) => {
// Draw a sunset landscape
const gradient = ctx.createLinearGradient(0, 0, 0, 300)
gradient.addColorStop(0, '#87CEEB')
gradient.addColorStop(1, '#E0F7FA')
gradient.addColorStop(0, '#2c3e50')
gradient.addColorStop(0.5, '#e67e22')
gradient.addColorStop(1, '#f1c40f')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 300, 300)
// Sun
// Draw a sun
ctx.beginPath()
ctx.arc(240, 60, 30, 0, Math.PI * 2)
ctx.fillStyle = '#FFD700'
ctx.arc(150, 200, 60, 0, Math.PI * 2)
ctx.fillStyle = '#f39c12'
ctx.fill()
// Mountains
// Draw mountains
ctx.beginPath()
ctx.moveTo(0, 300)
ctx.lineTo(100, 150)
ctx.lineTo(100, 200)
ctx.lineTo(200, 250)
ctx.lineTo(300, 100)
ctx.lineTo(300, 150)
ctx.lineTo(300, 300)
ctx.fillStyle = '#4CAF50'
ctx.fillStyle = '#2c3e50'
ctx.fill()
// House
ctx.fillStyle = '#795548'
ctx.fillRect(50, 220, 60, 60)
ctx.beginPath()
ctx.moveTo(40, 220)
ctx.lineTo(80, 180)
ctx.lineTo(120, 220)
ctx.fillStyle = '#F44336'
ctx.fill()
originalImage = ctx.getImageData(0, 0, 300, 300)
}
const generateNoise = (width, height) => {
const size = width * height * 4
const data = new Uint8ClampedArray(size)
for (let i = 0; i < size; i += 4) {
const val = Math.random() * 255
data[i] = val // R
data[i + 1] = val // G
data[i + 2] = val // B
data[i + 3] = 255 // A
const drawNoise = (ctx, amount) => {
const w = 300
const h = 300
const idata = ctx.getImageData(0, 0, w, h)
const buffer = new Uint32Array(idata.data.buffer)
// We need to blend the target image with noise based on 'amount' (0 to 1)
// But since we can't easily read back the target image every frame efficiently without offscreen canvas,
// let's do a simpler trick: Draw target, then draw semi-transparent noise on top.
// Actually, let's generate noise overlay.
// Amount 1.0 = Full Noise (Opaque)
// Amount 0.0 = No Noise (Transparent)
// Clear and draw target first
drawTargetImage(ctx)
if (amount <= 0) return
const noiseCanvas = document.createElement('canvas')
noiseCanvas.width = w
noiseCanvas.height = h
const nCtx = noiseCanvas.getContext('2d')
const nImgData = nCtx.createImageData(w, h)
const data = nImgData.data
for (let i = 0; i < data.length; i += 4) {
const gray = Math.random() * 255
data[i] = gray // R
data[i+1] = gray // G
data[i+2] = gray // B
data[i+3] = 255 // Alpha
}
return new ImageData(data, width, height)
nCtx.putImageData(nImgData, 0, 0)
ctx.globalAlpha = amount
ctx.drawImage(noiseCanvas, 0, 0)
ctx.globalAlpha = 1.0
}
// Pre-generate noise to keep it consistent
let noiseImage = null
const stepDescription = computed(() => {
if (currentStep.value === 0) return '纯噪声 (Pure Noise)'
if (currentStep.value < 10) return '隐约出现轮廓...'
if (currentStep.value < 30) return '色彩开始浮现...'
if (currentStep.value < 50) return '细节逐渐清晰...'
return '生成完成 (Done)!'
})
const startDenoise = () => {
if (isProcessing.value) return
isProcessing.value = true
currentStep.value = 0
const animate = () => {
if (currentStep.value >= totalSteps) {
isProcessing.value = false
return
}
currentStep.value++
const noiseLevel = 1 - (currentStep.value / totalSteps)
// Non-linear ease out for better visual
const visualNoise = Math.pow(noiseLevel, 1.5)
const ctx = canvasRef.value.getContext('2d')
drawNoise(ctx, visualNoise)
animationFrame = requestAnimationFrame(animate)
}
animate()
}
const reset = () => {
if (animationFrame) cancelAnimationFrame(animationFrame)
isProcessing.value = false
currentStep.value = 0
const ctx = canvasRef.value.getContext('2d')
drawNoise(ctx, 1.0)
}
onMounted(() => {
loadBaseImage()
noiseImage = generateNoise(300, 300)
// Always start from step 0 (Start of the process)
// Forward: Step 0 = Clean Image
// Reverse: Step 0 = Noisy Image
currentStep.value = 0
draw()
reset()
})
onUnmounted(() => {
stopAnimation()
})
const draw = () => {
const canvas = canvasRef.value
if (!canvas || !originalImage) return
const ctx = canvas.getContext('2d')
// Calculate noise ratio based on mode and step
// Forward: 0 -> 100 (Clean -> Noisy)
// Reverse: 100 -> 0 (Noisy -> Clean)
// But wait, the slider is just "Progress".
// Let's define currentStep as "Amount of Noise" for simplicity in logic,
// but for UI, we want:
// Forward Mode: Slider 0 (Clean) -> 100 (Noisy)
// Reverse Mode: Slider 0 (Noisy) -> 100 (Clean)
let noiseRatio = 0
if (mode.value === 'forward') {
noiseRatio = currentStep.value / totalSteps
} else {
// In reverse mode, slider 0 means start (Noisy), 100 means end (Clean)
// So noise amount is 1 - slider
noiseRatio = 1 - currentStep.value / totalSteps
}
// Non-linear interpolation for better visual effect
// noiseRatio = Math.pow(noiseRatio, 1.5)
const w = canvas.width
const h = canvas.height
const output = ctx.createImageData(w, h)
const d = output.data
const o = originalImage.data
const n = noiseImage.data
for (let i = 0; i < d.length; i += 4) {
// Simple linear interpolation
// Pixel = (1 - alpha) * Original + alpha * Noise
// Note: This is a simplified diffusion visualization.
// Real diffusion adds noise: x_t = sqrt(alpha_bar) * x_0 + sqrt(1 - alpha_bar) * epsilon
// Using simple blending for visualization
d[i] = o[i] * (1 - noiseRatio) + n[i] * noiseRatio
d[i + 1] = o[i + 1] * (1 - noiseRatio) + n[i + 1] * noiseRatio
d[i + 2] = o[i + 2] * (1 - noiseRatio) + n[i + 2] * noiseRatio
d[i + 3] = 255
}
ctx.putImageData(output, 0, 0)
}
const togglePlay = () => {
if (isPlaying.value) {
stopAnimation()
} else {
startAnimation()
}
}
const startAnimation = () => {
isPlaying.value = true
// Reset if at end
if (currentStep.value >= totalSteps) {
currentStep.value = 0
}
const animate = () => {
if (currentStep.value < totalSteps) {
currentStep.value += 1
draw()
animationFrame = requestAnimationFrame(animate)
} else {
stopAnimation()
}
}
animationFrame = requestAnimationFrame(animate)
}
const stopAnimation = () => {
isPlaying.value = false
if (animationFrame) {
cancelAnimationFrame(animationFrame)
animationFrame = null
}
}
watch(mode, () => {
stopAnimation()
currentStep.value = 0
draw()
})
const formatTooltip = (val) => {
return `Step ${val}`
}
</script>
<style scoped>
.diffusion-process-demo {
.diffusion-magic {
margin: 20px 0;
max-width: 400px; /* Compact width */
margin-left: auto;
margin-right: auto;
font-family: var(--vp-font-family-base);
}
.header-controls {
display: flex;
justify-content: space-between;
align-items: center;
.magic-frame {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 16px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
}
.canvas-container {
display: flex;
flex-direction: column;
align-items: center;
margin: 20px 0;
.canvas-wrapper {
position: relative;
}
.noise-canvas {
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
max-width: 100%;
height: auto;
}
.step-indicator {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
background: rgba(255, 255, 255, 0.8);
padding: 5px 15px;
border-radius: 20px;
font-size: 0.8em;
font-weight: bold;
backdrop-filter: blur(4px);
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
width: 80%;
}
.step-indicator .el-progress {
width: 100%;
padding-bottom: 100%; /* Square aspect ratio */
background: #000;
}
.slider-control {
padding: 0 20px;
margin-bottom: 20px;
canvas {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
image-rendering: pixelated;
}
.slider-labels {
.status-overlay {
position: absolute;
bottom: 16px;
left: 16px;
right: 16px;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(4px);
padding: 8px 12px;
border-radius: 8px;
color: #fff;
opacity: 0;
transform: translateY(10px);
transition: all 0.3s ease;
pointer-events: none;
}
.status-overlay.visible {
opacity: 1;
transform: translateY(0);
}
.step-counter {
font-size: 10px;
opacity: 0.8;
text-transform: uppercase;
letter-spacing: 1px;
}
.step-desc {
font-size: 14px;
font-weight: 600;
margin-top: 2px;
}
.controls {
padding: 16px;
display: flex;
justify-content: space-between;
font-size: 0.8em;
color: var(--el-text-color-secondary);
margin-top: -10px;
gap: 12px;
background: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-divider);
}
.explanation-alert {
margin-top: 20px;
button {
flex: 1;
border: none;
padding: 10px;
border-radius: 8px;
font-weight: 600;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.magic-btn {
background: var(--vp-c-brand);
color: white;
}
.magic-btn:hover:not(:disabled) {
background: var(--vp-c-brand-dark);
}
.magic-btn:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.reset-btn {
background: var(--vp-c-bg-alt);
color: var(--vp-c-text-1);
flex: 0.4;
}
.reset-btn:hover:not(:disabled) {
background: var(--vp-c-bg-mute);
}
.info-bar {
margin-top: 12px;
font-size: 13px;
color: var(--vp-c-text-2);
display: flex;
gap: 8px;
line-height: 1.4;
padding: 0 8px;
}
</style>
@@ -1,364 +1,306 @@
<template>
<div class="flow-matching-demo">
<el-card shadow="never">
<div class="demo-card">
<div class="controls">
<el-button type="primary" @click="startAnimation" :disabled="isPlaying">
<el-icon><VideoPlay /></el-icon> 开始对比演示 (Start Demo)
</el-button>
<button class="play-btn" @click="startRace" :disabled="isPlaying">
<span class="icon">{{ isPlaying ? 'Running...' : '🚀 开始比赛 (Start Race)' }}</span>
</button>
</div>
<div class="comparison-grid">
<!-- Diffusion -->
<div class="viz-panel">
<div class="panel-header">
<el-icon color="#F56C6C"><RefreshLeft /></el-icon>
<span>Diffusion (扩散模型)</span>
<div class="track-container">
<!-- Track 1: Diffusion -->
<div class="track">
<div class="track-info">
<span class="track-name">Diffusion (迷宫模式)</span>
<span class="step-count">{{ diffSteps }} Steps</span>
</div>
<div class="canvas-wrapper">
<canvas ref="diffCanvasRef" width="300" height="200"></canvas>
<div class="labels">
<span class="label-noise">噪声 (Noise)</span>
<span class="label-img">图像 (Image)</span>
</div>
</div>
<div class="stats-box">
<el-statistic title="步数 (Steps)" :value="diffSteps" />
<el-tag type="danger">路径弯曲 (Curved)</el-tag>
<canvas ref="diffCanvasRef" width="400" height="100"></canvas>
<div class="marker start">噪声</div>
<div class="marker end">图像</div>
</div>
</div>
<!-- Flow Matching -->
<div class="viz-panel">
<div class="panel-header">
<el-icon color="#67C23A"><Right /></el-icon>
<span>Flow Matching (流匹配)</span>
<!-- Track 2: Flow Matching -->
<div class="track">
<div class="track-info">
<span class="track-name">Flow Matching (直通模式)</span>
<span class="step-count highlight">{{ flowSteps }} Steps</span>
</div>
<div class="canvas-wrapper">
<canvas ref="flowCanvasRef" width="300" height="200"></canvas>
<div class="labels">
<span class="label-noise">噪声 (Noise)</span>
<span class="label-img">图像 (Image)</span>
</div>
</div>
<div class="stats-box">
<el-statistic title="步数 (Steps)" :value="flowSteps" />
<el-tag type="success">路径直线 (Straight)</el-tag>
<canvas ref="flowCanvasRef" width="400" height="100"></canvas>
<div class="marker start">噪声</div>
<div class="marker end">图像</div>
</div>
</div>
</div>
</div>
<el-divider />
<el-alert
title="为什么 Flow Matching 更快?"
type="success"
:closable="false"
show-icon
>
<template #default>
<p>
<strong>Diffusion</strong>
就像在迷雾中摸索路径充满了随机性需要走很多弯路步数多才能到达终点
<br />
<strong>Flow Matching</strong> 就像使用了 GPS
导航直接找到了从噪声到图像的<strong
>直线最优路径 (Optimal Transport)</strong
>因此只需要极少的步数
</p>
</template>
</el-alert>
</el-card>
<div class="info-bar">
<span class="icon">💡</span>
<span>
<strong>核心区别</strong>
Diffusion 就像在走迷宫虽然也能到终点但绕了很多弯路步数多Flow Matching 则是直接修了一条直线高速公路所以 8 步就能走完别人 50 步的路
</span>
</div>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { VideoPlay, RefreshLeft, Right } from '@element-plus/icons-vue'
const diffCanvasRef = ref(null)
const flowCanvasRef = ref(null)
const isPlaying = ref(false)
const diffSteps = ref(0)
const flowSteps = ref(0)
let animationId = null
let animationFrame = null
// Constants
const TARGET_STEPS_DIFF = 50
const TARGET_STEPS_FLOW = 8
const DURATION = 3000 // 3 seconds for the whole race
// Animation State
let diffProgress = 0
let flowProgress = 0
const diffSpeed = 0.005 // Slow
const flowSpeed = 0.02 // Fast
// Particles state
let particles = []
const NUM_PARTICLES = 5
// Particles
const particles = []
onMounted(() => {
drawStatic(diffCanvasRef.value, 'curve')
drawStatic(flowCanvasRef.value, 'line')
})
onUnmounted(() => {
stopAnimation()
})
const startAnimation = () => {
if (isPlaying.value) return
isPlaying.value = true
diffProgress = 0
flowProgress = 0
diffSteps.value = 0
flowSteps.value = 0
animate()
}
const stopAnimation = () => {
isPlaying.value = false
if (animationFrame) {
cancelAnimationFrame(animationFrame)
animationFrame = null
}
}
const animate = () => {
let finished = 0
// Update Diffusion
if (diffProgress < 1) {
diffProgress += diffSpeed
diffSteps.value = Math.floor(diffProgress * 50) // Simulate 50 steps
drawFrame(diffCanvasRef.value, diffProgress, 'curve')
} else {
diffSteps.value = 50
drawFrame(diffCanvasRef.value, 1, 'curve')
finished++
class Particle {
constructor(type) {
this.type = type // 'diff' or 'flow'
this.progress = 0
this.path = []
this.noiseOffset = Math.random() * 1000
this.yOffset = (Math.random() - 0.5) * 60 // Spread vertically
}
// Update Flow
if (flowProgress < 1) {
flowProgress += flowSpeed
flowSteps.value = Math.floor(flowProgress * 8) // Simulate 8 steps
drawFrame(flowCanvasRef.value, flowProgress, 'line')
} else {
flowSteps.value = 8
drawFrame(flowCanvasRef.value, 1, 'line')
finished++
update(dt) {
// Speed varies: Flow is faster because it covers distance in fewer steps?
// Actually, let's make them finish at the same TIME, but show the path difference.
// Or make Flow finish faster. Let's make Flow finish faster.
const speed = this.type === 'flow' ? 0.8 : 0.3
this.progress += speed * dt
if (this.progress > 1) this.progress = 1
// Calculate Position
const startX = 20
const endX = 380
const startY = 50 + this.yOffset
const endY = 50
// Linear interpolation base
let x = startX + (endX - startX) * this.progress
let y = startY + (endY - startY) * this.progress
if (this.type === 'diff') {
// Add noise to path
if (this.progress < 1) {
const noise = Math.sin(this.progress * 20 + this.noiseOffset) * 30 * (1 - this.progress)
y += noise
}
}
this.path.push({x, y})
return {x, y}
}
if (finished < 2) {
animationFrame = requestAnimationFrame(animate)
} else {
isPlaying.value = false
}
}
const drawStatic = (canvas, type) => {
if (!canvas) return
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
drawBackground(ctx, w, h)
drawPath(ctx, w, h, type, false)
drawEndpoints(ctx, w, h)
}
const drawFrame = (canvas, progress, type) => {
if (!canvas) return
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
ctx.clearRect(0, 0, w, h)
drawBackground(ctx, w, h)
drawPath(ctx, w, h, type, true) // active path
drawEndpoints(ctx, w, h)
// Draw Particle
const pos = getPosition(progress, type, w, h)
// Draw Trail
ctx.beginPath()
if (type === 'curve') {
ctx.moveTo(30, h - 30)
// Re-calculate curve up to progress
for (let t = 0; t <= progress; t += 0.01) {
const p = getPosition(t, type, w, h)
draw(ctx) {
ctx.beginPath()
ctx.moveTo(this.path[0].x, this.path[0].y)
for (let p of this.path) {
ctx.lineTo(p.x, p.y)
}
} else {
ctx.moveTo(30, h - 30)
ctx.lineTo(pos.x, pos.y)
ctx.strokeStyle = this.type === 'flow' ? '#10b981' : '#f43f5e'
ctx.lineWidth = 2
ctx.stroke()
const current = this.path[this.path.length - 1]
ctx.beginPath()
ctx.arc(current.x, current.y, 4, 0, Math.PI * 2)
ctx.fillStyle = this.type === 'flow' ? '#10b981' : '#f43f5e'
ctx.fill()
}
ctx.strokeStyle = type === 'curve' ? '#F56C6C' : '#67C23A'
ctx.lineWidth = 3
ctx.stroke()
// Draw Head
ctx.beginPath()
ctx.arc(pos.x, pos.y, 6, 0, Math.PI * 2)
ctx.fillStyle = type === 'curve' ? '#F56C6C' : '#67C23A'
ctx.fill()
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
ctx.stroke()
}
const drawBackground = (ctx, w, h) => {
ctx.fillStyle = '#f9f9f9' // Light bg
// Grid
ctx.strokeStyle = '#eee'
const startRace = () => {
if (isPlaying.value) return
isPlaying.value = true
diffSteps.value = 0
flowSteps.value = 0
particles = []
// Create particles
for(let i=0; i<NUM_PARTICLES; i++) {
particles.push(new Particle('diff'))
particles.push(new Particle('flow'))
}
let lastTime = performance.now()
const animate = (time) => {
const dt = (time - lastTime) / 1000
lastTime = time
const dCtx = diffCanvasRef.value.getContext('2d')
const fCtx = flowCanvasRef.value.getContext('2d')
// Clear
dCtx.clearRect(0, 0, 400, 100)
fCtx.clearRect(0, 0, 400, 100)
// Draw Guidelines
drawGuide(dCtx)
drawGuide(fCtx)
let allFinished = true
particles.forEach(p => {
p.update(dt)
if (p.progress < 1) allFinished = false
if (p.type === 'diff') p.draw(dCtx)
else p.draw(fCtx)
})
// Update steps counter simulation
// Flow finishes in 8 steps, Diff in 50
// Map progress to steps
const flowP = particles.find(p => p.type === 'flow')
const diffP = particles.find(p => p.type === 'diff')
if (flowP) flowSteps.value = Math.floor(flowP.progress * TARGET_STEPS_FLOW)
if (diffP) diffSteps.value = Math.floor(diffP.progress * TARGET_STEPS_DIFF)
if (!allFinished) {
animationId = requestAnimationFrame(animate)
} else {
isPlaying.value = false
flowSteps.value = TARGET_STEPS_FLOW
diffSteps.value = TARGET_STEPS_DIFF
}
}
requestAnimationFrame(animate)
}
const drawGuide = (ctx) => {
ctx.strokeStyle = 'rgba(128,128,128,0.1)'
ctx.lineWidth = 1
ctx.beginPath()
for (let x = 0; x <= w; x += 20) {
ctx.moveTo(x, 0)
ctx.lineTo(x, h)
}
for (let y = 0; y <= h; y += 20) {
ctx.moveTo(0, y)
ctx.lineTo(w, y)
}
ctx.stroke()
}
const drawEndpoints = (ctx, w, h) => {
// Start (Noise)
ctx.beginPath()
ctx.arc(30, h - 30, 8, 0, Math.PI * 2)
ctx.fillStyle = '#909399'
ctx.fill()
// End (Image)
ctx.beginPath()
ctx.arc(w - 30, 30, 8, 0, Math.PI * 2)
ctx.fillStyle = '#409EFF'
ctx.fill()
}
const drawPath = (ctx, w, h, type, isActive) => {
ctx.beginPath()
ctx.moveTo(30, h - 30)
if (type === 'line') {
ctx.lineTo(w - 30, 30)
} else {
// Bezier curve for diffusion
ctx.quadraticCurveTo(w * 0.2, 30, w - 30, 30)
}
ctx.strokeStyle = isActive ? 'rgba(0,0,0,0.1)' : '#ddd'
ctx.lineWidth = 2
ctx.setLineDash([5, 5])
ctx.beginPath()
ctx.moveTo(20, 50)
ctx.lineTo(380, 50)
ctx.stroke()
ctx.setLineDash([])
}
const getPosition = (t, type, w, h) => {
const startX = 30
const startY = h - 30
const endX = w - 30
const endY = 30
onMounted(() => {
// Initial draw
const dCtx = diffCanvasRef.value.getContext('2d')
const fCtx = flowCanvasRef.value.getContext('2d')
drawGuide(dCtx)
drawGuide(fCtx)
})
if (type === 'line') {
return {
x: startX + (endX - startX) * t,
y: startY + (endY - startY) * t
}
} else {
// Quadratic Bezier: (1-t)^2 * P0 + 2(1-t)t * P1 + t^2 * P2
// Control Point
const cpX = w * 0.2
const cpY = 30
const x =
Math.pow(1 - t, 2) * startX +
2 * (1 - t) * t * cpX +
Math.pow(t, 2) * endX
const y =
Math.pow(1 - t, 2) * startY +
2 * (1 - t) * t * cpY +
Math.pow(t, 2) * endY
// Add some random jitter for diffusion look if t < 1
// const jitter = t < 1 ? (Math.random() - 0.5) * 5 : 0
// return { x: x + jitter, y: y + jitter }
return { x, y }
}
}
onUnmounted(() => {
if (animationId) cancelAnimationFrame(animationId)
})
</script>
<style scoped>
.flow-matching-demo {
margin: 20px 0;
font-family: var(--vp-font-family-base);
}
.demo-card {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 20px;
}
.controls {
text-align: center;
margin-bottom: 20px;
}
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
@media (max-width: 600px) {
.comparison-grid {
grid-template-columns: 1fr;
}
}
.viz-panel {
background: var(--el-fill-color-light);
border-radius: 8px;
padding: 15px;
display: flex;
flex-direction: column;
gap: 10px;
justify-content: center;
}
.panel-header {
.play-btn {
background: var(--vp-c-brand);
color: white;
border: none;
padding: 8px 24px;
border-radius: 20px;
font-weight: 600;
cursor: pointer;
transition: transform 0.1s;
}
.play-btn:hover:not(:disabled) {
transform: scale(1.05);
}
.play-btn:disabled {
opacity: 0.7;
cursor: default;
}
.track {
margin-bottom: 24px;
}
.track-info {
display: flex;
align-items: center;
gap: 5px;
font-weight: bold;
font-size: 0.9em;
color: var(--el-text-color-primary);
justify-content: space-between;
margin-bottom: 8px;
font-size: 14px;
font-weight: 600;
}
.step-count {
font-family: monospace;
background: var(--vp-c-bg-alt);
padding: 2px 8px;
border-radius: 4px;
}
.step-count.highlight {
color: #10b981;
}
.canvas-wrapper {
position: relative;
background: #fff;
border-radius: 4px;
overflow: hidden;
box-shadow: inset 0 0 10px rgba(0, 0, 0, 0.05);
background: var(--vp-c-bg);
border-radius: 8px;
border: 1px solid var(--vp-c-divider);
height: 100px;
}
.marker {
position: absolute;
bottom: 4px;
font-size: 10px;
color: var(--vp-c-text-3);
}
.marker.start { left: 10px; }
.marker.end { right: 10px; }
canvas {
width: 100%;
height: auto;
display: block;
height: 100%;
}
.labels {
position: absolute;
bottom: 5px;
left: 5px;
right: 5px;
.info-bar {
margin-top: 16px;
font-size: 13px;
color: var(--vp-c-text-2);
display: flex;
justify-content: space-between;
font-size: 10px;
color: #999;
pointer-events: none;
}
.stats-box {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 5px;
gap: 8px;
line-height: 1.4;
padding: 0 8px;
}
</style>
@@ -1,569 +1,208 @@
<!--
ImageGenQuickStartDemo.vue
AI 绘画快速体验组件
用途
让用户在文章开头就能体验 AI 绘画的魅力通过交互式演示理解文生图的基本概念
交互功能
- 预设提示词选择快速体验不同风格的图像生成
- 模拟生成过程展示从文本到图像的渐进过程
- 参数调节调整生成步数CFG Scale 等参数
- 对比展示对比不同参数下的生成效果
-->
<template>
<div class="image-gen-quickstart">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><Picture /></el-icon>
<span>🎨 AI 绘画体验室</span>
<div class="quick-start-demo">
<div class="preset-row">
<div
v-for="(preset, index) in presets"
:key="index"
class="preset-card"
:class="{ active: selectedPreset === index }"
@click="selectPreset(index)"
>
<span class="preset-icon">{{ preset.icon }}</span>
<span class="preset-name">{{ preset.name }}</span>
</div>
</div>
<div class="preview-area">
<div class="canvas-wrapper">
<canvas ref="canvasRef" width="400" height="300"></canvas>
<div v-if="!isGenerating && !hasGenerated" class="placeholder-text">
👈 点击上方风格开始创作
</div>
</template>
<div class="demo-layout">
<!-- 左侧控制面板 -->
<div class="control-panel">
<div class="input-section">
<label>提示词 (Prompt)</label>
<el-input
v-model="prompt"
type="textarea"
:rows="3"
placeholder="描述你想生成的图像..."
/>
<div class="prompt-tags">
<el-tag
v-for="tag in presetPrompts"
:key="tag.label"
size="small"
class="prompt-tag"
@click="prompt = tag.prompt"
>
{{ tag.label }}
</el-tag>
</div>
</div>
<div class="params-section">
<div class="param-row">
<label>生成步数</label>
<el-slider v-model="steps" :min="10" :max="50" :step="5" show-stops />
</div>
<div class="param-row">
<label>CFG Scale (提示词遵循度)</label>
<el-slider v-model="cfgScale" :min="1" :max="15" :step="0.5" />
</div>
<div class="param-row">
<label>采样器</label>
<el-select v-model="sampler" size="small">
<el-option label="Euler" value="euler" />
<el-option label="DPM++" value="dpm" />
<el-option label="DDIM" value="ddim" />
</el-select>
</div>
</div>
<el-button
type="primary"
:loading="isGenerating"
@click="startGeneration"
class="generate-btn"
>
<el-icon><MagicStick /></el-icon>
{{ isGenerating ? '生成中...' : '开始生成' }}
</el-button>
</div>
<!-- 右侧生成展示 -->
<div class="display-panel">
<div class="canvas-container">
<canvas
ref="canvasRef"
width="400"
height="400"
class="gen-canvas"
/>
<div v-if="isGenerating" class="progress-overlay">
<el-progress
type="circle"
:percentage="progress"
:status="progress === 100 ? 'success' : ''"
/>
<p class="step-info">Step {{ currentStep }} / {{ steps }}</p>
</div>
</div>
<div class="image-info" v-if="!isGenerating && hasGenerated">
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="分辨率">512 × 512</el-descriptions-item>
<el-descriptions-item label="生成步数">{{ steps }}</el-descriptions-item>
<el-descriptions-item label="CFG Scale">{{ cfgScale }}</el-descriptions-item>
<el-descriptions-item label="采样器">{{ sampler }}</el-descriptions-item>
</el-descriptions>
</div>
<div v-if="isGenerating" class="loading-overlay">
<div class="spinner"></div>
<div>AI 正在绘制 {{ presets[selectedPreset].name }}...</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>小提示</strong>
提示词越详细生成效果越好尝试使用 "风格词" "赛博朋克""水彩画"来控制图像风格
</p>
</div>
</el-card>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Picture, MagicStick } from '@element-plus/icons-vue'
const canvasRef = ref(null)
const prompt = ref('一只戴着墨镜的猫,赛博朋克风格,霓虹灯光')
const steps = ref(20)
const cfgScale = ref(7.5)
const sampler = ref('euler')
const isGenerating = ref(false)
const progress = ref(0)
const currentStep = ref(0)
const hasGenerated = ref(false)
const selectedPreset = ref(-1)
const presetPrompts = [
{ label: '🐱 赛博朋克', prompt: '一只戴着墨镜的猫,赛博朋克风格,霓虹灯光' },
{ label: '🏔️ 山水画', prompt: '中国山水画,云雾缭绕,水墨风格' },
{ label: '🚀 太空', prompt: '宇航员在火星表面,日落时分,科幻风格' },
{ label: '🌸 樱花', prompt: '樱花树下,日本传统建筑,春天,柔和光线' },
const presets = [
{ name: '赛博朋克 (Cyberpunk)', icon: '🌃', color: ['#2b0055', '#ff00aa', '#00ffff'] },
{ name: '油画风景 (Oil Painting)', icon: '🎨', color: ['#556b2f', '#8b4513', '#ffdead'] },
{ name: '二次元 (Anime)', icon: '🌸', color: ['#ffb7c5', '#87ceeb', '#ffffff'] }
]
// 模拟生成过程
const startGeneration = async () => {
const selectPreset = (index) => {
if (isGenerating.value) return
selectedPreset.value = index
generate(presets[index])
}
const generate = (preset) => {
isGenerating.value = true
progress.value = 0
currentStep.value = 0
hasGenerated.value = false
const ctx = canvasRef.value.getContext('2d')
// Clear
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, 400, 300)
const canvas = canvasRef.value
const ctx = canvas.getContext('2d')
// 生成噪声图像作为起点
const generateNoise = () => {
const imageData = ctx.createImageData(400, 400)
for (let i = 0; i < imageData.data.length; i += 4) {
const val = Math.random() * 255
imageData.data[i] = val
imageData.data[i + 1] = val
imageData.data[i + 2] = val
imageData.data[i + 3] = 255
let progress = 0
const totalSteps = 60
const animate = () => {
progress++
// Draw Noise mixed with colors
const noiseLevel = 1 - (progress / totalSteps)
// Draw base colors (simple composition)
const gradient = ctx.createLinearGradient(0, 0, 400, 300)
gradient.addColorStop(0, preset.color[0])
gradient.addColorStop(0.5, preset.color[1])
gradient.addColorStop(1, preset.color[2])
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 400, 300)
// Add noise overlay
if (noiseLevel > 0) {
const imgData = ctx.getImageData(0, 0, 400, 300)
const data = imgData.data
for(let i=0; i<data.length; i+=4) {
if (Math.random() < noiseLevel) {
const gray = Math.random() * 255
data[i] = (data[i] + gray) / 2
data[i+1] = (data[i+1] + gray) / 2
data[i+2] = (data[i+2] + gray) / 2
}
}
ctx.putImageData(imgData, 0, 0)
}
if (progress < totalSteps) {
requestAnimationFrame(animate)
} else {
isGenerating.value = false
hasGenerated.value = true
}
return imageData
}
// 模拟渐进生成
for (let i = 0; i <= steps.value; i++) {
await new Promise(resolve => setTimeout(resolve, 100))
currentStep.value = i
progress.value = Math.round((i / steps.value) * 100)
// 绘制渐进图像(模拟)
const noiseRatio = 1 - (i / steps.value)
drawSimulatedImage(ctx, noiseRatio)
}
isGenerating.value = false
hasGenerated.value = true
animate()
}
// 根据提示词绘制模拟图像
const drawSimulatedImage = (ctx, noiseRatio) => {
const width = 400
const height = 400
// 清空画布
ctx.fillStyle = '#1a1a2e'
ctx.fillRect(0, 0, width, height)
// 根据提示词关键词绘制不同内容
const promptLower = prompt.value.toLowerCase()
// 赛博朋克风格
if (promptLower.includes('赛博') || promptLower.includes('cyber')) {
drawCyberpunkScene(ctx, width, height, noiseRatio)
}
// 山水画
else if (promptLower.includes('山水') || promptLower.includes('水墨')) {
drawLandscape(ctx, width, height, noiseRatio)
}
// 太空
else if (promptLower.includes('太空') || promptLower.includes('宇航员')) {
drawSpaceScene(ctx, width, height, noiseRatio)
}
// 樱花
else if (promptLower.includes('樱花') || promptLower.includes('日本')) {
drawSakuraScene(ctx, width, height, noiseRatio)
}
// 默认:抽象艺术
else {
drawAbstractArt(ctx, width, height, noiseRatio)
}
// 添加噪声
if (noiseRatio > 0) {
addNoise(ctx, width, height, noiseRatio)
}
}
const drawCyberpunkScene = (ctx, w, h, noise) => {
// 霓虹背景
const gradient = ctx.createLinearGradient(0, 0, 0, h)
gradient.addColorStop(0, '#0a0a1a')
gradient.addColorStop(1, '#1a0a2e')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, w, h)
// 霓虹灯条
ctx.shadowBlur = 20 * (1 - noise)
ctx.shadowColor = '#ff00ff'
ctx.fillStyle = `rgba(255, 0, 255, ${0.8 * (1 - noise)})`
ctx.fillRect(50, 100, 300, 5)
ctx.shadowColor = '#00ffff'
ctx.fillStyle = `rgba(0, 255, 255, ${0.8 * (1 - noise)})`
ctx.fillRect(100, 200, 200, 5)
// 猫的形状(简化)
ctx.shadowBlur = 0
ctx.fillStyle = `rgba(100, 100, 150, ${0.9 * (1 - noise)})`
ctx.beginPath()
ctx.ellipse(200, 280, 60, 50, 0, 0, Math.PI * 2)
ctx.fill()
// 耳朵
ctx.beginPath()
ctx.moveTo(160, 250)
ctx.lineTo(150, 200)
ctx.lineTo(180, 240)
ctx.fill()
ctx.beginPath()
ctx.moveTo(240, 250)
ctx.lineTo(250, 200)
ctx.lineTo(220, 240)
ctx.fill()
// 墨镜
ctx.fillStyle = `rgba(0, 0, 0, ${0.9 * (1 - noise)})`
ctx.beginPath()
ctx.ellipse(180, 270, 20, 12, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(220, 270, 20, 12, 0, 0, Math.PI * 2)
ctx.fill()
// 镜片反光
ctx.fillStyle = `rgba(255, 0, 255, ${0.6 * (1 - noise)})`
ctx.beginPath()
ctx.ellipse(175, 268, 5, 3, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(215, 268, 5, 3, 0, 0, Math.PI * 2)
ctx.fill()
}
const drawLandscape = (ctx, w, h, noise) => {
// 天空渐变
const gradient = ctx.createLinearGradient(0, 0, 0, h)
gradient.addColorStop(0, '#e8f4f8')
gradient.addColorStop(0.5, '#c8e0e8')
gradient.addColorStop(1, '#a8c8d8')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, w, h)
// 远山
ctx.fillStyle = `rgba(100, 120, 140, ${0.5 * (1 - noise)})`
ctx.beginPath()
ctx.moveTo(0, 250)
ctx.lineTo(100, 150)
ctx.lineTo(200, 200)
ctx.lineTo(300, 120)
ctx.lineTo(400, 180)
ctx.lineTo(400, 400)
ctx.lineTo(0, 400)
ctx.fill()
// 近山
ctx.fillStyle = `rgba(60, 80, 100, ${0.7 * (1 - noise)})`
ctx.beginPath()
ctx.moveTo(0, 300)
ctx.lineTo(150, 220)
ctx.lineTo(300, 280)
ctx.lineTo(400, 240)
ctx.lineTo(400, 400)
ctx.lineTo(0, 400)
ctx.fill()
// 云雾
ctx.fillStyle = `rgba(255, 255, 255, ${0.4 * (1 - noise)})`
for (let i = 0; i < 5; i++) {
ctx.beginPath()
ctx.ellipse(80 + i * 70, 180 + i * 20, 40, 20, 0, 0, Math.PI * 2)
ctx.fill()
}
}
const drawSpaceScene = (ctx, w, h, noise) => {
// 太空背景
ctx.fillStyle = '#0a0a15'
ctx.fillRect(0, 0, w, h)
// 星星
ctx.fillStyle = `rgba(255, 255, 255, ${1 - noise})`
for (let i = 0; i < 100; i++) {
const x = (i * 37) % w
const y = (i * 73) % h
ctx.beginPath()
ctx.arc(x, y, Math.random() * 2, 0, Math.PI * 2)
ctx.fill()
}
// 火星地面
ctx.fillStyle = `rgba(180, 80, 40, ${0.8 * (1 - noise)})`
ctx.beginPath()
ctx.moveTo(0, 350)
ctx.lineTo(100, 320)
ctx.lineTo(200, 340)
ctx.lineTo(300, 310)
ctx.lineTo(400, 330)
ctx.lineTo(400, 400)
ctx.lineTo(0, 400)
ctx.fill()
// 宇航员(简化)
ctx.fillStyle = `rgba(220, 220, 230, ${0.9 * (1 - noise)})`
ctx.beginPath()
ctx.arc(200, 220, 40, 0, Math.PI * 2)
ctx.fill()
// 头盔反光
ctx.fillStyle = `rgba(255, 200, 100, ${0.5 * (1 - noise)})`
ctx.beginPath()
ctx.arc(185, 210, 15, 0, Math.PI * 2)
ctx.fill()
// 日落
const sunGradient = ctx.createRadialGradient(300, 100, 0, 300, 100, 60)
sunGradient.addColorStop(0, `rgba(255, 200, 100, ${1 - noise})`)
sunGradient.addColorStop(1, `rgba(255, 100, 50, ${0.5 * (1 - noise)})`)
ctx.fillStyle = sunGradient
ctx.beginPath()
ctx.arc(300, 100, 50, 0, Math.PI * 2)
ctx.fill()
}
const drawSakuraScene = (ctx, w, h, noise) => {
// 天空
const gradient = ctx.createLinearGradient(0, 0, 0, h)
gradient.addColorStop(0, '#ffe4e1')
gradient.addColorStop(1, '#ffb6c1')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, w, h)
// 传统建筑屋顶
ctx.fillStyle = `rgba(80, 60, 60, ${0.9 * (1 - noise)})`
ctx.beginPath()
ctx.moveTo(100, 300)
ctx.lineTo(150, 200)
ctx.lineTo(250, 200)
ctx.lineTo(300, 300)
ctx.fill()
// 樱花树
ctx.fillStyle = `rgba(139, 90, 43, ${0.9 * (1 - noise)})`
ctx.fillRect(50, 200, 20, 200)
// 樱花
ctx.fillStyle = `rgba(255, 183, 197, ${0.8 * (1 - noise)})`
for (let i = 0; i < 30; i++) {
const x = 30 + (i * 13) % 80
const y = 150 + (i * 17) % 100
ctx.beginPath()
ctx.arc(x, y, 8 + Math.random() * 5, 0, Math.PI * 2)
ctx.fill()
}
// 飘落的樱花
ctx.fillStyle = `rgba(255, 192, 203, ${0.6 * (1 - noise)})`
for (let i = 0; i < 20; i++) {
const x = (i * 23) % w
const y = 250 + (i * 31) % 150
ctx.beginPath()
ctx.ellipse(x, y, 4, 2, i, 0, Math.PI * 2)
ctx.fill()
}
}
const drawAbstractArt = (ctx, w, h, noise) => {
// 渐变背景
const gradient = ctx.createLinearGradient(0, 0, w, h)
gradient.addColorStop(0, '#667eea')
gradient.addColorStop(1, '#764ba2')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, w, h)
// 抽象形状
const colors = ['#f093fb', '#f5576c', '#4facfe', '#00f2fe']
for (let i = 0; i < 8; i++) {
ctx.fillStyle = colors[i % colors.length] + Math.floor((1 - noise) * 255).toString(16).padStart(2, '0')
ctx.beginPath()
const x = 100 + (i * 50) % 300
const y = 100 + (i * 70) % 250
const r = 30 + i * 5
ctx.arc(x, y, r, 0, Math.PI * 2)
ctx.fill()
}
}
const addNoise = (ctx, w, h, ratio) => {
const imageData = ctx.getImageData(0, 0, w, h)
for (let i = 0; i < imageData.data.length; i += 4) {
const noise = (Math.random() - 0.5) * 50 * ratio
imageData.data[i] = Math.max(0, Math.min(255, imageData.data[i] + noise))
imageData.data[i + 1] = Math.max(0, Math.min(255, imageData.data[i + 1] + noise))
imageData.data[i + 2] = Math.max(0, Math.min(255, imageData.data[i + 2] + noise))
}
ctx.putImageData(imageData, 0, 0)
}
onMounted(() => {
// 初始绘制
const canvas = canvasRef.value
if (canvas) {
const ctx = canvas.getContext('2d')
drawSimulatedImage(ctx, 1)
}
})
</script>
<style scoped>
.image-gen-quickstart {
margin: 1rem 0;
.quick-start-demo {
margin: 20px 0;
font-family: var(--vp-font-family-base);
}
.header-title {
.preset-row {
display: flex;
gap: 12px;
margin-bottom: 16px;
overflow-x: auto;
padding-bottom: 4px;
}
.preset-card {
flex: 1;
min-width: 120px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 12px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.preset-card:hover {
transform: translateY(-2px);
border-color: var(--vp-c-brand);
}
.preset-card.active {
background: var(--vp-c-brand-dimm);
border-color: var(--vp-c-brand);
color: var(--vp-c-brand-dark);
}
.preset-icon {
font-size: 24px;
}
.preset-name {
font-size: 12px;
font-weight: 600;
}
.demo-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
.preview-area {
border-radius: 12px;
overflow: hidden;
border: 1px solid var(--vp-c-divider);
background: #000;
}
@media (max-width: 768px) {
.demo-layout {
grid-template-columns: 1fr;
}
}
.control-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.input-section label,
.params-section label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 8px;
color: var(--vp-c-text-2);
}
.prompt-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
}
.prompt-tag {
cursor: pointer;
transition: all 0.2s;
}
.prompt-tag:hover {
transform: translateY(-2px);
}
.param-row {
margin-bottom: 12px;
}
.param-row label {
font-size: 0.8rem;
margin-bottom: 4px;
}
.generate-btn {
width: 100%;
margin-top: auto;
}
.display-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.canvas-container {
.canvas-wrapper {
position: relative;
width: 100%;
aspect-ratio: 1;
background: var(--vp-c-bg-mute);
border-radius: 8px;
overflow: hidden;
height: 300px;
display: flex;
align-items: center;
justify-content: center;
}
.gen-canvas {
canvas {
width: 100%;
height: 100%;
object-fit: contain;
object-fit: cover;
}
.progress-overlay {
.placeholder-text {
position: absolute;
inset: 0;
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
pointer-events: none;
}
.loading-overlay {
position: absolute;
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(2px);
color: white;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
gap: 16px;
gap: 12px;
padding: 20px;
border-radius: 8px;
}
.step-info {
color: white;
font-size: 0.9rem;
.spinner {
width: 24px;
height: 24px;
border: 3px solid rgba(255,255,255,0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.image-info {
font-size: 0.8rem;
}
.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;
@keyframes spin {
to { transform: rotate(360deg); }
}
</style>
@@ -1,290 +1,304 @@
<template>
<div class="latent-space-viz">
<el-card shadow="never">
<div class="viz-container">
<!-- Pixel Space -->
<div class="space-block">
<div class="space-header">
<el-icon :size="20"><Picture /></el-icon>
<span class="space-title">像素空间 (Pixel Space)</span>
</div>
<div class="grid-wrapper pixel-wrapper">
<div class="pixel-grid">
<div
v-for="n in 256"
:key="n"
class="pixel-cell"
:style="getPixelStyle(n)"
></div>
</div>
<div class="grid-overlay">
<span>HD Image</span>
<span class="res-tag">1024x1024</span>
</div>
</div>
<div class="data-stats">
<div class="stat-row">
<span class="label">维度:</span>
<span class="value">3 (RGB)</span>
</div>
<div class="stat-row">
<span class="label">数据量:</span>
<span class="value">~300</span>
</div>
</div>
</div>
<!-- Transformation -->
<div class="transform-process">
<div class="process-arrow">
<div class="vae-box">
<span class="vae-label">VAE Encoder</span>
<el-icon><Filter /></el-icon>
</div>
<el-icon :size="24" class="arrow-icon"><Right /></el-icon>
</div>
<el-tag type="danger" size="small" effect="dark" class="compress-tag"
>压缩 48x</el-tag
>
</div>
<!-- Latent Space -->
<div class="space-block highlight">
<div class="space-header">
<el-icon :size="20" color="#E6A23C"><Cpu /></el-icon>
<span class="space-title">潜空间 (Latent Space)</span>
</div>
<div class="grid-wrapper latent-wrapper">
<div class="latent-grid">
<div
v-for="n in 16"
:key="n"
class="latent-cell"
:style="getLatentStyle(n)"
></div>
</div>
<div class="grid-overlay">
<span>Latent Feature</span>
<span class="res-tag">64x64</span>
</div>
</div>
<div class="data-stats">
<div class="stat-row">
<span class="label">维度:</span>
<span class="value">4 (Channels)</span>
</div>
<div class="stat-row">
<span class="label">数据量:</span>
<span class="value">~1.6</span>
</div>
</div>
<div class="viz-card">
<!-- Left: The Output (Pixel Space) -->
<div class="preview-section">
<div class="emoji-display" :style="{ transform: `scale(${1 + zoomLevel})` }">
{{ currentEmoji }}
</div>
<div class="label">像素空间 (Pixel Space)</div>
<div class="sub-label">最终看到的图像</div>
</div>
<el-divider />
<!-- Center: The Mechanism -->
<div class="connection">
<div class="arrow"> 映射 </div>
<div class="vae-tag">VAE Decoder</div>
</div>
<el-row :gutter="20">
<el-col :span="8">
<el-statistic title="压缩比" value="1:48">
<template #suffix>
<el-icon style="vertical-align: -0.125em"><Scissor /></el-icon>
</template>
</el-statistic>
</el-col>
<el-col :span="16">
<el-alert
title="为什么要压缩?"
type="success"
:closable="false"
description="直接处理 300 万个像素太慢了。VAE 把图像压缩成「压缩饼干」(潜变量),保留了核心特征(语义、构图),扔掉了冗余细节。AI 在这个小空间里画画,速度飞快!"
show-icon
/>
</el-col>
</el-row>
</el-card>
<!-- Right: The Input (Latent Space) -->
<div class="control-section">
<div class="latent-grid" ref="gridRef" @mousedown="startDrag" @touchstart="startDrag">
<div class="grid-lines"></div>
<div class="axis-label x-axis">开心值 (Happiness)</div>
<div class="axis-label y-axis">惊讶值 (Surprise)</div>
<!-- The Latent Point -->
<div
class="latent-point"
:style="{ left: `${point.x}%`, top: `${point.y}%` }"
>
<div class="tooltip">Latent Vector: [{{ ((point.x-50)/50).toFixed(1) }}, {{ ((50-point.y)/50).toFixed(1) }}]</div>
</div>
</div>
<div class="label">潜空间 (Latent Space)</div>
<div class="sub-label">拖动红点改变特征</div>
</div>
</div>
<div class="info-bar">
<span class="icon">💡</span>
<span>
<strong>核心原理</strong>
在像素空间里修改图片很难要改几千个像素但在潜空间里我们只需要修改两个坐标开心值惊讶值就能生成完全不同的表情这就是 AI "画画" 的本质在数学空间里找坐标
</span>
</div>
</div>
</template>
<script setup>
import { Picture, Cpu, Right, Filter, Scissor } from '@element-plus/icons-vue'
import { ref, computed, onMounted, onUnmounted } from 'vue'
const getPixelStyle = (n) => {
// Simulate a natural image with smooth color transitions
const r = 100 + Math.sin(n * 0.1) * 50
const g = 150 + Math.cos(n * 0.1) * 50
const b = 200 + Math.sin(n * 0.05) * 50
return {
backgroundColor: `rgb(${r}, ${g}, ${b})`,
opacity: 0.8 + Math.random() * 0.2
const gridRef = ref(null)
const isDragging = ref(false)
const point = ref({ x: 50, y: 50 }) // Percentage 0-100
const zoomLevel = ref(0)
// Emoji map based on quadrants
// X: Unhappy -> Happy
// Y: Calm -> Surprised (Top is 0 in CSS, so small Y is high surprise?)
// Let's map:
// X (0-100): Sad -> Happy
// Y (0-100): Surprised -> Sleepy (Top is 0, so 0 is Surprised, 100 is Sleepy)
const currentEmoji = computed(() => {
const x = point.value.x // 0 (Sad) to 100 (Happy)
const y = point.value.y // 0 (Surprised) to 100 (Sleepy)
if (x < 33) { // Sad Zone
if (y < 33) return '😨' // Sad + Surprised = Fear
if (y > 66) return '😪' // Sad + Sleepy = Tired
return '😢' // Just Sad
} else if (x > 66) { // Happy Zone
if (y < 33) return '🤩' // Happy + Surprised = Starstruck
if (y > 66) return '😌' // Happy + Sleepy = Relieved
return '😃' // Just Happy
} else { // Neutral Zone
if (y < 33) return '😮' // Neutral + Surprised
if (y > 66) return '😴' // Neutral + Sleepy
return '😐' // Just Neutral
}
})
const handleMove = (event) => {
if (!isDragging.value) return
const grid = gridRef.value.getBoundingClientRect()
const clientX = event.touches ? event.touches[0].clientX : event.clientX
const clientY = event.touches ? event.touches[0].clientY : event.clientY
let newX = ((clientX - grid.left) / grid.width) * 100
let newY = ((clientY - grid.top) / grid.height) * 100
// Clamp
newX = Math.max(0, Math.min(100, newX))
newY = Math.max(0, Math.min(100, newY))
point.value = { x: newX, y: newY }
}
const getLatentStyle = (n) => {
// Simulate high-level features (more abstract, high contrast colors)
const hue = (n * 137) % 360
return {
backgroundColor: `hsl(${hue}, 70%, 60%)`,
boxShadow: `0 0 5px hsl(${hue}, 70%, 60%)`
}
const startDrag = (event) => {
isDragging.value = true
handleMove(event)
// Prevent default to stop scrolling on mobile
if (event.type === 'touchstart') event.preventDefault()
}
const stopDrag = () => {
isDragging.value = false
}
onMounted(() => {
window.addEventListener('mousemove', handleMove)
window.addEventListener('mouseup', stopDrag)
window.addEventListener('touchmove', handleMove)
window.addEventListener('touchend', stopDrag)
})
onUnmounted(() => {
window.removeEventListener('mousemove', handleMove)
window.removeEventListener('mouseup', stopDrag)
window.removeEventListener('touchmove', handleMove)
window.removeEventListener('touchend', stopDrag)
})
</script>
<style scoped>
.latent-space-viz {
margin: 20px 0;
font-family: var(--vp-font-family-base);
}
.viz-container {
.viz-card {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 24px;
gap: 20px;
flex-wrap: wrap;
margin-bottom: 20px;
}
.space-block {
.preview-section, .control-section {
flex: 1;
min-width: 200px;
background: var(--el-fill-color-lighter);
border-radius: 8px;
padding: 15px;
border: 1px solid var(--el-border-color-lighter);
}
.space-block.highlight {
border-color: var(--el-color-warning-light-5);
background: var(--el-color-warning-light-9);
}
.space-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 15px;
font-weight: bold;
color: var(--el-text-color-primary);
}
.grid-wrapper {
position: relative;
background: #000;
border-radius: 4px;
overflow: hidden;
margin-bottom: 15px;
display: flex;
align-items: center;
justify-content: center;
}
.pixel-wrapper {
height: 160px;
}
.latent-wrapper {
height: 100px; /* Smaller representation */
width: 100px;
margin: 0 auto 15px auto;
}
.pixel-grid {
display: grid;
grid-template-columns: repeat(16, 1fr);
width: 100%;
height: 100%;
}
.pixel-cell {
width: 100%;
height: 100%;
}
.latent-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
width: 100%;
height: 100%;
gap: 2px;
padding: 5px;
}
.latent-cell {
width: 100%;
height: 100%;
border-radius: 2px;
}
.grid-overlay {
position: absolute;
bottom: 0;
left: 0;
width: 100%;
background: rgba(0, 0, 0, 0.6);
color: #fff;
font-size: 0.75em;
padding: 4px 8px;
display: flex;
justify-content: space-between;
align-items: center;
}
.res-tag {
font-family: monospace;
opacity: 0.8;
}
.data-stats {
font-size: 0.85em;
color: var(--el-text-color-secondary);
}
.stat-row {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.transform-process {
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
color: var(--el-text-color-secondary);
min-width: 200px;
}
.process-arrow {
.emoji-display {
font-size: 80px;
line-height: 1;
height: 100px;
display: flex;
align-items: center;
gap: 5px;
justify-content: center;
transition: transform 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
cursor: default;
filter: drop-shadow(0 4px 12px rgba(0,0,0,0.1));
}
.vae-box {
border: 1px solid var(--el-border-color);
border-radius: 4px;
.latent-grid {
width: 200px;
height: 200px;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-brand);
border-radius: 12px;
position: relative;
cursor: crosshair;
overflow: hidden;
box-shadow: inset 0 2px 8px rgba(0,0,0,0.05);
}
.grid-lines {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image:
linear-gradient(var(--vp-c-divider) 1px, transparent 1px),
linear-gradient(90deg, var(--vp-c-divider) 1px, transparent 1px);
background-size: 20px 20px;
opacity: 0.3;
}
.latent-point {
width: 20px;
height: 20px;
background: var(--vp-c-brand);
border: 3px solid #fff;
border-radius: 50%;
position: absolute;
transform: translate(-50%, -50%);
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
transition: transform 0.1s;
}
.latent-point:hover {
transform: translate(-50%, -50%) scale(1.2);
}
.tooltip {
position: absolute;
bottom: 25px;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
color: #fff;
padding: 4px 8px;
font-size: 0.8em;
display: flex;
align-items: center;
gap: 4px;
background: #fff;
border-radius: 4px;
font-size: 10px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.2s;
}
.compress-tag {
transform: scale(0.9);
.latent-point:hover .tooltip,
.latent-point:active .tooltip {
opacity: 1;
}
.axis-label {
position: absolute;
font-size: 10px;
color: var(--vp-c-text-2);
pointer-events: none;
}
.x-axis {
bottom: 4px;
right: 8px;
}
.y-axis {
top: 8px;
left: 8px;
writing-mode: vertical-rl;
transform: rotate(180deg);
}
.connection {
display: flex;
flex-direction: column;
align-items: center;
color: var(--vp-c-text-2);
font-size: 12px;
}
.arrow {
margin-bottom: 4px;
font-weight: bold;
}
.vae-tag {
background: var(--vp-c-brand-dimm);
color: var(--vp-c-brand-dark);
padding: 2px 8px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
}
.label {
margin-top: 12px;
font-weight: 600;
color: var(--vp-c-text-1);
}
.sub-label {
font-size: 12px;
color: var(--vp-c-text-2);
}
.info-bar {
margin-top: 16px;
background: var(--vp-c-bg-alt);
padding: 12px 16px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
color: var(--vp-c-text-2);
display: flex;
gap: 8px;
}
.icon {
font-size: 16px;
}
@media (max-width: 600px) {
.viz-container {
flex-direction: column;
.viz-card {
flex-direction: column-reverse;
}
.transform-process {
.connection {
transform: rotate(90deg);
margin: 10px 0;
}
.compress-tag {
display: none; /* Hide tag when rotated to avoid layout issues */
}
}
</style>
@@ -1,59 +1,45 @@
<template>
<div class="prompt-visualizer">
<div class="demo-container">
<!-- Left: Prompt -->
<div class="prompt-section">
<div class="section-title">📝 提示词</div>
<div class="prompt-input">
"cyberpunk cat, neon lights, futuristic city"
</div>
<div class="token-list">
<div
v-for="(token, index) in tokens"
:key="index"
class="token-item"
:style="{ opacity: token.weight }"
>
<div class="token-text">{{ token.text }}</div>
<div class="token-weight">
权重: {{ (token.weight * 100).toFixed(0) }}%
</div>
</div>
</div>
<div class="viz-card">
<div class="input-display">
<span class="label">Prompt:</span>
<span class="text">"cyberpunk cat, neon lights, futuristic city"</span>
</div>
<!-- Right: Attention Visualization -->
<div class="attention-section">
<div class="section-title">🎯 交叉注意力可视化</div>
<div class="attention-grid">
<div
v-for="(item, index) in attentionMap"
:key="index"
class="attention-cell"
>
<div class="cell-token">{{ item.token }}</div>
<div class="cell-bar">
<div
class="bar-fill"
:style="{ width: item.attention * 100 + '%' }"
></div>
</div>
<div class="cell-value">
{{ (item.attention * 100).toFixed(0) }}%
</div>
</div>
<div class="tokens-row">
<div
v-for="(token, index) in tokens"
:key="index"
class="token-pill"
:style="{ opacity: 0.4 + (token.weight * 0.6) }"
>
{{ token.text }}
<div class="tooltip">关注度: {{ (token.weight * 100).toFixed(0) }}%</div>
</div>
</div>
<div class="arrow-down"> CLIP Encoding & Attention</div>
<div class="image-map">
<!-- Abstract representation of an image being attended to -->
<div class="map-layer" style="background: #2b0055; opacity: 0.9;">
<span>City Base</span>
</div>
<div class="map-layer" style="background: #ff00aa; width: 60%; height: 60%; opacity: 0.8;">
<span>Neon Glow</span>
</div>
<div class="map-layer" style="background: #fff; width: 30%; height: 30%; border-radius: 50%;">
<span>Cat</span>
</div>
</div>
</div>
<div class="explanation">
<p>
<span class="icon">💡</span>
<strong>交叉注意力机制</strong> AI 理解提示词的每个词
当生成图片时AI "关注"不同的词 "cyberpunk" 影响整体风格"cat"
决定主体"neon lights" 控制灯光效果 词的顺序和权重都会影响最终画面
</p>
<div class="info-bar">
<span class="icon">💡</span>
<span>
<strong>交叉注意力 (Cross-Attention)</strong>
AI 在画画时每画一笔都会回头看一眼 Prompt当它画背景时"city" 单词会亮起来当它画主角时"cat" 单词会亮起来
</span>
</div>
</div>
</template>
@@ -62,153 +48,128 @@
import { ref } from 'vue'
const tokens = ref([
{ text: 'cyberpunk', weight: 0.9 },
{ text: 'cyberpunk', weight: 0.8 },
{ text: 'cat', weight: 1.0 },
{ text: 'neon', weight: 0.7 },
{ text: 'lights', weight: 0.6 },
{ text: 'futuristic', weight: 0.8 },
{ text: 'city', weight: 0.5 }
])
const attentionMap = ref([
{ token: 'cyberpunk', attention: 0.9 },
{ token: 'cat', attention: 1.0 },
{ token: 'neon', attention: 0.7 },
{ token: 'lights', attention: 0.6 },
{ token: 'futuristic', attention: 0.8 },
{ token: 'city', attention: 0.5 }
{ text: 'futuristic', weight: 0.5 },
{ text: 'city', weight: 0.9 }
])
</script>
<style scoped>
.prompt-visualizer {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 20px;
background: var(--vp-c-bg-soft);
margin: 20px 0;
font-family: var(--vp-font-family-base);
}
.demo-container {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 20px;
}
@media (max-width: 768px) {
.demo-container {
grid-template-columns: 1fr;
}
}
.section-title {
font-weight: bold;
font-size: 0.95rem;
color: var(--vp-c-text-1);
margin-bottom: 15px;
}
.prompt-section {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 15px;
}
.prompt-input {
.viz-card {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 12px;
border-radius: 12px;
padding: 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
}
.input-display {
font-family: monospace;
font-size: 0.85rem;
color: var(--vp-c-text-1);
margin-bottom: 15px;
}
.token-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.token-item {
display: flex;
justify-content: space-between;
align-items: center;
background: var(--vp-c-bg-soft);
padding: 8px 12px;
border-radius: 4px;
font-size: 0.85rem;
}
.token-text {
color: var(--vp-c-text-1);
font-weight: 600;
}
.token-weight {
color: var(--vp-c-brand);
font-size: 0.75rem;
}
.attention-section {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 15px;
padding: 8px 12px;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
width: 100%;
text-align: center;
}
.attention-grid {
.label {
color: var(--vp-c-text-2);
margin-right: 8px;
}
.tokens-row {
display: flex;
flex-direction: column;
gap: 10px;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.attention-cell {
.token-pill {
background: var(--vp-c-brand);
color: white;
padding: 4px 12px;
border-radius: 16px;
font-size: 13px;
font-weight: 600;
position: relative;
cursor: help;
transition: transform 0.2s;
}
.token-pill:hover {
transform: scale(1.1);
z-index: 10;
}
.tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
background: rgba(0,0,0,0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-size: 10px;
white-space: nowrap;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s;
margin-bottom: 6px;
}
.token-pill:hover .tooltip {
opacity: 1;
}
.arrow-down {
font-size: 12px;
color: var(--vp-c-text-2);
font-weight: 600;
}
.image-map {
width: 200px;
height: 200px;
background: #000;
position: relative;
border-radius: 8px;
overflow: hidden;
display: flex;
align-items: center;
gap: 10px;
font-size: 0.85rem;
justify-content: center;
}
.cell-token {
width: 80px;
color: var(--vp-c-text-1);
font-weight: 600;
.map-layer {
position: absolute;
display: flex;
align-items: center;
justify-content: center;
color: rgba(255,255,255,0.8);
font-size: 10px;
font-weight: bold;
box-shadow: 0 0 20px rgba(0,0,0,0.5);
}
.cell-bar {
flex: 1;
height: 20px;
background: var(--vp-c-bg-soft);
border-radius: 3px;
overflow: hidden;
}
.bar-fill {
height: 100%;
background: var(--vp-c-brand);
border-radius: 3px;
transition: width 0.5s ease;
}
.cell-value {
width: 40px;
text-align: right;
color: var(--vp-c-brand);
font-weight: 600;
font-size: 0.8rem;
}
.explanation {
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9em;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
.info-bar {
margin-top: 16px;
font-size: 13px;
color: var(--vp-c-text-2);
display: flex;
gap: 8px;
line-height: 1.4;
padding: 0 8px;
}
</style>