feat: save current work to dev branch
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
<!--
|
||||
EmbeddingDemo.vue
|
||||
词向量空间可视化演示
|
||||
|
||||
用途:
|
||||
直观展示“词向量”的概念:将词映射到坐标空间中,距离代表相似度。
|
||||
展示经典的向量算术:King - Man + Woman ≈ Queen
|
||||
|
||||
交互功能:
|
||||
- 2D 坐标系展示:预置几组词向量(动物、国家、职业)。
|
||||
- 算术演示:用户点击“King - Man + Woman”按钮,动画展示向量移动过程。
|
||||
- 缩放/平移:简单的视图控制。
|
||||
-->
|
||||
<template>
|
||||
<div class="embedding-demo">
|
||||
<div class="demo-controls">
|
||||
<div class="btn-group">
|
||||
<button
|
||||
v-for="mode in modes"
|
||||
:key="mode.id"
|
||||
:class="{ active: currentMode === mode.id }"
|
||||
@click="setMode(mode.id)"
|
||||
>
|
||||
{{ mode.label }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="info-text">
|
||||
{{ modes.find(m => m.id === currentMode)?.desc }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="canvas-container" ref="canvasContainer">
|
||||
<!-- 简单的 SVG 坐标系 -->
|
||||
<svg viewBox="0 0 400 300" class="vector-canvas">
|
||||
<!-- Grid lines -->
|
||||
<g class="grid">
|
||||
<line x1="0" y1="150" x2="400" y2="150" stroke="var(--vp-c-divider)" />
|
||||
<line x1="200" y1="0" x2="200" y2="300" stroke="var(--vp-c-divider)" />
|
||||
</g>
|
||||
|
||||
<!-- Vectors/Points -->
|
||||
<g class="points">
|
||||
<g
|
||||
v-for="point in activePoints"
|
||||
:key="point.id"
|
||||
class="point-group"
|
||||
:class="{ highlight: point.highlight }"
|
||||
:transform="`translate(${point.x}, ${point.y})`"
|
||||
>
|
||||
<circle r="4" :fill="point.color" />
|
||||
<text
|
||||
y="-8"
|
||||
text-anchor="middle"
|
||||
class="point-label"
|
||||
:fill="point.color"
|
||||
>{{ point.word }}</text>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<!-- Calculation Arrows (for King/Queen demo) -->
|
||||
<g v-if="currentMode === 'analogy'" class="arrows">
|
||||
<!-- King -> Man -->
|
||||
<line
|
||||
:x1="getPoint('king').x" :y1="getPoint('king').y"
|
||||
:x2="getPoint('man').x" :y2="getPoint('man').y"
|
||||
stroke="rgba(0,0,0,0.2)" stroke-dasharray="4" marker-end="url(#arrowhead)"
|
||||
/>
|
||||
<!-- Queen -> Woman -->
|
||||
<line
|
||||
:x1="getPoint('queen').x" :y1="getPoint('queen').y"
|
||||
:x2="getPoint('woman').x" :y2="getPoint('woman').y"
|
||||
stroke="var(--vp-c-brand)" stroke-width="2" marker-end="url(#arrowhead-brand)"
|
||||
/>
|
||||
<text x="390" y="280" text-anchor="end" class="math-label" fill="var(--vp-c-text-2)">King - Man ≈ Queen - Woman</text>
|
||||
</g>
|
||||
|
||||
<defs>
|
||||
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="rgba(0,0,0,0.2)" />
|
||||
</marker>
|
||||
<marker id="arrowhead-brand" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="var(--vp-c-brand)" />
|
||||
</marker>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const currentMode = ref('cluster')
|
||||
|
||||
const modes = [
|
||||
{ id: 'cluster', label: '语义聚类', desc: '语义相近的词在空间中距离更近。' },
|
||||
{ id: 'analogy', label: '向量算术', desc: 'King - Man + Woman ≈ Queen (方向平行)' }
|
||||
]
|
||||
|
||||
const basePoints = [
|
||||
// Cluster 1: Animals
|
||||
{ id: 'cat', word: 'Cat', x: 80, y: 80, color: '#f87171', group: 'animal' },
|
||||
{ id: 'dog', word: 'Dog', x: 100, y: 70, color: '#f87171', group: 'animal' },
|
||||
{ id: 'tiger', word: 'Tiger', x: 60, y: 100, color: '#f87171', group: 'animal' },
|
||||
|
||||
// Cluster 2: Technology
|
||||
{ id: 'computer', word: 'Computer', x: 300, y: 200, color: '#60a5fa', group: 'tech' },
|
||||
{ id: 'phone', word: 'Phone', x: 320, y: 220, color: '#60a5fa', group: 'tech' },
|
||||
{ id: 'ai', word: 'AI', x: 280, y: 210, color: '#60a5fa', group: 'tech' },
|
||||
|
||||
// Cluster 3: Royalty (Analogy)
|
||||
{ id: 'king', word: 'King', x: 100, y: 200, color: '#fbbf24', group: 'royal' },
|
||||
{ id: 'queen', word: 'Queen', x: 220, y: 200, color: '#fbbf24', group: 'royal' },
|
||||
{ id: 'man', word: 'Man', x: 100, y: 120, color: '#a78bfa', group: 'gender' },
|
||||
{ id: 'woman', word: 'Woman', x: 220, y: 120, color: '#a78bfa', group: 'gender' },
|
||||
]
|
||||
|
||||
const activePoints = computed(() => {
|
||||
if (currentMode.value === 'cluster') {
|
||||
return basePoints.filter(p => ['animal', 'tech'].includes(p.group))
|
||||
} else {
|
||||
return basePoints.filter(p => ['royal', 'gender'].includes(p.group))
|
||||
}
|
||||
})
|
||||
|
||||
const getPoint = (id) => basePoints.find(p => p.id === id) || { x: 0, y: 0 }
|
||||
|
||||
const setMode = (mode) => {
|
||||
currentMode.value = mode
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.embedding-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.demo-controls {
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background-color: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
button.active {
|
||||
background-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.info-text {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
padding: 1rem;
|
||||
background-color: var(--vp-c-bg);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.vector-canvas {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
height: 300px;
|
||||
border: 1px dashed var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.point-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.math-label {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.point-group {
|
||||
transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,400 @@
|
||||
<template>
|
||||
<div class="linear-attention-demo">
|
||||
<div class="mode-switch">
|
||||
<button
|
||||
:class="{ active: mode === 'standard' }"
|
||||
@click="mode = 'standard'"
|
||||
>
|
||||
标准 Attention (网状连接)
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'linear' }"
|
||||
@click="mode = 'linear'"
|
||||
>
|
||||
线性 Attention (接力传递)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visual-area">
|
||||
<div class="control-panel">
|
||||
<div class="label">参与者数量 (N): {{ nValue }}</div>
|
||||
<input type="range" v-model="nValue" min="3" max="12" step="1" class="slider">
|
||||
</div>
|
||||
|
||||
<div class="viz-canvas-container">
|
||||
<!-- Canvas for dynamic drawing -->
|
||||
<svg class="viz-svg" viewBox="0 0 400 300">
|
||||
<!-- STANDARD MODE: Mesh / Web -->
|
||||
<g v-if="mode === 'standard'">
|
||||
<!-- Active Query Animation -->
|
||||
<g class="active-query-scan">
|
||||
<!-- Current processing node (last one) -->
|
||||
<circle
|
||||
:cx="circleNodes[circleNodes.length-1].x"
|
||||
:cy="circleNodes[circleNodes.length-1].y"
|
||||
r="16"
|
||||
fill="none"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="3"
|
||||
opacity="0.5"
|
||||
>
|
||||
<animate attributeName="r" values="12;20;12" dur="2s" repeatCount="indefinite" />
|
||||
<animate attributeName="opacity" values="0.8;0;0.8" dur="2s" repeatCount="indefinite" />
|
||||
</circle>
|
||||
|
||||
<!-- Scanning rays from last node to all others -->
|
||||
<line
|
||||
v-for="(node, idx) in circleNodes.slice(0, circleNodes.length-1)"
|
||||
:key="'ray'+idx"
|
||||
:x1="circleNodes[circleNodes.length-1].x"
|
||||
:y1="circleNodes[circleNodes.length-1].y"
|
||||
:x2="node.x"
|
||||
:y2="node.y"
|
||||
stroke="var(--vp-c-brand)"
|
||||
stroke-width="2"
|
||||
stroke-dasharray="4"
|
||||
class="scanning-ray"
|
||||
>
|
||||
<animate attributeName="stroke-dashoffset" values="20;0" dur="1s" repeatCount="indefinite" />
|
||||
</line>
|
||||
</g>
|
||||
|
||||
<!-- Background Mesh -->
|
||||
<g class="connections">
|
||||
<line
|
||||
v-for="(link, idx) in meshLinks"
|
||||
:key="idx"
|
||||
:x1="link.x1" :y1="link.y1"
|
||||
:x2="link.x2" :y2="link.y2"
|
||||
class="connection-line"
|
||||
:style="{ animationDelay: idx * 0.05 + 's' }"
|
||||
/>
|
||||
</g>
|
||||
<!-- Draw Nodes -->
|
||||
<circle
|
||||
v-for="(node, idx) in circleNodes"
|
||||
:key="idx"
|
||||
:cx="node.x" :cy="node.y"
|
||||
r="12"
|
||||
class="node-circle standard"
|
||||
:class="{ 'current-node': idx === circleNodes.length - 1 }"
|
||||
/>
|
||||
<text
|
||||
v-for="(node, idx) in circleNodes"
|
||||
:key="'t'+idx"
|
||||
:x="node.x" :y="node.y"
|
||||
dy="4"
|
||||
text-anchor="middle"
|
||||
class="node-text"
|
||||
>{{ idx + 1 }}</text>
|
||||
</g>
|
||||
|
||||
<!-- LINEAR MODE: Relay / Chain -->
|
||||
<g v-else>
|
||||
<!-- Relay Path -->
|
||||
<line
|
||||
x1="40" y1="150"
|
||||
:x2="40 + (nValue - 1) * 60" y2="150"
|
||||
class="relay-track"
|
||||
/>
|
||||
|
||||
<!-- Passing Message Animation -->
|
||||
<circle
|
||||
cx="0" cy="0" r="8"
|
||||
class="message-token"
|
||||
>
|
||||
<animateMotion
|
||||
:path="relayPath"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
/>
|
||||
</circle>
|
||||
|
||||
<!-- Nodes -->
|
||||
<g v-for="(node, idx) in linearNodes" :key="idx">
|
||||
<circle
|
||||
:cx="node.x" :cy="node.y"
|
||||
r="12"
|
||||
class="node-circle linear"
|
||||
/>
|
||||
<text
|
||||
:x="node.x" :y="node.y"
|
||||
dy="4"
|
||||
text-anchor="middle"
|
||||
class="node-text"
|
||||
>{{ idx + 1 }}</text>
|
||||
<!-- State Box (Memory) -->
|
||||
<rect
|
||||
:x="node.x - 15" :y="node.y + 20"
|
||||
width="30" height="20"
|
||||
rx="4"
|
||||
class="memory-box"
|
||||
/>
|
||||
<text :x="node.x" :y="node.y + 34" text-anchor="middle" font-size="8" fill="white">Mem</text>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="stats-panel">
|
||||
<div class="stat-item">
|
||||
<div class="stat-label">连接/操作次数</div>
|
||||
<div class="stat-value" :class="mode === 'standard' ? 'text-red' : 'text-green'">
|
||||
{{ connectionCount }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stat-desc">
|
||||
<span v-if="mode === 'standard'">
|
||||
每个人都要找其他人。<br>N={{ nValue }} 时,连接数高达 {{ nValue * nValue }}!
|
||||
</span>
|
||||
<span v-else>
|
||||
每个人只传给下一个人。<br>N={{ nValue }} 时,操作数仅为 {{ nValue }}。
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="analogy-box">
|
||||
<div class="analogy-title">💡 核心区别:要不要回头看?</div>
|
||||
<div v-if="mode === 'standard'">
|
||||
<b>回看模式 (Retrospective)</b>:
|
||||
<br>想象你在考试。每做一道新题,你都要<b>把之前做过的所有题目再检查一遍</b>,确认有没有关联。
|
||||
<br>题目越多,你需要检查的次数就越多,最后累死在检查上。
|
||||
</div>
|
||||
<div v-else>
|
||||
<b>状态模式 (Recurrent)</b>:
|
||||
<br>想象你在跑步。你不需要记得前 100 步每一步踩在哪,你只需要知道<b>现在的速度和位置</b>(State)。
|
||||
<br>跑第 1000 步和跑第 1 步一样轻松,因为你不需要回头。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const mode = ref('standard')
|
||||
const nValue = ref(5)
|
||||
|
||||
// Coordinates for Standard Mode (Circle Layout)
|
||||
const circleNodes = computed(() => {
|
||||
const nodes = []
|
||||
const centerX = 200
|
||||
const centerY = 150
|
||||
const radius = 100
|
||||
|
||||
for (let i = 0; i < nValue.value; i++) {
|
||||
const angle = (i / nValue.value) * 2 * Math.PI - Math.PI / 2
|
||||
nodes.push({
|
||||
x: centerX + radius * Math.cos(angle),
|
||||
y: centerY + radius * Math.sin(angle)
|
||||
})
|
||||
}
|
||||
return nodes
|
||||
})
|
||||
|
||||
// Links for Standard Mode (All-to-All)
|
||||
const meshLinks = computed(() => {
|
||||
const links = []
|
||||
const nodes = circleNodes.value
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = 0; j < nodes.length; j++) {
|
||||
links.push({
|
||||
x1: nodes[i].x,
|
||||
y1: nodes[i].y,
|
||||
x2: nodes[j].x,
|
||||
y2: nodes[j].y
|
||||
})
|
||||
}
|
||||
}
|
||||
return links
|
||||
})
|
||||
|
||||
// Coordinates for Linear Mode (Line Layout)
|
||||
const linearNodes = computed(() => {
|
||||
const nodes = []
|
||||
const startX = 40
|
||||
const gap = 60
|
||||
const y = 150
|
||||
|
||||
for (let i = 0; i < nValue.value; i++) {
|
||||
nodes.push({
|
||||
x: startX + i * gap,
|
||||
y: y
|
||||
})
|
||||
}
|
||||
return nodes
|
||||
})
|
||||
|
||||
// SVG Path for animation in Linear Mode
|
||||
const relayPath = computed(() => {
|
||||
const nodes = linearNodes.value
|
||||
if (nodes.length < 2) return ""
|
||||
// Start from first node, go to last node
|
||||
return `M ${nodes[0].x} ${nodes[0].y} L ${nodes[nodes.length-1].x} ${nodes[nodes.length-1].y}`
|
||||
})
|
||||
|
||||
const connectionCount = computed(() => {
|
||||
if (mode.value === 'standard') {
|
||||
return nValue.value * nValue.value
|
||||
} else {
|
||||
return nValue.value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.linear-attention-demo {
|
||||
padding: 20px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 12px;
|
||||
margin: 20px 0;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mode-switch button {
|
||||
padding: 8px 20px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.mode-switch button.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 4px 12px var(--vp-c-brand-dimm);
|
||||
}
|
||||
|
||||
.visual-area {
|
||||
background: var(--vp-c-bg);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.slider {
|
||||
accent-color: var(--vp-c-brand);
|
||||
width: 150px;
|
||||
}
|
||||
|
||||
.viz-canvas-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg-alt);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 15px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.viz-svg {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
/* SVG Elements */
|
||||
.node-circle {
|
||||
fill: var(--vp-c-bg);
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.node-circle.standard {
|
||||
stroke: var(--vp-c-red);
|
||||
}
|
||||
|
||||
.node-circle.linear {
|
||||
stroke: var(--vp-c-green);
|
||||
}
|
||||
|
||||
.node-text {
|
||||
font-size: 10px;
|
||||
fill: var(--vp-c-text-1);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.connection-line {
|
||||
stroke: var(--vp-c-red);
|
||||
stroke-width: 1;
|
||||
opacity: 0;
|
||||
animation: fadeInLine 0.5s forwards;
|
||||
}
|
||||
|
||||
@keyframes fadeInLine {
|
||||
to { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.relay-track {
|
||||
stroke: var(--vp-c-divider);
|
||||
stroke-width: 2;
|
||||
stroke-dasharray: 4;
|
||||
}
|
||||
|
||||
.message-token {
|
||||
fill: var(--vp-c-green);
|
||||
}
|
||||
|
||||
.memory-box {
|
||||
fill: var(--vp-c-green);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Stats */
|
||||
.stats-panel {
|
||||
text-align: center;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2em;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.text-red { color: var(--vp-c-red); }
|
||||
.text-green { color: var(--vp-c-green); }
|
||||
|
||||
.stat-desc {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.9em;
|
||||
margin-top: 5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Analogy */
|
||||
.analogy-box {
|
||||
margin-top: 20px;
|
||||
background: var(--vp-c-bg-mute);
|
||||
padding: 15px;
|
||||
border-radius: 8px;
|
||||
font-size: 0.9em;
|
||||
line-height: 1.6;
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.analogy-title {
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,335 @@
|
||||
<template>
|
||||
<div class="llm-quick-start">
|
||||
<div class="header">
|
||||
<div class="title">🤖 LLM 初体验:从闲聊到业务实战</div>
|
||||
<div class="subtitle">大模型不仅能聊天,更是生产力工具。试试看它如何处理这些业务需求:</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-window">
|
||||
<div v-if="messages.length === 0" class="empty-state">
|
||||
<div class="emoji">💼</div>
|
||||
<p>请选择一个业务场景开始体验</p>
|
||||
</div>
|
||||
|
||||
<div class="messages" ref="messagesRef">
|
||||
<div v-for="(msg, index) in messages" :key="index" class="message" :class="msg.role">
|
||||
<div class="avatar">{{ msg.role === 'user' ? '🧑💻' : '🤖' }}</div>
|
||||
<div class="content">
|
||||
<div v-if="msg.role === 'user'" class="user-text">{{ msg.content }}</div>
|
||||
<div v-else class="assistant-content">
|
||||
<pre v-if="msg.isCode"><code>{{ msg.content }}</code></pre>
|
||||
<div v-else>{{ msg.content }}</div>
|
||||
</div>
|
||||
<span v-if="msg.role === 'assistant' && isGenerating && index === messages.length - 1" class="cursor">|</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<div class="quick-actions" v-if="!isGenerating">
|
||||
<button v-for="q in questions" :key="q.text" @click="ask(q)" class="action-btn">
|
||||
<span class="btn-icon">{{ q.icon }}</span>
|
||||
<span class="btn-text">{{ q.text }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="status-text" v-else>
|
||||
正在思考业务逻辑并生成 Token...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, nextTick } from 'vue'
|
||||
|
||||
const questions = [
|
||||
{ icon: '🤔', text: '给我想一个请假的理由', type: 'casual' },
|
||||
{ icon: '🐍', text: '帮我写一个 Python 爬虫', type: 'code' },
|
||||
{ icon: '🎩', text: '用鲁迅的语气夸我', type: 'casual' },
|
||||
{ icon: '📊', text: '分析这份销售数据的趋势', type: 'analysis' },
|
||||
{ icon: '📝', text: '为这款咖啡杯写一段小红书文案', type: 'marketing' }
|
||||
]
|
||||
|
||||
const answers = {
|
||||
"给我想一个请假的理由": {
|
||||
isCode: false,
|
||||
text: "老板,我感觉身体不适,可能是昨天写代码太投入,CPU(大脑)过热导致系统(身体)宕机了,申请重启(休息)一天。"
|
||||
},
|
||||
"帮我写一个 Python 爬虫": {
|
||||
isCode: true,
|
||||
text: `import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
def fetch_titles(url):
|
||||
response = requests.get(url)
|
||||
soup = BeautifulSoup(response.text, 'html.parser')
|
||||
|
||||
# 提取所有 h1 标签
|
||||
titles = [tag.text.strip() for tag in soup.find_all('h1')]
|
||||
return titles
|
||||
|
||||
# 使用示例
|
||||
url = "https://example.com"
|
||||
print(f"正在爬取 {url} 的标题...")
|
||||
# titles = fetch_titles(url)
|
||||
# print(titles)`
|
||||
},
|
||||
"用鲁迅的语气夸我": {
|
||||
isCode: false,
|
||||
text: "我向来是不惮以最坏的恶意来推测中国人的,然而我还不料,也不信竟会遇见这样优秀的人。你的代码,很有几分风骨。"
|
||||
},
|
||||
"分析这份销售数据的趋势": {
|
||||
isCode: false,
|
||||
text: "基于您提供的数据,我发现以下几个关键趋势:\n\n1. 📈 **总体增长**:Q3 销售额同比增长了 25%,主要得益于线上渠道的爆发。\n2. ⚠️ **库存预警**:热销品类 A 的周转天数已降至 5 天,建议立即补货。\n3. 💡 **潜力市场**:华南地区的转化率(3.2%)显著高于平均水平,建议加大该区域的广告投放。"
|
||||
},
|
||||
"为这款咖啡杯写一段小红书文案": {
|
||||
isCode: false,
|
||||
text: "☕️ **早八人的续命神器!这款咖啡杯真的太懂我了**\n\n家人们谁懂啊!😭 作为一个每天靠咖啡续命的打工人,终于挖到了这款宝藏杯子!\n\n✨ **颜值绝绝子**:奶油白配色,拿在手里就是妥妥的 ins 风,摆在工位上心情都变好了!\n🌡️ **保温超长待机**:早上泡的冰美式,下午还是冰冰凉,这也太适合夏天了吧!\n🔒 **密封不漏水**:直接塞包里也不怕洒,挤地铁必备!\n\n👇 评论区蹲一个链接,带你一起实现咖啡自由! #好物分享 #高颜值水杯 #打工人日常"
|
||||
}
|
||||
}
|
||||
|
||||
const messages = ref([])
|
||||
const isGenerating = ref(false)
|
||||
const messagesRef = ref(null)
|
||||
|
||||
const ask = async (qObj) => {
|
||||
messages.value.push({ role: 'user', content: qObj.text })
|
||||
isGenerating.value = true
|
||||
|
||||
await wait(600)
|
||||
|
||||
const answerData = answers[qObj.text]
|
||||
const fullAnswer = answerData ? answerData.text : "正在思考..."
|
||||
|
||||
messages.value.push({
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
isCode: answerData ? answerData.isCode : false
|
||||
})
|
||||
|
||||
const answerIdx = messages.value.length - 1
|
||||
|
||||
// Typing animation
|
||||
for (let i = 0; i < fullAnswer.length; i++) {
|
||||
messages.value[answerIdx].content += fullAnswer[i]
|
||||
scrollToBottom()
|
||||
// Code typing is usually faster looking
|
||||
const speed = answerData.isCode ? 10 : (30 + Math.random() * 30)
|
||||
await wait(speed)
|
||||
}
|
||||
|
||||
isGenerating.value = false
|
||||
}
|
||||
|
||||
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (messagesRef.value) {
|
||||
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.llm-quick-start {
|
||||
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;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
background: linear-gradient(120deg, var(--vp-c-brand), #9c27b0);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 14px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.chat-window {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
height: 320px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: var(--vp-c-text-3);
|
||||
}
|
||||
|
||||
.empty-state .emoji {
|
||||
font-size: 48px;
|
||||
margin-bottom: 12px;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.message {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
max-width: 90%;
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
align-self: flex-end;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.message.assistant {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--vp-c-bg-mute);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 20px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.content {
|
||||
background: var(--vp-c-bg-mute);
|
||||
padding: 10px 16px;
|
||||
border-radius: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
position: relative;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.user .content {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
.message.assistant .content {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-bottom-left-radius: 2px;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.assistant-content pre {
|
||||
margin: 8px 0 0;
|
||||
padding: 8px;
|
||||
background: #1e1e1e;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.assistant-content code {
|
||||
font-family: 'Menlo', 'Monaco', monospace;
|
||||
font-size: 12px;
|
||||
color: #d4d4d4;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 2px;
|
||||
height: 14px;
|
||||
background: currentColor;
|
||||
margin-left: 2px;
|
||||
vertical-align: middle;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
.input-area {
|
||||
margin-top: 16px;
|
||||
min-height: 50px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-1);
|
||||
transition: all 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.status-text::before {
|
||||
content: '';
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--vp-c-brand);
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.5s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(5px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@keyframes pulse { 0% { opacity: 0.4; transform: scale(0.8); } 50% { opacity: 1; transform: scale(1.1); } 100% { opacity: 0.4; transform: scale(0.8); } }
|
||||
</style>
|
||||
@@ -0,0 +1,553 @@
|
||||
<template>
|
||||
<div class="moe-demo-container">
|
||||
<!-- Header / Mode Switch -->
|
||||
<div class="demo-header">
|
||||
<div class="mode-tabs">
|
||||
<button
|
||||
v-for="mode in ['dense', 'moe']"
|
||||
:key="mode"
|
||||
:class="['mode-tab', { active: architecture === mode }]"
|
||||
@click="setArchitecture(mode)"
|
||||
>
|
||||
{{ mode === 'dense' ? 'Dense (传统模型)' : 'MoE (混合专家)' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mode-desc">
|
||||
{{ architecture === 'dense' ? '全能天才:每个问题都动用整个大脑 (100% 激活)' : '专家团队:根据问题指派专人处理 (稀疏激活)' }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interactive Area -->
|
||||
<div class="visual-stage">
|
||||
<!-- Step 1: Input Selection -->
|
||||
<div class="stage-section input-section">
|
||||
<div class="section-label">1. 输入指令 (Input)</div>
|
||||
<div class="task-selector">
|
||||
<button
|
||||
v-for="(task, idx) in tasks"
|
||||
:key="idx"
|
||||
class="task-btn"
|
||||
:class="{ selected: selectedTask.label === task.label }"
|
||||
@click="selectTask(task)"
|
||||
:disabled="processing"
|
||||
>
|
||||
<span class="task-icon">{{ task.icon }}</span>
|
||||
<span class="task-text">{{ task.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="token-stream" :class="{ 'flowing': processing && currentStep >= 1 }">
|
||||
<div class="token-particle">{{ selectedTask.icon }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="flow-arrow">⬇️</div>
|
||||
|
||||
<!-- Step 2: Processing Unit (Dense or MoE) -->
|
||||
<div class="stage-section process-section">
|
||||
<div class="section-label">
|
||||
2. 模型处理 (Processing)
|
||||
<span v-if="processing" class="status-badge">计算中...</span>
|
||||
</div>
|
||||
|
||||
<!-- Dense Visualization -->
|
||||
<div v-if="architecture === 'dense'" class="dense-visualization">
|
||||
<div class="dense-block" :class="{ 'activating': processing && currentStep === 2 }">
|
||||
<div class="dense-label">前馈神经网络 (FFN)</div>
|
||||
<div class="neuron-grid">
|
||||
<div v-for="n in 32" :key="n" class="neuron"></div>
|
||||
</div>
|
||||
<div class="activation-info" v-if="processing && currentStep === 2">
|
||||
🔥 激活率: 100% (全员过载)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MoE Visualization -->
|
||||
<div v-else class="moe-visualization">
|
||||
<!-- Router -->
|
||||
<div class="router-node" :class="{ 'active': processing && currentStep === 1 }">
|
||||
<div class="router-label">门控路由 (Router)</div>
|
||||
<div class="router-action" v-if="processing && currentStep >= 1">
|
||||
🔍 识别意图: "{{ selectedTask.type }}"
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Connections -->
|
||||
<div class="connections">
|
||||
<div
|
||||
v-for="(expert, idx) in experts"
|
||||
:key="idx"
|
||||
class="connection-line"
|
||||
:class="{
|
||||
'active': processing && currentStep >= 2 && isExpertSelected(idx),
|
||||
'inactive': processing && currentStep >= 2 && !isExpertSelected(idx)
|
||||
}"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Experts -->
|
||||
<div class="experts-grid">
|
||||
<div
|
||||
v-for="(expert, idx) in experts"
|
||||
:key="idx"
|
||||
class="expert-card"
|
||||
:class="{
|
||||
'active': processing && currentStep >= 2 && isExpertSelected(idx),
|
||||
'inactive': processing && currentStep >= 2 && !isExpertSelected(idx)
|
||||
}"
|
||||
>
|
||||
<div class="expert-icon">{{ expert.icon }}</div>
|
||||
<div class="expert-name">{{ expert.name }}</div>
|
||||
<div class="expert-role">{{ expert.role }}</div>
|
||||
<div class="expert-status" v-if="processing && currentStep >= 2 && isExpertSelected(idx)">
|
||||
✅ 激活
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Arrow -->
|
||||
<div class="flow-arrow">⬇️</div>
|
||||
|
||||
<!-- Step 3: Output -->
|
||||
<div class="stage-section output-section">
|
||||
<div class="section-label">3. 生成结果 (Output)</div>
|
||||
<div class="output-box" :class="{ 'revealed': currentStep === 3 }">
|
||||
<div v-if="currentStep === 3" class="output-content">
|
||||
<span class="output-icon">{{ selectedTask.icon }}</span>
|
||||
<span class="typing-effect">{{ selectedTask.output }}</span>
|
||||
</div>
|
||||
<div v-else class="placeholder">等待处理...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controls -->
|
||||
<div class="demo-controls">
|
||||
<button class="run-btn" @click="runDemo" :disabled="processing">
|
||||
{{ processing ? '正在推理...' : '▶️ 开始生成 (Run Inference)' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const architecture = ref('moe')
|
||||
const processing = ref(false)
|
||||
const currentStep = ref(0) // 0: idle, 1: router, 2: experts, 3: output
|
||||
|
||||
const experts = [
|
||||
{ icon: '💻', name: '代码专家', role: 'Python/JS/Rust' },
|
||||
{ icon: '🎨', name: '创意专家', role: '诗歌/小说/绘画' },
|
||||
{ icon: '📐', name: '逻辑专家', role: '数学/推理/证明' },
|
||||
{ icon: '🌍', name: '语言专家', role: '翻译/润色/摘要' }
|
||||
]
|
||||
|
||||
const tasks = [
|
||||
{ label: '写 Python 脚本', type: '编程', icon: '🐍', expertIdx: 0, output: 'def fib(n): return n if n < 2 else...' },
|
||||
{ label: '写七言绝句', type: '文学', icon: '🌸', expertIdx: 1, output: '窗含西岭千秋雪,门泊东吴万里船...' },
|
||||
{ label: '解二元方程', type: '数学', icon: '✖️', expertIdx: 2, output: 'x = 5, y = -2 (过程略)' },
|
||||
{ label: '翻译成英文', type: '翻译', icon: '🔤', expertIdx: 3, output: 'To be, or not to be, that is the question.' }
|
||||
]
|
||||
|
||||
const selectedTask = ref(tasks[0])
|
||||
|
||||
const setArchitecture = (mode) => {
|
||||
if (processing.value) return
|
||||
architecture.value = mode
|
||||
resetDemo()
|
||||
}
|
||||
|
||||
const selectTask = (task) => {
|
||||
if (processing.value) return
|
||||
selectedTask.value = task
|
||||
resetDemo()
|
||||
}
|
||||
|
||||
const resetDemo = () => {
|
||||
currentStep.value = 0
|
||||
}
|
||||
|
||||
const isExpertSelected = (idx) => {
|
||||
if (architecture.value === 'dense') return true // All active in dense
|
||||
return idx === selectedTask.value.expertIdx
|
||||
}
|
||||
|
||||
const runDemo = async () => {
|
||||
if (processing.value) return
|
||||
processing.value = true
|
||||
currentStep.value = 0
|
||||
|
||||
// Step 1: Input -> Router
|
||||
await wait(300)
|
||||
currentStep.value = 1
|
||||
|
||||
// Step 2: Router -> Expert / Dense Processing
|
||||
await wait(800)
|
||||
currentStep.value = 2
|
||||
|
||||
// Step 3: Expert -> Output
|
||||
await wait(1200)
|
||||
currentStep.value = 3
|
||||
|
||||
// Finish
|
||||
await wait(500)
|
||||
processing.value = false
|
||||
}
|
||||
|
||||
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.moe-demo-container {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
max-width: 600px;
|
||||
margin: 20px auto;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.demo-header {
|
||||
text-align: center;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.mode-tabs {
|
||||
display: inline-flex;
|
||||
background: var(--vp-c-bg-mute);
|
||||
padding: 4px;
|
||||
border-radius: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mode-tab {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.mode-tab.active {
|
||||
background: var(--vp-c-bg);
|
||||
color: var(--vp-c-brand);
|
||||
box-shadow: 0 2px 4px rgba(0,0,0,0.05);
|
||||
}
|
||||
|
||||
.mode-desc {
|
||||
font-size: 13px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
/* Stage */
|
||||
.visual-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.stage-section {
|
||||
width: 100%;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
color: var(--vp-c-text-3);
|
||||
margin-bottom: 12px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
/* Input Section */
|
||||
.task-selector {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-mute);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.task-btn:hover {
|
||||
background: var(--vp-c-bg-soft);
|
||||
}
|
||||
|
||||
.task-btn.selected {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-dimm);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.token-stream {
|
||||
height: 4px;
|
||||
background: var(--vp-c-divider);
|
||||
margin-top: 12px;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.token-particle {
|
||||
position: absolute;
|
||||
top: -12px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
opacity: 0;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.token-stream.flowing .token-particle {
|
||||
opacity: 1;
|
||||
top: 0;
|
||||
animation: slideDown 0.5s forwards;
|
||||
}
|
||||
|
||||
/* Process Section */
|
||||
.dense-visualization {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dense-block {
|
||||
width: 80%;
|
||||
background: var(--vp-c-bg-mute);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.dense-block.activating {
|
||||
background: var(--vp-c-brand);
|
||||
box-shadow: 0 0 20px var(--vp-c-brand-dimm);
|
||||
}
|
||||
|
||||
.dense-block.activating .neuron {
|
||||
background: #fff;
|
||||
box-shadow: 0 0 4px #fff;
|
||||
}
|
||||
|
||||
.dense-label {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.dense-block.activating .dense-label {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.neuron-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, 1fr);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.neuron {
|
||||
width: 100%;
|
||||
padding-bottom: 100%;
|
||||
background: var(--vp-c-divider);
|
||||
border-radius: 50%;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.activation-info {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: white;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* MoE Visualization */
|
||||
.router-node {
|
||||
background: var(--vp-c-bg-mute);
|
||||
border: 2px dashed var(--vp-c-text-3);
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.router-node.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background: var(--vp-c-brand-dimm);
|
||||
}
|
||||
|
||||
.router-label {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.router-action {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-brand);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.connections {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
height: 20px;
|
||||
margin-bottom: -10px; /* Overlap slightly */
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.connection-line {
|
||||
width: 2px;
|
||||
height: 100%;
|
||||
background: var(--vp-c-divider);
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.connection-line.active {
|
||||
background: var(--vp-c-brand);
|
||||
box-shadow: 0 0 8px var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.experts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.expert-card {
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 8px 4px;
|
||||
text-align: center;
|
||||
transition: all 0.3s;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.expert-card.active {
|
||||
opacity: 1;
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.expert-card.inactive {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.expert-icon { font-size: 20px; margin-bottom: 4px; }
|
||||
.expert-name { font-size: 11px; font-weight: bold; margin-bottom: 2px; }
|
||||
.expert-role { font-size: 9px; color: var(--vp-c-text-3); }
|
||||
.expert-status { font-size: 9px; color: var(--vp-c-brand); margin-top: 4px; font-weight: bold; }
|
||||
|
||||
|
||||
/* Output Section */
|
||||
.output-box {
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--vp-c-bg-mute);
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.output-box.revealed {
|
||||
background: var(--vp-c-bg-soft);
|
||||
border: 1px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.output-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-family: monospace;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
font-size: 12px;
|
||||
color: var(--vp-c-text-3);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Controls */
|
||||
.demo-controls {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 10px 24px;
|
||||
border-radius: 20px;
|
||||
font-size: 14px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.run-btn:hover:not(:disabled) {
|
||||
background: var(--vp-c-brand-dark);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.run-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.flow-arrow {
|
||||
text-align: center;
|
||||
color: var(--vp-c-divider);
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.5; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,358 @@
|
||||
<!--
|
||||
NextTokenPrediction.vue
|
||||
下一个 Token 预测演示组件
|
||||
|
||||
用途:
|
||||
展示 LLM 生成文本的核心机制——Next Token Prediction(下一个词预测)。
|
||||
让用户体验模型是如何基于概率分布来选择下一个词的。
|
||||
|
||||
交互功能:
|
||||
- 上下文展示:显示当前生成的文本序列。
|
||||
- 概率可视化:动态展示 Top-K 候选词及其概率条。
|
||||
- 交互式生成:用户点击候选词来决定生成的走向(模拟 Sampling 过程)。
|
||||
- 场景切换:提供几个经典预设场景(英文句子、中文句子、代码片段)。
|
||||
-->
|
||||
<template>
|
||||
<div class="prediction-demo">
|
||||
<div class="header">
|
||||
<div class="scene-selector">
|
||||
<label>Scenario / 场景:</label>
|
||||
<select v-model="currentSceneKey" @change="resetScene">
|
||||
<option value="en-fox">English: The quick brown...</option>
|
||||
<option value="zh-ai">中文: 人工智能...</option>
|
||||
<option value="code">Code: if (x > 0)...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="reset-btn" @click="resetScene" title="Reset">
|
||||
<span class="icon">↺</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="context-window">
|
||||
<div class="context-content">
|
||||
<span
|
||||
v-for="(token, index) in tokenizedContext"
|
||||
:key="index"
|
||||
class="context-token"
|
||||
>{{ token }}</span>
|
||||
<span class="cursor"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prediction-panel">
|
||||
<div class="panel-title">
|
||||
<span>🤖 AI Prediction (Top 3 Candidates)</span>
|
||||
<span class="temperature-hint">Temperature: 0.7</span>
|
||||
</div>
|
||||
|
||||
<div class="candidates-list">
|
||||
<div
|
||||
v-for="(candidate, index) in currentCandidates"
|
||||
:key="index"
|
||||
class="candidate-item"
|
||||
@click="selectCandidate(candidate)"
|
||||
>
|
||||
<div class="candidate-info">
|
||||
<span class="candidate-text">"{{ candidate.text }}"</span>
|
||||
<span class="candidate-prob">{{ (candidate.prob * 100).toFixed(1) }}%</span>
|
||||
</div>
|
||||
<div class="prob-bar-bg">
|
||||
<div
|
||||
class="prob-bar-fill"
|
||||
:style="{ width: `${candidate.prob * 100}%` }"
|
||||
:class="`rank-${index}`"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="explanation">
|
||||
<p>
|
||||
<strong>原理:</strong> LLM 并不是一次性写出整段话,而是像上面这样,基于前面的内容(Context),计算下一个最可能出现的 Token 的概率,然后选择一个(Sampling)填上去,再重复这个过程。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
|
||||
const scenes = {
|
||||
'en-fox': {
|
||||
initial: 'The quick brown',
|
||||
logic: (text) => {
|
||||
if (text.endsWith('brown')) return [
|
||||
{ text: ' fox', prob: 0.85 },
|
||||
{ text: ' dog', prob: 0.10 },
|
||||
{ text: ' cat', prob: 0.05 }
|
||||
]
|
||||
if (text.endsWith('fox')) return [
|
||||
{ text: ' jumps', prob: 0.92 },
|
||||
{ text: ' runs', prob: 0.05 },
|
||||
{ text: ' sleeps', prob: 0.03 }
|
||||
]
|
||||
if (text.endsWith('jumps')) return [
|
||||
{ text: ' over', prob: 0.98 },
|
||||
{ text: ' up', prob: 0.01 },
|
||||
{ text: ' down', prob: 0.01 }
|
||||
]
|
||||
if (text.endsWith('over')) return [
|
||||
{ text: ' the', prob: 0.95 },
|
||||
{ text: ' a', prob: 0.04 },
|
||||
{ text: ' my', prob: 0.01 }
|
||||
]
|
||||
if (text.endsWith('the')) return [
|
||||
{ text: ' lazy', prob: 0.88 },
|
||||
{ text: ' big', prob: 0.08 },
|
||||
{ text: ' old', prob: 0.04 }
|
||||
]
|
||||
if (text.endsWith('lazy')) return [
|
||||
{ text: ' dog', prob: 0.90 },
|
||||
{ text: ' cat', prob: 0.08 },
|
||||
{ text: ' fox', prob: 0.02 }
|
||||
]
|
||||
return [
|
||||
{ text: '.', prob: 0.80 },
|
||||
{ text: ' and', prob: 0.15 },
|
||||
{ text: '!', prob: 0.05 }
|
||||
]
|
||||
}
|
||||
},
|
||||
'zh-ai': {
|
||||
initial: '人工智能',
|
||||
logic: (text) => {
|
||||
if (text.endsWith('人工智能')) return [
|
||||
{ text: '是', prob: 0.75 },
|
||||
{ text: '技术', prob: 0.15 },
|
||||
{ text: '发展', prob: 0.10 }
|
||||
]
|
||||
if (text.endsWith('是')) return [
|
||||
{ text: '未来', prob: 0.40 },
|
||||
{ text: '一种', prob: 0.35 },
|
||||
{ text: '什么', prob: 0.25 }
|
||||
]
|
||||
if (text.endsWith('一种')) return [
|
||||
{ text: '技术', prob: 0.55 },
|
||||
{ text: '工具', prob: 0.30 },
|
||||
{ text: '科学', prob: 0.15 }
|
||||
]
|
||||
if (text.endsWith('未来')) return [
|
||||
{ text: '的', prob: 0.85 },
|
||||
{ text: '方向', prob: 0.10 },
|
||||
{ text: '趋势', prob: 0.05 }
|
||||
]
|
||||
return [
|
||||
{ text: '。', prob: 0.60 },
|
||||
{ text: ',', prob: 0.30 },
|
||||
{ text: '!', prob: 0.10 }
|
||||
]
|
||||
}
|
||||
},
|
||||
'code': {
|
||||
initial: 'if (x > 0) {',
|
||||
logic: (text) => {
|
||||
if (text.endsWith('{')) return [
|
||||
{ text: '\n return', prob: 0.60 },
|
||||
{ text: '\n print', prob: 0.30 },
|
||||
{ text: '\n x', prob: 0.10 }
|
||||
]
|
||||
if (text.includes('return')) return [
|
||||
{ text: ' true', prob: 0.50 },
|
||||
{ text: ' x', prob: 0.30 },
|
||||
{ text: ' false', prob: 0.20 }
|
||||
]
|
||||
if (text.includes('print')) return [
|
||||
{ text: '("Hello")', prob: 0.70 },
|
||||
{ text: '(x)', prob: 0.25 },
|
||||
{ text: '()', prob: 0.05 }
|
||||
]
|
||||
return [
|
||||
{ text: ';', prob: 0.90 },
|
||||
{ text: ' + 1', prob: 0.08 },
|
||||
{ text: '.', prob: 0.02 }
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const currentSceneKey = ref('en-fox')
|
||||
const context = ref('')
|
||||
|
||||
const tokenizedContext = computed(() => {
|
||||
// 简单分词用于展示:按空格或特定字符切分
|
||||
// 这里仅做视觉效果,不影响逻辑
|
||||
return context.value.match(/(\s+|\S+)/g) || []
|
||||
})
|
||||
|
||||
const currentCandidates = computed(() => {
|
||||
const scene = scenes[currentSceneKey.value]
|
||||
return scene.logic(context.value)
|
||||
})
|
||||
|
||||
const selectCandidate = (candidate) => {
|
||||
context.value += candidate.text
|
||||
}
|
||||
|
||||
const resetScene = () => {
|
||||
context.value = scenes[currentSceneKey.value].initial
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
resetScene()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.prediction-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
overflow: hidden;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.scene-selector {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
select {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.reset-btn {
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background-color: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.reset-btn:hover {
|
||||
background-color: var(--vp-c-bg-mute);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.context-window {
|
||||
padding: 1.5rem;
|
||||
min-height: 100px;
|
||||
background-color: var(--vp-c-bg);
|
||||
border-bottom: 1px dashed var(--vp-c-divider);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.context-content {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.context-token {
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 1.2em;
|
||||
background-color: var(--vp-c-brand);
|
||||
vertical-align: middle;
|
||||
margin-left: 2px;
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
|
||||
.prediction-panel {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.panel-title {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.candidates-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.candidate-item {
|
||||
position: relative;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.candidate-item:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.candidate-info {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.prob-bar-bg {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
z-index: 1;
|
||||
opacity: 0.15;
|
||||
}
|
||||
|
||||
.prob-bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.5s ease-out;
|
||||
}
|
||||
|
||||
.rank-0 { background-color: #10b981; }
|
||||
.rank-1 { background-color: #3b82f6; }
|
||||
.rank-2 { background-color: #f59e0b; }
|
||||
|
||||
.explanation {
|
||||
padding: 0.75rem 1rem;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
border-top: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,371 @@
|
||||
<!--
|
||||
RNNvsTransformer.vue
|
||||
RNN vs Transformer 架构对比演示
|
||||
|
||||
用途:
|
||||
对比两种处理序列数据的核心架构:
|
||||
- RNN: 串行处理,记忆随距离衰减。
|
||||
- Transformer: 并行处理,Self-Attention 机制捕捉长距离依赖。
|
||||
|
||||
交互功能:
|
||||
- 架构切换:RNN / Transformer (Self-Attention)。
|
||||
- 动态演示:
|
||||
- RNN: 逐步输入单词,观察 Hidden State 的变化。
|
||||
- Transformer: 鼠标悬停在单词上,显示其关注(Attend to)的其他单词(Attention Map)。
|
||||
-->
|
||||
<template>
|
||||
<div class="arch-demo">
|
||||
<div class="control-tabs">
|
||||
<button
|
||||
:class="{ active: mode === 'rnn' }"
|
||||
@click="mode = 'rnn'"
|
||||
>
|
||||
🐌 RNN (Sequential)
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'transformer' }"
|
||||
@click="mode = 'transformer'"
|
||||
>
|
||||
⚡ Transformer (Parallel + Attention)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<!-- RNN Visualization -->
|
||||
<div v-if="mode === 'rnn'" class="rnn-viz">
|
||||
<div class="sequence-display">
|
||||
<div
|
||||
v-for="(word, idx) in rnnWords"
|
||||
:key="idx"
|
||||
class="word-item"
|
||||
:class="{ active: currentRnnStep === idx }"
|
||||
>
|
||||
{{ word }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rnn-process">
|
||||
<div class="hidden-state-track">
|
||||
<div
|
||||
class="hidden-state-box"
|
||||
:style="{ opacity: rnnMemoryOpacity }"
|
||||
>
|
||||
<div class="memory-content">
|
||||
Memory (h)
|
||||
<div class="memory-level" :style="{ height: rnnMemoryStrength + '%' }"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="arrow-right">→</div>
|
||||
<div class="output-box">Output: {{ rnnOutput }}</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<button @click="playRnn" :disabled="isPlayingRnn">
|
||||
{{ isPlayingRnn ? 'Processing...' : '▶ Play Sequence' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="desc-text">
|
||||
RNN 从左到右逐个读取。注意看 Memory(记忆),随着句子变长,最早的信息("The")可能会被后面的信息冲淡,这就是“长距离依赖”问题。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Transformer Visualization -->
|
||||
<div v-else class="transformer-viz">
|
||||
<div class="sentence-container">
|
||||
<div
|
||||
v-for="(word, idx) in transformerWords"
|
||||
:key="idx"
|
||||
class="t-word"
|
||||
:class="{
|
||||
'hovered': hoveredWordIndex === idx,
|
||||
'attended': getAttentionScore(hoveredWordIndex, idx) > 0
|
||||
}"
|
||||
@mouseenter="hoveredWordIndex = idx"
|
||||
@mouseleave="hoveredWordIndex = -1"
|
||||
:style="{
|
||||
backgroundColor: getAttentionColor(hoveredWordIndex, idx)
|
||||
}"
|
||||
>
|
||||
{{ word }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="attention-info" v-if="hoveredWordIndex !== -1">
|
||||
<p>
|
||||
Current Focus: <strong>"{{ transformerWords[hoveredWordIndex] }}"</strong>
|
||||
</p>
|
||||
<p class="sub-info">
|
||||
Paying attention to:
|
||||
<span v-for="(attn, idx) in currentAttentions" :key="idx">
|
||||
<span v-if="attn.score > 0.01">
|
||||
"{{ transformerWords[attn.idx] }}" ({{ Math.round(attn.score * 100) }}%)
|
||||
</span>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="attention-info" v-else>
|
||||
<p>👆 鼠标悬停在任意单词上,查看它在“关注”谁。</p>
|
||||
</div>
|
||||
|
||||
<p class="desc-text">
|
||||
Transformer 一眼看完整个句子(并行)。Self-Attention 机制让每个词都能直接“看见”其他词,无论距离多远。
|
||||
<br>例如:悬停在 <strong>"it"</strong> 上,你会发现它强烈关注 <strong>"animal"</strong>,因为它指代的就是 animal。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const mode = ref('rnn')
|
||||
|
||||
// RNN Data
|
||||
const rnnWords = ['The', 'quick', 'brown', 'fox', 'jumps', 'over', 'the', 'lazy', 'dog']
|
||||
const currentRnnStep = ref(-1)
|
||||
const isPlayingRnn = ref(false)
|
||||
const rnnMemoryOpacity = ref(0.3)
|
||||
const rnnMemoryStrength = ref(0)
|
||||
const rnnOutput = ref('...')
|
||||
|
||||
const playRnn = async () => {
|
||||
isPlayingRnn.value = true
|
||||
currentRnnStep.value = -1
|
||||
rnnMemoryStrength.value = 0
|
||||
rnnOutput.value = '...'
|
||||
|
||||
for (let i = 0; i < rnnWords.length; i++) {
|
||||
currentRnnStep.value = i
|
||||
// Memory accumulates but also decays
|
||||
rnnMemoryStrength.value = Math.min(100, rnnMemoryStrength.value * 0.8 + 30)
|
||||
rnnMemoryOpacity.value = 0.5 + (i / rnnWords.length) * 0.5
|
||||
rnnOutput.value = `h${i}`
|
||||
await new Promise(r => setTimeout(r, 800))
|
||||
}
|
||||
|
||||
isPlayingRnn.value = false
|
||||
rnnOutput.value = 'Done'
|
||||
}
|
||||
|
||||
// Transformer Data
|
||||
const transformerWords = ['The', 'animal', 'didn\'t', 'cross', 'the', 'street', 'because', 'it', 'was', 'too', 'tired', '.']
|
||||
|
||||
// Pre-defined attention matrix (simplified for demo)
|
||||
// Source -> Targets (scores)
|
||||
const attentionMap = {
|
||||
7: { // "it"
|
||||
1: 0.8, // animal
|
||||
5: 0.1, // street
|
||||
7: 1.0 // itself
|
||||
},
|
||||
10: { // "tired"
|
||||
1: 0.6, // animal
|
||||
7: 0.9, // it
|
||||
10: 1.0
|
||||
},
|
||||
3: { // "cross"
|
||||
1: 0.5, // animal
|
||||
5: 0.5, // street
|
||||
3: 1.0
|
||||
}
|
||||
}
|
||||
|
||||
const hoveredWordIndex = ref(-1)
|
||||
|
||||
const currentAttentions = computed(() => {
|
||||
if (hoveredWordIndex.value === -1) return []
|
||||
const map = attentionMap[hoveredWordIndex.value] || {}
|
||||
|
||||
return transformerWords.map((_, idx) => {
|
||||
let score = map[idx]
|
||||
if (score === undefined) {
|
||||
// Default behavior if not in map: attend to self strongly, neighbors weakly
|
||||
if (idx === hoveredWordIndex.value) score = 1.0
|
||||
else if (Math.abs(idx - hoveredWordIndex.value) === 1) score = 0.1
|
||||
else score = 0.0
|
||||
}
|
||||
return { idx, score }
|
||||
}).sort((a, b) => b.score - a.score)
|
||||
})
|
||||
|
||||
const getAttentionScore = (sourceIdx, targetIdx) => {
|
||||
if (sourceIdx === -1) return 0
|
||||
const map = attentionMap[sourceIdx]
|
||||
|
||||
if (map) {
|
||||
return map[targetIdx] || 0
|
||||
} else {
|
||||
// Default behavior if not in map
|
||||
if (sourceIdx === targetIdx) return 1.0
|
||||
if (Math.abs(sourceIdx - targetIdx) === 1) return 0.1
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
const getAttentionColor = (sourceIdx, targetIdx) => {
|
||||
if (sourceIdx === -1) return 'transparent'
|
||||
const score = getAttentionScore(sourceIdx, targetIdx)
|
||||
if (score === 0) return 'transparent'
|
||||
// Purple alpha
|
||||
return `rgba(139, 92, 246, ${score * 0.6})`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.arch-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.control-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.control-tabs button {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--vp-c-text-2);
|
||||
transition: all 0.2s;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.control-tabs button.active {
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-brand);
|
||||
border-bottom: 2px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
padding: 2rem;
|
||||
min-height: 250px;
|
||||
}
|
||||
|
||||
/* RNN Styles */
|
||||
.sequence-display {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.word-item {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
background-color: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
opacity: 0.5;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.word-item.active {
|
||||
opacity: 1;
|
||||
border-color: var(--vp-c-brand);
|
||||
background-color: var(--vp-c-brand-soft);
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.rnn-process {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.hidden-state-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hidden-state-box {
|
||||
width: 100px;
|
||||
height: 80px;
|
||||
border: 2px solid var(--vp-c-text-2);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
background-color: var(--vp-c-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.memory-content {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
font-size: 0.8rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.memory-level {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: var(--vp-c-brand);
|
||||
opacity: 0.3;
|
||||
transition: height 0.3s;
|
||||
}
|
||||
|
||||
.output-box {
|
||||
padding: 0.5rem;
|
||||
border: 1px dashed var(--vp-c-text-2);
|
||||
border-radius: 4px;
|
||||
min-width: 80px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Transformer Styles */
|
||||
.sentence-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.t-word {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.t-word:hover {
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.attention-info {
|
||||
text-align: center;
|
||||
min-height: 3rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.sub-info {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
margin-top: 2rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
text-align: center;
|
||||
max-width: 600px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="thinking-demo">
|
||||
<div class="mode-switch">
|
||||
<button
|
||||
:class="{ active: mode === 'fast' }"
|
||||
@click="switchMode('fast')"
|
||||
>
|
||||
⚡️ 传统快思考 (System 1)
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: mode === 'slow' }"
|
||||
@click="switchMode('slow')"
|
||||
>
|
||||
🧠 深度慢思考 (System 2)
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="demo-display">
|
||||
<div class="question-box">
|
||||
<strong>用户提问:</strong>
|
||||
<p>9.11 和 9.9 哪个大?</p>
|
||||
</div>
|
||||
|
||||
<div class="process-area">
|
||||
<!-- Fast Mode Visualization -->
|
||||
<div v-if="mode === 'fast'" class="fast-track">
|
||||
<div class="model-node">LLM</div>
|
||||
<div class="arrow">➜</div>
|
||||
<div class="output-box">
|
||||
<div class="typing-effect" v-if="generating">
|
||||
{{ displayedOutput }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ fastOutput }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slow Mode Visualization -->
|
||||
<div v-else class="slow-track">
|
||||
<div class="model-node">Thinking LLM</div>
|
||||
<div class="arrow">➜</div>
|
||||
<div class="output-container">
|
||||
<!-- Thinking Process -->
|
||||
<div class="thought-bubble" :class="{ visible: showThoughts }">
|
||||
<div class="bubble-header" @click="toggleThoughts">
|
||||
💭 思考过程 (Chain of Thought)
|
||||
<span class="toggle-icon">{{ thoughtsOpen ? '▼' : '▶' }}</span>
|
||||
</div>
|
||||
<div class="bubble-content" v-show="thoughtsOpen">
|
||||
<div class="typing-effect-thought" v-if="generatingThoughts">
|
||||
{{ displayedThoughts }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ slowThoughts }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Final Answer -->
|
||||
<div class="output-box final-answer" v-if="showFinalAnswer">
|
||||
<div class="typing-effect" v-if="generatingFinal">
|
||||
{{ displayedOutput }}
|
||||
</div>
|
||||
<div v-else>
|
||||
{{ slowOutput }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
<button class="run-btn" @click="runSimulation" :disabled="isRunning">
|
||||
{{ isRunning ? '生成中...' : '开始生成' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="metrics" v-if="completed">
|
||||
<div class="metric-item">
|
||||
<span class="label">Token 消耗:</span>
|
||||
<span class="value">{{ mode === 'fast' ? '5' : '150' }} tokens</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="label">耗时:</span>
|
||||
<span class="value">{{ mode === 'fast' ? '0.2s' : '5.0s' }}</span>
|
||||
</div>
|
||||
<div class="metric-item">
|
||||
<span class="label">准确率:</span>
|
||||
<span class="value" :class="mode === 'fast' ? 'bad' : 'good'">
|
||||
{{ mode === 'fast' ? '❌ 错误' : '✅ 正确' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const mode = ref('fast')
|
||||
const isRunning = ref(false)
|
||||
const completed = ref(false)
|
||||
|
||||
// Fast Mode Data
|
||||
const fastOutput = "9.11 比 9.9 大。"
|
||||
const displayedOutput = ref('')
|
||||
|
||||
// Slow Mode Data
|
||||
const slowThoughts = `首先比较整数部分,都是9,相等。
|
||||
接下来比较小数部分。
|
||||
9.11 的小数部分是 0.11。
|
||||
9.9 的小数部分是 0.9。
|
||||
比较第一位小数:1 < 9。
|
||||
所以 0.11 小于 0.9。
|
||||
结论:9.11 小于 9.9。`
|
||||
const slowOutput = "9.11 比 9.9 小。"
|
||||
|
||||
const displayedThoughts = ref('')
|
||||
const generating = ref(false)
|
||||
const generatingThoughts = ref(false)
|
||||
const generatingFinal = ref(false)
|
||||
const showThoughts = ref(false)
|
||||
const showFinalAnswer = ref(false)
|
||||
const thoughtsOpen = ref(true)
|
||||
|
||||
const switchMode = (newMode) => {
|
||||
if (isRunning.value) return
|
||||
mode.value = newMode
|
||||
reset()
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
displayedOutput.value = ''
|
||||
displayedThoughts.value = ''
|
||||
generating.value = false
|
||||
generatingThoughts.value = false
|
||||
generatingFinal.value = false
|
||||
showThoughts.value = false
|
||||
showFinalAnswer.value = false
|
||||
completed.value = false
|
||||
thoughtsOpen.value = true
|
||||
}
|
||||
|
||||
const typeText = async (text, targetRef, speed = 30) => {
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
targetRef.value += text[i]
|
||||
await new Promise(r => setTimeout(r, speed))
|
||||
}
|
||||
}
|
||||
|
||||
const runSimulation = async () => {
|
||||
reset()
|
||||
isRunning.value = true
|
||||
|
||||
if (mode.value === 'fast') {
|
||||
generating.value = true
|
||||
await typeText(fastOutput, displayedOutput, 50)
|
||||
generating.value = false
|
||||
} else {
|
||||
// Thinking phase
|
||||
showThoughts.value = true
|
||||
generatingThoughts.value = true
|
||||
await typeText(slowThoughts, displayedThoughts, 20)
|
||||
generatingThoughts.value = false
|
||||
|
||||
await new Promise(r => setTimeout(r, 500)) // Pause
|
||||
|
||||
// Final answer phase
|
||||
showFinalAnswer.value = true
|
||||
generatingFinal.value = true
|
||||
await typeText(slowOutput, displayedOutput, 50)
|
||||
generatingFinal.value = false
|
||||
}
|
||||
|
||||
completed.value = true
|
||||
isRunning.value = false
|
||||
}
|
||||
|
||||
const toggleThoughts = () => {
|
||||
thoughtsOpen.value = !thoughtsOpen.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.thinking-demo {
|
||||
padding: 20px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
margin: 20px 0;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.mode-switch {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.mode-switch button {
|
||||
padding: 8px 16px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.mode-switch button.active {
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.question-box {
|
||||
background: var(--vp-c-bg-mute);
|
||||
padding: 10px 15px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 20px;
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.question-box p {
|
||||
margin: 5px 0 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.process-area {
|
||||
min-height: 150px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.fast-track, .slow-track {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 15px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.model-node {
|
||||
padding: 10px 15px;
|
||||
background: var(--vp-c-brand-dimm);
|
||||
border: 2px solid var(--vp-c-brand);
|
||||
border-radius: 8px;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand-dark);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 1.5em;
|
||||
color: var(--vp-c-text-3);
|
||||
padding-top: 5px;
|
||||
}
|
||||
|
||||
.output-container {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.output-box {
|
||||
padding: 15px;
|
||||
background: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
min-height: 50px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.final-answer {
|
||||
border-color: var(--vp-c-green-dimm);
|
||||
background: var(--vp-c-green-soft);
|
||||
color: var(--vp-c-green-darker);
|
||||
}
|
||||
|
||||
.thought-bubble {
|
||||
border: 1px dashed var(--vp-c-text-3);
|
||||
border-radius: 6px;
|
||||
background: var(--vp-c-bg-alt);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.thought-bubble.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.bubble-header {
|
||||
padding: 8px 12px;
|
||||
background: var(--vp-c-bg-mute);
|
||||
font-size: 0.9em;
|
||||
color: var(--vp-c-text-2);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.bubble-content {
|
||||
padding: 10px;
|
||||
font-size: 0.9em;
|
||||
color: var(--vp-c-text-2);
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
border-top: 1px dashed var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.controls {
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.run-btn {
|
||||
padding: 10px 30px;
|
||||
background: var(--vp-c-brand);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 1em;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.run-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
background: var(--vp-c-bg-mute);
|
||||
padding: 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.metric-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--vp-c-text-3);
|
||||
font-size: 0.8em;
|
||||
}
|
||||
|
||||
.value {
|
||||
font-weight: bold;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.bad { color: var(--vp-c-red); }
|
||||
.good { color: var(--vp-c-green); }
|
||||
</style>
|
||||
@@ -0,0 +1,392 @@
|
||||
<!--
|
||||
TokenizationDemo.vue
|
||||
分词原理演示组件
|
||||
|
||||
用途:
|
||||
展示大语言模型如何“看”文本。通过将文本拆解为 Token,让用户理解 Token 是 LLM 处理的最小单位。
|
||||
|
||||
交互功能:
|
||||
- 文本输入:用户可输入任意文本。
|
||||
- 实时分词:模拟 Tokenizer 将文本切分为 Token。
|
||||
- 映射展示:显示 Token 文本与其对应的(模拟)数字 ID。
|
||||
- 颜色编码:使用不同颜色区分相邻 Token,直观展示切分边界。
|
||||
-->
|
||||
<template>
|
||||
<div class="token-demo">
|
||||
<div class="control-panel">
|
||||
<div class="main-controls">
|
||||
<div class="input-group">
|
||||
<label>Input Text / 输入文本</label>
|
||||
<textarea
|
||||
v-model="inputText"
|
||||
rows="3"
|
||||
placeholder="Type something to see how AI reads it..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="settings-group">
|
||||
<label>Algorithm / 算法</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-option" :class="{ active: algorithm === 'bpe' }">
|
||||
<input type="radio" v-model="algorithm" value="bpe">
|
||||
<span>BPE (GPT-4)</span>
|
||||
</label>
|
||||
<label class="radio-option" :class="{ active: algorithm === 'word' }">
|
||||
<input type="radio" v-model="algorithm" value="word">
|
||||
<span>Word (Legacy)</span>
|
||||
</label>
|
||||
<label class="radio-option" :class="{ active: algorithm === 'char' }">
|
||||
<input type="radio" v-model="algorithm" value="char">
|
||||
<span>Character (Raw)</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stats">
|
||||
<div class="stat-item">
|
||||
<span class="value">{{ tokens.length }}</span>
|
||||
<span class="label">Tokens</span>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<span class="value">{{ inputText.length }}</span>
|
||||
<span class="label">Characters / 字符</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Tokenizer Process Visualization -->
|
||||
<div class="tokenizer-arrow">
|
||||
⬇
|
||||
</div>
|
||||
|
||||
<div class="visualization-area">
|
||||
<div class="token-list">
|
||||
<div
|
||||
v-for="(token, index) in tokens"
|
||||
:key="index"
|
||||
class="token-chip"
|
||||
:class="`color-${index % 5}`"
|
||||
@mouseover="hoverIndex = index"
|
||||
@mouseleave="hoverIndex = -1"
|
||||
>
|
||||
<span class="token-text">{{ token.text }}</span>
|
||||
<span class="token-id">{{ token.id }}</span>
|
||||
<div class="tooltip" v-if="hoverIndex === index">
|
||||
ID: {{ token.id }}<br>
|
||||
Type: {{ token.type }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<p>
|
||||
<span class="icon">💡</span>
|
||||
<strong>Note:</strong>
|
||||
LLM 不直接理解单词,它们处理的是数字(Token IDs)。
|
||||
对于英文,一个 Token 通常是一个单词或单词的一部分(如 "ing");
|
||||
对于中文,一个 Token 通常是一个汉字或词组。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const inputText = ref('The quick brown fox jumps over the lazy dog. \n今天天气真不错!')
|
||||
const hoverIndex = ref(-1)
|
||||
const algorithm = ref('bpe')
|
||||
|
||||
// 模拟不同分词算法
|
||||
const tokens = computed(() => {
|
||||
const text = inputText.value
|
||||
const result = []
|
||||
let idCounter = 1000
|
||||
|
||||
// Helper to generate consistent fake ID
|
||||
const generateId = (str) => {
|
||||
let hash = 0
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = str.charCodeAt(i) + ((hash << 5) - hash)
|
||||
}
|
||||
return Math.abs(hash) % 50000
|
||||
}
|
||||
|
||||
if (algorithm.value === 'bpe') {
|
||||
// 1. BPE (Subword) Simulation
|
||||
// 模拟:保留常用词,拆分生僻词/后缀,中文字符独立
|
||||
const regex = /([a-zA-Z]+)|([\u4e00-\u9fa5])|(\s+)|(.+?)/g
|
||||
let match
|
||||
while ((match = regex.exec(text)) !== null) {
|
||||
if (match[0]) {
|
||||
let type = 'other'
|
||||
if (match[1]) type = 'word (en)'
|
||||
else if (match[2]) type = 'char (zh)'
|
||||
else if (match[3]) type = 'whitespace'
|
||||
else type = 'punctuation'
|
||||
|
||||
result.push({ text: match[0], id: generateId(match[0]), type })
|
||||
}
|
||||
}
|
||||
} else if (algorithm.value === 'word') {
|
||||
// 2. Word-based Simulation
|
||||
// 简单按空格拆分,标点符号也可能粘连
|
||||
const words = text.split(/(\s+)/)
|
||||
words.forEach(w => {
|
||||
if (w) {
|
||||
let type = /^\s+$/.test(w) ? 'whitespace' : 'word'
|
||||
result.push({ text: w, id: generateId(w), type })
|
||||
}
|
||||
})
|
||||
} else if (algorithm.value === 'char') {
|
||||
// 3. Character-based Simulation
|
||||
// 每个字符都是一个 Token
|
||||
for (let char of text) {
|
||||
let type = 'char'
|
||||
if (/\s/.test(char)) type = 'whitespace'
|
||||
result.push({ text: char, id: generateId(char), type })
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.token-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
padding: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
}
|
||||
|
||||
.control-panel {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.main-controls {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-width: 0; /* Prevent flex item from overflowing */
|
||||
}
|
||||
|
||||
.input-group {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.input-group label,
|
||||
.settings-group label {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background-color: var(--vp-c-bg);
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.radio-option:hover {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
}
|
||||
|
||||
.radio-option.active {
|
||||
border-color: var(--vp-c-brand);
|
||||
background-color: var(--vp-c-brand-soft);
|
||||
color: var(--vp-c-brand-dark);
|
||||
}
|
||||
|
||||
.radio-option input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tokenizer-arrow {
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
color: var(--vp-c-text-3);
|
||||
margin: 0.5rem 0;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.stats {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
background-color: var(--vp-c-bg);
|
||||
padding: 0.5rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.stat-item .value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--vp-c-brand);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.stat-item .label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.visualization-area {
|
||||
background-color: var(--vp-c-bg);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
min-height: 100px;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.token-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.token-chip {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 4px 6px;
|
||||
border-radius: 4px;
|
||||
cursor: help;
|
||||
transition: transform 0.1s;
|
||||
}
|
||||
|
||||
.token-chip:hover {
|
||||
transform: scale(1.05);
|
||||
z-index: 10;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.token-text {
|
||||
font-size: 1rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.token-id {
|
||||
font-size: 0.6rem;
|
||||
opacity: 0.6;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* Color palette for tokens */
|
||||
.color-0 { background-color: rgba(255, 99, 132, 0.2); border: 1px solid rgba(255, 99, 132, 0.3); }
|
||||
.color-1 { background-color: rgba(54, 162, 235, 0.2); border: 1px solid rgba(54, 162, 235, 0.3); }
|
||||
.color-2 { background-color: rgba(255, 206, 86, 0.2); border: 1px solid rgba(255, 206, 86, 0.3); }
|
||||
.color-3 { background-color: rgba(75, 192, 192, 0.2); border: 1px solid rgba(75, 192, 192, 0.3); }
|
||||
.color-4 { background-color: rgba(153, 102, 255, 0.2); border: 1px solid rgba(153, 102, 255, 0.3); }
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background-color: var(--vp-c-text-1);
|
||||
color: var(--vp-c-bg);
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
margin-bottom: 6px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.tooltip::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 50%;
|
||||
margin-left: -4px;
|
||||
border-width: 4px;
|
||||
border-style: solid;
|
||||
border-color: var(--vp-c-text-1) transparent transparent transparent;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.info-box .icon {
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.control-panel {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stats {
|
||||
flex-direction: row;
|
||||
width: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,396 @@
|
||||
<!--
|
||||
TokenizerToMatrix.vue
|
||||
从分词到矩阵的转换过程演示
|
||||
|
||||
用途:
|
||||
详细展示 LLM 处理文本的第一步:
|
||||
Text (文本) -> Tokens (分词) -> IDs (数字索引) -> One-hot (独热编码) / Embedding Lookup (查表) -> Matrix (输入矩阵)
|
||||
|
||||
交互功能:
|
||||
- 步骤导航:分步演示每个转换阶段。
|
||||
- 动态输入:允许用户输入短语,实时看到转换结果。
|
||||
- 矩阵可视化:直观展示最终生成的数字矩阵。
|
||||
-->
|
||||
<template>
|
||||
<div class="matrix-demo">
|
||||
<div class="control-bar">
|
||||
<input
|
||||
v-model="inputText"
|
||||
type="text"
|
||||
placeholder="输入一段文本..."
|
||||
class="text-input"
|
||||
:disabled="currentStep > 0"
|
||||
/>
|
||||
<div class="step-controls">
|
||||
<button
|
||||
class="step-btn prev"
|
||||
:disabled="currentStep === 0"
|
||||
@click="currentStep--"
|
||||
>
|
||||
← 上一步
|
||||
</button>
|
||||
<div class="step-indicator">
|
||||
Step {{ currentStep + 1 }} / 4
|
||||
</div>
|
||||
<button
|
||||
class="step-btn next"
|
||||
:disabled="currentStep === 3"
|
||||
@click="currentStep++"
|
||||
>
|
||||
下一步 →
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="visualization-stage">
|
||||
<!-- Step 1: Tokenization -->
|
||||
<div class="stage-content" v-if="currentStep === 0">
|
||||
<h3 class="stage-title">Step 1: Tokenization (分词)</h3>
|
||||
<p class="stage-desc">计算机首先将文本切分为最小的语义单位(Token)。</p>
|
||||
<div class="token-container">
|
||||
<div
|
||||
v-for="(token, idx) in tokens"
|
||||
:key="idx"
|
||||
class="token-box"
|
||||
:style="{ borderColor: getTokenColor(idx) }"
|
||||
>
|
||||
<span class="token-val">{{ token.text }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: ID Mapping -->
|
||||
<div class="stage-content" v-if="currentStep === 1">
|
||||
<h3 class="stage-title">Step 2: ID Mapping (索引映射)</h3>
|
||||
<p class="stage-desc">在词表(Vocabulary)中查找每个 Token 对应的唯一数字 ID。</p>
|
||||
<div class="mapping-container">
|
||||
<div v-for="(token, idx) in tokens" :key="idx" class="mapping-row">
|
||||
<div class="token-box sm" :style="{ borderColor: getTokenColor(idx) }">
|
||||
{{ token.text }}
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="vocab-lookup">
|
||||
<span class="vocab-label">Vocab Lookup</span>
|
||||
</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="id-box">
|
||||
{{ token.id }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Embedding Lookup -->
|
||||
<div class="stage-content" v-if="currentStep === 2">
|
||||
<h3 class="stage-title">Step 3: Embedding Lookup (向量查表)</h3>
|
||||
<p class="stage-desc">每个 ID 对应一个预训练好的高维向量(这里简化为 4 维)。</p>
|
||||
<div class="lookup-container">
|
||||
<div v-for="(token, idx) in tokens" :key="idx" class="lookup-row">
|
||||
<div class="id-box">{{ token.id }}</div>
|
||||
<div class="arrow">→</div>
|
||||
<div class="vector-row">
|
||||
<span class="bracket">[</span>
|
||||
<span v-for="(val, vIdx) in token.vector" :key="vIdx" class="vector-val">
|
||||
{{ val.toFixed(2) }}
|
||||
</span>
|
||||
<span class="bracket">]</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 4: Input Matrix -->
|
||||
<div class="stage-content" v-if="currentStep === 3">
|
||||
<h3 class="stage-title">Step 4: Matrix Construction (构建矩阵)</h3>
|
||||
<p class="stage-desc">所有向量堆叠在一起,形成了输入矩阵(Shape: [Batch, Seq_Len, Dim])。这就是 LLM 真正“看见”的东西。</p>
|
||||
<div class="matrix-container">
|
||||
<div class="matrix-bracket left"></div>
|
||||
<div class="matrix-grid">
|
||||
<div v-for="(token, rIdx) in tokens" :key="rIdx" class="matrix-row">
|
||||
<div
|
||||
v-for="(val, cIdx) in token.vector"
|
||||
:key="cIdx"
|
||||
class="matrix-cell"
|
||||
:style="{ backgroundColor: getHeatmapColor(val) }"
|
||||
:title="val.toFixed(4)"
|
||||
>
|
||||
{{ val.toFixed(1) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="matrix-bracket right"></div>
|
||||
<div class="matrix-label">
|
||||
Shape: ({{ tokens.length }}, 4)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const inputText = ref('我爱人工智能')
|
||||
const currentStep = ref(0)
|
||||
|
||||
const colors = ['#f87171', '#60a5fa', '#fbbf24', '#34d399', '#a78bfa']
|
||||
|
||||
// 模拟 Tokenizer 和 Embedding
|
||||
const tokens = computed(() => {
|
||||
const text = inputText.value || ''
|
||||
// 简单按字/词切分模拟
|
||||
const rawTokens = text.match(/[\u4e00-\u9fa5]|[a-zA-Z]+|\s+|./g) || []
|
||||
|
||||
return rawTokens.map((t, i) => {
|
||||
// 确定性伪随机生成 ID 和 Vector
|
||||
let hash = 0
|
||||
for (let j = 0; j < t.length; j++) hash = t.charCodeAt(j) + ((hash << 5) - hash)
|
||||
const id = Math.abs(hash) % 10000
|
||||
|
||||
// 生成 4 维向量
|
||||
const vector = []
|
||||
for(let k=0; k<4; k++) {
|
||||
const val = Math.sin(id * (k+1)) // 伪随机值 -1 ~ 1
|
||||
vector.push(val)
|
||||
}
|
||||
|
||||
return { text: t, id, vector }
|
||||
})
|
||||
})
|
||||
|
||||
const getTokenColor = (idx) => colors[idx % colors.length]
|
||||
|
||||
const getHeatmapColor = (val) => {
|
||||
// val is -1 to 1
|
||||
// Map to blue (negative) -> white (0) -> red (positive)
|
||||
const opacity = Math.abs(val)
|
||||
if (val > 0) return `rgba(239, 68, 68, ${opacity})` // Red
|
||||
return `rgba(59, 130, 246, ${opacity})` // Blue
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.matrix-demo {
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
margin: 1rem 0;
|
||||
font-family: var(--vp-font-family-mono);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.control-bar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.step-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
background-color: var(--vp-c-bg);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.step-btn:hover:not(:disabled) {
|
||||
border-color: var(--vp-c-brand);
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.step-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.visualization-stage {
|
||||
padding: 2rem;
|
||||
min-height: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stage-title {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
margin-bottom: 0.5rem;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.stage-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-bottom: 2rem;
|
||||
text-align: center;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
/* Step 1 Styles */
|
||||
.token-container {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.token-box {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 2px solid;
|
||||
border-radius: 6px;
|
||||
background-color: var(--vp-c-bg);
|
||||
font-weight: bold;
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.token-box.sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Step 2 Styles */
|
||||
.mapping-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mapping-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.vocab-lookup {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background-color: var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.id-box {
|
||||
font-family: monospace;
|
||||
color: var(--vp-c-brand);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
/* Step 3 Styles */
|
||||
.lookup-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.lookup-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.vector-row {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.vector-val {
|
||||
width: 40px;
|
||||
text-align: right;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Step 4 Styles */
|
||||
.matrix-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.matrix-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.matrix-row {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.matrix-cell {
|
||||
width: 40px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.7rem;
|
||||
color: #fff; /* text always white for contrast on colored bg */
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
.matrix-bracket {
|
||||
width: 10px;
|
||||
border: 2px solid var(--vp-c-text-1);
|
||||
position: absolute;
|
||||
top: -5px;
|
||||
bottom: -5px;
|
||||
}
|
||||
|
||||
.matrix-bracket.left {
|
||||
left: -15px;
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.matrix-bracket.right {
|
||||
right: -15px;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.matrix-label {
|
||||
position: absolute;
|
||||
bottom: -30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
font-size: 0.8rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.control-bar {
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: auto;
|
||||
flex: 1;
|
||||
max-width: 300px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user