docs: update content and components across multiple files

- Refine chapter introductions in zh-cn docs for clarity and conciseness
- Update navigation links to include '/easy-vibe' prefix
- Simplify UI components (ChapterIntroduction, ContextWindowVisualizer)
- Add new agent-related demo components (AgentMemoryDemo, AgentToolUseDemo)
- Improve context compression demo with better visuals and metrics
- Adjust styling and layout across various components
This commit is contained in:
sanbuphy
2026-02-03 01:46:03 +08:00
parent ad95658a11
commit e5b1c6cc88
31 changed files with 11651 additions and 2156 deletions
@@ -98,16 +98,16 @@ const hasTags = computed(() => props.tags && props.tags.length > 0)
<style scoped>
.chapter-introduction {
margin: 24px 0;
border-radius: 16px;
margin: 16px 0;
border-radius: 12px;
background-color: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.04);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
.objective-section {
padding: 24px 28px;
padding: 16px 20px;
background: linear-gradient(
to right,
rgba(var(--vp-c-brand-rgb), 0.05),
@@ -119,25 +119,25 @@ const hasTags = computed(() => props.tags && props.tags.length > 0)
.objective-label {
display: flex;
align-items: center;
margin-bottom: 16px;
margin-bottom: 10px;
color: var(--vp-c-brand);
}
.icon {
font-size: 1.4em;
margin-right: 8px;
font-size: 1.2em;
margin-right: 6px;
}
.title {
font-size: 1em;
font-size: 0.95em;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.content {
font-size: 1.15em;
line-height: 1.7;
font-size: 1em;
line-height: 1.5;
color: var(--vp-c-text-1);
font-weight: 500;
}
@@ -146,30 +146,30 @@ const hasTags = computed(() => props.tags && props.tags.length > 0)
.tags-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
gap: 8px;
}
.description-text {
font-size: 1.15em;
line-height: 1.7;
font-size: 1em;
line-height: 1.5;
color: var(--vp-c-text-1);
}
.description-text.has-tags {
margin-top: 16px;
font-size: 1em;
margin-top: 10px;
font-size: 0.95em;
color: var(--vp-c-text-2);
border-top: 1px solid var(--vp-c-divider);
padding-top: 12px;
padding-top: 10px;
}
.objective-tag {
display: inline-flex;
align-items: center;
padding: 6px 14px;
padding: 4px 10px;
background-color: var(--vp-c-bg-alt);
border: 1px solid var(--vp-c-divider);
border-radius: 99px;
font-size: 1em;
font-size: 0.9em;
font-weight: 600;
color: var(--vp-c-text-1);
transition: all 0.2s;
@@ -193,9 +193,9 @@ const hasTags = computed(() => props.tags && props.tags.length > 0)
}
.metric-card {
flex: 1 1 240px;
flex: 1 1 200px;
background-color: var(--vp-c-bg-soft);
padding: 20px 24px;
padding: 14px 18px;
display: flex;
align-items: flex-start;
gap: 16px;
@@ -207,7 +207,7 @@ const hasTags = computed(() => props.tags && props.tags.length > 0)
}
.card-icon {
font-size: 1.7em;
font-size: 1.4em;
line-height: 1;
padding-top: 2px;
}
@@ -219,16 +219,16 @@ const hasTags = computed(() => props.tags && props.tags.length > 0)
}
.card-label {
font-size: 0.85em;
font-size: 0.8em;
color: var(--vp-c-text-2);
margin-bottom: 8px;
margin-bottom: 4px;
font-weight: 600;
text-transform: uppercase;
}
.card-value {
font-size: 1em;
line-height: 1.5;
font-size: 0.95em;
line-height: 1.4;
color: var(--vp-c-text-1);
}
@@ -236,7 +236,7 @@ const hasTags = computed(() => props.tags && props.tags.length > 0)
display: inline-block;
color: var(--vp-c-brand-dark);
font-weight: 800;
font-size: 1.15em;
font-size: 1.1em;
margin-top: 2px;
}
@@ -244,21 +244,21 @@ const hasTags = computed(() => props.tags && props.tags.length > 0)
.output-container {
display: flex;
flex-direction: column;
gap: 4px;
gap: 2px;
}
.core-output {
font-size: 1.3em;
font-size: 1.1em;
font-weight: 800;
color: var(--vp-c-brand);
line-height: 1.4;
line-height: 1.3;
margin-bottom: 2px;
}
.output-desc {
font-size: 0.93em;
font-size: 0.9em;
color: var(--vp-c-text-2);
line-height: 1.4;
line-height: 1.3;
}
.output-desc :deep(strong) {
@@ -269,12 +269,12 @@ const hasTags = computed(() => props.tags && props.tags.length > 0)
/* Mobile adjustments */
@media (max-width: 640px) {
.metric-card {
padding: 16px 20px;
padding: 12px 16px;
flex-basis: 100%;
}
.objective-section {
padding: 20px;
padding: 14px 16px;
}
}
</style>
@@ -0,0 +1,440 @@
<template>
<div class="memory-demo">
<div class="header">
<div class="title">💾 Agent 的记忆系统</div>
</div>
<!-- 快捷操作 -->
<div class="quick-actions">
<button
v-for="action in quickActions"
:key="action"
class="action-btn"
@click="sendMessage(action)"
:disabled="isTyping"
>
{{ action }}
</button>
<button class="action-btn reset" @click="resetConversation">🔄 重置</button>
</div>
<!-- 主区域 -->
<div class="main-area">
<!-- 对话区 -->
<div class="chat-box">
<div class="box-header">💬 对话</div>
<div class="messages" ref="chatContainer">
<div v-for="(msg, i) in messages.slice(-4)" :key="i" class="msg-row" :class="msg.role">
<span class="avatar">{{ msg.role === 'user' ? '👤' : '🤖' }}</span>
<span class="text">{{ msg.content }}</span>
</div>
<div v-if="isTyping" class="msg-row assistant typing">
<span class="avatar">🤖</span>
<span class="dots"><span></span><span></span><span></span></span>
</div>
<div v-if="messages.length === 0" class="empty-msg">点击上方按钮开始对话</div>
</div>
</div>
<!-- 三种记忆并排 -->
<div class="memory-row">
<div class="memory-card">
<div class="card-header">
<span> 短期记忆</span>
<span class="count">{{ shortTermMemory.length }}</span>
</div>
<div class="card-body">
<div v-for="(item, i) in shortTermMemory.slice(-3)" :key="i" class="mem-item">
<span class="role">{{ item.role === 'user' ? 'U' : 'A' }}</span>
<span class="content">{{ truncate(item.content, 20) }}</span>
</div>
<div v-if="shortTermMemory.length === 0" class="empty"></div>
</div>
</div>
<div class="memory-card">
<div class="card-header">
<span>📝 工作记忆</span>
<span class="count">{{ Object.keys(workingMemory).length }}</span>
</div>
<div class="card-body">
<div v-for="(v, k) in workingMemory" :key="k" class="mem-item kv">
<span class="key">{{ k }}</span>
<span class="value">{{ v }}</span>
</div>
<div v-if="Object.keys(workingMemory).length === 0" class="empty"></div>
</div>
</div>
<div class="memory-card">
<div class="card-header">
<span>🗄 长期记忆</span>
<span class="count">{{ longTermMemory.length }}</span>
</div>
<div class="card-body">
<div v-for="(item, i) in longTermMemory.slice(-2)" :key="i" class="mem-item">
<span class="tag">{{ item.category }}</span>
<span class="content">{{ item.content }}</span>
</div>
<div v-if="longTermMemory.length === 0" class="empty"></div>
</div>
</div>
</div>
</div>
<!-- 记忆操作提示 -->
<div v-if="lastOp" class="op-bar">
<span>{{ lastOp.icon }}</span>
<span>{{ lastOp.text }}</span>
</div>
<!-- 提示 -->
<div class="tip-bar">
<span>💡</span>
<span><strong>短期</strong>=当前对话<strong>工作</strong>=临时变量<strong>长期</strong>=跨会话知识</span>
</div>
</div>
</template>
<script setup>
import { ref, nextTick } from 'vue'
const messages = ref([])
const shortTermMemory = ref([])
const workingMemory = ref({})
const longTermMemory = ref([])
const isTyping = ref(false)
const lastOp = ref(null)
const quickActions = [
'我叫张三',
'我喜欢 Python',
'推荐编程书',
'我叫什么?'
]
const responses = {
'我叫张三': {
reply: '好的,我记住了你叫张三。',
op: { icon: '💾', text: '长期记忆: 姓名=张三' },
update: () => longTermMemory.value.push({ category: '身份', content: '姓名: 张三' })
},
'我喜欢 Python': {
reply: '收到!记录了你偏好 Python。',
op: { icon: '💾', text: '工作记忆: 偏好=Python | 长期记忆: 技术偏好' },
update: () => {
workingMemory.value['偏好'] = 'Python'
longTermMemory.value.push({ category: '偏好', content: '编程语言: Python' })
}
},
'推荐编程书': {
reply: '基于你偏好 Python,推荐《流畅的Python》。',
op: { icon: '🔍', text: '检索工作记忆: 偏好=Python → 生成推荐' }
},
'我叫什么?': {
reply: '你叫张三。',
op: { icon: '🔍', text: '检索长期记忆: 姓名=张三' }
}
}
const sendMessage = async (text) => {
messages.value.push({ role: 'user', content: text })
shortTermMemory.value.push({ role: 'user', content: text })
isTyping.value = true
scrollToBottom()
await wait(600)
const config = responses[text] || { reply: '收到', op: null, update: () => {} }
config.update()
lastOp.value = config.op
messages.value.push({ role: 'assistant', content: config.reply })
shortTermMemory.value.push({ role: 'assistant', content: config.reply })
isTyping.value = false
scrollToBottom()
}
const resetConversation = () => {
messages.value = []
shortTermMemory.value = []
workingMemory.value = {}
longTermMemory.value = []
lastOp.value = null
isTyping.value = false
}
const scrollToBottom = async () => {
await nextTick()
const container = document.querySelector('.messages')
if (container) container.scrollTop = container.scrollHeight
}
const truncate = (text, len) => text.length > len ? text.slice(0, len) + '...' : text
const wait = (ms) => new Promise(r => setTimeout(r, ms))
</script>
<style scoped>
.memory-demo {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 20px;
margin: 20px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
text-align: center;
margin-bottom: 16px;
}
.title {
font-size: 17px;
font-weight: 700;
background: linear-gradient(120deg, var(--vp-c-brand), #9c27b0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* 快捷操作 */
.quick-actions {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.action-btn {
padding: 8px 14px;
border: 1px solid var(--vp-c-divider);
border-radius: 16px;
background: var(--vp-c-bg);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.action-btn:hover:not(:disabled) {
background: var(--vp-c-brand-soft);
border-color: var(--vp-c-brand);
}
.action-btn.reset {
background: #fee2e2;
border-color: #fecaca;
color: #991b1b;
}
.action-btn:disabled { opacity: 0.5; cursor: not-allowed; }
/* 主区域 */
.main-area {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 12px;
margin-bottom: 16px;
}
@media (max-width: 768px) {
.main-area { grid-template-columns: 1fr; }
}
/* 对话区 */
.chat-box {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
overflow: hidden;
}
.box-header {
padding: 10px 12px;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
font-size: 12px;
font-weight: 600;
}
.messages {
padding: 12px;
min-height: 120px;
max-height: 160px;
overflow-y: auto;
}
.msg-row {
display: flex;
gap: 8px;
margin-bottom: 10px;
align-items: flex-start;
}
.msg-row.user { flex-direction: row-reverse; }
.avatar {
font-size: 14px;
flex-shrink: 0;
}
.text {
padding: 8px 12px;
border-radius: 10px;
font-size: 12px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
}
.msg-row.user .text {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.dots {
display: flex;
gap: 4px;
padding: 8px 12px;
background: var(--vp-c-bg-soft);
border-radius: 10px;
}
.dots span {
width: 6px;
height: 6px;
background: var(--vp-c-text-3);
border-radius: 50%;
animation: bounce 1.4s infinite;
}
.dots span:nth-child(1) { animation-delay: 0s; }
.dots span:nth-child(2) { animation-delay: 0.2s; }
.dots span:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: scale(0); }
40% { transform: scale(1); }
}
.empty-msg {
text-align: center;
color: var(--vp-c-text-3);
padding: 40px 0;
font-size: 12px;
}
/* 记忆行 */
.memory-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
@media (max-width: 600px) {
.memory-row { grid-template-columns: 1fr; }
}
.memory-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
overflow: hidden;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
font-size: 12px;
font-weight: 600;
}
.count {
padding: 2px 8px;
background: var(--vp-c-bg);
border-radius: 10px;
font-size: 11px;
color: var(--vp-c-text-2);
}
.card-body {
padding: 10px;
min-height: 80px;
}
.mem-item {
display: flex;
gap: 6px;
padding: 6px 8px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
margin-bottom: 6px;
font-size: 11px;
align-items: center;
}
.mem-item .role {
font-weight: 600;
color: var(--vp-c-brand);
min-width: 14px;
}
.mem-item .content {
color: var(--vp-c-text-1);
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mem-item.kv .key {
font-weight: 600;
color: var(--vp-c-brand);
}
.mem-item.kv .value {
color: var(--vp-c-text-1);
}
.mem-item .tag {
padding: 1px 6px;
background: var(--vp-c-brand-soft);
border-radius: 4px;
font-size: 10px;
color: var(--vp-c-brand-dark);
}
.empty {
text-align: center;
color: var(--vp-c-text-3);
padding: 20px 0;
font-size: 12px;
}
/* 操作提示 */
.op-bar {
display: flex;
gap: 8px;
padding: 10px 14px;
background: #dcfce7;
border-radius: 8px;
margin-bottom: 16px;
font-size: 12px;
color: #166534;
}
/* 提示 */
.tip-bar {
display: flex;
gap: 8px;
padding: 10px 14px;
background: var(--vp-c-brand-soft);
border-radius: 8px;
font-size: 12px;
color: var(--vp-c-text-1);
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,837 @@
<template>
<div class="multi-tool-principle">
<div class="header">
<div class="title">🔧 多工具调用原理Agent 如何"串联"工具完成任务</div>
<div class="subtitle">理解 Agent 的链式思考(Chain-of-Thought)和工具编排机制</div>
</div>
<!-- 场景选择 -->
<div class="scenario-tabs">
<button
v-for="s in scenarios"
:key="s.id"
:class="['tab-btn', { active: currentScenario === s.id }]"
@click="selectScenario(s.id)"
>
<span>{{ s.icon }}</span>
<span>{{ s.name }}</span>
</button>
</div>
<!-- 用户意图 -->
<div class="intent-box">
<div class="intent-label">👤 用户意图</div>
<div class="intent-text">{{ currentData.intent }}</div>
</div>
<!-- 执行流程可视化 -->
<div class="execution-flow">
<div class="flow-title">🔄 工具调用执行流程</div>
<!-- 思考阶段 -->
<div class="phase thinking-phase" :class="{ active: currentPhase >= 0 }">
<div class="phase-header">
<span class="phase-icon">🧠</span>
<span class="phase-name">思考规划</span>
<span class="phase-status">{{ currentPhase > 0 ? '✅ 完成' : currentPhase === 0 ? '🔄 进行中' : '⏳ 等待' }}</span>
</div>
<div v-if="currentPhase >= 0" class="phase-content">
<div class="thought-steps">
<div v-for="(step, idx) in currentData.planningSteps" :key="idx" class="thought-step">
<span class="step-num">{{ idx + 1 }}</span>
<span class="step-text">{{ step }}</span>
</div>
</div>
</div>
</div>
<!-- 工具执行阶段 -->
<div class="phase tools-phase" :class="{ active: currentPhase >= 1 }">
<div class="phase-header">
<span class="phase-icon">🔧</span>
<span class="phase-name">工具执行</span>
<span class="phase-status">{{ currentPhase > 1 ? '✅ 完成' : currentPhase === 1 ? '🔄 进行中' : '⏳ 等待' }}</span>
</div>
<div v-if="currentPhase >= 1" class="phase-content">
<div class="tools-chain">
<div
v-for="(tool, idx) in currentData.tools"
:key="idx"
class="tool-node"
:class="{
completed: currentTool > idx,
executing: currentTool === idx,
pending: currentTool < idx
}"
>
<div class="node-connector" v-if="idx > 0">
<div class="connector-line" :class="{ active: currentTool >= idx }"></div>
</div>
<div class="node-content">
<div class="node-icon">{{ tool.icon }}</div>
<div class="node-name">{{ tool.name }}</div>
<div class="node-status">
<span v-if="currentTool > idx" class="status-done"></span>
<span v-else-if="currentTool === idx" class="status-running">
<span class="pulse"></span>
</span>
<span v-else class="status-wait"></span>
</div>
</div>
<!-- 工具详情 -->
<div v-if="currentTool >= idx" class="tool-detail-popup">
<div class="detail-row">
<span class="detail-label">输入:</span>
<code class="detail-code">{{ tool.input }}</code>
</div>
<div v-if="currentTool > idx" class="detail-row">
<span class="detail-label">输出:</span>
<span class="detail-output">{{ truncate(tool.output, 50) }}</span>
</div>
</div>
</div>
</div>
<!-- 数据流转示意 -->
<div v-if="currentPhase === 1" class="data-flow-hint">
<div class="flow-arrow"> 数据在工具间流转上一步的输出成为下一步的输入</div>
</div>
</div>
</div>
<!-- 结果整合阶段 -->
<div class="phase result-phase" :class="{ active: currentPhase >= 2 }">
<div class="phase-header">
<span class="phase-icon">📝</span>
<span class="phase-name">结果整合</span>
<span class="phase-status">{{ currentPhase > 2 ? '✅ 完成' : currentPhase === 2 ? '🔄 进行中' : '⏳ 等待' }}</span>
</div>
<div v-if="currentPhase >= 2" class="phase-content">
<div class="integration-steps">
<div class="integration-step" :class="{ done: integrationStep >= 0 }">
<span class="check">{{ integrationStep >= 0 ? '✓' : '○' }}</span>
<span>收集所有工具输出</span>
</div>
<div class="integration-step" :class="{ done: integrationStep >= 1 }">
<span class="check">{{ integrationStep >= 1 ? '✓' : '○' }}</span>
<span>去重与验证</span>
</div>
<div class="integration-step" :class="{ done: integrationStep >= 2 }">
<span class="check">{{ integrationStep >= 2 ? '✓' : '○' }}</span>
<span>结构化整理</span>
</div>
<div class="integration-step" :class="{ done: integrationStep >= 3 }">
<span class="check">{{ integrationStep >= 3 ? '✓' : '○' }}</span>
<span>生成自然语言回复</span>
</div>
</div>
</div>
</div>
<!-- 最终输出 -->
<div class="phase output-phase" :class="{ active: currentPhase >= 3 }">
<div class="phase-header">
<span class="phase-icon">💬</span>
<span class="phase-name">最终输出</span>
<span class="phase-status">{{ currentPhase >= 3 ? '✅ 完成' : '⏳ 等待' }}</span>
</div>
<div v-if="currentPhase >= 3" class="phase-content">
<div class="final-output">
<div class="output-bubble">{{ currentData.finalOutput }}</div>
</div>
</div>
</div>
</div>
<!-- 控制按钮 -->
<div class="controls">
<button v-if="!isRunning && currentPhase === -1" class="control-btn primary" @click="startDemo">
开始演示
</button>
<button v-else-if="isRunning" class="control-btn" disabled>
执行中...
</button>
<button v-else class="control-btn secondary" @click="reset">
🔄 重新演示
</button>
</div>
<!-- 原理说明 -->
<div class="principle-explanation">
<div class="explanation-title">📚 核心原理</div>
<div class="explanation-grid">
<div class="explanation-card">
<div class="card-icon">🧩</div>
<div class="card-title">任务分解</div>
<div class="card-desc">Agent 将复杂任务拆解为多个子任务每个子任务对应一个工具调用</div>
</div>
<div class="explanation-card">
<div class="card-icon">🔗</div>
<div class="card-title">链式调用</div>
<div class="card-desc">工具按依赖关系串联执行前一个工具的输出成为后一个工具的输入</div>
</div>
<div class="explanation-card">
<div class="card-icon">🔄</div>
<div class="card-title">动态调整</div>
<div class="card-desc">根据中间结果Agent 可以动态决定下一步调用哪个工具</div>
</div>
<div class="explanation-card">
<div class="card-icon">🎯</div>
<div class="card-title">结果整合</div>
<div class="card-desc">将所有工具输出整合为连贯有用的最终回复</div>
</div>
</div>
</div>
<!-- LLM 对比 -->
<div class="comparison-section">
<div class="comparison-title"> 为什么需要多工具调用</div>
<div class="comparison-table">
<div class="comparison-row header">
<div class="col scenario">场景</div>
<div class="col llm">普通 LLM</div>
<div class="col agent">Agent + 多工具</div>
</div>
<div v-for="(item, idx) in comparisons" :key="idx" class="comparison-row">
<div class="col scenario">{{ item.scenario }}</div>
<div class="col llm">{{ item.llm }}</div>
<div class="col agent">{{ item.agent }}</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const scenarios = [
{
id: 'travel',
icon: '✈️',
name: '旅行规划',
intent: '规划一个3天2晚的东京旅行,预算1万元',
planningSteps: [
'分析需求:东京、3天2晚、预算1万',
'确定需要查询:机票、酒店、景点、路线、预算',
'规划工具调用顺序:机票→酒店→景点→路线→预算汇总'
],
tools: [
{ icon: '✈️', name: '查机票', input: '{from:上海, to:东京, date:3.15}', output: '往返¥3,200' },
{ icon: '🏨', name: '查酒店', input: '{city:东京, nights:2, budget:3000}', output: '新宿酒店¥1,200/晚' },
{ icon: '📍', name: '查景点', input: '{city:东京, days:3}', output: '推荐5个景点' },
{ icon: '🗺️', name: '规划路线', input: '{spots:[...], days:3}', output: '3天路线规划' },
{ icon: '💰', name: '算预算', input: '{items:[...]}', output: '总计¥8,400' }
],
finalOutput: '✈️ 东京3天2晚行程已规划好!\n• 机票:¥3,200\n• 酒店:¥2,400\n• 餐饮交通:¥2,000\n• 门票购物:¥1,000\n• 总计:¥8,400(剩余¥1,600'
},
{
id: 'research',
icon: '📊',
name: '行业研究',
intent: '生成2024年新能源汽车行业分析报告',
planningSteps: [
'分析需求:行业报告需要市场数据、厂商信息、技术趋势、政策',
'确定数据来源:市场数据库、公司信息、技术文献、政策文件',
'规划工具调用:市场数据→厂商排名→技术趋势→政策→可视化→报告生成'
],
tools: [
{ icon: '📈', name: '市场数据', input: '{industry:NEV, year:2024}', output: '销量1700万辆,+35%' },
{ icon: '🏢', name: '厂商信息', input: '{industry:NEV, top:10}', output: '比亚迪302万,特斯拉181万...' },
{ icon: '🔋', name: '技术趋势', input: '{field:NEV, tech:[电池,智驾]}', output: '固态电池、L2+智驾普及' },
{ icon: '📋', name: '政策查询', input: '{region:全球, topic:NEV}', output: '中国减免购置税至2027' },
{ icon: '📊', name: '数据可视化', input: '{type:饼图, data:市场份额}', output: '生成6个图表' },
{ icon: '📝', name: '报告生成', input: '{sections:[...]}', output: '12页完整报告' }
],
finalOutput: '📊 2024新能源汽车行业分析报告已完成!\n• 全球销量1700万辆(+35%)\n• 比亚迪领先(302万辆)\n• 技术趋势:固态电池、800V快充\n• 完整报告:12页,6个图表'
},
{
id: 'shopping',
icon: '🛒',
name: '智能购物',
intent: '买5000元笔记本,编程+轻度游戏',
planningSteps: [
'分析需求:5000元、编程、轻度游戏',
'确定评估维度:机型、规格、价格、评价、性能跑分',
'规划工具调用:搜索→查规格→比价格→看评价→跑分对比'
],
tools: [
{ icon: '🔍', name: '搜索机型', input: '{category:笔记本, budget:5000}', output: '找到6款候选机型' },
{ icon: '⚙️', name: '查规格', input: '{products:[...]}', output: 'CPU/内存/屏幕参数' },
{ icon: '💰', name: '比价格', input: '{products:[...]}', output: '价格对比表' },
{ icon: '⭐', name: '看评价', input: '{products:[...], source:电商}', output: '好评率96% vs 94%' },
{ icon: '📊', name: '跑分对比', input: '{products:[...], tests:[CPU,GPU]}', output: 'R7>i5,续航8h vs 6.5h' }
],
finalOutput: '💻 笔记本推荐结果\n🥇 首选:联想小新Pro16(¥4,999\n• R7-7840HS/16G/1TB/2.5K\n• 性能强、屏幕好、存储大\n\n🥈 备选:ThinkBook14+(¥5,299\n• 做工好、续航长、接口全'
}
]
const comparisons = [
{ scenario: '查天气+穿衣建议', llm: '只能推测,无法获取实时数据', agent: '调用天气API获取实时数据,再给出穿衣建议' },
{ scenario: '股票分析', llm: '无法获取股价,只能泛泛而谈', agent: '股价+新闻+技术分析,三个工具串联完成深度分析' },
{ scenario: '旅行规划', llm: '只能给建议,无法查询实时价格', agent: '机票+酒店+景点+路线+预算,5个工具完成完整规划' },
{ scenario: '数据分析', llm: '无法访问数据,只能讲分析方法', agent: '查询+分组+计算+可视化,6个工具完成完整分析' }
]
const currentScenario = ref('travel')
const currentPhase = ref(-1)
const currentTool = ref(-1)
const integrationStep = ref(-1)
const isRunning = ref(false)
const currentData = computed(() => scenarios.find(s => s.id === currentScenario.value))
const selectScenario = (id) => {
currentScenario.value = id
reset()
}
const startDemo = async () => {
isRunning.value = true
currentPhase.value = 0
currentTool.value = -1
integrationStep.value = -1
// 思考阶段
await wait(1500)
// 工具执行阶段
currentPhase.value = 1
const tools = currentData.value.tools
for (let i = 0; i < tools.length; i++) {
currentTool.value = i
await wait(1200)
}
currentTool.value = tools.length
await wait(500)
// 结果整合阶段
currentPhase.value = 2
for (let i = 0; i < 4; i++) {
integrationStep.value = i
await wait(600)
}
// 最终输出
await wait(300)
currentPhase.value = 3
isRunning.value = false
}
const reset = () => {
currentPhase.value = -1
currentTool.value = -1
integrationStep.value = -1
isRunning.value = false
}
const wait = (ms) => new Promise(r => setTimeout(r, ms))
const truncate = (str, len) => str.length > len ? str.slice(0, len) + '...' : str
</script>
<style scoped>
.multi-tool-principle {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 20px;
margin: 20px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
text-align: center;
margin-bottom: 16px;
}
.title {
font-size: 17px;
font-weight: 700;
background: linear-gradient(120deg, var(--vp-c-brand), #9c27b0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 12px;
color: var(--vp-c-text-2);
margin-top: 4px;
}
/* 场景标签 */
.scenario-tabs {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.tab-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid var(--vp-c-divider);
border-radius: 20px;
background: var(--vp-c-bg);
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
}
.tab-btn:hover {
background: var(--vp-c-bg-alt);
}
.tab-btn.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand-dark);
}
/* 用户意图 */
.intent-box {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 14px;
margin-bottom: 16px;
}
.intent-label {
font-size: 12px;
font-weight: 600;
color: var(--vp-c-text-2);
margin-bottom: 6px;
}
.intent-text {
font-size: 14px;
color: var(--vp-c-text-1);
}
/* 执行流程 */
.execution-flow {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 16px;
margin-bottom: 16px;
}
.flow-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 14px;
color: var(--vp-c-text-1);
}
/* 阶段 */
.phase {
margin-bottom: 12px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
opacity: 0.5;
transition: all 0.3s;
}
.phase.active {
opacity: 1;
border-color: var(--vp-c-brand);
}
.phase-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
}
.phase-icon {
font-size: 16px;
}
.phase-name {
flex: 1;
font-size: 13px;
font-weight: 600;
}
.phase-status {
font-size: 11px;
padding: 4px 10px;
border-radius: 12px;
background: var(--vp-c-bg);
}
.phase-content {
padding: 14px;
}
/* 思考步骤 */
.thought-steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.thought-step {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 10px;
background: #fef3c7;
border-radius: 6px;
}
.step-num {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--vp-c-brand);
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 600;
flex-shrink: 0;
}
.step-text {
font-size: 12px;
color: #92400e;
line-height: 1.5;
}
/* 工具链 */
.tools-chain {
display: flex;
flex-direction: column;
gap: 12px;
}
.tool-node {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
border: 2px solid transparent;
transition: all 0.3s;
position: relative;
}
.tool-node.completed {
border-color: #86efac;
background: #f0fdf4;
}
.tool-node.executing {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
}
.tool-node.pending {
opacity: 0.5;
}
.node-connector {
position: absolute;
left: 24px;
top: -14px;
width: 2px;
height: 14px;
}
.connector-line {
width: 100%;
height: 100%;
background: var(--vp-c-divider);
transition: background 0.3s;
}
.connector-line.active {
background: var(--vp-c-brand);
}
.node-content {
display: flex;
align-items: center;
gap: 10px;
flex: 1;
}
.node-icon {
font-size: 20px;
}
.node-name {
flex: 1;
font-size: 13px;
font-weight: 500;
}
.node-status {
font-size: 14px;
}
.status-done {
color: #16a34a;
}
.status-running .pulse {
display: inline-block;
width: 10px;
height: 10px;
background: var(--vp-c-brand);
border-radius: 50%;
animation: pulse 1s infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.5; transform: scale(1.2); }
}
.status-wait {
color: var(--vp-c-text-3);
}
/* 工具详情 */
.tool-detail-popup {
width: 100%;
margin-top: 10px;
padding: 10px;
background: var(--vp-c-bg);
border-radius: 6px;
font-size: 11px;
}
.detail-row {
display: flex;
gap: 8px;
margin-bottom: 6px;
}
.detail-row:last-child {
margin-bottom: 0;
}
.detail-label {
color: var(--vp-c-text-2);
flex-shrink: 0;
}
.detail-code {
background: #1e1e1e;
color: #d4d4d4;
padding: 2px 6px;
border-radius: 3px;
font-family: monospace;
}
.detail-output {
color: #16a34a;
}
.data-flow-hint {
text-align: center;
margin-top: 12px;
padding: 10px;
background: var(--vp-c-brand-soft);
border-radius: 6px;
font-size: 12px;
color: var(--vp-c-brand-dark);
}
/* 整合步骤 */
.integration-steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.integration-step {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
font-size: 12px;
transition: all 0.3s;
}
.integration-step.done {
background: #dcfce7;
color: #166534;
}
.check {
font-weight: 600;
}
/* 最终输出 */
.final-output {
padding: 12px;
background: #dcfce7;
border-radius: 8px;
}
.output-bubble {
font-size: 13px;
color: #166534;
line-height: 1.6;
white-space: pre-wrap;
}
/* 控制按钮 */
.controls {
display: flex;
justify-content: center;
margin-bottom: 20px;
}
.control-btn {
padding: 10px 24px;
border-radius: 8px;
font-size: 14px;
cursor: pointer;
transition: all 0.2s;
border: none;
}
.control-btn.primary {
background: var(--vp-c-brand);
color: white;
}
.control-btn.primary:hover {
background: var(--vp-c-brand-dark);
}
.control-btn.secondary {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-1);
}
.control-btn.secondary:hover {
background: var(--vp-c-bg-alt);
}
.control-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 原理解释 */
.principle-explanation {
margin-bottom: 20px;
}
.explanation-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--vp-c-text-1);
}
.explanation-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
}
@media (max-width: 600px) {
.explanation-grid {
grid-template-columns: 1fr;
}
}
.explanation-card {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 14px;
text-align: center;
}
.card-icon {
font-size: 24px;
margin-bottom: 8px;
}
.card-title {
font-size: 13px;
font-weight: 600;
margin-bottom: 6px;
}
.card-desc {
font-size: 11px;
color: var(--vp-c-text-2);
line-height: 1.5;
}
/* 对比表格 */
.comparison-section {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 14px;
}
.comparison-title {
font-size: 14px;
font-weight: 600;
margin-bottom: 12px;
color: var(--vp-c-text-1);
}
.comparison-table {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--vp-c-divider);
border-radius: 6px;
overflow: hidden;
}
.comparison-row {
display: grid;
grid-template-columns: 100px 1fr 1fr;
gap: 12px;
padding: 10px 12px;
background: var(--vp-c-bg);
font-size: 12px;
align-items: center;
}
.comparison-row.header {
background: var(--vp-c-bg-soft);
font-weight: 600;
}
.col.scenario {
font-weight: 500;
}
.col.llm {
color: #6b7280;
}
.col.agent {
color: var(--vp-c-brand-dark);
}
</style>
@@ -0,0 +1,541 @@
<template>
<div class="planning-demo">
<div class="header">
<div class="title">📋 Agent 的规划能力</div>
</div>
<!-- 任务选择 -->
<div class="task-tabs">
<button
v-for="task in tasks"
:key="task.id"
:class="['task-btn', { active: currentTask === task.id }]"
@click="selectTask(task.id)"
>
<span>{{ task.icon }}</span>
<span>{{ task.name }}</span>
<span class="complexity" :class="task.complexity">{{ task.complexityLabel }}</span>
</button>
</div>
<!-- 目标 -->
<div class="goal-bar">
<span class="label">🎯</span>
<span class="text">{{ currentTaskData.goal }}</span>
</div>
<!-- 执行区域 -->
<div class="execution-area">
<!-- 步骤进度条 -->
<div class="steps-progress">
<div
v-for="(step, index) in currentTaskData.steps"
:key="index"
class="step-node"
:class="{ completed: stepStatus[index] === 'completed', running: stepStatus[index] === 'running' }"
>
<div class="node-circle">{{ index + 1 }}</div>
<div class="node-name">{{ step.name }}</div>
<div v-if="index < currentTaskData.steps.length - 1" class="node-line"></div>
</div>
</div>
<!-- 日志和思考 -->
<div class="info-row">
<div class="log-box">
<div class="box-header">
<span>📝 执行日志</span>
<span v-if="executionStatus === 'running'" class="status running">执行中</span>
<span v-else-if="executionStatus === 'completed'" class="status completed">已完成</span>
</div>
<div class="log-content">
<div v-if="logs.length === 0" class="empty">点击"开始执行"查看过程</div>
<div v-for="(log, i) in logs.slice(-4)" :key="i" class="log-line" :class="log.type">
<span class="time">{{ log.time }}</span>
<span class="icon">{{ log.icon }}</span>
<span class="msg" v-html="log.message"></span>
</div>
</div>
</div>
<div v-if="currentThought" class="thought-box">
<div class="box-header">🧠 正在思考</div>
<div class="thought-content">{{ currentThought }}</div>
</div>
</div>
</div>
<!-- 控制栏 -->
<div class="control-bar">
<button v-if="executionStatus === 'idle'" class="ctrl-btn primary" @click="startExecution">
开始执行
</button>
<button v-else-if="executionStatus === 'running'" class="ctrl-btn" disabled>
执行中...
</button>
<button v-else class="ctrl-btn" @click="reset">
🔄 重置
</button>
<div v-if="executionStatus === 'completed'" class="stats">
<span class="stat">{{ currentTaskData.steps.length }} 步骤</span>
<span class="stat">{{ executionTime }}s</span>
<span class="stat">{{ toolCalls }} 调用</span>
</div>
<div class="step-dots">
<span v-for="n in currentTaskData.steps.length" :key="n" :class="['dot', { active: stepStatus[n-1] === 'completed' }]"></span>
</div>
</div>
<!-- 提示 -->
<div class="tip-bar">
<span>💡</span>
<span>规划核心将复杂任务分解为<strong>原子操作</strong>根据上一步结果<strong>动态调整</strong>后续计划</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
const tasks = [
{
id: 'simple',
icon: '🌤️',
name: '查天气',
complexity: 'easy',
complexityLabel: '简单',
goal: '查询北京今天的天气',
steps: [
{ name: '调用天气 API', tool: 'weather_api' },
{ name: '格式化结果', tool: 'formatter' }
],
logs: [
{ type: 'think', icon: '🧠', message: '需要查询北京天气' },
{ type: 'action', icon: '🔧', message: 'weather_api(city="北京")' },
{ type: 'result', icon: '📥', message: '晴, 25°C, 空气质量良' },
{ type: 'complete', icon: '✅', message: '北京今天天气晴朗' }
]
},
{
id: 'medium',
icon: '📊',
name: '数据分析',
complexity: 'medium',
complexityLabel: '中等',
goal: '分析销售 CSV,找出销售额最高月份',
steps: [
{ name: '读取 CSV', tool: 'file_reader' },
{ name: '解析数据', tool: 'data_parser' },
{ name: '聚合计算', tool: 'calculator' },
{ name: '生成报告', tool: 'report_generator' }
],
logs: [
{ type: 'think', icon: '🧠', message: '读取销售数据文件' },
{ type: 'action', icon: '🔧', message: 'file_reader(path="sales.csv")' },
{ type: 'result', icon: '📥', message: '读取 1200 行数据' },
{ type: 'think', icon: '🧠', message: '解析数据结构' },
{ type: 'action', icon: '🔧', message: 'data_parser(data)' },
{ type: 'result', icon: '📥', message: '解析完成' },
{ type: 'think', icon: '🧠', message: '按月份聚合销售额' },
{ type: 'action', icon: '🔧', message: 'calculator.aggregate(by="month")' },
{ type: 'result', icon: '📥', message: '11月销售额最高 ¥320K' },
{ type: 'complete', icon: '✅', message: '分析完成' }
]
},
{
id: 'complex',
icon: '🔬',
name: '研究报告',
complexity: 'hard',
complexityLabel: '复杂',
goal: '调研 AI Agent 进展,撰写完整报告',
steps: [
{ name: '搜索资讯', tool: 'web_search' },
{ name: '阅读文章', tool: 'web_reader' },
{ name: '提取信息', tool: 'extractor' },
{ name: '搜索厂商', tool: 'web_search' },
{ name: '生成大纲', tool: 'planner' },
{ name: '撰写报告', tool: 'writer' }
],
logs: [
{ type: 'think', icon: '🧠', message: '搜索最新 AI Agent 资讯' },
{ type: 'action', icon: '🔧', message: 'web_search("AI Agent 2024")' },
{ type: 'result', icon: '📥', message: '找到 15 篇文章' },
{ type: 'action', icon: '🔧', message: 'web_reader(urls=[...])' },
{ type: 'result', icon: '📥', message: '成功读取内容' },
{ type: 'action', icon: '🔧', message: 'extractor(fields=[...])' },
{ type: 'result', icon: '📥', message: '提取 45 个数据点' },
{ type: 'action', icon: '🔧', message: 'web_search("AI Agent companies")' },
{ type: 'result', icon: '📥', message: 'OpenAI, Anthropic, Microsoft...' },
{ type: 'action', icon: '🔧', message: 'planner.generate_outline()' },
{ type: 'result', icon: '📥', message: '大纲生成完成' },
{ type: 'action', icon: '🔧', message: 'writer.generate_content()' },
{ type: 'complete', icon: '✅', message: '报告生成完成,2500字' }
]
}
]
const currentTask = ref('simple')
const executionStatus = ref('idle')
const stepStatus = ref([])
const logs = ref([])
const currentThought = ref('')
const executionTime = ref(0)
const toolCalls = ref(0)
const currentTaskData = computed(() => tasks.find(t => t.id === currentTask.value))
const selectTask = (id) => {
currentTask.value = id
reset()
}
const reset = () => {
executionStatus.value = 'idle'
stepStatus.value = new Array(currentTaskData.value.steps.length).fill('pending')
logs.value = []
currentThought.value = ''
executionTime.value = 0
toolCalls.value = 0
}
const startExecution = async () => {
executionStatus.value = 'running'
stepStatus.value = new Array(currentTaskData.value.steps.length).fill('pending')
logs.value = []
toolCalls.value = 0
const startTime = Date.now()
const taskLogs = currentTaskData.value.logs
for (let i = 0; i < taskLogs.length; i++) {
const log = taskLogs[i]
if (log.type === 'think') currentThought.value = log.message
if (log.type === 'action') {
const stepIndex = Math.min(toolCalls.value, currentTaskData.value.steps.length - 1)
stepStatus.value = stepStatus.value.map((s, idx) => {
if (idx < stepIndex) return 'completed'
if (idx === stepIndex) return 'running'
return 'pending'
})
toolCalls.value++
}
if (log.type === 'complete') currentThought.value = ''
logs.value.push({ ...log, time: new Date().toLocaleTimeString('zh-CN', { hour12: false, hour: '2-digit', minute: '2-digit', second: '2-digit' }) })
await wait(700)
}
stepStatus.value = stepStatus.value.map(() => 'completed')
executionTime.value = ((Date.now() - startTime) / 1000).toFixed(1)
executionStatus.value = 'completed'
}
const wait = (ms) => new Promise(resolve => setTimeout(resolve, ms))
reset()
</script>
<style scoped>
.planning-demo {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 20px;
margin: 20px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
text-align: center;
margin-bottom: 16px;
}
.title {
font-size: 17px;
font-weight: 700;
background: linear-gradient(120deg, var(--vp-c-brand), #9c27b0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* 任务标签 */
.task-tabs {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.task-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid var(--vp-c-divider);
border-radius: 20px;
background: var(--vp-c-bg);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.task-btn.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand-dark);
}
.complexity {
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
margin-left: 4px;
}
.complexity.easy { background: #dcfce7; color: #166534; }
.complexity.medium { background: #fef3c7; color: #92400e; }
.complexity.hard { background: #fee2e2; color: #991b1b; }
/* 目标 */
.goal-bar {
background: var(--vp-c-brand-soft);
border-left: 3px solid var(--vp-c-brand);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 16px;
font-size: 14px;
}
.goal-bar .label { margin-right: 8px; }
.goal-bar .text { font-weight: 600; }
/* 步骤进度 */
.steps-progress {
display: flex;
align-items: flex-start;
gap: 8px;
margin-bottom: 16px;
overflow-x: auto;
padding-bottom: 8px;
}
.step-node {
display: flex;
flex-direction: column;
align-items: center;
position: relative;
min-width: 100px;
}
.node-circle {
width: 32px;
height: 32px;
border-radius: 50%;
background: var(--vp-c-bg);
border: 2px solid var(--vp-c-divider);
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 700;
margin-bottom: 6px;
transition: all 0.3s;
}
.step-node.running .node-circle {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
animation: pulse 1.5s infinite;
}
.step-node.completed .node-circle {
border-color: #22c55e;
background: #dcfce7;
color: #166534;
}
@keyframes pulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }
}
.node-name {
font-size: 11px;
text-align: center;
color: var(--vp-c-text-2);
}
.step-node.completed .node-name,
.step-node.running .node-name {
color: var(--vp-c-text-1);
font-weight: 600;
}
.node-line {
position: absolute;
top: 16px;
right: -16px;
width: 24px;
height: 2px;
background: var(--vp-c-divider);
}
.step-node.completed + .step-node .node-line {
background: #22c55e;
}
/* 信息行 */
.info-row {
display: grid;
grid-template-columns: 2fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
@media (max-width: 600px) {
.info-row { grid-template-columns: 1fr; }
}
.log-box, .thought-box {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.box-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--vp-c-bg-soft);
border-bottom: 1px solid var(--vp-c-divider);
font-size: 12px;
font-weight: 600;
}
.status {
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
}
.status.running { background: #fef3c7; color: #92400e; }
.status.completed { background: #dcfce7; color: #166534; }
.log-content {
padding: 10px 12px;
min-height: 100px;
max-height: 140px;
overflow-y: auto;
}
.empty {
color: var(--vp-c-text-3);
text-align: center;
padding: 30px 0;
font-size: 12px;
}
.log-line {
display: flex;
gap: 8px;
font-size: 12px;
margin-bottom: 6px;
align-items: flex-start;
}
.log-line .time {
color: var(--vp-c-text-3);
font-size: 10px;
min-width: 55px;
}
.log-line .icon {
font-size: 11px;
}
.log-line .msg {
color: var(--vp-c-text-1);
flex: 1;
}
.log-line.think .msg { color: #3b82f6; }
.log-line.action .msg { color: #f59e0b; }
.log-line.result .msg { color: #10b981; }
.log-line.complete .msg { color: #8b5cf6; font-weight: 600; }
.thought-content {
padding: 12px;
font-size: 13px;
color: var(--vp-c-text-1);
font-style: italic;
line-height: 1.5;
}
/* 控制栏 */
.control-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.ctrl-btn {
padding: 8px 18px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.ctrl-btn.primary {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.stats {
display: flex;
gap: 12px;
}
.stat {
padding: 4px 10px;
background: var(--vp-c-bg);
border-radius: 4px;
font-size: 12px;
color: var(--vp-c-text-2);
}
.step-dots {
display: flex;
gap: 4px;
}
.dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--vp-c-divider);
}
.dot.active { background: #22c55e; }
/* 提示 */
.tip-bar {
display: flex;
gap: 8px;
padding: 10px 14px;
background: var(--vp-c-brand-soft);
border-radius: 8px;
font-size: 12px;
color: var(--vp-c-text-1);
}
</style>
@@ -0,0 +1,666 @@
<template>
<div class="agent-chat-demo">
<div class="header">
<div class="title">🤖 Agent 初体验"能说""能做"</div>
<div class="subtitle">体验 Agent 如何自动调用工具完成任务</div>
</div>
<!-- 场景选择 -->
<div class="scenario-tabs">
<button
v-for="s in scenarios"
:key="s.id"
:class="['tab-btn', { active: currentScenario === s.id }]"
@click="selectScenario(s.id)"
>
<span>{{ s.icon }}</span>
<span>{{ s.name }}</span>
</button>
</div>
<!-- 聊天窗口 -->
<div class="chat-window">
<!-- 用户消息 -->
<div class="message user">
<div class="avatar">👤</div>
<div class="bubble">{{ currentScenarioData.query }}</div>
</div>
<!-- LLM 回复对比 -->
<div class="message llm">
<div class="avatar">🤖</div>
<div class="bubble llm-bubble">
<div class="llm-label">普通 LLM</div>
<div class="llm-content">{{ currentScenarioData.llmResponse }}</div>
</div>
</div>
<!-- Agent 回复 -->
<div class="message agent">
<div class="avatar agent-avatar">🦾</div>
<div class="bubble agent-bubble">
<div class="agent-label">Agent 智能体</div>
<!-- 思考过程可折叠 -->
<div v-if="showThinking" class="thinking-section">
<div class="thinking-header" @click="toggleThinking">
<span>🧠 思考过程</span>
<span class="toggle-icon">{{ thinkingExpanded ? '▼' : '▶' }}</span>
</div>
<div v-if="thinkingExpanded" class="thinking-content">
<div class="thought-item">{{ currentScenarioData.thinking }}</div>
</div>
</div>
<!-- 工具调用可折叠 -->
<div v-if="showTools" ref="toolsSection" class="tools-section">
<div class="tools-header" @click="toggleTools">
<span>🔧 工具调用 ({{ currentScenarioData.tools.length }})</span>
<span class="toggle-icon">{{ toolsExpanded ? '▼' : '▶' }}</span>
</div>
<div v-if="toolsExpanded" class="tools-list">
<div
v-for="(tool, idx) in currentScenarioData.tools"
:key="idx"
:ref="el => setToolRef(el, idx)"
class="tool-item"
:class="{ completed: toolExecuted > idx, executing: toolExecuting === idx }"
>
<div class="tool-status">
<span v-if="toolExecuted > idx"></span>
<span v-else-if="toolExecuting === idx" class="spinner"></span>
<span v-else></span>
</div>
<div class="tool-info">
<div class="tool-name">{{ tool.name }}</div>
<div v-if="toolExecuted > idx || toolExecuting === idx" class="tool-detail">
<code class="tool-params">{{ tool.params }}</code>
<div v-if="toolExecuted > idx" class="tool-result">{{ tool.result }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- 最终回复 -->
<div v-if="showResponse" class="final-response">
<div class="response-header">💬 最终回复</div>
<div class="response-content">{{ currentScenarioData.agentResponse }}</div>
</div>
<!-- 执行按钮 -->
<button v-if="!isExecuting && !executionComplete" class="execute-btn" @click="startExecution">
Agent 执行
</button>
<button v-else-if="executionComplete" class="execute-btn reset" @click="reset">
🔄 重置对话
</button>
</div>
</div>
</div>
<!-- 核心区别 -->
<div class="insight-bar">
<span class="insight-label">💡 核心区别</span>
<span class="insight-text">{{ currentScenarioData.insight }}</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
const scenarios = [
{
id: 'weather',
icon: '🌤️',
name: '查天气',
query: '北京今天天气怎么样?适合穿什么衣服?',
llmResponse: '我无法获取实时天气信息。北京一般在春季比较温和,建议穿薄外套。',
thinking: '用户想知道北京今天的天气和穿衣建议。我需要:1) 查询实时天气 2) 根据温度给出穿衣建议',
tools: [
{ name: 'weather_api', params: '{"city": "北京", "date": "today"}', result: '☀️ 晴,15-25°C,空气质量良' }
],
agentResponse: '北京今天天气晴朗,15-25°C,空气质量良。建议穿薄外套或长袖T恤,早晚温差较大,可以带件薄外套。',
insight: 'Agent 调用天气 API 获取实时数据,LLM 只能基于训练数据推测。'
},
{
id: 'calculate',
icon: '🧮',
name: '复杂计算',
query: '帮我算一下:如果贷款100万,年利率4.2%,30年等额本息,每月还多少?总利息多少?',
llmResponse: '根据公式计算,月供大约5000元左右,总利息约80万。这只是估算,建议用专业计算器。',
thinking: '这是房贷计算问题,需要用到等额本息公式。月供 = 贷款本金 × 月利率 × (1+月利率)^还款月数 / [(1+月利率)^还款月数 - 1]',
tools: [
{ name: 'calculator', params: '{"principal": 1000000, "rate": 0.042, "years": 30}', result: '月供: ¥4,890.19, 总利息: ¥760,468.40' }
],
agentResponse: '计算结果:\n• 每月还款:¥4,890.19\n• 还款总额:¥1,760,468.40\n• 总利息:¥760,468.40\n\n30年下来利息约占本金的76%。',
insight: 'Agent 调用计算器确保 100% 准确,LLM 心算可能出错。'
},
{
id: 'stock',
icon: '📈',
name: '股票分析',
query: '分析一下特斯拉股票最近的表现,并预测明天走势',
llmResponse: '我无法获取实时股票数据。特斯拉是知名电动车公司,股价波动较大,建议查看专业财经网站。',
thinking: '用户需要特斯拉股票的近期表现分析和预测。我需要:1) 获取最新股价 2) 获取历史数据 3) 进行技术分析',
tools: [
{ name: 'stock_api', params: '{"symbol": "TSLA", "period": "1mo"}', result: '当前价: $248.50, 月涨幅: +12.3%, 成交量: 1.2亿' },
{ name: 'news_search', params: '{"query": "Tesla stock news", "limit": 5}', result: '找到5条相关新闻:财报超预期、新车型发布...' },
{ name: 'technical_analysis', params: '{"data": "TSLA_price_data", "indicators": ["MA", "RSI"]}', result: 'RSI: 68(接近超买), MA20: $235, 趋势: 上升' }
],
agentResponse: '特斯拉(TSLA)最近表现:\n📈 月涨幅 +12.3%,现报 $248.50\n📊 技术指标:RSI 68(接近超买),站上20日均线\n📰 消息面:财报超预期、新车型发布利好\n\n预测:短期可能回调,中长期看好。',
insight: 'Agent 串联多个工具(股价+新闻+技术分析)完成复杂分析任务。'
},
{
id: 'travel',
icon: '✈️',
name: '旅行规划',
query: '帮我规划一个3天2晚的东京旅行,预算1万人民币,包含机票、酒店、景点',
llmResponse: '东京是个好地方!我可以给你一些建议:浅草寺、东京塔、银座购物区都很值得去。预算1万可能有点紧张,建议提前预订。',
thinking: '用户需要完整的东京3天2晚旅行规划。我需要:1) 查询机票价格 2) 搜索酒店 3) 推荐景点 4) 规划路线 5) 计算总预算',
tools: [
{ name: 'flight_search', params: '{"from": "上海", "to": "东京", "depart": "2024-03-15", "return": "2024-03-17"}', result: '往返机票: ¥3,200 (春秋航空)' },
{ name: 'hotel_search', params: '{"city": "东京", "checkin": "2024-03-15", "nights": 2, "budget": 3000}', result: '新宿华盛顿酒店: ¥1,200/晚, 评分4.5' },
{ name: 'attractions_search', params: '{"city": "东京", "days": 3}', result: '推荐景点: 浅草寺、东京塔、涩谷十字路口、明治神宫、秋叶原' },
{ name: 'route_planner', params: '{"spots": ["浅草寺", "东京塔", "涩谷", "秋叶原"], "days": 3}', result: 'Day1: 浅草寺→东京塔, Day2: 涩谷→明治神宫, Day3: 秋叶原→银座' },
{ name: 'budget_calculator', params: '{"flight": 3200, "hotel": 2400, "food": 1500, "transport": 500, "tickets": 800}', result: '总预算: ¥8,400 (剩余¥1,600用于购物)' }
],
agentResponse: '✈️ 东京3天2晚行程规划\n\n📅 Day1: 浅草寺→东京塔\n📅 Day2: 涩谷→明治神宫\n📅 Day3: 秋叶原→银座\n\n💰 预算明细:\n• 往返机票: ¥3,200\n• 酒店2晚: ¥2,400\n• 餐饮: ¥1,500\n• 交通: ¥500\n• 门票: ¥800\n• 总计: ¥8,400 (剩余¥1,600购物)',
insight: 'Agent 调用5个工具完成机票、酒店、景点、路线、预算的完整规划。'
},
{
id: 'shopping',
icon: '🛒',
name: '智能购物',
query: '我想买一台5000元左右的笔记本电脑,主要用于编程和轻度游戏,推荐几款并对比',
llmResponse: '5000元预算可以买到不错的笔记本。推荐联想小新Pro、华为MateBook、小米RedmiBook。具体配置建议16GB内存、512GB SSD。',
thinking: '用户需要5000元价位的编程+游戏笔记本推荐。我需要:1) 搜索当前热门机型 2) 获取详细规格参数 3) 查询实时价格 4) 查看用户评价 5) 进行性能对比',
tools: [
{ name: 'product_search', params: '{"category": "laptop", "budget": 5000, "usage": "programming,gaming"}', result: '找到6款: 联想小新Pro16、ThinkBook14+、华为MateBook14、RedmiBook Pro15、荣耀MagicBook14、机械革命极光Pro' },
{ name: 'specs_query', params: '{"products": ["联想小新Pro16", "ThinkBook14+", "华为MateBook14"]}', result: '小新Pro16: R7-7840HS/16G/1TB/2.5K; ThinkBook14+: i5-13500H/16G/512G/2.8K; MateBook14: i5-1340P/16G/512G/2K触控' },
{ name: 'price_compare', params: '{"products": ["联想小新Pro16", "ThinkBook14+", "华为MateBook14"]}', result: '小新Pro16: ¥4,999; ThinkBook14+: ¥5,299; MateBook14: ¥5,499' },
{ name: 'reviews_search', params: '{"products": ["联想小新Pro16", "ThinkBook14+"], "source": "jd,tmall"}', result: '小新Pro16: 好评率96%, praised for screen and performance; ThinkBook14+: 好评率94%, praised for build quality' },
{ name: 'benchmark_compare', params: '{"products": ["联想小新Pro16", "ThinkBook14+"], "tests": ["cpu", "gpu", "battery"]}', result: 'CPU: 小新Pro16(R7) > ThinkBook14+(i5); GPU: 两者均为核显; 续航: ThinkBook14+ 8h > 小新Pro16 6.5h' }
],
agentResponse: '💻 5000元笔记本推荐对比\n\n🥇 首选: 联想小新Pro16\n• 配置: R7-7840HS/16G/1TB/2.5K 120Hz\n• 价格: ¥4,999\n• 优势: 性能强、屏幕好、存储大\n• 适合: 编程+轻度游戏\n\n🥈 备选: ThinkBook14+\n• 配置: i5-13500H/16G/512G/2.8K\n• 价格: ¥5,299\n• 优势: 做工好、续航长、接口全\n• 适合: 商务办公+编程',
insight: 'Agent 调用5个工具完成搜索、查规格、比价格、看评价、跑分对比的完整购物决策。'
},
{
id: 'report',
icon: '📊',
name: '研究报告',
query: '帮我生成一份2024年新能源汽车行业分析报告,包含市场规模、主要玩家、技术趋势',
llmResponse: '新能源汽车行业正在快速发展。特斯拉、比亚迪是行业领导者。市场规模预计持续增长,电池技术是关键。建议查阅专业行业报告获取详细数据。',
thinking: '用户需要一份完整的新能源汽车行业分析报告。我需要:1) 查询最新市场数据 2) 搜索主要厂商信息 3) 获取技术发展趋势 4) 查找政策法规 5) 生成数据可视化 6) 整理成报告格式',
tools: [
{ name: 'market_data', params: '{"industry": "NEV", "year": 2024, "metrics": ["size", "growth", "penetration"]}', result: '2024年全球NEV销量: 1,700万辆(+35%), 中国占比60%, 渗透率: 全球18%, 中国35%' },
{ name: 'company_search', params: '{"industry": "NEV", "ranking": "top10"}', result: 'TOP5: 比亚迪(302万辆)、特斯拉(181万辆)、大众(77万辆)、吉利(68万辆)、上汽(63万辆)' },
{ name: 'tech_trends', params: '{"field": "NEV", "technologies": ["battery", "autonomous", "charging"]}', result: '电池: 固态电池量产在即, 能量密度>400Wh/kg; 智驾: L2+普及率>50%, L4开始商用; 充电: 800V高压平台普及, 5分钟补能200km' },
{ name: 'policy_search', params: '{"region": "global", "topic": "NEV_policy", "year": 2024}', result: '中国: 购置税减免延续至2027年; 欧盟: 2035年禁售燃油车; 美国: IRA法案补贴本土化生产' },
{ name: 'data_visualization', params: '{"type": "chart", "data": "NEV_market_share", "chart_type": "pie"}', result: '生成市场份额饼图: 比亚迪35%, 特斯拉21%, 其他44%' },
{ name: 'report_generator', params: '{"title": "2024新能源汽车行业分析报告", "sections": ["market", "players", "tech", "policy"], "format": "markdown"}', result: '报告已生成: 12页, 包含6个图表, 3个数据表格' }
],
agentResponse: '📊 2024新能源汽车行业分析报告\n\n📈 市场规模\n• 全球销量: 1,700万辆 (+35%)\n• 中国市场占比: 60%\n• 全球渗透率: 18% (中国35%)\n\n🏆 主要玩家\n1. 比亚迪: 302万辆 (35%)\n2. 特斯拉: 181万辆 (21%)\n3. 大众: 77万辆\n\n🔋 技术趋势\n• 固态电池能量密度>400Wh/kg\n• L2+智驾普及率超50%\n• 800V高压快充普及\n\n📋 报告已生成: 12页完整分析',
insight: 'Agent 调用6个工具完成数据收集、分析、可视化和报告生成的完整研究流程。'
},
{
id: 'code-run',
icon: '💻',
name: '代码执行',
query: '生成一个1-100的随机数列表,找出其中的质数,并计算平均值',
llmResponse: '我可以写出代码,但无法实际执行。质数是指只能被1和自身整除的数,比如2,3,5,7...',
thinking: '用户需要:1) 生成随机数列表 2) 筛选质数 3) 计算平均值。我需要在沙箱中执行Python代码',
tools: [
{ name: 'python_executor', params: '{"code": "import random; nums = [random.randint(1,100) for _ in range(20)]; primes = [n for n in nums if all(n%i!=0 for i in range(2,int(n**0.5)+1)) and n>1]; print(f\"随机数: {nums}\"); print(f\"质数: {primes}\"); print(f\"平均值: {sum(nums)/len(nums):.2f}\")"}', result: '随机数: [42, 17, 89, 23, 56, 71, 34, 91, 13, 67...]\n质数: [17, 89, 23, 71, 13, 67, 47, 79]\n平均值: 52.35' }
],
agentResponse: '执行结果:\n🎲 生成20个随机数:42, 17, 89, 23, 56, 71...\n🔢 其中质数:17, 89, 23, 71, 13, 67, 47, 79 (共8个)\n📊 所有数平均值:52.35',
insight: 'Agent 在沙箱中实际执行代码,LLM 只能推理无法验证结果。'
},
{
id: 'data-analysis',
icon: '📉',
name: '数据分析',
query: '分析一下我们公司上季度的销售数据,找出增长最快的产品线和地区',
llmResponse: '我无法访问您公司的内部销售数据。建议您使用Excel或专业BI工具进行分析,关注同比和环比增长率。',
thinking: '用户需要分析公司销售数据。我需要:1) 连接数据库获取销售数据 2) 按产品线分组统计 3) 按地区分组统计 4) 计算增长率 5) 生成可视化图表 6) 输出分析报告',
tools: [
{ name: 'database_query', params: '{"table": "sales", "period": "Q4_2023", "fields": ["product", "region", "amount", "quantity"]}', result: '获取Q4销售记录: 15,230条, 涵盖12个产品线, 8个地区' },
{ name: 'data_groupby', params: '{"data": "sales_q4", "group": "product", "agg": ["sum", "count", "avg"]}', result: '产品线统计: 电子产品¥2.1M(+45%), 服装¥1.8M(+12%), 家居¥1.2M(+28%)...' },
{ name: 'data_groupby', params: '{"data": "sales_q4", "group": "region", "agg": ["sum", "growth"]}', result: '地区统计: 华东¥3.2M(+38%), 华南¥2.1M(+52%), 华北¥1.5M(+15%)...' },
{ name: 'growth_calculator', params: '{"current": "Q4_2023", "previous": "Q3_2023", "dimensions": ["product", "region"]}', result: '增长最快产品线: 电子产品+45%; 增长最快地区: 华南+52%' },
{ name: 'chart_generator', params: '{"type": "bar", "data": "product_growth", "title": "产品线增长率对比"}', result: '生成柱状图: 电子产品45%, 家居28%, 服装12%' },
{ name: 'chart_generator', params: '{"type": "map", "data": "region_sales", "title": "地区销售热力图"}', result: '生成中国地图热力图: 华东、华南为深色高值区' }
],
agentResponse: '📊 Q4销售数据分析报告\n\n🏆 增长最快产品线\n1. 电子产品: +45% (¥2.1M)\n2. 家居用品: +28% (¥1.2M)\n3. 服装: +12% (¥1.8M)\n\n🌍 增长最快地区\n1. 华南: +52% (¥2.1M)\n2. 华东: +38% (¥3.2M)\n3. 西南: +22% (¥0.8M)\n\n💡 建议: 加大电子产品投入,重点拓展华南市场',
insight: 'Agent 调用6个工具完成数据查询、分组统计、增长率计算、可视化的完整分析流程。'
}
]
const currentScenario = ref('weather')
const isExecuting = ref(false)
const executionComplete = ref(false)
const toolExecuting = ref(-1)
const toolExecuted = ref(0)
const showThinking = ref(false)
const showTools = ref(false)
const showResponse = ref(false)
const thinkingExpanded = ref(true)
const toolsExpanded = ref(true)
const toolsSection = ref(null)
const toolRefs = ref([])
const currentScenarioData = computed(() => scenarios.find(s => s.id === currentScenario.value))
const setToolRef = (el, idx) => {
if (el) {
toolRefs.value[idx] = el
}
}
const selectScenario = (id) => {
currentScenario.value = id
reset()
}
const startExecution = async () => {
isExecuting.value = true
executionComplete.value = false
toolExecuting.value = -1
toolExecuted.value = 0
showThinking.value = true
showTools.value = false
showResponse.value = false
thinkingExpanded.value = true
toolsExpanded.value = true
// 显示思考
await wait(800)
// 显示工具调用
showTools.value = true
toolsExpanded.value = true
await nextTick()
const tools = currentScenarioData.value.tools
for (let i = 0; i < tools.length; i++) {
toolExecuting.value = i
// 滚动到当前执行的工具
await nextTick()
const toolEl = toolRefs.value[i]
if (toolEl && toolsSection.value) {
toolEl.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
await wait(1000)
toolExecuted.value = i + 1
toolExecuting.value = -1
await wait(300)
}
// 显示最终回复
await wait(500)
showResponse.value = true
isExecuting.value = false
executionComplete.value = true
}
const reset = () => {
isExecuting.value = false
executionComplete.value = false
toolExecuting.value = -1
toolExecuted.value = 0
showThinking.value = false
showTools.value = false
showResponse.value = false
}
const toggleThinking = () => {
thinkingExpanded.value = !thinkingExpanded.value
}
const toggleTools = () => {
toolsExpanded.value = !toolsExpanded.value
}
const wait = (ms) => new Promise(r => setTimeout(r, ms))
</script>
<style scoped>
.agent-chat-demo {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 20px;
margin: 20px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
text-align: center;
margin-bottom: 16px;
}
.title {
font-size: 17px;
font-weight: 700;
background: linear-gradient(120deg, var(--vp-c-brand), #9c27b0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.subtitle {
font-size: 12px;
color: var(--vp-c-text-2);
margin-top: 4px;
}
/* 场景标签 */
.scenario-tabs {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.tab-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid var(--vp-c-divider);
border-radius: 20px;
background: var(--vp-c-bg);
cursor: pointer;
transition: all 0.2s;
font-size: 13px;
}
.tab-btn:hover {
background: var(--vp-c-bg-alt);
}
.tab-btn.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand-dark);
}
/* 聊天窗口 */
.chat-window {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
/* 消息 */
.message {
display: flex;
gap: 10px;
align-items: flex-start;
}
.message.user {
flex-direction: row-reverse;
}
.avatar {
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
flex-shrink: 0;
}
.avatar.agent-avatar {
background: var(--vp-c-brand-soft);
border-color: var(--vp-c-brand);
}
.bubble {
max-width: 75%;
padding: 12px 14px;
border-radius: 14px;
font-size: 13px;
line-height: 1.5;
}
.message.user .bubble {
background: var(--vp-c-brand);
color: white;
border-bottom-right-radius: 4px;
}
.message.llm .bubble {
background: #f3f4f6;
border: 1px solid #e5e7eb;
border-bottom-left-radius: 4px;
}
.message.agent .bubble {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-bottom-left-radius: 4px;
max-width: 85%;
}
.llm-label, .agent-label {
font-size: 11px;
font-weight: 600;
margin-bottom: 6px;
color: var(--vp-c-text-2);
}
.agent-label {
color: var(--vp-c-brand);
}
.llm-content {
color: #6b7280;
}
/* 思考过程 */
.thinking-section {
margin-bottom: 10px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.thinking-header, .tools-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: var(--vp-c-bg);
cursor: pointer;
font-size: 12px;
font-weight: 500;
transition: background 0.2s;
}
.thinking-header:hover, .tools-header:hover {
background: var(--vp-c-bg-alt);
}
.toggle-icon {
font-size: 10px;
color: var(--vp-c-text-2);
}
.thinking-content {
padding: 10px 12px;
background: #fef3c7;
font-size: 12px;
color: #92400e;
}
.thought-item {
line-height: 1.6;
}
/* 工具调用 */
.tools-section {
margin-bottom: 10px;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.tools-list {
padding: 10px;
background: var(--vp-c-bg);
}
.tool-item {
display: flex;
gap: 10px;
padding: 10px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
margin-bottom: 8px;
border: 1px solid var(--vp-c-divider);
transition: all 0.3s;
}
.tool-item:last-child {
margin-bottom: 0;
}
.tool-item.completed {
border-color: #86efac;
background: #f0fdf4;
}
.tool-item.executing {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
}
.tool-status {
font-size: 14px;
flex-shrink: 0;
}
.spinner {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.tool-info {
flex: 1;
min-width: 0;
}
.tool-name {
font-weight: 600;
font-size: 12px;
margin-bottom: 6px;
}
.tool-params {
display: block;
background: #1e1e1e;
color: #d4d4d4;
padding: 6px 8px;
border-radius: 4px;
font-size: 10px;
font-family: monospace;
overflow-x: auto;
white-space: nowrap;
margin-bottom: 6px;
}
.tool-result {
font-size: 11px;
color: #16a34a;
padding: 6px 8px;
background: #dcfce7;
border-radius: 4px;
white-space: pre-wrap;
}
/* 最终回复 */
.final-response {
margin-top: 10px;
padding: 12px;
background: #dcfce7;
border: 1px solid #86efac;
border-radius: 8px;
}
.response-header {
font-size: 11px;
font-weight: 600;
color: #166534;
margin-bottom: 6px;
}
.response-content {
font-size: 13px;
color: #166534;
line-height: 1.6;
white-space: pre-wrap;
}
/* 执行按钮 */
.execute-btn {
margin-top: 12px;
width: 100%;
padding: 10px;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 8px;
font-size: 13px;
cursor: pointer;
transition: background 0.2s;
}
.execute-btn:hover {
background: var(--vp-c-brand-dark);
}
.execute-btn.reset {
background: #6b7280;
}
.execute-btn.reset:hover {
background: #4b5563;
}
/* 核心区别 */
.insight-bar {
margin-top: 16px;
padding: 12px 16px;
background: var(--vp-c-brand-soft);
border-radius: 8px;
font-size: 13px;
}
.insight-label {
font-weight: 600;
color: var(--vp-c-brand-dark);
}
.insight-text {
color: var(--vp-c-text-1);
}
</style>
@@ -0,0 +1,520 @@
<template>
<div class="tool-use-demo">
<div class="header">
<div class="title">🔧 揭秘Agent 如何调用工具</div>
</div>
<!-- 场景选择 -->
<div class="scenario-tabs">
<button
v-for="s in scenarios"
:key="s.id"
:class="['tab-btn', { active: currentScenario === s.id }]"
@click="selectScenario(s.id)"
>
<span>{{ s.icon }}</span>
<span>{{ s.name }}</span>
</button>
</div>
<!-- 用户输入 -->
<div class="user-input-bar">
<span class="label">👤</span>
<span class="text">"{{ currentData.userInput }}"</span>
</div>
<!-- 横向流程 -->
<div ref="flowRowRef" class="flow-row">
<!-- 步骤1: 理解 -->
<div class="flow-card" :class="{ active: currentStep >= 1 }">
<div class="card-num">1</div>
<div class="card-body">
<div class="card-title">分析需求</div>
<div v-if="currentStep >= 1" class="card-content">
<div class="intent-box">
<div class="intent-label">用户想要</div>
<div class="intent-value">{{ currentData.intent.type }}</div>
</div>
<div class="extract-box">
<div class="extract-label">提取信息</div>
<div class="extract-tags">
<span v-for="(e, i) in currentData.intent.entities" :key="i" class="entity">{{ e }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="flow-arrow" :class="{ active: currentStep >= 2 }"></div>
<!-- 步骤2: 选工具 -->
<div class="flow-card" :class="{ active: currentStep >= 2 }">
<div class="card-num">2</div>
<div class="card-body">
<div class="card-title">选择工具</div>
<div v-if="currentStep >= 2" class="card-content">
<div class="tool-list">
<div
v-for="tool in currentData.availableTools.slice(0, 2)"
:key="tool.name"
class="tool-mini"
:class="{ selected: tool.selected }"
>
<span>{{ tool.icon }}</span>
<span class="tool-name">{{ tool.name }}</span>
<span v-if="tool.selected" class="check"></span>
</div>
</div>
</div>
</div>
</div>
<div class="flow-arrow" :class="{ active: currentStep >= 3 }"></div>
<!-- 步骤3: 构造参数 -->
<div class="flow-card" :class="{ active: currentStep >= 3 }">
<div class="card-num">3</div>
<div class="card-body">
<div class="card-title">构造参数</div>
<div v-if="currentStep >= 3" class="card-content">
<code class="params-code">{{ JSON.stringify(currentData.finalParams.params) }}</code>
</div>
</div>
</div>
<div class="flow-arrow" :class="{ active: currentStep >= 4 }"></div>
<!-- 步骤4: 执行 -->
<div class="flow-card" :class="{ active: currentStep >= 4 }">
<div class="card-num">4</div>
<div class="card-body">
<div class="card-title">执行返回</div>
<div v-if="currentStep >= 4" class="card-content">
<div class="exec-flow">
<span class="from">Agent</span>
<span class="arrow"></span>
<span class="to">{{ currentData.selectedTool }}</span>
<span class="arrow"></span>
<span class="from">结果</span>
</div>
</div>
</div>
</div>
</div>
<!-- 最终结果 -->
<div v-if="currentStep >= 4" class="final-result">
<span class="result-label">💬 回复</span>
<span class="result-text">{{ currentData.finalResponse }}</span>
</div>
<!-- 控制栏 -->
<div class="control-bar">
<button v-if="currentStep === 0" class="ctrl-btn primary" @click="nextStep">
开始演示
</button>
<button v-else-if="currentStep < 4" class="ctrl-btn primary" @click="nextStep">
下一步
</button>
<button v-else class="ctrl-btn" @click="reset">
🔄 重置
</button>
<div class="step-dots">
<span v-for="n in 4" :key="n" :class="['dot', { active: currentStep >= n }]"></span>
</div>
</div>
<!-- 提示 -->
<div class="tip-bar">
<span>💡</span>
<span>Tool Calling 本质LLM 生成结构化文本JSON外部系统执行后返回结果</span>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick } from 'vue'
const scenarios = [
{
id: 'weather',
icon: '🌤️',
name: '查天气',
userInput: '明天上海需要带伞吗?',
intent: { type: '天气查询', entities: ['明天', '上海'], confidence: 95 },
availableTools: [
{ name: 'weather_api', icon: '🌤️', description: '获取天气', selected: true, score: 95 },
{ name: 'calculator', icon: '🧮', description: '数学计算', selected: false, score: 10 },
],
selectedTool: 'weather_api',
finalParams: { tool: 'weather_api', params: { city: '上海', date: 'tomorrow' } },
finalResponse: '明天上海有小雨,建议带伞。气温 8-15°C。'
},
{
id: 'calculate',
icon: '🧮',
name: '计算',
userInput: '1250 除以 25 乘以 8 等于多少',
intent: { type: '数学计算', entities: ['1250', '25', '8'], confidence: 98 },
availableTools: [
{ name: 'weather_api', icon: '🌤️', description: '获取天气', selected: false, score: 5 },
{ name: 'calculator', icon: '🧮', description: '数学计算', selected: true, score: 98 },
],
selectedTool: 'calculator',
finalParams: { tool: 'calculator', params: { expression: '(1250/25)*8' } },
finalResponse: '计算结果:400。'
},
{
id: 'search',
icon: '🔍',
name: '搜索',
userInput: '搜索最近关于人工智能的新闻',
intent: { type: '信息检索', entities: ['AI', '新闻'], confidence: 92 },
availableTools: [
{ name: 'web_search', icon: '🔍', description: '网络搜索', selected: true, score: 92 },
{ name: 'calculator', icon: '🧮', description: '数学计算', selected: false, score: 5 },
],
selectedTool: 'web_search',
finalParams: { tool: 'web_search', params: { query: 'AI news', max: 5 } },
finalResponse: '为您找到 5 条最新 AI 新闻...'
}
]
const currentScenario = ref('weather')
const currentStep = ref(0)
const currentData = computed(() => scenarios.find(s => s.id === currentScenario.value))
const selectScenario = (id) => {
currentScenario.value = id
reset()
}
const flowRowRef = ref(null)
const nextStep = () => {
if (currentStep.value < 4) {
currentStep.value++
// 自动滚动到当前步骤
nextTick(() => {
if (flowRowRef.value) {
const cards = flowRowRef.value.querySelectorAll('.flow-card')
const currentCard = cards[currentStep.value - 1]
if (currentCard) {
currentCard.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' })
}
}
})
}
}
const reset = () => { currentStep.value = 0 }
</script>
<style scoped>
.tool-use-demo {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 12px;
padding: 20px;
margin: 20px 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
.header {
text-align: center;
margin-bottom: 16px;
}
.title {
font-size: 17px;
font-weight: 700;
background: linear-gradient(120deg, var(--vp-c-brand), #9c27b0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
/* 场景标签 */
.scenario-tabs {
display: flex;
gap: 8px;
justify-content: center;
margin-bottom: 16px;
flex-wrap: wrap;
}
.tab-btn {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border: 1px solid var(--vp-c-divider);
border-radius: 16px;
background: var(--vp-c-bg);
cursor: pointer;
font-size: 13px;
transition: all 0.2s;
}
.tab-btn.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand-dark);
}
/* 用户输入 */
.user-input-bar {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 10px 14px;
margin-bottom: 16px;
font-size: 14px;
}
.user-input-bar .label { margin-right: 8px; }
.user-input-bar .text { font-weight: 600; color: var(--vp-c-text-1); }
/* 横向流程 */
.flow-row {
display: flex;
align-items: stretch;
gap: 8px;
margin-bottom: 16px;
overflow-x: auto;
}
@media (max-width: 768px) {
.flow-row {
flex-direction: column;
}
.flow-arrow {
transform: rotate(90deg);
}
}
.flow-card {
flex: 1;
min-width: 140px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 10px;
padding: 12px;
opacity: 0.4;
transition: all 0.3s;
display: flex;
flex-direction: column;
}
.flow-card.active {
opacity: 1;
border-color: var(--vp-c-brand);
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
}
.card-num {
width: 22px;
height: 22px;
border-radius: 50%;
background: var(--vp-c-bg-mute);
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
margin-bottom: 8px;
}
.flow-card.active .card-num {
background: var(--vp-c-brand);
color: white;
}
.card-title {
font-size: 12px;
font-weight: 600;
margin-bottom: 8px;
color: var(--vp-c-text-1);
}
.card-content {
font-size: 12px;
}
/* 意图内容 */
.intent-box {
margin-bottom: 8px;
}
.intent-label {
font-size: 10px;
color: var(--vp-c-text-2);
margin-bottom: 4px;
}
.intent-value {
display: inline-block;
padding: 4px 10px;
background: var(--vp-c-brand-soft);
color: var(--vp-c-brand-dark);
border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
.extract-box {
margin-top: 8px;
}
.extract-label {
font-size: 10px;
color: var(--vp-c-text-2);
margin-bottom: 4px;
}
.extract-tags {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.entity {
padding: 3px 8px;
background: #fef3c7;
border: 1px solid #fde68a;
border-radius: 4px;
font-size: 11px;
color: #92400e;
font-weight: 500;
}
/* 工具列表 */
.tool-list {
display: flex;
flex-direction: column;
gap: 6px;
}
.tool-mini {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 8px;
background: var(--vp-c-bg-soft);
border-radius: 6px;
font-size: 11px;
}
.tool-mini.selected {
background: #dcfce7;
border: 1px solid #86efac;
}
.tool-name { flex: 1; }
.check { color: #16a34a; font-weight: 700; }
/* 参数代码 */
.params-code {
display: block;
background: #1e1e1e;
color: #d4d4d4;
padding: 8px;
border-radius: 6px;
font-size: 10px;
overflow-x: auto;
white-space: nowrap;
}
/* 执行流程 */
.exec-flow {
display: flex;
align-items: center;
gap: 4px;
font-size: 11px;
flex-wrap: wrap;
}
.from, .to {
padding: 3px 8px;
border-radius: 4px;
font-weight: 600;
}
.from { background: var(--vp-c-brand-soft); color: var(--vp-c-brand-dark); }
.to { background: #fef3c7; color: #92400e; }
.arrow { color: var(--vp-c-text-3); }
/* 箭头 */
.flow-arrow {
display: flex;
align-items: center;
justify-content: center;
color: var(--vp-c-divider);
font-size: 18px;
transition: all 0.3s;
}
.flow-arrow.active { color: var(--vp-c-brand); }
/* 最终结果 */
.final-result {
background: var(--vp-c-brand-soft);
border-left: 3px solid var(--vp-c-brand);
border-radius: 8px;
padding: 12px 14px;
margin-bottom: 16px;
font-size: 13px;
}
.result-label { font-weight: 600; margin-right: 8px; }
.result-text { color: var(--vp-c-text-1); }
/* 控制栏 */
.control-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.ctrl-btn {
padding: 8px 18px;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.ctrl-btn.primary {
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.step-dots {
display: flex;
gap: 6px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--vp-c-divider);
}
.dot.active { background: var(--vp-c-brand); }
/* 提示 */
.tip-bar {
display: flex;
gap: 8px;
padding: 10px 14px;
background: var(--vp-c-brand-soft);
border-radius: 8px;
font-size: 12px;
color: var(--vp-c-text-1);
}
</style>
@@ -0,0 +1,438 @@
<!--
AudioQuickStartDemo.vue
AI 音频快速体验组件
用途
让用户在文章开头就能体验 AI 音频的魅力通过交互式演示理解文本转语音的基本概念
交互功能
- 文本输入和语音合成
- 声音选择不同音色
- 语速和音调调节
- 实时波形可视化
-->
<template>
<div class="audio-quickstart">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><Microphone /></el-icon>
<span>🎙 AI 语音合成体验室</span>
</div>
</template>
<div class="demo-layout">
<!-- 左侧控制面板 -->
<div class="control-panel">
<div class="input-section">
<label>输入文本</label>
<el-input
v-model="inputText"
type="textarea"
:rows="4"
placeholder="输入你想让 AI 朗读的文本..."
/>
</div>
<div class="voice-section">
<label>选择声音</label>
<div class="voice-options">
<div
v-for="voice in voices"
:key="voice.id"
class="voice-card"
:class="{ active: selectedVoice === voice.id }"
@click="selectedVoice = voice.id"
>
<div class="voice-icon">{{ voice.icon }}</div>
<div class="voice-name">{{ voice.name }}</div>
<div class="voice-desc">{{ voice.description }}</div>
</div>
</div>
</div>
<div class="params-section">
<div class="param-row">
<label>语速</label>
<el-slider v-model="speed" :min="0.5" :max="2" :step="0.1" />
</div>
<div class="param-row">
<label>音调</label>
<el-slider v-model="pitch" :min="-10" :max="10" :step="1" />
</div>
</div>
<el-button
type="primary"
:loading="isSynthesizing"
@click="synthesize"
class="synthesize-btn"
>
<el-icon><VideoPlay /></el-icon>
{{ isSynthesizing ? '合成中...' : '开始合成' }}
</el-button>
</div>
<!-- 右侧可视化展示 -->
<div class="display-panel">
<div class="waveform-container">
<canvas
ref="waveformCanvas"
width="400"
height="200"
class="waveform-canvas"
/>
<div v-if="!hasAudio" class="placeholder">
<el-icon :size="48"><Microphone /></el-icon>
<p>点击"开始合成"生成语音</p>
</div>
</div>
<div class="audio-controls" v-if="hasAudio">
<el-button circle @click="togglePlay">
<el-icon v-if="isPlaying"><VideoPause /></el-icon>
<el-icon v-else><VideoPlay /></el-icon>
</el-button>
<el-slider v-model="playbackProgress" :max="100" class="progress-slider" />
<span class="time-display">{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</span>
</div>
<div class="audio-info" v-if="hasAudio">
<el-descriptions :column="2" size="small" border>
<el-descriptions-item label="采样率">22.05 kHz</el-descriptions-item>
<el-descriptions-item label="声道">单声道</el-descriptions-item>
<el-descriptions-item label="语速">{{ speed }}x</el-descriptions-item>
<el-descriptions-item label="音调">{{ pitch > 0 ? '+' : '' }}{{ pitch }}</el-descriptions-item>
</el-descriptions>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>小提示</strong>
现代 TTS 模型可以生成非常自然的语音尝试不同的声音和参数找到最适合你场景的音色
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { Microphone, VideoPlay, VideoPause } from '@element-plus/icons-vue'
const inputText = ref('你好,我是 AI 语音助手。我可以将文字转换成自然流畅的语音。')
const selectedVoice = ref('female1')
const speed = ref(1.0)
const pitch = ref(0)
const isSynthesizing = ref(false)
const hasAudio = ref(false)
const isPlaying = ref(false)
const playbackProgress = ref(0)
const currentTime = ref(0)
const duration = ref(0)
const waveformCanvas = ref(null)
let animationId = null
const voices = [
{ id: 'female1', name: '女声 1', icon: '👩', description: '温柔甜美' },
{ id: 'female2', name: '女声 2', icon: '👧', description: '活泼可爱' },
{ id: 'male1', name: '男声 1', icon: '👨', description: '沉稳磁性' },
{ id: 'male2', name: '男声 2', icon: '👦', description: '年轻活力' }
]
// 模拟波形动画
const drawWaveform = (isActive = false) => {
const canvas = waveformCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
ctx.clearRect(0, 0, width, height)
// 绘制背景网格
ctx.strokeStyle = '#e0e0e0'
ctx.lineWidth = 1
for (let i = 0; i < width; i += 40) {
ctx.beginPath()
ctx.moveTo(i, 0)
ctx.lineTo(i, height)
ctx.stroke()
}
for (let i = 0; i < height; i += 40) {
ctx.beginPath()
ctx.moveTo(0, i)
ctx.lineTo(width, i)
ctx.stroke()
}
// 绘制波形
const centerY = height / 2
ctx.strokeStyle = isActive ? '#409eff' : '#909399'
ctx.lineWidth = 2
ctx.beginPath()
for (let x = 0; x < width; x++) {
let amplitude = 0
if (isActive) {
// 模拟语音波形
const t = Date.now() / 1000
amplitude = Math.sin(x * 0.05 + t * 5) * 30 +
Math.sin(x * 0.1 + t * 3) * 20 +
Math.random() * 10
} else {
// 静态低振幅波形
amplitude = Math.sin(x * 0.02) * 5
}
if (x === 0) {
ctx.moveTo(x, centerY + amplitude)
} else {
ctx.lineTo(x, centerY + amplitude)
}
}
ctx.stroke()
// 填充波形下方
if (isActive) {
ctx.lineTo(width, centerY)
ctx.lineTo(0, centerY)
ctx.closePath()
ctx.fillStyle = 'rgba(64, 158, 255, 0.1)'
ctx.fill()
}
}
const synthesize = async () => {
isSynthesizing.value = true
// 模拟合成过程
await new Promise(resolve => setTimeout(resolve, 1500))
hasAudio.value = true
isSynthesizing.value = false
duration.value = inputText.value.length * 0.15 / speed.value
// 开始波形动画
startWaveformAnimation()
}
const startWaveformAnimation = () => {
const animate = () => {
if (!isPlaying.value) {
drawWaveform(false)
return
}
drawWaveform(true)
animationId = requestAnimationFrame(animate)
}
animate()
}
const togglePlay = () => {
isPlaying.value = !isPlaying.value
if (isPlaying.value) {
startWaveformAnimation()
simulatePlayback()
} else {
cancelAnimationFrame(animationId)
drawWaveform(false)
}
}
const simulatePlayback = () => {
if (!isPlaying.value) return
const interval = setInterval(() => {
if (!isPlaying.value || currentTime.value >= duration.value) {
clearInterval(interval)
isPlaying.value = false
currentTime.value = 0
playbackProgress.value = 0
return
}
currentTime.value += 0.1
playbackProgress.value = (currentTime.value / duration.value) * 100
}, 100)
}
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
onMounted(() => {
drawWaveform(false)
})
onUnmounted(() => {
cancelAnimationFrame(animationId)
})
</script>
<style scoped>
.audio-quickstart {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.demo-layout {
grid-template-columns: 1fr;
}
}
.control-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.input-section label,
.voice-section label,
.params-section label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 8px;
color: var(--vp-c-text-2);
}
.voice-options {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.voice-card {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 12px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.voice-card:hover {
border-color: var(--vp-c-brand);
}
.voice-card.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.voice-icon {
font-size: 1.5rem;
margin-bottom: 4px;
}
.voice-name {
font-weight: 500;
font-size: 0.875rem;
}
.voice-desc {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.param-row {
margin-bottom: 12px;
}
.param-row label {
font-size: 0.8rem;
margin-bottom: 4px;
}
.synthesize-btn {
width: 100%;
margin-top: auto;
}
.display-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.waveform-container {
position: relative;
width: 100%;
aspect-ratio: 2;
background: var(--vp-c-bg-mute);
border-radius: 8px;
overflow: hidden;
}
.waveform-canvas {
width: 100%;
height: 100%;
}
.placeholder {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--vp-c-text-3);
gap: 8px;
}
.audio-controls {
display: flex;
align-items: center;
gap: 12px;
}
.progress-slider {
flex: 1;
}
.time-display {
font-size: 0.875rem;
color: var(--vp-c-text-2);
min-width: 100px;
text-align: right;
}
.audio-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;
}
</style>
File diff suppressed because it is too large Load Diff
@@ -1,172 +1,352 @@
<template>
<div class="context-compression-demo">
<div class="input-section">
<div class="label">Original Text (Long)</div>
<textarea v-model="originalText" rows="6"></textarea>
<div class="stats">Length: {{ originalText.length }} chars</div>
</div>
<div class="actions">
<button
@click="compress('summary')"
:class="{ active: mode === 'summary' }"
>
📝 Summarize
</button>
<button
@click="compress('extract')"
:class="{ active: mode === 'extract' }"
>
🔑 Extract Key Points
</button>
<button @click="compress('json')" :class="{ active: mode === 'json' }">
JSON Structure
</button>
</div>
<div class="output-section">
<div class="label">Compressed Context</div>
<div class="result-box">
<div v-if="compressedText" class="result-content">
{{ compressedText }}
</div>
<div v-else class="placeholder">Select a compression strategy...</div>
</div>
<div class="stats" v-if="compressedText">
Length: {{ compressedText.length }} chars
<span class="ratio">(Ratio: {{ compressionRatio }}%)</span>
</div>
</div>
</div>
</template>
<!--
* Component: ContextCompressionDemo.vue
* Description: Demonstrates various context compression techniques to save tokens.
* Features:
* - Strategies: Summarization, Extraction, Structured Format (JSON)
* - Real-time comparison of original vs compressed text
* - Metrics display (Token count, Compression Ratio)
-->
<script setup>
import { ref, computed } from 'vue'
const originalText = ref(
`Context engineering involves optimizing the prompt given to a large language model (LLM) to ensure it has the necessary information to generate accurate and relevant responses. One of the main challenges is the limited context window of LLMs, which restricts the amount of text they can process at once. To overcome this, developers use techniques like summarization, where long documents are condensed into shorter versions retaining key information. Another technique is retrieval-augmented generation (RAG), which fetches only the most relevant pieces of information from a database based on the user's query.`
`上下文工程(Context Engineering)是指优化提供给大语言模型(LLM)的提示词,以确保其拥有生成准确且相关回复所需的信息。其中的一个主要挑战是 LLM 的上下文窗口有限,这限制了它们一次能处理的文本量。为了克服这个问题,开发者使用了诸如摘要生成(Summarization)等技术,将长文档浓缩为保留关键信息的短版本。另一种技术是检索增强生成(RAG),它根据用户的查询从数据库中仅获取最相关的片段。此外,通过将非结构化文本转换为 JSON 等结构化数据,也可以减少冗余字符,提高信息密度。`
)
const strategies = [
{ id: 'summary', label: '📝 摘要生成', desc: '保留大意,缩减篇幅' },
{ id: 'extract', label: '🔑 关键词提取', desc: '提取核心要点' },
{ id: 'json', label: '⚙️ 结构化数据', desc: '转换为 JSON 格式' }
]
const currentMode = ref('')
const compressedText = ref('')
const mode = ref('')
const isCompressing = ref(false)
const originalTokens = computed(() => Math.ceil(originalText.value.length * 0.7)) // Approximation
const compressedTokens = computed(() => Math.ceil(compressedText.value.length * 0.7))
const compressionRatio = computed(() => {
if (!originalText.value.length) return 0
return Math.round(
(compressedText.value.length / originalText.value.length) * 100
)
if (!originalText.value.length || !compressedText.value.length) return 0
return Math.round((1 - compressedText.value.length / originalText.value.length) * 100)
})
const compress = (strategy) => {
mode.value = strategy
const compress = async (mode) => {
if (isCompressing.value) return
currentMode.value = mode
isCompressing.value = true
compressedText.value = ''
if (strategy === 'summary') {
compressedText.value =
'Context engineering optimizes LLM prompts to handle limited context windows. Key techniques include summarization (condensing text) and RAG (retrieving relevant info dynamically).'
} else if (strategy === 'extract') {
compressedText.value =
'- Goal: Optimize prompts for LLMs\n- Challenge: Limited context window\n- Solution 1: Summarization\n- Solution 2: RAG (Retrieval-Augmented Generation)'
} else if (strategy === 'json') {
compressedText.value = JSON.stringify(
{
topic: 'Context Engineering',
problem: 'Limited Context Window',
solutions: ['Summarization', 'RAG']
},
null,
2
)
// Simulate API delay
await new Promise(r => setTimeout(r, 600))
if (mode === 'summary') {
compressedText.value = '上下文工程旨在优化 LLM 提示词以解决上下文窗口限制。主要技术包括摘要生成(浓缩关键信息)、RAG(按需检索相关片段)以及结构化数据转换(提高信息密度)。'
} else if (mode === 'extract') {
compressedText.value = '- 目标: 优化 LLM 提示词\n- 挑战: 上下文窗口有限\n- 方案1: 摘要生成 (Summarization)\n- 方案2: 检索增强生成 (RAG)\n- 方案3: 结构化数据 (JSON)'
} else if (mode === 'json') {
compressedText.value = JSON.stringify({
topic: "Context Engineering",
problem: "Limited Context Window",
solutions: ["Summarization", "RAG", "Structured Data"]
}, null, 2)
}
isCompressing.value = false
}
</script>
<template>
<div class="context-compression-demo">
<!-- Control Panel -->
<div class="control-panel">
<div class="strategy-group">
<button
v-for="s in strategies"
:key="s.id"
class="strategy-btn"
:class="{ active: currentMode === s.id }"
@click="compress(s.id)"
:title="s.desc"
>
<div class="btn-label">{{ s.label }}</div>
<div class="btn-desc">{{ s.desc }}</div>
</button>
</div>
</div>
<!-- Main Comparison Area -->
<div class="comparison-area">
<!-- Original -->
<div class="text-column original">
<div class="column-header">
<span class="badge">原始文本</span>
<span class="meta">{{ originalTokens }} Tokens</span>
</div>
<textarea
v-model="originalText"
class="text-content"
placeholder="在此输入长文本..."
></textarea>
</div>
<!-- Arrow -->
<div class="process-arrow">
<div class="arrow-icon" :class="{ compressing: isCompressing }">
{{ isCompressing ? '⚙️' : '➡️' }}
</div>
<div class="ratio-badge" v-if="compressedText && !isCompressing">
-{{ compressionRatio }}%
</div>
</div>
<!-- Compressed -->
<div class="text-column compressed">
<div class="column-header">
<span class="badge success">压缩后</span>
<span class="meta" v-if="compressedText">{{ compressedTokens }} Tokens</span>
</div>
<div class="text-content result-box" :class="{ empty: !compressedText }">
<div v-if="isCompressing" class="loading-state">
<span class="spinner"></span> 压缩中...
</div>
<pre v-else-if="compressedText">{{ compressedText }}</pre>
<div v-else class="placeholder">
请选择一种压缩策略
<br>
<small>点击上方按钮开始</small>
</div>
</div>
</div>
</div>
<!-- Metrics Bar -->
<div class="metrics-bar" v-if="compressedText && !isCompressing">
<div class="progress-bg">
<div class="progress-fill" :style="{ width: (100 - compressionRatio) + '%' }"></div>
<div class="progress-label">占用空间: {{ 100 - compressionRatio }}%</div>
</div>
<div class="saved-label">节省了 {{ compressionRatio }}% Token</div>
</div>
</div>
</template>
<style scoped>
.context-compression-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
padding: 1.5rem;
overflow: hidden;
margin: 1rem 0;
font-family: var(--vp-font-family-mono);
}
.label {
font-weight: bold;
margin-bottom: 0.5rem;
font-size: 0.9rem;
color: var(--vp-c-text-1);
}
textarea {
width: 100%;
padding: 0.8rem;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
background: var(--vp-c-bg);
font-family: inherit;
font-size: 0.9rem;
resize: vertical;
}
.stats {
font-size: 0.8rem;
color: var(--vp-c-text-3);
margin-top: 0.3rem;
text-align: right;
}
.actions {
display: flex;
gap: 0.5rem;
margin: 1.5rem 0;
flex-direction: column;
}
.control-panel {
padding: 1rem;
background-color: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
}
.strategy-group {
display: flex;
gap: 1rem;
justify-content: center;
flex-wrap: wrap;
}
button {
padding: 0.5rem 1rem;
background: var(--vp-c-bg);
.strategy-btn {
flex: 1;
min-width: 140px;
padding: 0.8rem;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
border-radius: 8px;
background-color: var(--vp-c-bg-alt);
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.strategy-btn:hover {
border-color: var(--vp-c-brand);
background-color: var(--vp-c-bg-soft);
}
.strategy-btn.active {
border-color: var(--vp-c-brand);
background-color: var(--vp-c-brand-dimm);
color: var(--vp-c-brand-dark);
}
.btn-label {
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.2rem;
color: var(--vp-c-text-1);
}
.btn-desc {
font-size: 0.75rem;
color: var(--vp-c-text-2);
}
/* Comparison Area */
.comparison-area {
display: flex;
padding: 1.5rem;
gap: 1rem;
align-items: stretch;
min-height: 250px;
}
.text-column {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.column-header {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.85rem;
}
.badge {
background-color: var(--vp-c-bg-mute);
padding: 0.2rem 0.5rem;
border-radius: 4px;
color: var(--vp-c-text-2);
font-weight: 600;
}
.badge.success {
background-color: var(--vp-c-green-dimm);
color: var(--vp-c-green-dark);
}
.meta {
color: var(--vp-c-text-2);
font-family: var(--vp-font-mono);
}
.text-content {
flex: 1;
width: 100%;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background-color: var(--vp-c-bg);
font-size: 0.9rem;
line-height: 1.6;
resize: none;
color: var(--vp-c-text-1);
font-family: var(--vp-font-mono);
}
button:hover {
background: var(--vp-c-bg-alt);
border-color: var(--vp-c-brand);
}
button.active {
background: var(--vp-c-brand);
color: white;
.text-content:focus {
border-color: var(--vp-c-brand);
outline: none;
}
.result-box {
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 1rem;
min-height: 100px;
background-color: var(--vp-c-bg-alt);
overflow-y: auto;
position: relative;
}
.result-content {
white-space: pre-wrap;
font-size: 0.9rem;
color: var(--vp-c-text-1);
.result-box.empty {
display: flex;
align-items: center;
justify-content: center;
}
.placeholder {
color: var(--vp-c-text-3);
font-style: italic;
text-align: center;
margin-top: 1rem;
}
.ratio {
color: var(--vp-c-brand);
/* Process Arrow */
.process-arrow {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 0.5rem;
width: 50px;
}
.arrow-icon {
font-size: 1.5rem;
transition: transform 0.5s;
}
.arrow-icon.compressing {
animation: spin 1s linear infinite;
}
.ratio-badge {
font-size: 0.75rem;
font-weight: bold;
margin-left: 0.5rem;
color: var(--vp-c-green);
background-color: var(--vp-c-green-dimm);
padding: 0.2rem 0.4rem;
border-radius: 10px;
}
/* Metrics Bar */
.metrics-bar {
padding: 1rem;
background-color: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 1rem;
}
.progress-bg {
flex: 1;
height: 20px;
background-color: var(--vp-c-bg-mute);
border-radius: 10px;
overflow: hidden;
position: relative;
display: flex;
align-items: center;
}
.progress-fill {
height: 100%;
background-color: var(--vp-c-brand);
transition: width 0.5s ease;
}
.progress-label {
position: absolute;
left: 10px;
font-size: 0.75rem;
color: white;
text-shadow: 0 0 2px rgba(0,0,0,0.5);
font-weight: bold;
}
.saved-label {
font-size: 0.9rem;
font-weight: bold;
color: var(--vp-c-green);
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
@media (max-width: 640px) {
.comparison-area {
flex-direction: column;
}
.process-arrow {
width: 100%;
flex-direction: row;
height: 40px;
}
}
</style>
@@ -1,55 +1,96 @@
<!--
ContextWindowVisualizer.vue
上下文窗口可视化组件
用途
直观展示 LLM Context Window (上下文窗口) 限制
演示 Token 如何填充窗口以及当超出限制时会发生什么溢出/截断
交互功能
- 文本输入实时计算 Token 数量
- 预设填充快速填充短/长文本以触发不同状态
- 进度条可视化展示 Token 占用比例
- 溢出警告当超出最大 Token 数时显示警告
-->
<template>
<div class="context-window-visualizer">
<div class="context-visualizer">
<div class="control-panel">
<div class="stat-box">
<div class="stat-label">Token Usage</div>
<div class="stat-value" :class="{ error: isOverflow }">
{{ usedTokens }} / {{ maxTokens }}
<div class="stat-group">
<div class="stat-item">
<span class="value" :class="{ error: isOverflow }">{{ usedTokens }}</span>
<span class="label">Used Tokens</span>
</div>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="value">{{ maxTokens }}</span>
<span class="label">Limit</span>
</div>
</div>
<div class="progress-bar-container">
<div
class="progress-bar"
:style="{
width: usagePercentage + '%',
backgroundColor: progressBarColor
}"
></div>
<div class="progress-container">
<div class="progress-bar-bg">
<div
class="progress-bar-fill"
:style="{
width: `${Math.min(usagePercentage, 100)}%`,
backgroundColor: progressBarColor
}"
></div>
</div>
<div class="percentage-label">{{ usagePercentage.toFixed(1) }}%</div>
</div>
</div>
<div class="visualization-area">
<div class="window-frame">
<div class="window-header">Context Window (Model Memory)</div>
<div class="window-frame" :class="{ overflow: isOverflow }">
<div class="window-header">
<span class="icon">🧠</span>
<span>Model Context Window</span>
</div>
<div class="token-stream">
<transition-group name="token-list">
<transition-group name="list">
<span
v-for="(token, index) in tokenizedText"
:key="index"
class="token-chip"
:class="{ overflow: index >= maxTokens }"
:class="getTokenClass(index)"
>
{{ token }}
</span>
</transition-group>
</div>
<div v-if="isOverflow" class="overflow-warning">
Context Overflow! The model ignores everything beyond this point.
<div v-if="isOverflow" class="overflow-indicator">
<div class="overflow-line"></div>
<span class="overflow-text"> Context Limit Reached (Truncated)</span>
</div>
</div>
</div>
<div class="input-area">
<div class="input-section">
<div class="input-header">
<label>Input Text / 输入内容</label>
<div class="actions">
<button class="action-btn" @click="fillLorem(30)">+ Short</button>
<button class="action-btn" @click="fillLorem(120)">+ Overflow</button>
<button class="action-btn outline" @click="clear">Clear</button>
</div>
</div>
<textarea
v-model="inputText"
placeholder="Type here to see how tokens fill up the context window..."
placeholder="Type here to see how tokens fill up the memory..."
rows="4"
></textarea>
<div class="presets">
<button @click="fillLorem(50)">Add Short Text</button>
<button @click="fillLorem(200)">Add Long Text</button>
<button @click="clear">Clear</button>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>Note:</strong>
Context Window 是模型的短期记忆就像黑板只有那么大写满了就必须擦掉旧的才能写新的
一旦溢出模型不仅会"忘记"前面的内容甚至可能无法处理新的请求
</p>
</div>
</div>
</template>
@@ -61,7 +102,9 @@ const maxTokens = 100
const inputText = ref('Context engineering is the art of managing information.')
// Simple mock tokenizer: split by space for demonstration
// In reality, tokens are subwords, but space-split is good enough for concept
const tokenizedText = computed(() => {
if (!inputText.value) return []
return inputText.value
.trim()
.split(/\s+/)
@@ -70,43 +113,27 @@ const tokenizedText = computed(() => {
const usedTokens = computed(() => tokenizedText.value.length)
const isOverflow = computed(() => usedTokens.value > maxTokens)
const usagePercentage = computed(() =>
Math.min((usedTokens.value / maxTokens) * 100, 100)
)
const usagePercentage = computed(() => (usedTokens.value / maxTokens) * 100)
const progressBarColor = computed(() => {
if (isOverflow.value) return '#ef4444'
if (usagePercentage.value > 80) return '#f59e0b'
return '#10b981'
if (isOverflow.value) return 'var(--vp-c-danger-1)'
if (usagePercentage.value > 80) return 'var(--vp-c-warning-1)'
return 'var(--vp-c-success-1)'
})
const getTokenClass = (index) => {
if (index >= maxTokens) return 'token-overflow'
return `token-normal color-${index % 5}`
}
const fillLorem = (count) => {
const words = [
'lorem',
'ipsum',
'dolor',
'sit',
'amet',
'consectetur',
'adipiscing',
'elit',
'sed',
'do',
'eiusmod',
'tempor',
'incididunt',
'ut',
'labore',
'et',
'dolore',
'magna',
'aliqua'
'lorem', 'ipsum', 'dolor', 'sit', 'amet', 'consectetur',
'adipiscing', 'elit', 'sed', 'do', 'eiusmod', 'tempor',
'incididunt', 'ut', 'labore', 'et', 'dolore', 'magna', 'aliqua'
]
let text = []
for (let i = 0; i < count; i++) {
text.push(words[Math.floor(Math.random() * words.length)])
}
inputText.value += (inputText.value ? ' ' : '') + text.join(' ')
const newText = Array.from({ length: count }, () => words[Math.floor(Math.random() * words.length)]).join(' ')
inputText.value = newText
}
const clear = () => {
@@ -115,7 +142,7 @@ const clear = () => {
</script>
<style scoped>
.context-window-visualizer {
.context-visualizer {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
@@ -125,31 +152,74 @@ const clear = () => {
}
.control-panel {
margin-bottom: 1.5rem;
}
.stat-box {
display: flex;
justify-content: space-between;
margin-bottom: 0.5rem;
font-weight: bold;
}
.stat-value.error {
color: #ef4444;
}
.progress-bar-container {
height: 10px;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
background: var(--vp-c-bg);
border-radius: 5px;
overflow: hidden;
padding: 1rem;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
}
.progress-bar {
.stat-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.stat-item .value {
font-size: 1.5rem;
font-weight: bold;
line-height: 1;
}
.stat-item .value.error {
color: var(--vp-c-danger-1);
}
.stat-item .label {
font-size: 0.75rem;
color: var(--vp-c-text-2);
margin-top: 0.25rem;
}
.stat-divider {
font-size: 1.5rem;
color: var(--vp-c-divider);
}
.progress-container {
flex: 1;
display: flex;
align-items: center;
gap: 1rem;
}
.progress-bar-bg {
flex: 1;
height: 12px;
background-color: var(--vp-c-bg-alt);
border-radius: 6px;
overflow: hidden;
}
.progress-bar-fill {
height: 100%;
transition: all 0.3s ease;
transition: width 0.3s ease, background-color 0.3s ease;
}
.percentage-label {
font-size: 0.9rem;
font-weight: bold;
width: 4rem;
text-align: right;
}
.visualization-area {
@@ -157,100 +227,164 @@ const clear = () => {
}
.window-frame {
border: 2px dashed var(--vp-c-divider);
border: 2px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
background: var(--vp-c-bg);
min-height: 120px;
position: relative;
min-height: 150px;
transition: border-color 0.3s;
overflow: hidden;
}
.window-frame.overflow {
border-color: var(--vp-c-danger-1);
}
.window-header {
position: absolute;
top: -12px;
left: 10px;
background: var(--vp-c-bg-soft);
padding: 0 10px;
font-size: 0.8rem;
color: var(--vp-c-text-2);
background: var(--vp-c-bg-alt);
padding: 0.5rem 1rem;
font-size: 0.9rem;
font-weight: bold;
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 0.5rem;
}
.token-stream {
padding: 1rem;
display: flex;
flex-wrap: wrap;
gap: 4px;
max-height: 200px;
overflow-y: auto;
}
.token-chip {
padding: 2px 6px;
background: #e0f2fe;
color: #0369a1;
border-radius: 4px;
font-size: 0.8rem;
transition: all 0.3s ease;
font-size: 0.85rem;
transition: all 0.2s;
}
.token-chip.overflow {
background: #fee2e2;
color: #b91c1c;
opacity: 0.5;
.token-normal {
background-color: var(--vp-c-brand-soft);
color: var(--vp-c-brand-dark);
}
/* Color cycling for tokens to show boundaries */
.color-0 { background-color: rgba(255, 99, 132, 0.15); color: #c0392b; }
.color-1 { background-color: rgba(54, 162, 235, 0.15); color: #2980b9; }
.color-2 { background-color: rgba(255, 206, 86, 0.15); color: #d35400; }
.color-3 { background-color: rgba(75, 192, 192, 0.15); color: #16a085; }
.color-4 { background-color: rgba(153, 102, 255, 0.15); color: #8e44ad; }
.token-overflow {
background-color: var(--vp-c-bg-alt);
color: var(--vp-c-text-3);
text-decoration: line-through;
opacity: 0.6;
}
.overflow-warning {
margin-top: 1rem;
.overflow-indicator {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: rgba(239, 68, 68, 0.1);
border-top: 1px dashed var(--vp-c-danger-1);
padding: 0.5rem;
background: #fef2f2;
border: 1px solid #fecaca;
color: #b91c1c;
border-radius: 4px;
font-size: 0.8rem;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
color: var(--vp-c-danger-1);
font-weight: bold;
font-size: 0.9rem;
}
.input-area {
.input-section {
margin-bottom: 1rem;
}
.input-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.5rem;
}
.input-header label {
font-size: 0.9rem;
font-weight: bold;
}
.actions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.75rem;
border-radius: 4px;
background-color: var(--vp-c-brand);
color: white;
font-size: 0.8rem;
border: none;
cursor: pointer;
transition: background-color 0.2s;
}
.action-btn:hover {
background-color: var(--vp-c-brand-dark);
}
.action-btn.outline {
background-color: transparent;
border: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-1);
}
.action-btn.outline:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
textarea {
width: 100%;
padding: 0.8rem;
border-radius: 6px;
padding: 0.75rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
font-family: inherit;
resize: vertical;
}
.presets {
display: flex;
gap: 0.5rem;
textarea:focus {
outline: none;
border-color: var(--vp-c-brand);
}
button {
padding: 0.4rem 0.8rem;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.8rem;
transition: background 0.2s;
.info-box {
background-color: var(--vp-c-bg-alt);
padding: 1rem;
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.5;
color: var(--vp-c-text-2);
}
button:hover {
background: var(--vp-c-brand-dark);
.info-box .icon {
margin-right: 0.5rem;
}
.token-list-enter-active,
.token-list-leave-active {
/* Animations */
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
}
.token-list-enter-from,
.token-list-leave-to {
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateY(10px);
}
@@ -1,315 +1,551 @@
<template>
<div class="rag-simulation-demo">
<div class="layout">
<!-- Left: Long-term Memory (Vector DB) -->
<div class="panel vector-db">
<div class="panel-header">📚 Long-term Memory (Vector DB)</div>
<div class="documents">
<div
v-for="doc in documents"
:key="doc.id"
class="doc-card"
:class="{ retrieved: doc.retrieved }"
>
<div class="doc-icon">📄</div>
<div class="doc-content">{{ doc.content }}</div>
<div class="doc-meta">
ID: {{ doc.id }} | Vector: {{ doc.vector }}
</div>
</div>
</div>
</div>
<!-- Center: Query & Retrieval Process -->
<div class="process-area">
<div class="search-box">
<input
v-model="query"
placeholder="Ask a question..."
@keyup.enter="search"
/>
<button @click="search" :disabled="isSearching">
{{ isSearching ? 'Searching...' : '🔍 Retrieve' }}
</button>
</div>
<div class="arrow-down"></div>
<div class="retrieval-status" :class="{ active: isSearching }">
<div class="status-step" v-if="step >= 1">1. Embed Query</div>
<div class="status-step" v-if="step >= 2">2. Semantic Search</div>
<div class="status-step" v-if="step >= 3">3. Retrieve Top-K</div>
</div>
<div class="arrow-down"></div>
<!-- Right: Augmented Context -->
<div class="panel context-builder">
<div class="panel-header">📦 Augmented Context</div>
<div class="context-content">
<div class="context-section system">
<span class="label">System:</span>
You are a helpful assistant. Use the following context to answer
the user.
</div>
<div
class="context-section retrieved"
v-if="retrievedDocs.length > 0"
>
<span class="label">Retrieved Context:</span>
<div
v-for="doc in retrievedDocs"
:key="doc.id"
class="retrieved-item"
>
- {{ doc.content }}
</div>
</div>
<div class="context-section user">
<span class="label">User:</span>
{{ lastQuery }}
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<!--
* Component: RAGSimulationDemo.vue
* Description: Demonstrates the Retrieval-Augmented Generation (RAG) process.
* Features:
* - Interactive search simulation
* - Visual representation of Vector DB and Document retrieval
* - Step-by-step animation of the RAG pipeline
* - Visualization of context augmentation
-->
<script setup>
import { ref } from 'vue'
import { ref, computed } from 'vue'
const query = ref('How do I reset my password?')
const query = ref('如何重置密码?')
const lastQuery = ref('')
const isSearching = ref(false)
const step = ref(0)
const currentStep = ref(0)
const searchTime = ref(0)
const documents = ref([
{
id: 1,
content: 'To reset password, go to settings page.',
vector: '[0.1, 0.9]',
retrieved: false
title: '密码重置指南',
content: '用户可以通过点击设置页面的"忘记密码"链接来重置密码。系统会发送验证邮件。',
vector: [0.12, 0.88, 0.05],
score: 0
},
{
id: 2,
content: 'Pricing starts at $10/month.',
vector: '[0.8, 0.2]',
retrieved: false
title: '定价策略',
content: '基础版每月 $10,专业版每月 $29。企业版需要联系销售团队获取报价。',
vector: [0.85, 0.15, 0.10],
score: 0
},
{
id: 3,
content: 'Contact support at support@example.com.',
vector: '[0.3, 0.5]',
retrieved: false
title: 'API 文档',
content: '所有 API 请求都需要在 Header 中包含 Bearer Token 进行身份验证。',
vector: [0.30, 0.20, 0.95],
score: 0
},
{
id: 4,
content: 'Click "Forgot Password" on login screen.',
vector: '[0.2, 0.8]',
retrieved: false
title: '账户安全',
content: '为了账户安全,建议开启双重认证 (2FA)。定期修改密码也是好习惯。',
vector: [0.15, 0.85, 0.12],
score: 0
}
])
const retrievedDocs = ref([])
const steps = [
{ id: 1, label: 'Embedding', desc: '将问题转换为向量' },
{ id: 2, label: 'Similarity Search', desc: '计算向量相似度' },
{ id: 3, label: 'Retrieval', desc: '提取 Top-K 相关文档' },
{ id: 4, label: 'Augmentation', desc: '注入上下文窗口' }
]
const search = async () => {
if (isSearching.value) return
isSearching.value = true
lastQuery.value = query.value
step.value = 0
// Reset previous state
documents.value.forEach((d) => (d.retrieved = false))
retrievedDocs.value = []
// Step 1: Embedding
await wait(500)
step.value = 1
// Step 2: Search
await wait(500)
step.value = 2
// Mock semantic search logic (simple keyword match for demo)
const keywords = query.value.toLowerCase().split(' ')
const matches = documents.value
.map((doc) => {
let score = 0
keywords.forEach((k) => {
if (doc.content.toLowerCase().includes(k)) score++
})
return { ...doc, score }
})
const retrievedDocs = computed(() => {
return documents.value
.filter(doc => doc.score > 0.7)
.sort((a, b) => b.score - a.score)
.slice(0, 2) // Top 2
})
// Step 3: Retrieve
await wait(500)
step.value = 3
matches.forEach((m) => {
const doc = documents.value.find((d) => d.id === m.id)
if (doc) doc.retrieved = true
})
retrievedDocs.value = matches
isSearching.value = false
const calculateSimilarity = (q, docVector) => {
// Mock similarity calculation based on keywords for demo purposes
// In reality, this would be a vector dot product
if (q.includes('密码') || q.includes('安全')) {
if (docVector[1] > 0.8) return 0.92 + (Math.random() * 0.05)
if (docVector[0] > 0.8) return 0.15
return 0.4 + (Math.random() * 0.1)
}
if (q.includes('价格') || q.includes('多少钱')) {
if (docVector[0] > 0.8) return 0.95
return 0.1
}
return Math.random() * 0.3
}
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const search = async () => {
if (isSearching.value || !query.value) return
isSearching.value = true
lastQuery.value = query.value
currentStep.value = 1
searchTime.value = 0
// Reset scores
documents.value.forEach(d => d.score = 0)
// Step 1: Embedding (Simulated)
await new Promise(r => setTimeout(r, 800))
currentStep.value = 2
// Step 2: Search
const startTime = performance.now()
documents.value.forEach(doc => {
doc.score = calculateSimilarity(query.value, doc.vector)
})
await new Promise(r => setTimeout(r, 800))
searchTime.value = Math.round(performance.now() - startTime) + 45 // Add base latency
currentStep.value = 3
// Step 3: Retrieval
await new Promise(r => setTimeout(r, 800))
currentStep.value = 4
// Step 4: Complete
await new Promise(r => setTimeout(r, 800))
isSearching.value = false
}
</script>
<template>
<div class="rag-simulation-demo">
<!-- Control Panel -->
<div class="control-panel">
<div class="search-bar">
<input
v-model="query"
type="text"
placeholder="输入问题 (例如: 怎么重置密码?)"
@keyup.enter="search"
:disabled="isSearching"
/>
<button
class="search-btn"
@click="search"
:disabled="isSearching || !query"
>
{{ isSearching ? '检索中...' : '🔍 开始检索' }}
</button>
</div>
<div class="step-indicator">
<div
v-for="s in steps"
:key="s.id"
class="step-dot"
:class="{ active: currentStep >= s.id, current: currentStep === s.id }"
:title="s.label"
></div>
</div>
</div>
<!-- Main Visualization -->
<div class="viz-container">
<!-- Left: Vector Database -->
<div class="panel vector-db" :class="{ dimmed: currentStep === 4 }">
<div class="panel-header">
<span class="icon">🗄</span> 向量数据库 (Knowledge Base)
</div>
<div class="doc-list">
<div
v-for="doc in documents"
:key="doc.id"
class="doc-card"
:class="{
'scanning': currentStep === 2,
'matched': doc.score > 0.7 && currentStep >= 3,
'rejected': doc.score <= 0.7 && currentStep >= 3
}"
:style="{ '--score': doc.score }"
>
<div class="doc-icon">📄</div>
<div class="doc-info">
<div class="doc-title">{{ doc.title }}</div>
<div class="doc-preview">{{ doc.content.substring(0, 20) }}...</div>
</div>
<div class="doc-score" v-if="currentStep >= 2 && doc.score > 0">
{{ (doc.score * 100).toFixed(0) }}%
</div>
<div class="vector-visual">
<span v-for="(v,i) in doc.vector" :key="i" :style="{ height: v * 10 + 'px' }"></span>
</div>
</div>
</div>
</div>
<!-- Center: Pipeline Visuals -->
<div class="pipeline-arrow">
<div class="arrow-line" :class="{ active: isSearching }"></div>
<div class="pipeline-status" v-if="currentStep > 0">
{{ steps[currentStep - 1]?.label }}
</div>
</div>
<!-- Right: Augmented Context -->
<div class="panel context-window" :class="{ active: currentStep === 4 }">
<div class="panel-header">
<span class="icon">🤖</span> 增强后的上下文 (Final Prompt)
</div>
<div class="prompt-content">
<div class="prompt-section system">
<span class="tag">System</span>
<p>你是一个帮助用户的 AI 助手请基于以下上下文回答用户的问题</p>
</div>
<div class="prompt-section context" v-if="currentStep >= 3">
<span class="tag">Context (RAG)</span>
<div v-if="retrievedDocs.length > 0">
<div v-for="doc in retrievedDocs" :key="doc.id" class="retrieved-item">
<span class="bullet"></span> {{ doc.content }}
</div>
</div>
<div v-else class="empty-context">
(暂无相关文档)
</div>
</div>
<div class="prompt-section user" v-if="lastQuery">
<span class="tag">User</span>
<p>{{ lastQuery }}</p>
</div>
<div class="placeholder-text" v-else>
等待查询...
</div>
</div>
</div>
</div>
<!-- Metrics Footer -->
<div class="metrics-footer">
<div class="metric">
<span class="label">检索耗时:</span>
<span class="value">{{ searchTime }} ms</span>
</div>
<div class="metric">
<span class="label">命中数量:</span>
<span class="value">{{ retrievedDocs.length }} docs</span>
</div>
</div>
</div>
</template>
<style scoped>
.rag-simulation-demo {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background-color: var(--vp-c-bg-soft);
padding: 1.5rem;
overflow: hidden;
margin: 1rem 0;
font-family: var(--vp-font-family-mono);
}
.layout {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.panel {
flex: 1;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 1rem;
min-width: 250px;
}
.panel-header {
font-weight: bold;
border-bottom: 1px solid var(--vp-c-divider);
padding-bottom: 0.5rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.doc-card {
padding: 0.5rem;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
margin-bottom: 0.5rem;
background: var(--vp-c-bg-alt);
transition: all 0.3s;
font-size: 0.8rem;
}
.doc-card.retrieved {
border-color: #10b981;
background: #ecfdf5;
transform: translateX(5px);
box-shadow: 0 2px 8px rgba(16, 185, 129, 0.2);
}
.doc-meta {
font-size: 0.7rem;
color: var(--vp-c-text-3);
margin-top: 4px;
}
.process-area {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
min-width: 250px;
}
.search-box {
.control-panel {
padding: 1rem;
background-color: var(--vp-c-bg);
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 1rem;
}
.search-bar {
display: flex;
width: 100%;
gap: 0.5rem;
margin-bottom: 1rem;
flex: 1;
min-width: 280px;
}
input {
flex: 1;
padding: 0.6rem;
padding: 0.5rem 0.8rem;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
}
button {
padding: 0.5rem 1rem;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:disabled {
opacity: 0.6;
}
.retrieval-status {
padding: 1rem;
background: var(--vp-c-bg);
border: 1px dashed var(--vp-c-divider);
border-radius: 6px;
width: 100%;
text-align: center;
margin: 0.5rem 0;
background-color: var(--vp-c-bg-alt);
color: var(--vp-c-text-1);
}
input:focus {
border-color: var(--vp-c-brand);
outline: none;
}
.status-step {
color: var(--vp-c-brand);
font-weight: bold;
margin: 0.2rem 0;
font-size: 0.9rem;
.search-btn {
padding: 0.5rem 1rem;
background-color: var(--vp-c-brand);
color: white;
border-radius: 6px;
font-weight: 500;
transition: background-color 0.2s;
}
.search-btn:hover:not(:disabled) {
background-color: var(--vp-c-brand-dark);
}
.search-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.context-content {
font-size: 0.85rem;
.step-indicator {
display: flex;
gap: 0.4rem;
}
.step-dot {
width: 10px;
height: 10px;
border-radius: 50%;
background-color: var(--vp-c-divider);
transition: all 0.3s;
}
.step-dot.active {
background-color: var(--vp-c-brand);
}
.step-dot.current {
transform: scale(1.4);
box-shadow: 0 0 4px var(--vp-c-brand);
}
/* Viz Container */
.viz-container {
display: flex;
padding: 1.5rem;
gap: 1rem;
background-color: var(--vp-c-bg-alt);
min-height: 350px;
align-items: stretch;
}
.panel {
flex: 1;
background-color: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
display: flex;
flex-direction: column;
gap: 0.8rem;
transition: all 0.5s ease;
}
.panel.dimmed {
opacity: 0.6;
filter: grayscale(0.5);
}
.panel.active {
border-color: var(--vp-c-brand);
box-shadow: 0 0 15px rgba(var(--vp-c-brand-rgb), 0.1);
}
.context-section {
padding: 0.5rem;
border-radius: 4px;
background: var(--vp-c-bg-alt);
border-left: 3px solid #ccc;
.panel-header {
padding: 0.8rem;
font-weight: 600;
font-size: 0.9rem;
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 0.5rem;
background-color: var(--vp-c-bg-soft);
}
.context-section.system {
border-color: #f59e0b;
}
.context-section.retrieved {
border-color: #10b981;
background: #ecfdf5;
}
.context-section.user {
border-color: #3b82f6;
.doc-list {
padding: 0.8rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
overflow-y: auto;
max-height: 300px;
}
.label {
font-weight: bold;
display: block;
margin-bottom: 0.3rem;
.doc-card {
padding: 0.6rem;
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
display: flex;
align-items: center;
gap: 0.6rem;
font-size: 0.85rem;
position: relative;
transition: all 0.3s;
background-color: var(--vp-c-bg);
}
.doc-card.scanning {
animation: pulse 1s infinite;
border-color: var(--vp-c-brand-dimm);
}
.doc-card.matched {
border-color: var(--vp-c-green);
background-color: var(--vp-c-green-dimm);
transform: translateX(5px);
}
.doc-card.rejected {
opacity: 0.5;
}
.doc-icon {
font-size: 1.2rem;
}
.doc-info {
flex: 1;
overflow: hidden;
}
.doc-title {
font-weight: 600;
color: var(--vp-c-text-1);
}
.doc-preview {
color: var(--vp-c-text-2);
font-size: 0.75rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.doc-score {
font-family: var(--vp-font-mono);
font-weight: bold;
color: var(--vp-c-brand);
}
.vector-visual {
display: flex;
gap: 2px;
align-items: flex-end;
height: 15px;
width: 20px;
}
.vector-visual span {
width: 4px;
background-color: var(--vp-c-text-3);
border-radius: 1px;
}
/* Pipeline Arrow */
.pipeline-arrow {
width: 40px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
position: relative;
}
.arrow-line {
width: 100%;
height: 4px;
background-color: var(--vp-c-divider);
border-radius: 2px;
transition: all 0.3s;
}
.arrow-line.active {
background: linear-gradient(90deg, var(--vp-c-brand), var(--vp-c-brand-light));
background-size: 200% 100%;
animation: flow 1s linear infinite;
}
.pipeline-status {
position: absolute;
top: 40%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--vp-c-brand);
color: white;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 0.7rem;
white-space: nowrap;
z-index: 10;
}
/* Context Window */
.prompt-content {
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
font-family: var(--vp-font-mono);
font-size: 0.85rem;
overflow-y: auto;
}
.prompt-section {
background-color: var(--vp-c-bg-soft);
padding: 0.8rem;
border-radius: 6px;
border-left: 3px solid transparent;
}
.prompt-section.system {
border-left-color: var(--vp-c-yellow);
}
.prompt-section.context {
border-left-color: var(--vp-c-green);
background-color: rgba(var(--vp-c-green-rgb), 0.1);
}
.prompt-section.user {
border-left-color: var(--vp-c-brand);
}
.tag {
display: inline-block;
font-size: 0.7rem;
font-weight: bold;
text-transform: uppercase;
margin-bottom: 0.4rem;
color: var(--vp-c-text-2);
}
.retrieved-item {
margin-bottom: 0.3rem;
color: #047857;
margin-top: 0.4rem;
color: var(--vp-c-text-1);
}
.empty-context {
color: var(--vp-c-text-3);
font-style: italic;
text-align: center;
}
.placeholder-text {
text-align: center;
color: var(--vp-c-text-3);
margin-top: 2rem;
}
.arrow-down {
color: var(--vp-c-text-3);
margin: 0.5rem 0;
/* Metrics Footer */
.metrics-footer {
display: flex;
justify-content: space-around;
padding: 0.8rem;
background-color: var(--vp-c-bg);
border-top: 1px solid var(--vp-c-divider);
font-size: 0.85rem;
}
.metric .label {
color: var(--vp-c-text-2);
margin-right: 0.5rem;
}
.metric .value {
font-weight: bold;
color: var(--vp-c-text-1);
}
@keyframes flow {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
@keyframes pulse {
0% { opacity: 1; }
50% { opacity: 0.6; }
100% { opacity: 1; }
}
@media (max-width: 768px) {
.viz-container {
flex-direction: column;
}
.pipeline-arrow {
width: 100%;
height: 40px;
flex-direction: row;
}
.arrow-line {
width: 4px;
height: 100%;
}
}
</style>
@@ -1,125 +1,190 @@
<!--
SelectiveContextDemo.vue
选择性上下文保留演示
用途
展示如何通过 "Pinning" (钉住) 机制来保护关键信息不被滑动窗口移除
演示 System Prompt 和关键用户指令如何长期保留
交互功能
- 发送消息添加新内容
- 钉住/取消钉住手动选择要保留的消息
- 自动管理演示当窗口满时未钉住的消息优先被移除
-->
<template>
<div class="selective-context-demo">
<div class="viz-container">
<div class="window-frame">
<div class="window-header">
<span>Smart Context Window</span>
<span class="capacity">{{ usedSlots }} / {{ maxSlots }} Slots</span>
<div class="control-panel">
<div class="stat-group">
<div class="stat-item">
<span class="value">{{ totalMessages }}</span>
<span class="label">Total Messages</span>
</div>
<!-- Pinned Messages -->
<div class="section pinned">
<div class="section-label">📌 Pinned (Always Kept)</div>
<div
v-for="msg in pinnedMessages"
:key="msg.id"
class="message-bubble pinned"
>
<div class="msg-content">
<span class="role">{{ msg.role }}:</span> {{ msg.content }}
</div>
<button class="pin-btn active" @click="togglePin(msg)">📌</button>
</div>
<div class="stat-divider">/</div>
<div class="stat-item">
<span class="value">{{ maxSlots }}</span>
<span class="label">Window Capacity</span>
</div>
</div>
<div class="usage-bar">
<div
class="usage-fill"
:style="{ width: `${(totalMessages / maxSlots) * 100}%` }"
:class="{ full: totalMessages >= maxSlots }"
></div>
</div>
</div>
<!-- Scrolling Messages -->
<div class="section scrolling">
<div class="section-label">📜 Scrolling (FIFO)</div>
<div class="visualization-area">
<!-- Pinned Section -->
<div class="context-section pinned-section">
<div class="section-header">
<span class="icon">📌</span>
<span class="title">Pinned Context (Protected)</span>
<span class="count">{{ pinnedMessages.length }} items</span>
</div>
<div class="message-list">
<transition-group name="list">
<div
v-for="msg in scrollingMessages"
v-for="msg in pinnedMessages"
:key="msg.id"
class="message-bubble"
class="message-card pinned"
:class="msg.role.toLowerCase()"
>
<div class="msg-content">
<span class="role">{{ msg.role }}:</span> {{ msg.content }}
<div class="card-header">
<span class="role-badge">{{ msg.role }}</span>
<button
class="pin-btn active"
@click="togglePin(msg)"
:disabled="msg.role === 'System'"
title="Unpin message"
>
<span v-if="msg.role === 'System'">🔒 Fixed</span>
<span v-else>📌 Unpin</span>
</button>
</div>
<button class="pin-btn" @click="togglePin(msg)">📌</button>
<div class="card-content">{{ msg.content }}</div>
</div>
</transition-group>
</div>
</div>
<!-- Scrolling Section -->
<div class="context-section scrolling-section">
<div class="section-header">
<span class="icon">📜</span>
<span class="title">Scrolling Context (FIFO)</span>
<span class="count">{{ scrollingMessages.length }} items</span>
</div>
<div class="message-list">
<transition-group name="list">
<div
v-for="msg in scrollingMessages"
:key="msg.id"
class="message-card scrolling"
:class="msg.role.toLowerCase()"
>
<div class="card-header">
<span class="role-badge">{{ msg.role }}</span>
<button class="pin-btn" @click="togglePin(msg)" title="Pin message">
📌 Pin
</button>
</div>
<div class="card-content">{{ msg.content }}</div>
</div>
</transition-group>
<div v-if="scrollingMessages.length === 0" class="empty-state">
No scrolling messages...
</div>
</div>
</div>
</div>
<div class="controls">
<div class="input-section">
<div class="input-group">
<input
v-model="newMessage"
@keyup.enter="sendMessage"
placeholder="Add a fact or message..."
placeholder="Add a new fact or message..."
/>
<button @click="sendMessage">Add</button>
<button class="send-btn" @click="sendMessage" :disabled="!newMessage.trim()">
Add
</button>
</div>
<div class="info-text">
<p>
Try pinning a message. Pinned messages stay in the window even as new
messages push old ones out.
</p>
<div class="presets">
<button class="preset-btn" @click="addPreset('My name is Alice.')">User: My name is Alice</button>
<button class="preset-btn" @click="addPreset('The password is 1234.')">User: Password is 1234</button>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>Note:</strong>
"选择性保留" 解决了滑动窗口的遗忘问题
System Prompt 通常被永久钉住用户也可以通过某些机制 RAG 记忆模块将重要信息如名字密码钉在窗口中防止被挤出
</p>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const maxSlots = 5
const maxSlots = 6
const messages = ref([
{
id: 1,
role: 'System',
content: 'You are a helpful assistant.',
content: 'You are a helpful AI assistant focused on coding.',
pinned: true
},
{ id: 2, role: 'User', content: 'My name is Alice.', pinned: false },
{ id: 3, role: 'AI', content: 'Hello Alice!', pinned: false }
{ id: 2, role: 'User', content: 'Hi, I want to learn Vue.', pinned: false },
{ id: 3, role: 'AI', content: 'Sure! Vue is a progressive framework.', pinned: false }
])
const newMessage = ref('')
let msgId = 4
const pinnedMessages = computed(() => messages.value.filter((m) => m.pinned))
const scrollingMessages = computed(() =>
messages.value.filter((m) => !m.pinned)
)
const usedSlots = computed(() => messages.value.length)
const scrollingMessages = computed(() => messages.value.filter((m) => !m.pinned))
const totalMessages = computed(() => messages.value.length)
const sendMessage = () => {
if (!newMessage.value.trim()) return
// Add new message
messages.value.push({
id: msgId++,
role: 'User',
content: newMessage.value,
pinned: false
})
addMessage('User', newMessage.value)
newMessage.value = ''
}
// Enforce limit logic:
// If total > max, remove oldest NON-PINNED message
if (messages.value.length > maxSlots) {
const unpinned = messages.value.filter((m) => !m.pinned)
if (unpinned.length > 0) {
// Find index of oldest unpinned
const oldestUnpinned = unpinned[0]
const indexToRemove = messages.value.findIndex(
(m) => m.id === oldestUnpinned.id
)
if (indexToRemove !== -1) {
messages.value.splice(indexToRemove, 1)
}
const addPreset = (text) => {
addMessage('User', text)
}
const addMessage = (role, content) => {
// If full, remove oldest unpinned message
if (messages.value.length >= maxSlots) {
const firstUnpinnedIndex = messages.value.findIndex(m => !m.pinned)
if (firstUnpinnedIndex !== -1) {
messages.value.splice(firstUnpinnedIndex, 1)
} else {
// If all are pinned and we add one more, we can't remove anything (in this simple logic),
// or we reject the new one. Let's just remove the newly added one to show "Full".
messages.value.pop()
alert('Context Window Full with Pinned Messages!')
// If all are pinned (rare edge case), we might force remove or block
// For demo, we'll block adding
alert("Context window full of pinned messages! Unpin something first.")
return
}
}
messages.value.push({
id: msgId++,
role,
content,
pinned: false
})
}
const togglePin = (msg) => {
if (msg.role === 'System') return // System prompt is always pinned
// If pinning would exceed capacity (unlikely in this logic but possible if we change rules)
// Logic: Pinning just changes state, doesn't add new msg.
msg.pinned = !msg.pinned
}
</script>
@@ -134,80 +199,189 @@ const togglePin = (msg) => {
font-family: var(--vp-font-family-mono);
}
.window-frame {
border: 2px solid var(--vp-c-brand);
border-radius: 8px;
padding: 1rem;
.control-panel {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
background: var(--vp-c-bg);
min-height: 300px;
padding: 1rem;
border-radius: 6px;
border: 1px solid var(--vp-c-divider);
}
.stat-group {
display: flex;
align-items: baseline;
gap: 0.5rem;
min-width: 120px;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
}
.window-header {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
.stat-item .value {
font-size: 1.2rem;
font-weight: bold;
border-bottom: 1px solid var(--vp-c-divider);
padding-bottom: 0.5rem;
}
.section {
margin-bottom: 1rem;
padding: 0.5rem;
border-radius: 6px;
}
.section.pinned {
background: #fffbeb;
border: 1px solid #fcd34d;
}
.section.scrolling {
background: #f3f4f6;
border: 1px solid #e5e7eb;
flex: 1;
}
.section-label {
.stat-item .label {
font-size: 0.7rem;
text-transform: uppercase;
color: var(--vp-c-text-2);
margin-bottom: 0.5rem;
}
.message-bubble {
.stat-divider {
font-size: 1.2rem;
color: var(--vp-c-divider);
}
.usage-bar {
flex: 1;
height: 8px;
background: var(--vp-c-bg-alt);
border-radius: 4px;
overflow: hidden;
}
.usage-fill {
height: 100%;
background-color: var(--vp-c-brand);
transition: width 0.3s ease;
}
.usage-fill.full {
background-color: var(--vp-c-warning-1);
}
.visualization-area {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
}
.context-section {
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg);
overflow: hidden;
}
.pinned-section {
border-color: var(--vp-c-brand);
background: rgba(var(--vp-c-brand-rgb), 0.02);
}
.section-header {
padding: 0.5rem 1rem;
background: var(--vp-c-bg-alt);
border-bottom: 1px solid var(--vp-c-divider);
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.85rem;
font-weight: bold;
}
.pinned-section .section-header {
background: rgba(var(--vp-c-brand-rgb), 0.1);
color: var(--vp-c-brand-dark);
}
.section-header .count {
margin-left: auto;
font-size: 0.75rem;
opacity: 0.7;
}
.message-list {
padding: 1rem;
min-height: 80px;
}
.message-card {
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
padding: 0.75rem;
margin-bottom: 0.75rem;
background: var(--vp-c-bg);
transition: all 0.3s ease;
}
.message-card:last-child {
margin-bottom: 0;
}
.message-card.pinned {
border-left: 3px solid var(--vp-c-brand);
}
.message-card.scrolling {
border-left: 3px solid var(--vp-c-text-3);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
margin-bottom: 0.5rem;
background: white;
border-radius: 4px;
border: 1px solid var(--vp-c-divider);
font-size: 0.9rem;
}
.message-bubble.pinned {
border-left: 3px solid #f59e0b;
.role-badge {
font-size: 0.7rem;
text-transform: uppercase;
font-weight: bold;
padding: 2px 6px;
border-radius: 4px;
background: var(--vp-c-bg-alt);
color: var(--vp-c-text-2);
}
.pin-btn {
background: none;
border: none;
background: transparent;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
padding: 2px 8px;
font-size: 0.75rem;
cursor: pointer;
opacity: 0.3;
font-size: 1rem;
color: var(--vp-c-text-2);
transition: all 0.2s;
}
.pin-btn:hover:not(:disabled) {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.pin-btn:hover,
.pin-btn.active {
opacity: 1;
background: var(--vp-c-brand);
color: white;
border-color: var(--vp-c-brand);
}
.controls {
margin-top: 1rem;
.pin-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
background: var(--vp-c-bg-alt);
}
.card-content {
font-size: 0.9rem;
line-height: 1.4;
}
.empty-state {
text-align: center;
color: var(--vp-c-text-3);
font-style: italic;
font-size: 0.85rem;
}
.input-section {
margin-bottom: 1rem;
}
.input-group {
@@ -218,32 +392,78 @@ const togglePin = (msg) => {
input {
flex: 1;
padding: 0.6rem;
padding: 0.75rem;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
border-radius: 6px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
}
button {
padding: 0.5rem 1rem;
input:focus {
outline: none;
border-color: var(--vp-c-brand);
}
.send-btn {
padding: 0 1.5rem;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 4px;
border-radius: 6px;
font-weight: bold;
cursor: pointer;
}
.info-text {
font-size: 0.8rem;
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.presets {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.preset-btn {
font-size: 0.75rem;
padding: 4px 8px;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
background: transparent;
color: var(--vp-c-text-2);
cursor: pointer;
}
.preset-btn:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
}
.info-box {
background-color: var(--vp-c-bg-alt);
padding: 1rem;
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.5;
color: var(--vp-c-text-2);
}
.info-box .icon {
margin-right: 0.5rem;
}
/* Animations */
.list-enter-active,
.list-leave-active {
transition: all 0.3s ease;
transition: all 0.4s ease;
}
.list-enter-from,
.list-leave-to {
opacity: 0;
transform: translateX(20px);
transform: scale(0.95);
}
.list-move {
transition: transform 0.4s ease;
}
</style>
@@ -1,56 +1,111 @@
<!--
SlidingWindowDemo.vue
滑动窗口机制演示
用途
展示 "Sliding Window" (滑动窗口) 如何处理长对话
当新消息进入时最旧的消息被移除上下文演示遗忘机制
交互功能
- 发送消息用户可发送消息AI 自动回复
- 自动演示一键模拟长对话观察窗口滑动
- 视觉反馈清晰展示哪些消息在"窗口内"活跃哪些在"窗口外"遗忘
-->
<template>
<div class="sliding-window-demo">
<div class="viz-container">
<!-- Hidden Messages (History) -->
<div class="message-zone history">
<div class="zone-label">History (Forgotten)</div>
<transition-group name="list">
<div
v-for="msg in historyMessages"
:key="msg.id"
class="message-bubble faded"
>
<span class="role">{{ msg.role }}:</span> {{ msg.content }}
</div>
</transition-group>
<div class="control-panel">
<div class="info-stat">
<span class="label">Window Size / 窗口大小</span>
<span class="value">{{ windowSize }} Messages</span>
</div>
<div class="actions">
<button class="action-btn" @click="autoPlay" :disabled="isAutoPlaying">
Auto Play
</button>
<button class="action-btn outline" @click="reset">
Reset
</button>
</div>
</div>
<!-- Active Window -->
<div class="window-frame">
<div class="window-header">
<span>Active Context Window</span>
<span class="capacity">Capacity: {{ windowSize }} msgs</span>
<div class="visualization-area">
<div class="conversation-stream">
<!-- Forgotten / History Zone -->
<div class="zone history-zone">
<div class="zone-label">
<span class="icon">🗑</span> Forgotten (History)
</div>
<transition-group name="fade-list">
<div
v-for="msg in historyMessages"
:key="msg.id"
class="message-bubble history"
:class="msg.role.toLowerCase()"
>
<div class="avatar">{{ msg.role === 'User' ? '👤' : '🤖' }}</div>
<div class="content">
<div class="role-name">{{ msg.role }}</div>
<div class="text">{{ msg.content }}</div>
</div>
</div>
</transition-group>
<div v-if="historyMessages.length === 0" class="empty-placeholder">
No history yet...
</div>
</div>
<div class="message-zone active">
<transition-group name="list">
<!-- Divider -->
<div class="window-divider">
<span> Out of Context</span>
<div class="divider-line"></div>
<span> In Context</span>
</div>
<!-- Active Window Zone -->
<div class="zone active-zone">
<div class="zone-label">
<span class="icon">🖼</span> Active Context Window
</div>
<transition-group name="slide-list">
<div
v-for="msg in activeMessages"
:key="msg.id"
class="message-bubble"
:class="msg.role"
class="message-bubble active"
:class="msg.role.toLowerCase()"
>
<span class="role">{{ msg.role }}:</span> {{ msg.content }}
<div class="avatar">{{ msg.role === 'User' ? '👤' : '🤖' }}</div>
<div class="content">
<div class="role-name">{{ msg.role }}</div>
<div class="text">{{ msg.content }}</div>
</div>
</div>
</transition-group>
<div v-if="activeMessages.length === 0" class="empty-state">
Start chatting to fill the window...
<div v-if="activeMessages.length === 0" class="empty-placeholder">
Start the conversation...
</div>
</div>
</div>
</div>
<div class="controls">
<div class="input-group">
<input
v-model="newMessage"
@keyup.enter="sendMessage"
placeholder="Type a message..."
/>
<button @click="sendMessage">Send</button>
</div>
<div class="actions">
<button class="secondary" @click="reset">Reset</button>
</div>
<div class="input-section">
<input
v-model="newMessage"
@keyup.enter="sendMessage"
placeholder="Type a message..."
:disabled="isAutoPlaying"
/>
<button class="send-btn" @click="sendMessage" :disabled="!newMessage.trim() || isAutoPlaying">
Send
</button>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>Note:</strong>
滑动窗口是最简单的记忆管理策略它保证了 Token 永远不会溢出但代价是"健忘"
一旦消息滑出窗口进入上方灰色区域模型就完全不知道它的存在了
</p>
</div>
</div>
</template>
@@ -61,6 +116,7 @@ import { ref, computed } from 'vue'
const windowSize = 4
const messages = ref([])
const newMessage = ref('')
const isAutoPlaying = ref(false)
let msgId = 0
const activeMessages = computed(() => {
@@ -68,36 +124,60 @@ const activeMessages = computed(() => {
})
const historyMessages = computed(() => {
return messages.value.slice(
0,
Math.max(0, messages.value.length - windowSize)
)
return messages.value.slice(0, Math.max(0, messages.value.length - windowSize))
})
const sendMessage = () => {
if (!newMessage.value.trim()) return
messages.value.push({
id: msgId++,
role: 'User',
content: newMessage.value
})
addMessage('User', newMessage.value)
const userText = newMessage.value
newMessage.value = ''
// Simulate AI response
setTimeout(() => {
messages.value.push({
id: msgId++,
role: 'AI',
content: `Response to "${newMessage.value}"`
})
}, 500)
addMessage('AI', `I heard you say "${userText}". Interesting!`)
}, 600)
}
newMessage.value = ''
const addMessage = (role, content) => {
messages.value.push({
id: msgId++,
role,
content
})
}
const autoPlay = async () => {
isAutoPlaying.value = true
const script = [
"Hello there!",
"Hi! I'm an AI assistant.",
"What is your name?",
"I am Model GPT-X.",
"Do you remember my first message?",
"Yes, you said 'Hello there!'.",
"Tell me a joke.",
"Why did the chicken cross the road?",
"To get to the other side!",
"Haha, classic.",
"Wait, what was my name again?",
"I... I don't remember. It fell out of my context window!"
]
for (const line of script) {
if (!isAutoPlaying.value) break
const role = messages.value.length % 2 === 0 ? 'User' : 'AI'
addMessage(role, line)
await new Promise(r => setTimeout(r, 1500))
}
isAutoPlaying.value = false
}
const reset = () => {
messages.value = []
msgId = 0
isAutoPlaying.value = false
}
</script>
@@ -111,127 +191,260 @@ const reset = () => {
font-family: var(--vp-font-family-mono);
}
.viz-container {
display: flex;
flex-direction: column;
gap: 1rem;
margin-bottom: 1.5rem;
min-height: 300px;
}
.message-zone {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.message-zone.history {
opacity: 0.5;
border-bottom: 2px dashed var(--vp-c-divider);
padding-bottom: 1rem;
}
.window-frame {
border: 2px solid var(--vp-c-brand);
border-radius: 8px;
padding: 1rem;
background: var(--vp-c-bg);
position: relative;
flex: 1;
display: flex;
flex-direction: column;
}
.window-header {
.control-panel {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
font-size: 0.8rem;
font-weight: bold;
color: var(--vp-c-brand);
border-bottom: 1px solid var(--vp-c-divider);
padding-bottom: 0.5rem;
}
.message-bubble {
padding: 0.5rem 0.8rem;
align-items: center;
margin-bottom: 1.5rem;
background: var(--vp-c-bg);
padding: 1rem;
border-radius: 6px;
background: var(--vp-c-bg-alt);
font-size: 0.9rem;
border: 1px solid var(--vp-c-divider);
}
.message-bubble.User {
align-self: flex-end;
background: #eff6ff;
border-color: #bfdbfe;
color: #1e3a8a;
}
.message-bubble.AI {
align-self: flex-start;
background: #f0fdf4;
border-color: #bbf7d0;
color: #14532d;
}
.message-bubble.faded {
background: var(--vp-c-bg-soft);
color: var(--vp-c-text-3);
border-color: transparent;
}
.empty-state {
text-align: center;
color: var(--vp-c-text-3);
margin-top: 2rem;
}
.controls {
.info-stat {
display: flex;
flex-direction: column;
}
.info-stat .label {
font-size: 0.75rem;
color: var(--vp-c-text-2);
}
.info-stat .value {
font-weight: bold;
font-size: 1.1rem;
}
.actions {
display: flex;
gap: 0.5rem;
}
.input-group {
.action-btn {
padding: 0.25rem 0.75rem;
border-radius: 4px;
background-color: var(--vp-c-brand);
color: white;
font-size: 0.85rem;
border: none;
cursor: pointer;
transition: opacity 0.2s;
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn.outline {
background-color: transparent;
border: 1px solid var(--vp-c-divider);
color: var(--vp-c-text-1);
}
.visualization-area {
margin-bottom: 1.5rem;
background: var(--vp-c-bg-alt);
border-radius: 8px;
padding: 1rem;
border: 1px solid var(--vp-c-divider);
}
.conversation-stream {
display: flex;
flex-direction: column;
gap: 0;
}
.zone {
padding: 1rem;
border-radius: 6px;
transition: all 0.3s;
}
.history-zone {
background-color: rgba(0, 0, 0, 0.03);
border: 1px dashed var(--vp-c-divider);
margin-bottom: 0.5rem;
opacity: 0.6;
}
.active-zone {
background-color: var(--vp-c-bg);
border: 2px solid var(--vp-c-brand);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
margin-top: 0.5rem;
min-height: 150px;
}
.zone-label {
font-size: 0.8rem;
font-weight: bold;
color: var(--vp-c-text-2);
margin-bottom: 0.8rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.window-divider {
display: flex;
align-items: center;
gap: 1rem;
color: var(--vp-c-text-3);
font-size: 0.75rem;
margin: 0.5rem 0;
}
.divider-line {
flex: 1;
height: 1px;
background-color: var(--vp-c-divider);
}
.message-bubble {
display: flex;
gap: 0.8rem;
margin-bottom: 0.8rem;
padding: 0.6rem;
border-radius: 6px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
transition: all 0.5s ease;
}
.message-bubble.history {
filter: grayscale(100%);
opacity: 0.7;
}
.message-bubble.user .avatar {
order: 1;
}
.message-bubble.user {
flex-direction: row-reverse;
text-align: right;
}
.message-bubble.user .content {
align-items: flex-end;
}
.avatar {
font-size: 1.2rem;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
background: var(--vp-c-bg-soft);
border-radius: 50%;
}
.content {
display: flex;
flex-direction: column;
max-width: 80%;
}
.role-name {
font-size: 0.7rem;
color: var(--vp-c-text-3);
margin-bottom: 0.2rem;
}
.text {
font-size: 0.9rem;
line-height: 1.4;
}
.empty-placeholder {
text-align: center;
color: var(--vp-c-text-3);
font-style: italic;
padding: 1rem;
font-size: 0.9rem;
}
.input-section {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
input {
flex: 1;
padding: 0.6rem;
padding: 0.75rem;
border: 1px solid var(--vp-c-divider);
border-radius: 4px;
border-radius: 6px;
background: var(--vp-c-bg);
color: var(--vp-c-text-1);
}
button {
padding: 0.5rem 1rem;
input:focus {
outline: none;
border-color: var(--vp-c-brand);
}
.send-btn {
padding: 0 1.5rem;
background: var(--vp-c-brand);
color: white;
border: none;
border-radius: 4px;
border-radius: 6px;
font-weight: bold;
cursor: pointer;
transition: background 0.2s;
}
button.secondary {
background: var(--vp-c-bg-alt);
color: var(--vp-c-text-1);
border: 1px solid var(--vp-c-divider);
.send-btn:hover {
background: var(--vp-c-brand-dark);
}
/* Transitions */
.list-enter-active,
.list-leave-active {
.send-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.info-box {
background-color: var(--vp-c-bg-alt);
padding: 1rem;
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.5;
color: var(--vp-c-text-2);
}
.info-box .icon {
margin-right: 0.5rem;
}
/* Animations */
.slide-list-enter-active,
.slide-list-leave-active,
.fade-list-enter-active,
.fade-list-leave-active {
transition: all 0.5s ease;
}
.list-enter-from {
.slide-list-enter-from {
opacity: 0;
transform: translateY(20px);
}
.list-leave-to {
.slide-list-leave-to {
opacity: 0;
transform: translateY(-20px);
}
.fade-list-enter-from {
opacity: 0;
}
.fade-list-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>
@@ -0,0 +1,545 @@
<!--
CFGScaleDemo.vue
CFG Scale 演示组件
用途
展示 Classifier-Free Guidance (CFG) Scale 如何影响生成结果帮助用户理解提示词遵循度的概念
交互功能
- CFG Scale 滑动调节
- 实时对比不同 CFG 值的效果
- 可视化 CFG 对图像的影响
-->
<template>
<div class="cfg-scale-demo">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><ScaleToOriginal /></el-icon>
<span> CFG Scale提示词遵循度</span>
</div>
</template>
<div class="demo-content">
<!-- CFG 控制 -->
<div class="cfg-control">
<div class="cfg-slider-section">
<div class="cfg-label">
<span>CFG Scale</span>
<el-tag type="primary" effect="dark" size="large">{{ cfgScale }}</el-tag>
</div>
<el-slider
v-model="cfgScale"
:min="1"
:max="15"
:step="0.5"
show-stops
:marks="{
1: '1\n(自由创作)',
7: '7\n(平衡)',
15: '15\n(严格遵循)'
}"
/>
</div>
<div class="cfg-presets">
<el-button
v-for="preset in cfgPresets"
:key="preset.value"
:type="cfgScale === preset.value ? 'primary' : ''"
size="small"
@click="cfgScale = preset.value"
>
{{ preset.label }}
</el-button>
</div>
</div>
<!-- 对比展示 -->
<div class="comparison-display">
<div class="comparison-item">
<div class="item-label">
<el-tag type="info">无条件生成</el-tag>
<span class="cfg-value">CFG = 1</span>
</div>
<canvas
ref="uncondCanvas"
width="200"
height="200"
class="comparison-canvas"
/>
<div class="item-desc">忽略提示词自由发挥</div>
</div>
<div class="comparison-arrow">
<el-icon :size="32"><ArrowRight /></el-icon>
<div class="guidance-formula">
<div class="formula">输出 = 无条件 + CFG × (有条件 - 无条件)</div>
<div class="formula-desc">CFG 越大提示词影响越强</div>
</div>
</div>
<div class="comparison-item">
<div class="item-label">
<el-tag type="success">当前设置</el-tag>
<span class="cfg-value">CFG = {{ cfgScale }}</span>
</div>
<canvas
ref="currentCanvas"
width="200"
height="200"
class="comparison-canvas"
/>
<div class="item-desc">{{ getCfgDescription() }}</div>
</div>
</div>
<!-- CFG 效果展示 -->
<div class="cfg-effects">
<div class="effects-title">不同 CFG 值的效果对比</div>
<div class="effects-grid">
<div
v-for="effect in cfgEffects"
:key="effect.value"
class="effect-item"
:class="{ active: cfgScale === effect.value }"
@click="cfgScale = effect.value"
>
<canvas
:ref="el => setEffectCanvas(el, effect.value)"
width="120"
height="120"
class="effect-canvas"
/>
<div class="effect-label">CFG {{ effect.value }}</div>
<div class="effect-desc">{{ effect.desc }}</div>
</div>
</div>
</div>
<!-- 推荐设置 -->
<div class="recommendations">
<div class="rec-title">🎯 推荐设置</div>
<div class="rec-grid">
<div class="rec-item">
<div class="rec-scenario">创意探索</div>
<div class="rec-value">CFG 3-5</div>
<div class="rec-desc"> AI 更多自由适合艺术探索</div>
</div>
<div class="rec-item">
<div class="rec-scenario">平衡模式</div>
<div class="rec-value">CFG 7-9</div>
<div class="rec-desc">大多数场景的最佳选择</div>
</div>
<div class="rec-item">
<div class="rec-scenario">精确控制</div>
<div class="rec-value">CFG 12-15</div>
<div class="rec-desc">严格遵循提示词但可能过饱和</div>
</div>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>CFG Scale 原理</strong>
CFG (Classifier-Free Guidance) 控制生成结果对提示词的遵循程度值越高图像越符合提示词描述但过高会导致图像过饱和或失真
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ScaleToOriginal, ArrowRight } from '@element-plus/icons-vue'
const cfgScale = ref(7.5)
const uncondCanvas = ref(null)
const currentCanvas = ref(null)
const effectCanvases = ref({})
const cfgPresets = [
{ label: '自由 (3)', value: 3 },
{ label: '平衡 (7)', value: 7 },
{ label: '严格 (12)', value: 12 }
]
const cfgEffects = [
{ value: 1, desc: '完全自由' },
{ value: 3, desc: '创意优先' },
{ value: 5, desc: '轻度引导' },
{ value: 7, desc: '平衡' },
{ value: 9, desc: '严格遵循' },
{ value: 12, desc: '非常严格' },
{ value: 15, desc: '过度饱和' }
]
const setEffectCanvas = (el, value) => {
if (el) {
effectCanvases.value[value] = el
}
}
// 绘制目标图像
const drawTargetImage = (ctx, width, height, cfgValue) => {
// 基础图像(提示词:一只蓝色的猫)
const baseColor = { r: 100, g: 150, b: 200 }
// 根据 CFG 值调整颜色饱和度
const saturationBoost = Math.min((cfgValue - 1) / 7, 1.5)
const color = {
r: Math.min(255, baseColor.r + saturationBoost * 50),
g: Math.max(0, baseColor.g - saturationBoost * 30),
b: Math.min(255, baseColor.b + saturationBoost * 30)
}
// 背景
ctx.fillStyle = '#f0f0f0'
ctx.fillRect(0, 0, width, height)
// 猫的形状
ctx.fillStyle = `rgb(${color.r}, ${color.g}, ${color.b})`
// 身体
ctx.beginPath()
ctx.ellipse(width / 2, height * 0.65, width * 0.25, height * 0.2, 0, 0, Math.PI * 2)
ctx.fill()
// 头
ctx.beginPath()
ctx.arc(width / 2, height * 0.4, width * 0.18, 0, Math.PI * 2)
ctx.fill()
// 耳朵
ctx.beginPath()
ctx.moveTo(width * 0.35, height * 0.3)
ctx.lineTo(width * 0.3, height * 0.15)
ctx.lineTo(width * 0.42, height * 0.25)
ctx.fill()
ctx.beginPath()
ctx.moveTo(width * 0.65, height * 0.3)
ctx.lineTo(width * 0.7, height * 0.15)
ctx.lineTo(width * 0.58, height * 0.25)
ctx.fill()
// 眼睛
ctx.fillStyle = '#fff'
ctx.beginPath()
ctx.ellipse(width * 0.45, height * 0.38, width * 0.05, height * 0.04, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(width * 0.55, height * 0.38, width * 0.05, height * 0.04, 0, 0, Math.PI * 2)
ctx.fill()
// 瞳孔
ctx.fillStyle = '#000'
ctx.beginPath()
ctx.arc(width * 0.45, height * 0.38, width * 0.025, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(width * 0.55, height * 0.38, width * 0.025, 0, Math.PI * 2)
ctx.fill()
// 添加噪声(模拟低 CFG 的自由度)
if (cfgValue < 5) {
const imageData = ctx.getImageData(0, 0, width, height)
const noiseAmount = (5 - cfgValue) / 5 * 30
for (let i = 0; i < imageData.data.length; i += 4) {
const noise = (Math.random() - 0.5) * noiseAmount
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)
}
// 添加过饱和效果(高 CFG
if (cfgValue > 10) {
const imageData = ctx.getImageData(0, 0, width, height)
const oversaturation = (cfgValue - 10) / 5
for (let i = 0; i < imageData.data.length; i += 4) {
// 增强对比度
const avg = (imageData.data[i] + imageData.data[i + 1] + imageData.data[i + 2]) / 3
imageData.data[i] = Math.min(255, imageData.data[i] + (imageData.data[i] - avg) * oversaturation)
imageData.data[i + 1] = Math.min(255, imageData.data[i + 1] + (imageData.data[i + 1] - avg) * oversaturation)
imageData.data[i + 2] = Math.min(255, imageData.data[i + 2] + (imageData.data[i + 2] - avg) * oversaturation)
}
ctx.putImageData(imageData, 0, 0)
}
}
const getCfgDescription = () => {
if (cfgScale.value <= 3) return '自由创作,AI 有更多发挥空间'
if (cfgScale.value <= 7) return '平衡模式,兼顾创意和遵循'
if (cfgScale.value <= 10) return '严格遵循提示词'
return '过度控制,可能导致图像失真'
}
const updateDisplay = () => {
// 更新无条件生成
if (uncondCanvas.value) {
const ctx = uncondCanvas.value.getContext('2d')
drawTargetImage(ctx, 200, 200, 1)
}
// 更新当前设置
if (currentCanvas.value) {
const ctx = currentCanvas.value.getContext('2d')
drawTargetImage(ctx, 200, 200, cfgScale.value)
}
// 更新效果网格
cfgEffects.forEach(effect => {
const canvas = effectCanvases.value[effect.value]
if (canvas) {
const ctx = canvas.getContext('2d')
drawTargetImage(ctx, 120, 120, effect.value)
}
})
}
onMounted(updateDisplay)
watch(cfgScale, updateDisplay)
</script>
<style scoped>
.cfg-scale-demo {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.cfg-control {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.cfg-slider-section {
margin-bottom: 16px;
}
.cfg-label {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.cfg-label span {
font-weight: 500;
}
.cfg-presets {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.comparison-display {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.comparison-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.item-label {
display: flex;
align-items: center;
gap: 8px;
}
.cfg-value {
font-weight: 600;
color: var(--vp-c-text-2);
}
.comparison-canvas {
width: 180px;
height: 180px;
background: var(--vp-c-bg);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.item-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
text-align: center;
}
.comparison-arrow {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
color: var(--vp-c-brand);
}
.guidance-formula {
text-align: center;
max-width: 200px;
}
.formula {
font-size: 0.75rem;
font-family: var(--vp-font-family-mono);
background: var(--vp-c-bg);
padding: 8px;
border-radius: 4px;
margin-bottom: 4px;
}
.formula-desc {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.cfg-effects {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.effects-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.effects-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 16px;
}
.effect-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 12px;
background: var(--vp-c-bg);
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.effect-item:hover {
border-color: var(--vp-c-brand);
}
.effect-item.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.effect-canvas {
width: 100px;
height: 100px;
border-radius: 6px;
}
.effect-label {
font-weight: 500;
font-size: 0.875rem;
}
.effect-desc {
font-size: 0.75rem;
color: var(--vp-c-text-3);
text-align: center;
}
.recommendations {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.rec-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.rec-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.rec-item {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.rec-scenario {
font-weight: 500;
margin-bottom: 8px;
}
.rec-value {
font-size: 1.25rem;
font-weight: 600;
color: var(--vp-c-brand);
margin-bottom: 8px;
}
.rec-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
@media (max-width: 640px) {
.comparison-display {
flex-direction: column;
}
.comparison-arrow {
transform: rotate(90deg);
margin: 8px 0;
}
}
</style>
@@ -0,0 +1,727 @@
<!--
ControlNetDemo.vue
ControlNet 控制网络演示组件
用途
展示 ControlNet 如何精确控制图像生成包括姿态边缘深度等控制方式
交互功能
- 不同控制类型切换
- 控制强度调节
- 可视化控制信号
- 对比有无 ControlNet 的效果
-->
<template>
<div class="controlnet-demo">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><Pointer /></el-icon>
<span>🎮 ControlNet精确控制</span>
</div>
</template>
<div class="demo-content">
<!-- 控制类型选择 -->
<div class="control-types">
<div
v-for="control in controlTypes"
:key="control.id"
class="control-card"
:class="{ active: selectedControl === control.id }"
@click="selectedControl = control.id"
>
<div class="control-icon">{{ control.icon }}</div>
<div class="control-name">{{ control.name }}</div>
<div class="control-desc">{{ control.description }}</div>
</div>
</div>
<!-- 可视化流程 -->
<div class="workflow-viz">
<div class="workflow-step">
<div class="step-label">输入图像</div>
<canvas
ref="inputCanvas"
width="200"
height="200"
class="workflow-canvas"
/>
</div>
<div class="workflow-arrow">
<el-icon :size="24"><ArrowRight /></el-icon>
<div class="arrow-label">提取</div>
</div>
<div class="workflow-step">
<div class="step-label">控制信号</div>
<canvas
ref="controlCanvas"
width="200"
height="200"
class="workflow-canvas control-signal"
/>
</div>
<div class="workflow-arrow">
<el-icon :size="24"><ArrowRight /></el-icon>
<div class="arrow-label">+ 提示词</div>
</div>
<div class="workflow-step">
<div class="step-label">生成结果</div>
<canvas
ref="outputCanvas"
width="200"
height="200"
class="workflow-canvas"
/>
</div>
</div>
<!-- 控制强度 -->
<div class="strength-control">
<div class="strength-header">
<span>控制强度 (Control Strength)</span>
<el-tag type="primary" effect="dark">{{ controlStrength }}</el-tag>
</div>
<el-slider
v-model="controlStrength"
:min="0"
:max="2"
:step="0.1"
show-stops
:marks="{
0: '无控制',
1: '平衡',
2: '强控制'
}"
/>
<div class="strength-desc">
{{ getStrengthDescription() }}
</div>
</div>
<!-- 对比展示 -->
<div class="comparison-section">
<div class="comparison-title">对比有无 ControlNet</div>
<div class="comparison-grid">
<div class="comparison-item">
<div class="item-label">
<el-tag type="info">仅文本生成</el-tag>
</div>
<canvas
ref="textOnlyCanvas"
width="180"
height="180"
class="comparison-canvas"
/>
<div class="item-desc">姿态随机不可控</div>
</div>
<div class="comparison-item">
<div class="item-label">
<el-tag type="success">ControlNet 控制</el-tag>
</div>
<canvas
ref="controlNetCanvas"
width="180"
height="180"
class="comparison-canvas"
/>
<div class="item-desc">姿态精确匹配输入</div>
</div>
</div>
</div>
<!-- 应用场景 -->
<div class="use-cases">
<div class="use-cases-title">🎯 典型应用场景</div>
<div class="use-cases-grid">
<div
v-for="useCase in useCases"
:key="useCase.title"
class="use-case-card"
>
<div class="use-case-icon">{{ useCase.icon }}</div>
<div class="use-case-title">{{ useCase.title }}</div>
<div class="use-case-desc">{{ useCase.description }}</div>
</div>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>ControlNet 原理</strong>
ControlNet 是一个附加在扩散模型上的神经网络它学习从输入图像中提取特定的结构信息如姿态边缘并用这些信息引导生成过程实现精确控制
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { Pointer, ArrowRight } from '@element-plus/icons-vue'
const selectedControl = ref('pose')
const controlStrength = ref(1.0)
const inputCanvas = ref(null)
const controlCanvas = ref(null)
const outputCanvas = ref(null)
const textOnlyCanvas = ref(null)
const controlNetCanvas = ref(null)
const controlTypes = [
{
id: 'pose',
name: 'OpenPose',
icon: '🕺',
description: '姿态控制,提取人体骨骼关键点'
},
{
id: 'canny',
name: 'Canny',
icon: '✏️',
description: '边缘检测,提取图像轮廓'
},
{
id: 'depth',
name: 'Depth',
icon: '📐',
description: '深度估计,控制空间结构'
},
{
id: 'scribble',
name: 'Scribble',
icon: '🎨',
description: '涂鸦控制,手绘引导生成'
},
{
id: 'segmentation',
name: 'Segmentation',
icon: '🧩',
description: '语义分割,控制物体布局'
}
]
const useCases = [
{
icon: '👗',
title: '虚拟试衣',
description: '保持人物姿态,更换服装款式'
},
{
icon: '🏠',
title: '室内设计',
description: '基于房间结构,生成不同装修风格'
},
{
icon: '🎭',
title: '角色一致性',
description: '保持角色姿态,改变服装或场景'
},
{
icon: '📐',
title: '产品展示',
description: '固定产品角度,变换背景和光照'
}
]
const getStrengthDescription = () => {
if (controlStrength.value < 0.5) {
return '控制较弱,生成结果更自由,但可能偏离预期结构'
} else if (controlStrength.value < 1.5) {
return '平衡模式,在遵循控制和保持创意之间取得平衡'
} else {
return '强控制模式,严格遵循输入结构,但可能牺牲一些图像质量'
}
}
// 绘制姿态骨架
const drawPoseSkeleton = (ctx, width, height, isControl = false) => {
ctx.clearRect(0, 0, width, height)
if (isControl) {
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, width, height)
ctx.strokeStyle = '#0f0'
ctx.fillStyle = '#0f0'
} else {
ctx.fillStyle = '#f0f0f0'
ctx.fillRect(0, 0, width, height)
ctx.strokeStyle = '#333'
ctx.fillStyle = '#333'
}
ctx.lineWidth = isControl ? 3 : 2
// 头部
ctx.beginPath()
ctx.arc(width * 0.5, height * 0.15, width * 0.08, 0, Math.PI * 2)
ctx.stroke()
// 身体
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.23)
ctx.lineTo(width * 0.5, height * 0.5)
ctx.stroke()
// 左臂
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.3)
ctx.lineTo(width * 0.25, height * 0.4)
ctx.stroke()
// 右臂
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.3)
ctx.lineTo(width * 0.75, height * 0.35)
ctx.stroke()
// 左腿
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.5)
ctx.lineTo(width * 0.35, height * 0.8)
ctx.stroke()
// 右腿
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.5)
ctx.lineTo(width * 0.65, height * 0.75)
ctx.stroke()
// 关节点
const joints = [
[0.5, 0.23], [0.5, 0.3], [0.5, 0.5],
[0.25, 0.4], [0.75, 0.35],
[0.35, 0.8], [0.65, 0.75]
]
joints.forEach(([x, y]) => {
ctx.beginPath()
ctx.arc(width * x, height * y, isControl ? 4 : 3, 0, Math.PI * 2)
ctx.fill()
})
}
// 绘制边缘检测
const drawCannyEdges = (ctx, width, height) => {
ctx.fillStyle = '#000'
ctx.fillRect(0, 0, width, height)
ctx.strokeStyle = '#fff'
ctx.lineWidth = 2
// 绘制简单的几何形状边缘
ctx.beginPath()
ctx.moveTo(width * 0.2, height * 0.2)
ctx.lineTo(width * 0.8, height * 0.2)
ctx.lineTo(width * 0.8, height * 0.8)
ctx.lineTo(width * 0.2, height * 0.8)
ctx.closePath()
ctx.stroke()
// 内部细节
ctx.beginPath()
ctx.arc(width * 0.5, height * 0.5, width * 0.2, 0, Math.PI * 2)
ctx.stroke()
}
// 绘制深度图
const drawDepthMap = (ctx, width, height) => {
// 创建深度渐变
const gradient = ctx.createRadialGradient(
width * 0.5, height * 0.5, 0,
width * 0.5, height * 0.5, width * 0.5
)
gradient.addColorStop(0, '#fff')
gradient.addColorStop(0.5, '#888')
gradient.addColorStop(1, '#000')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, width, height)
}
// 绘制涂鸦
const drawScribble = (ctx, width, height) => {
ctx.fillStyle = '#fff'
ctx.fillRect(0, 0, width, height)
ctx.strokeStyle = '#000'
ctx.lineWidth = 3
// 随机涂鸦线条
ctx.beginPath()
for (let i = 0; i < 5; i++) {
ctx.moveTo(Math.random() * width, Math.random() * height)
ctx.lineTo(Math.random() * width, Math.random() * height)
}
ctx.stroke()
}
// 绘制语义分割
const drawSegmentation = (ctx, width, height) => {
// 天空
ctx.fillStyle = '#87CEEB'
ctx.fillRect(0, 0, width, height * 0.4)
// 地面
ctx.fillStyle = '#8B4513'
ctx.fillRect(0, height * 0.6, width, height * 0.4)
// 建筑
ctx.fillStyle = '#808080'
ctx.fillRect(width * 0.3, height * 0.2, width * 0.4, height * 0.5)
// 树木
ctx.fillStyle = '#228B22'
ctx.beginPath()
ctx.arc(width * 0.15, height * 0.5, width * 0.1, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.arc(width * 0.85, height * 0.5, width * 0.1, 0, Math.PI * 2)
ctx.fill()
}
// 绘制生成结果
const drawOutput = (ctx, width, height, withControl = true) => {
ctx.fillStyle = '#f0f0f0'
ctx.fillRect(0, 0, width, height)
// 根据控制类型绘制不同的输出
if (selectedControl.value === 'pose') {
// 绘制一个人物,姿态与骨架匹配
const strength = withControl ? controlStrength.value : 0.3
// 头部
ctx.fillStyle = '#fdbcb4'
ctx.beginPath()
ctx.arc(width * 0.5, height * 0.15, width * 0.08 * (0.5 + strength * 0.5), 0, Math.PI * 2)
ctx.fill()
// 身体
ctx.fillStyle = '#4a90e2'
ctx.fillRect(
width * (0.5 - 0.08 * strength),
height * 0.23,
width * 0.16 * strength,
height * 0.27
)
// 简单的肢体
ctx.strokeStyle = '#fdbcb4'
ctx.lineWidth = 8 * strength
// 左臂
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.3)
ctx.lineTo(width * (0.25 + (0.5 - strength) * 0.3), height * 0.4)
ctx.stroke()
// 右臂
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.3)
ctx.lineTo(width * (0.75 - (0.5 - strength) * 0.3), height * 0.35)
ctx.stroke()
} else if (selectedControl.value === 'canny') {
// 边缘控制效果
const strength = withControl ? controlStrength.value : 0.3
ctx.strokeStyle = '#333'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(width * 0.2, height * 0.2)
ctx.lineTo(width * (0.8 - (1 - strength) * 0.3), height * 0.2)
ctx.lineTo(width * 0.8, height * (0.8 - (1 - strength) * 0.2))
ctx.lineTo(width * (0.2 + (1 - strength) * 0.3), height * 0.8)
ctx.closePath()
ctx.stroke()
}
}
const updateDisplay = () => {
// 输入图像
if (inputCanvas.value) {
const ctx = inputCanvas.value.getContext('2d')
drawPoseSkeleton(ctx, 200, 200, false)
}
// 控制信号
if (controlCanvas.value) {
const ctx = controlCanvas.value.getContext('2d')
switch (selectedControl.value) {
case 'pose':
drawPoseSkeleton(ctx, 200, 200, true)
break
case 'canny':
drawCannyEdges(ctx, 200, 200)
break
case 'depth':
drawDepthMap(ctx, 200, 200)
break
case 'scribble':
drawScribble(ctx, 200, 200)
break
case 'segmentation':
drawSegmentation(ctx, 200, 200)
break
}
}
// 输出
if (outputCanvas.value) {
const ctx = outputCanvas.value.getContext('2d')
drawOutput(ctx, 200, 200, true)
}
// 对比
if (textOnlyCanvas.value) {
const ctx = textOnlyCanvas.value.getContext('2d')
drawOutput(ctx, 180, 180, false)
}
if (controlNetCanvas.value) {
const ctx = controlNetCanvas.value.getContext('2d')
drawOutput(ctx, 180, 180, true)
}
}
onMounted(updateDisplay)
watch([selectedControl, controlStrength], updateDisplay)
</script>
<style scoped>
.controlnet-demo {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.control-types {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
}
.control-card {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
text-align: center;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.control-card:hover {
border-color: var(--vp-c-brand);
}
.control-card.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.control-icon {
font-size: 2rem;
margin-bottom: 8px;
}
.control-name {
font-weight: 600;
margin-bottom: 4px;
}
.control-desc {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.workflow-viz {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
padding: 20px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.workflow-step {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.step-label {
font-weight: 500;
color: var(--vp-c-text-2);
}
.workflow-canvas {
width: 160px;
height: 160px;
background: var(--vp-c-bg);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.workflow-canvas.control-signal {
background: #000;
}
.workflow-arrow {
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
color: var(--vp-c-text-3);
}
.arrow-label {
font-size: 0.75rem;
}
.strength-control {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.strength-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.strength-desc {
margin-top: 12px;
font-size: 0.875rem;
color: var(--vp-c-text-2);
text-align: center;
}
.comparison-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.comparison-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.comparison-grid {
display: flex;
justify-content: center;
gap: 32px;
flex-wrap: wrap;
}
.comparison-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.item-label {
font-weight: 500;
}
.comparison-canvas {
width: 150px;
height: 150px;
background: var(--vp-c-bg);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.item-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.use-cases {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.use-cases-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.use-cases-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
}
.use-case-card {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.use-case-icon {
font-size: 2rem;
margin-bottom: 8px;
}
.use-case-title {
font-weight: 600;
margin-bottom: 4px;
}
.use-case-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
@media (max-width: 640px) {
.workflow-viz {
flex-direction: column;
}
.workflow-arrow {
transform: rotate(90deg);
margin: 8px 0;
}
}
</style>
@@ -0,0 +1,569 @@
<!--
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>
</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>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>小提示</strong>
提示词越详细生成效果越好尝试使用 "风格词" "赛博朋克""水彩画"来控制图像风格
</p>
</div>
</el-card>
</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 presetPrompts = [
{ label: '🐱 赛博朋克猫', prompt: '一只戴着墨镜的猫,赛博朋克风格,霓虹灯光' },
{ label: '🏔️ 山水画', prompt: '中国山水画,云雾缭绕,水墨风格' },
{ label: '🚀 太空', prompt: '宇航员在火星表面,日落时分,科幻风格' },
{ label: '🌸 樱花', prompt: '樱花树下,日本传统建筑,春天,柔和光线' },
]
// 模拟生成过程
const startGeneration = async () => {
isGenerating.value = true
progress.value = 0
currentStep.value = 0
hasGenerated.value = false
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
}
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
}
// 根据提示词绘制模拟图像
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;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@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 {
position: relative;
width: 100%;
aspect-ratio: 1;
background: var(--vp-c-bg-mute);
border-radius: 8px;
overflow: hidden;
}
.gen-canvas {
width: 100%;
height: 100%;
object-fit: contain;
}
.progress-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
gap: 16px;
}
.step-info {
color: white;
font-size: 0.9rem;
}
.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;
}
</style>
@@ -0,0 +1,651 @@
<!--
LoRADemo.vue
LoRA 微调演示组件
用途
展示 LoRA (Low-Rank Adaptation) 如何以轻量级方式微调模型实现特定风格或角色的生成
交互功能
- LoRA 权重调节
- 基础模型 + LoRA 组合展示
- 对比不同权重的生成效果
- LoRA 融合可视化
-->
<template>
<div class="lora-demo">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><Collection /></el-icon>
<span>🎨 LoRA轻量级微调</span>
</div>
</template>
<div class="demo-content">
<!-- LoRA 概念说明 -->
<div class="concept-section">
<div class="concept-visual">
<div class="model-box base">
<div class="box-title">基础模型</div>
<div class="box-size">4-8 GB</div>
<div class="box-desc">通用知识</div>
</div>
<div class="plus-sign">+</div>
<div class="model-box lora">
<div class="box-title">LoRA 权重</div>
<div class="box-size">50-200 MB</div>
<div class="box-desc">特定风格/角色</div>
</div>
<div class="equals-sign">=</div>
<div class="model-box result">
<div class="box-title">定制模型</div>
<div class="box-size">无需合并</div>
<div class="box-desc">动态加载</div>
</div>
</div>
</div>
<!-- LoRA 权重调节 -->
<div class="weight-control-section">
<div class="weight-header">
<span>LoRA 权重调节</span>
<el-tag type="primary" effect="dark">{{ loraWeight }}</el-tag>
</div>
<el-slider
v-model="loraWeight"
:min="0"
:max="1.5"
:step="0.1"
show-stops
:marks="{
0: '无效果',
0.5: '轻微',
1: '标准',
1.5: '强烈'
}"
/>
<div class="lora-selector">
<el-radio-group v-model="selectedLoRA">
<el-radio-button label="anime">动漫风格</el-radio-button>
<el-radio-button label="realistic">写实风格</el-radio-button>
<el-radio-button label="sketch">素描风格</el-radio-button>
<el-radio-button label="3d">3D 风格</el-radio-button>
</el-radio-group>
</div>
</div>
<!-- 效果对比 -->
<div class="comparison-section">
<div class="comparison-title">生成效果对比</div>
<div class="comparison-grid">
<div class="comparison-item">
<div class="item-label">
<el-tag type="info">仅基础模型</el-tag>
</div>
<canvas
ref="baseCanvas"
width="200"
height="200"
class="comparison-canvas"
/>
<div class="item-desc">通用风格</div>
</div>
<div class="comparison-item main">
<div class="item-label">
<el-tag type="success">基础 + LoRA ({{ loraWeight }})</el-tag>
</div>
<canvas
ref="loraCanvas"
width="200"
height="200"
class="comparison-canvas main-canvas"
/>
<div class="item-desc">{{ getLoRADescription() }}</div>
</div>
</div>
</div>
<!-- LoRA 融合 -->
<div class="fusion-section">
<div class="fusion-title">🔀 LoRA 融合</div>
<div class="fusion-controls">
<div
v-for="(lora, index) in activeLoRAs"
:key="index"
class="fusion-item"
>
<el-tag :type="lora.type" closable @close="removeLoRA(index)">
{{ lora.name }}
</el-tag>
<el-slider
v-model="lora.weight"
:min="0"
:max="1"
:step="0.1"
size="small"
style="width: 120px"
/>
<span class="weight-display">{{ lora.weight }}</span>
</div>
<el-dropdown @command="addLoRA">
<el-button type="primary" size="small">
<el-icon><Plus /></el-icon> 添加 LoRA
</el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="anime">动漫风格</el-dropdown-item>
<el-dropdown-item command="realistic">写实风格</el-dropdown-item>
<el-dropdown-item command="sketch">素描风格</el-dropdown-item>
<el-dropdown-item command="3d">3D 风格</el-dropdown-item>
<el-dropdown-item command="watercolor">水彩风格</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<div class="fusion-result">
<canvas
ref="fusionCanvas"
width="250"
height="250"
class="fusion-canvas"
/>
<div class="fusion-formula">
<div class="formula-title">融合公式</div>
<div class="formula-content">
输出 = 基础模型 + Σ(LoRAᵢ × 权重ᵢ)
</div>
</div>
</div>
</div>
<!-- 应用场景 -->
<div class="use-cases">
<div class="use-cases-title">🎯 LoRA 典型应用</div>
<div class="use-cases-grid">
<div class="use-case-card">
<div class="use-case-icon">👤</div>
<div class="use-case-title">角色一致性</div>
<div class="use-case-desc">训练特定角色保持形象一致</div>
</div>
<div class="use-case-card">
<div class="use-case-icon">🎨</div>
<div class="use-case-title">艺术风格</div>
<div class="use-case-desc">模仿特定画家或艺术风格</div>
</div>
<div class="use-case-card">
<div class="use-case-icon">👗</div>
<div class="use-case-title">服装概念</div>
<div class="use-case-desc">特定服装或配饰设计</div>
</div>
<div class="use-case-card">
<div class="use-case-icon">🏢</div>
<div class="use-case-title">产品展示</div>
<div class="use-case-desc">特定产品或品牌风格</div>
</div>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>LoRA 原理</strong>
LoRA 通过在原始权重矩阵旁边添加低秩矩阵来进行微调只训练少量参数通常 < 1%就能实现特定风格或角色的学习相比完整微调LoRA 文件小训练快可组合使用
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { Collection, Plus } from '@element-plus/icons-vue'
const loraWeight = ref(0.8)
const selectedLoRA = ref('anime')
const baseCanvas = ref(null)
const loraCanvas = ref(null)
const fusionCanvas = ref(null)
const activeLoRAs = ref([
{ name: '动漫风格', type: 'primary', weight: 0.6 },
{ name: '水彩效果', type: 'success', weight: 0.3 }
])
const loraTypes = {
anime: { name: '动漫风格', type: 'primary', color: '#FFB6C1' },
realistic: { name: '写实风格', type: 'success', color: '#DDA0DD' },
sketch: { name: '素描风格', type: 'warning', color: '#D3D3D3' },
'3d': { name: '3D 风格', type: 'danger', color: '#87CEEB' },
watercolor: { name: '水彩效果', type: 'info', color: '#98FB98' }
}
const getLoRADescription = () => {
const descriptions = {
anime: '大眼睛、鲜明色彩的动漫风格',
realistic: '照片级真实感',
sketch: '手绘线条和阴影',
'3d': '立体感和材质渲染',
watercolor: '柔和的水彩晕染效果'
}
return descriptions[selectedLoRA.value] || ''
}
const addLoRA = (command) => {
const loraInfo = loraTypes[command]
if (loraInfo) {
activeLoRAs.value.push({
name: loraInfo.name,
type: loraInfo.type,
weight: 0.5
})
}
}
const removeLoRA = (index) => {
activeLoRAs.value.splice(index, 1)
}
// 绘制基础图像
const drawBaseImage = (ctx, width, height) => {
ctx.fillStyle = '#f5f5f5'
ctx.fillRect(0, 0, width, height)
// 绘制一个简单的角色轮廓
ctx.strokeStyle = '#666'
ctx.lineWidth = 2
// 头部
ctx.beginPath()
ctx.arc(width * 0.5, height * 0.3, width * 0.2, 0, Math.PI * 2)
ctx.stroke()
// 身体
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.5)
ctx.lineTo(width * 0.5, height * 0.8)
ctx.stroke()
// 手臂
ctx.beginPath()
ctx.moveTo(width * 0.5, height * 0.55)
ctx.lineTo(width * 0.25, height * 0.7)
ctx.moveTo(width * 0.5, height * 0.55)
ctx.lineTo(width * 0.75, height * 0.7)
ctx.stroke()
}
// 绘制 LoRA 效果
const drawLoRAImage = (ctx, width, height, loraType, weight) => {
// 先画基础
drawBaseImage(ctx, width, height)
// 根据 LoRA 类型添加效果
const effects = {
anime: () => {
// 动漫风格:大眼睛、鲜艳色彩
ctx.fillStyle = `rgba(255, 182, 193, ${weight * 0.5})`
ctx.fillRect(0, 0, width, height)
// 大眼睛
ctx.fillStyle = `rgba(100, 149, 237, ${weight})`
ctx.beginPath()
ctx.ellipse(width * 0.42, height * 0.28, width * 0.08 * weight, width * 0.1 * weight, 0, 0, Math.PI * 2)
ctx.fill()
ctx.beginPath()
ctx.ellipse(width * 0.58, height * 0.28, width * 0.08 * weight, width * 0.1 * weight, 0, 0, Math.PI * 2)
ctx.fill()
},
realistic: () => {
// 写实风格:阴影、细节
ctx.fillStyle = `rgba(139, 69, 19, ${weight * 0.3})`
ctx.fillRect(0, 0, width, height)
// 添加阴影
ctx.fillStyle = `rgba(0, 0, 0, ${weight * 0.2})`
ctx.beginPath()
ctx.ellipse(width * 0.5, height * 0.85, width * 0.3, height * 0.05, 0, 0, Math.PI * 2)
ctx.fill()
},
sketch: () => {
// 素描风格:线条、交叉阴影
ctx.strokeStyle = `rgba(0, 0, 0, ${weight * 0.5})`
ctx.lineWidth = 1
for (let i = 0; i < 10; i++) {
ctx.beginPath()
ctx.moveTo(0, i * height * 0.1)
ctx.lineTo(width, i * height * 0.1 + height * 0.1)
ctx.stroke()
}
},
'3d': () => {
// 3D 风格:渐变、立体感
const gradient = ctx.createRadialGradient(
width * 0.3, height * 0.3, 0,
width * 0.5, height * 0.5, width * 0.6
)
gradient.addColorStop(0, `rgba(255, 255, 255, ${weight * 0.5})`)
gradient.addColorStop(1, `rgba(0, 0, 0, ${weight * 0.2})`)
ctx.fillStyle = gradient
ctx.fillRect(0, 0, width, height)
}
}
if (effects[loraType]) {
effects[loraType]()
}
}
// 绘制融合效果
const drawFusionImage = (ctx, width, height) => {
ctx.fillStyle = '#f5f5f5'
ctx.fillRect(0, 0, width, height)
// 基础图像
drawBaseImage(ctx, width, height)
// 叠加所有 LoRA 效果
activeLoRAs.value.forEach(lora => {
const loraKey = Object.keys(loraTypes).find(
key => loraTypes[key].name === lora.name
)
if (loraKey) {
ctx.save()
ctx.globalAlpha = lora.weight
drawLoRAImage(ctx, width, height, loraKey, 1)
ctx.restore()
}
})
}
const updateDisplay = () => {
if (baseCanvas.value) {
const ctx = baseCanvas.value.getContext('2d')
drawBaseImage(ctx, 200, 200)
}
if (loraCanvas.value) {
const ctx = loraCanvas.value.getContext('2d')
drawLoRAImage(ctx, 200, 200, selectedLoRA.value, loraWeight.value)
}
if (fusionCanvas.value) {
const ctx = fusionCanvas.value.getContext('2d')
drawFusionImage(ctx, 250, 250)
}
}
onMounted(updateDisplay)
watch([loraWeight, selectedLoRA, activeLoRAs], updateDisplay, { deep: true })
</script>
<style scoped>
.lora-demo {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.concept-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.concept-visual {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
}
.model-box {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px 24px;
text-align: center;
border: 2px solid var(--vp-c-divider);
min-width: 120px;
}
.model-box.base {
border-color: #409eff;
}
.model-box.lora {
border-color: #67c23a;
}
.model-box.result {
border-color: #e6a23c;
}
.box-title {
font-weight: 600;
margin-bottom: 4px;
}
.box-size {
font-size: 0.75rem;
color: var(--vp-c-text-3);
margin-bottom: 4px;
}
.box-desc {
font-size: 0.8rem;
color: var(--vp-c-text-2);
}
.plus-sign, .equals-sign {
font-size: 1.5rem;
font-weight: 600;
color: var(--vp-c-text-3);
}
.weight-control-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.weight-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.lora-selector {
margin-top: 16px;
display: flex;
justify-content: center;
}
.comparison-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.comparison-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.comparison-grid {
display: flex;
justify-content: center;
gap: 32px;
flex-wrap: wrap;
}
.comparison-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.comparison-item.main {
transform: scale(1.1);
}
.item-label {
font-weight: 500;
}
.comparison-canvas {
width: 160px;
height: 160px;
background: var(--vp-c-bg);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.comparison-canvas.main-canvas {
border-color: var(--vp-c-brand);
}
.item-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.fusion-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.fusion-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.fusion-controls {
display: flex;
flex-wrap: wrap;
gap: 12px;
justify-content: center;
margin-bottom: 20px;
}
.fusion-item {
display: flex;
align-items: center;
gap: 8px;
background: var(--vp-c-bg);
padding: 8px 12px;
border-radius: 6px;
}
.weight-display {
font-size: 0.875rem;
color: var(--vp-c-text-3);
min-width: 40px;
}
.fusion-result {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
flex-wrap: wrap;
}
.fusion-canvas {
width: 200px;
height: 200px;
background: var(--vp-c-bg);
border-radius: 8px;
border: 2px solid var(--vp-c-brand);
}
.fusion-formula {
text-align: center;
}
.formula-title {
font-weight: 500;
margin-bottom: 8px;
}
.formula-content {
font-family: var(--vp-font-family-mono);
font-size: 0.875rem;
background: var(--vp-c-bg);
padding: 12px;
border-radius: 6px;
}
.use-cases {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.use-cases-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.use-cases-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
}
.use-case-card {
background: var(--vp-c-bg);
border-radius: 8px;
padding: 16px;
text-align: center;
}
.use-case-icon {
font-size: 2rem;
margin-bottom: 8px;
}
.use-case-title {
font-weight: 600;
margin-bottom: 4px;
}
.use-case-desc {
font-size: 0.75rem;
color: var(--vp-c-text-3);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
</style>
@@ -0,0 +1,497 @@
<!--
PromptEngineeringDemo.vue
提示词工程演示组件
用途
展示提示词如何影响生成结果帮助用户理解提示词工程的重要性
交互功能
- 提示词实时编辑
- 关键词提取和高亮
- 权重调节
- 对比不同提示词的效果
-->
<template>
<div class="prompt-engineering-demo">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><EditPen /></el-icon>
<span> 提示词工程实验室</span>
</div>
</template>
<div class="demo-layout">
<!-- 左侧提示词编辑 -->
<div class="prompt-panel">
<div class="prompt-input-section">
<label>提示词 (Prompt)</label>
<el-input
v-model="prompt"
type="textarea"
:rows="4"
placeholder="输入你的提示词..."
/>
</div>
<div class="prompt-analysis">
<div class="analysis-title">关键词分析</div>
<div class="keywords-list">
<div
v-for="(keyword, index) in analyzedKeywords"
:key="index"
class="keyword-item"
:class="keyword.type"
>
<span class="keyword-text">{{ keyword.text }}</span>
<el-slider
v-model="keyword.weight"
:min="0"
:max="2"
:step="0.1"
size="small"
class="weight-slider"
/>
<span class="weight-value">{{ keyword.weight.toFixed(1) }}</span>
</div>
</div>
</div>
<div class="prompt-tips">
<el-collapse>
<el-collapse-item title="💡 提示词技巧">
<ul class="tips-list">
<li><strong>主体描述</strong>明确你要画什么 "一只橘猫"</li>
<li><strong>风格词</strong>指定艺术风格 "水彩画""赛博朋克"</li>
<li><strong>质量词</strong>提升画质 "8k"" masterpiece""highly detailed"</li>
<li><strong>光照</strong>控制光线效果 "golden hour""volumetric lighting"</li>
<li><strong>权重语法</strong>使用 (word:1.5) 增加权重(word:0.5) 降低权重</li>
</ul>
</el-collapse-item>
</el-collapse>
</div>
</div>
<!-- 右侧效果预览 -->
<div class="preview-panel">
<div class="preview-tabs">
<el-tabs v-model="activeTab">
<el-tab-pane label="结构解析" name="structure">
<div class="structure-viz">
<div class="structure-section">
<div class="section-header">
<el-tag type="primary">主体 (Subject)</el-tag>
</div>
<div class="section-content">
{{ extractSubject() || '未检测到主体' }}
</div>
</div>
<div class="structure-section">
<div class="section-header">
<el-tag type="success">风格 (Style)</el-tag>
</div>
<div class="section-content">
{{ extractStyle() || '未检测到风格词' }}
</div>
</div>
<div class="structure-section">
<div class="section-header">
<el-tag type="warning">质量 (Quality)</el-tag>
</div>
<div class="section-content">
{{ extractQuality() || '未检测到质量词' }}
</div>
</div>
<div class="structure-section">
<div class="section-header">
<el-tag type="info">环境 (Environment)</el-tag>
</div>
<div class="section-content">
{{ extractEnvironment() || '未检测到环境描述' }}
</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="对比示例" name="comparison">
<div class="comparison-list">
<div
v-for="(example, index) in promptExamples"
:key="index"
class="comparison-item"
:class="{ active: selectedExample === index }"
@click="selectExample(index)"
>
<div class="example-prompt">{{ example.prompt }}</div>
<div class="example-desc">{{ example.description }}</div>
</div>
</div>
</el-tab-pane>
<el-tab-pane label="负面提示词" name="negative">
<div class="negative-prompt-section">
<label>负面提示词 (Negative Prompt)</label>
<el-input
v-model="negativePrompt"
type="textarea"
:rows="3"
placeholder="输入你不希望出现的内容..."
/>
<div class="negative-presets">
<el-tag
v-for="preset in negativePresets"
:key="preset"
size="small"
class="negative-preset-tag"
@click="addNegativePreset(preset)"
>
+ {{ preset }}
</el-tag>
</div>
</div>
</el-tab-pane>
</el-tabs>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>提示词工程的核心</strong>
好的提示词 = 清晰的描述 + 适当的风格词 + 质量增强词通过调整不同部分的权重可以精确控制生成结果
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { EditPen } from '@element-plus/icons-vue'
const prompt = ref('一只橘猫,坐在窗台上,阳光照射,水彩画风格,8k高清')
const negativePrompt = ref('模糊, 低质量, 变形, 多余的手指')
const activeTab = ref('structure')
const selectedExample = ref(0)
//
const keywordTypes = {
subject: ['猫', '狗', '人', '风景', '建筑', '汽车', '花', '树'],
style: ['水彩', '油画', '素描', '赛博朋克', '像素', '写实', '卡通', '动漫'],
quality: ['8k', '高清', ' masterpiece', 'detailed', 'high quality', '4k', 'sharp'],
environment: ['阳光', '雨天', '夜晚', '森林', '城市', '海边', '室内', '户外']
}
//
const analyzedKeywords = computed(() => {
const keywords = []
const words = prompt.value.split(/[,\s]+/).filter(w => w.length > 0)
words.forEach(word => {
let type = 'other'
if (keywordTypes.subject.some(k => word.includes(k))) type = 'subject'
else if (keywordTypes.style.some(k => word.includes(k))) type = 'style'
else if (keywordTypes.quality.some(k => word.toLowerCase().includes(k.toLowerCase()))) type = 'quality'
else if (keywordTypes.environment.some(k => word.includes(k))) type = 'environment'
keywords.push({
text: word,
type,
weight: 1.0
})
})
return keywords
})
//
const extractSubject = () => {
return analyzedKeywords.value
.filter(k => k.type === 'subject')
.map(k => k.text)
.join(', ')
}
const extractStyle = () => {
return analyzedKeywords.value
.filter(k => k.type === 'style')
.map(k => k.text)
.join(', ')
}
const extractQuality = () => {
return analyzedKeywords.value
.filter(k => k.type === 'quality')
.map(k => k.text)
.join(', ')
}
const extractEnvironment = () => {
return analyzedKeywords.value
.filter(k => k.type === 'environment')
.map(k => k.text)
.join(', ')
}
//
const promptExamples = [
{
prompt: '一只猫',
description: '基础描述,结果可能不够理想'
},
{
prompt: '一只橘猫,坐在窗台上',
description: '添加主体细节和场景'
},
{
prompt: '一只橘猫,坐在窗台上,阳光照射,水彩画风格',
description: '添加光照和风格'
},
{
prompt: '一只橘猫,坐在窗台上,阳光照射,水彩画风格,8k高清, masterpiece',
description: '完整提示词,包含质量词'
}
]
//
const negativePresets = [
'模糊',
'低质量',
'变形',
'多余的手指',
'扭曲的脸',
'噪点',
'水印',
'文字'
]
const selectExample = (index) => {
selectedExample.value = index
prompt.value = promptExamples[index].prompt
}
const addNegativePreset = (preset) => {
if (!negativePrompt.value.includes(preset)) {
negativePrompt.value = negativePrompt.value
? `${negativePrompt.value}, ${preset}`
: preset
}
}
</script>
<style scoped>
.prompt-engineering-demo {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 24px;
}
@media (max-width: 768px) {
.demo-layout {
grid-template-columns: 1fr;
}
}
.prompt-panel {
display: flex;
flex-direction: column;
gap: 16px;
}
.prompt-input-section label {
display: block;
font-size: 0.875rem;
font-weight: 500;
margin-bottom: 8px;
color: var(--vp-c-text-2);
}
.prompt-analysis {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
}
.analysis-title {
font-weight: 500;
margin-bottom: 12px;
}
.keywords-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.keyword-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px;
background: var(--vp-c-bg);
border-radius: 6px;
border-left: 3px solid var(--vp-c-divider);
}
.keyword-item.subject {
border-left-color: #409eff;
}
.keyword-item.style {
border-left-color: #67c23a;
}
.keyword-item.quality {
border-left-color: #e6a23c;
}
.keyword-item.environment {
border-left-color: #909399;
}
.keyword-text {
min-width: 80px;
font-size: 0.875rem;
}
.weight-slider {
flex: 1;
}
.weight-value {
min-width: 40px;
text-align: right;
font-size: 0.875rem;
color: var(--vp-c-text-3);
}
.prompt-tips {
margin-top: 8px;
}
.tips-list {
margin: 0;
padding-left: 20px;
}
.tips-list li {
margin-bottom: 8px;
line-height: 1.6;
}
.preview-panel {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
}
.structure-viz {
display: flex;
flex-direction: column;
gap: 16px;
}
.structure-section {
background: var(--vp-c-bg);
border-radius: 6px;
padding: 12px;
}
.section-header {
margin-bottom: 8px;
}
.section-content {
font-size: 0.9rem;
color: var(--vp-c-text-2);
min-height: 24px;
}
.comparison-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.comparison-item {
padding: 12px;
background: var(--vp-c-bg);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.comparison-item:hover {
border-color: var(--vp-c-brand);
}
.comparison-item.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.example-prompt {
font-weight: 500;
margin-bottom: 4px;
}
.example-desc {
font-size: 0.8rem;
color: var(--vp-c-text-3);
}
.negative-prompt-section {
display: flex;
flex-direction: column;
gap: 12px;
}
.negative-prompt-section label {
font-size: 0.875rem;
font-weight: 500;
}
.negative-presets {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.negative-preset-tag {
cursor: pointer;
transition: all 0.2s;
}
.negative-preset-tag:hover {
transform: translateY(-2px);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
</style>
@@ -0,0 +1,559 @@
<!--
SamplerComparisonDemo.vue
采样器对比演示组件
用途
展示不同采样器Euler, DPM++, DDIM 的生成特点帮助用户选择合适的采样器
交互功能
- 采样器选择对比
- 步数调节
- 生成路径可视化
- 速度/质量权衡展示
-->
<template>
<div class="sampler-demo">
<el-card shadow="never">
<template #header>
<div class="header-title">
<el-icon><Timer /></el-icon>
<span> 采样器对比</span>
</div>
</template>
<div class="demo-content">
<!-- 采样器列表 -->
<div class="sampler-list">
<div
v-for="sampler in samplers"
:key="sampler.id"
class="sampler-card"
:class="{ active: selectedSampler === sampler.id }"
@click="selectedSampler = sampler.id"
>
<div class="sampler-header">
<span class="sampler-name">{{ sampler.name }}</span>
<el-tag :type="sampler.speed" size="small">{{ sampler.speedLabel }}</el-tag>
</div>
<div class="sampler-desc">{{ sampler.description }}</div>
<div class="sampler-pros-cons">
<div class="pros">
<el-icon><CircleCheck /></el-icon>
{{ sampler.pros }}
</div>
<div class="cons">
<el-icon><CircleClose /></el-icon>
{{ sampler.cons }}
</div>
</div>
</div>
</div>
<!-- 可视化对比 -->
<div class="visualization-section">
<div class="viz-header">
<span class="viz-title">生成路径可视化</span>
<el-slider
v-model="steps"
:min="10"
:max="50"
:step="5"
show-stops
style="width: 200px"
/>
<span class="steps-label">{{ steps }} </span>
</div>
<div class="path-visualization">
<canvas
ref="pathCanvas"
width="600"
height="300"
class="path-canvas"
/>
</div>
<div class="sampler-details">
<el-descriptions :column="2" border>
<el-descriptions-item label="推荐步数">
{{ currentSampler.recommendedSteps }}
</el-descriptions-item>
<el-descriptions-item label="收敛速度">
{{ currentSampler.convergence }}
</el-descriptions-item>
<el-descriptions-item label="适用场景">
{{ currentSampler.useCase }}
</el-descriptions-item>
<el-descriptions-item label="稳定性">
<el-rate
:model-value="currentSampler.stability"
disabled
show-score
text-color="#ff9900"
/>
</el-descriptions-item>
</el-descriptions>
</div>
</div>
<!-- 推荐矩阵 -->
<div class="recommendation-matrix">
<div class="matrix-title">🎯 采样器选择指南</div>
<div class="matrix-grid">
<div class="matrix-row header">
<div class="matrix-cell">场景</div>
<div class="matrix-cell">推荐采样器</div>
<div class="matrix-cell">原因</div>
</div>
<div
v-for="rec in recommendations"
:key="rec.scenario"
class="matrix-row"
>
<div class="matrix-cell scenario">{{ rec.scenario }}</div>
<div class="matrix-cell">
<el-tag type="primary">{{ rec.sampler }}</el-tag>
</div>
<div class="matrix-cell reason">{{ rec.reason }}</div>
</div>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>采样器的作用</strong>
采样器决定了如何从噪声中逐步恢复图像不同的采样器有不同的数学特性影响生成速度质量和稳定性
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch } from 'vue'
import { Timer, CircleCheck, CircleClose } from '@element-plus/icons-vue'
const selectedSampler = ref('euler')
const steps = ref(20)
const pathCanvas = ref(null)
const samplers = [
{
id: 'euler',
name: 'Euler',
speed: 'success',
speedLabel: '快速',
description: '最简单高效的采样器,适合快速预览',
pros: '速度快,内存占用低',
cons: '步数少时可能不够精细',
recommendedSteps: '20-30',
convergence: '中等',
useCase: '快速迭代、草图生成',
stability: 3
},
{
id: 'euler_a',
name: 'Euler a',
speed: 'success',
speedLabel: '快速',
description: 'Euler 的祖先版本,更具创造性',
pros: '生成结果更有创意',
cons: '收敛性较差,结果不稳定',
recommendedSteps: '25-35',
convergence: '慢',
useCase: '艺术创作、探索性生成',
stability: 2
},
{
id: 'dpm',
name: 'DPM++ 2M',
speed: 'warning',
speedLabel: '中等',
description: '当前最流行的采样器,平衡了速度和质量',
pros: '质量高,收敛快',
cons: '计算量稍大',
recommendedSteps: '20-30',
convergence: '快',
useCase: '大多数场景的首选',
stability: 5
},
{
id: 'dpm_karras',
name: 'DPM++ 2M Karras',
speed: 'warning',
speedLabel: '中等',
description: '使用 Karras 噪声调度的 DPM++',
pros: '低步数也能出好效果',
cons: '需要更多显存',
recommendedSteps: '15-25',
convergence: '很快',
useCase: '高质量最终输出',
stability: 5
},
{
id: 'ddim',
name: 'DDIM',
speed: 'danger',
speedLabel: '较慢',
description: '确定性采样器,可复现结果',
pros: '确定性,相同种子结果一致',
cons: '速度较慢',
recommendedSteps: '25-50',
convergence: '中等',
useCase: '需要可复现结果的场景',
stability: 4
},
{
id: 'uni_pc',
name: 'UniPC',
speed: 'success',
speedLabel: '快速',
description: '新型采样器,5-10 步即可出图',
pros: '极快,低步数效果好',
cons: '较新,兼容性待验证',
recommendedSteps: '5-15',
convergence: '极快',
useCase: '实时应用、快速预览',
stability: 4
}
]
const currentSampler = computed(() => {
return samplers.find(s => s.id === selectedSampler.value) || samplers[0]
})
const recommendations = [
{
scenario: '快速预览',
sampler: 'Euler / UniPC',
reason: '步数少,速度快,适合快速尝试不同提示词'
},
{
scenario: '最终输出',
sampler: 'DPM++ 2M Karras',
reason: '质量高,收敛快,15-20 步即可出高质量图'
},
{
scenario: '艺术创作',
sampler: 'Euler a',
reason: '结果更有创意和随机性,适合探索'
},
{
scenario: '需要可复现',
sampler: 'DDIM',
reason: '确定性采样,相同参数结果完全一致'
}
]
//
const drawPathVisualization = () => {
const canvas = pathCanvas.value
if (!canvas) return
const ctx = canvas.getContext('2d')
const width = canvas.width
const height = canvas.height
//
ctx.fillStyle = '#f5f5f5'
ctx.fillRect(0, 0, width, height)
//
ctx.strokeStyle = '#ccc'
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(40, height - 40)
ctx.lineTo(width - 20, height - 40)
ctx.moveTo(40, height - 40)
ctx.lineTo(40, 20)
ctx.stroke()
//
ctx.fillStyle = '#666'
ctx.font = '12px sans-serif'
ctx.fillText('步数 →', width - 60, height - 20)
ctx.save()
ctx.translate(20, height / 2)
ctx.rotate(-Math.PI / 2)
ctx.fillText('图像质量 →', 0, 0)
ctx.restore()
// 线
const samplerCurves = {
euler: { color: '#67c23a', curve: t => 1 - Math.exp(-t * 2) },
euler_a: { color: '#e6a23c', curve: t => 1 - Math.exp(-t * 1.5) + Math.sin(t * 10) * 0.05 },
dpm: { color: '#409eff', curve: t => 1 - Math.exp(-t * 3) },
dpm_karras: { color: '#409eff', curve: t => 1 - Math.exp(-t * 4), dashed: true },
ddim: { color: '#f56c6c', curve: t => 1 - Math.exp(-t * 1.8) },
uni_pc: { color: '#909399', curve: t => 1 - Math.exp(-t * 5) }
}
const plotWidth = width - 60
const plotHeight = height - 60
Object.entries(samplerCurves).forEach(([id, config]) => {
if (id !== selectedSampler.value && id !== 'dpm_karras') return
ctx.strokeStyle = config.color
ctx.lineWidth = id === selectedSampler.value ? 3 : 2
ctx.setLineDash(config.dashed ? [5, 5] : [])
ctx.beginPath()
for (let i = 0; i <= steps.value; i++) {
const t = i / 50
const x = 40 + (i / 50) * plotWidth
const y = height - 40 - config.curve(t) * plotHeight * 0.9
if (i === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
}
ctx.stroke()
})
ctx.setLineDash([])
//
const currentX = 40 + (steps.value / 50) * plotWidth
ctx.strokeStyle = '#ff6b6b'
ctx.lineWidth = 2
ctx.beginPath()
ctx.moveTo(currentX, 20)
ctx.lineTo(currentX, height - 40)
ctx.stroke()
//
const selectedCurve = samplerCurves[selectedSampler.value]
const currentT = steps.value / 50
const currentY = height - 40 - selectedCurve.curve(currentT) * plotHeight * 0.9
ctx.fillStyle = '#ff6b6b'
ctx.beginPath()
ctx.arc(currentX, currentY, 6, 0, Math.PI * 2)
ctx.fill()
//
let legendY = 30
ctx.font = '12px sans-serif'
Object.entries(samplerCurves).forEach(([id, config]) => {
if (id !== selectedSampler.value) return
ctx.fillStyle = config.color
ctx.fillRect(width - 120, legendY, 15, 3)
ctx.fillStyle = '#666'
ctx.fillText(samplers.find(s => s.id === id)?.name || id, width - 100, legendY + 5)
legendY += 20
})
}
onMounted(drawPathVisualization)
watch([selectedSampler, steps], drawPathVisualization)
</script>
<style scoped>
.sampler-demo {
margin: 1rem 0;
}
.header-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.sampler-list {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 16px;
}
.sampler-card {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 16px;
cursor: pointer;
transition: all 0.2s;
border: 2px solid transparent;
}
.sampler-card:hover {
border-color: var(--vp-c-brand);
}
.sampler-card.active {
border-color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.sampler-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 8px;
}
.sampler-name {
font-weight: 600;
font-size: 1.1rem;
}
.sampler-desc {
font-size: 0.875rem;
color: var(--vp-c-text-2);
margin-bottom: 12px;
}
.sampler-pros-cons {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.8rem;
}
.pros {
color: #67c23a;
display: flex;
align-items: center;
gap: 4px;
}
.cons {
color: #f56c6c;
display: flex;
align-items: center;
gap: 4px;
}
.visualization-section {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.viz-header {
display: flex;
align-items: center;
gap: 16px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.viz-title {
font-weight: 500;
}
.steps-label {
font-size: 0.875rem;
color: var(--vp-c-text-2);
}
.path-visualization {
background: var(--vp-c-bg);
border-radius: 8px;
overflow: hidden;
margin-bottom: 16px;
}
.path-canvas {
width: 100%;
height: auto;
max-height: 300px;
}
.sampler-details {
margin-top: 16px;
}
.recommendation-matrix {
background: var(--vp-c-bg-soft);
border-radius: 8px;
padding: 20px;
}
.matrix-title {
font-weight: 500;
margin-bottom: 16px;
text-align: center;
}
.matrix-grid {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--vp-c-divider);
border-radius: 8px;
overflow: hidden;
}
.matrix-row {
display: grid;
grid-template-columns: 1fr 1.5fr 2fr;
background: var(--vp-c-bg);
}
.matrix-row.header {
background: var(--vp-c-bg-mute);
font-weight: 600;
}
.matrix-cell {
padding: 12px;
display: flex;
align-items: center;
}
.matrix-cell.scenario {
font-weight: 500;
}
.matrix-cell.reason {
font-size: 0.875rem;
color: var(--vp-c-text-2);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
@media (max-width: 640px) {
.matrix-row {
grid-template-columns: 1fr;
gap: 8px;
padding: 12px;
}
.matrix-row.header {
display: none;
}
.matrix-cell {
padding: 4px;
}
.matrix-cell::before {
content: attr(data-label);
font-weight: 600;
margin-right: 8px;
}
}
</style>
@@ -0,0 +1,535 @@
<!--
UNetDenoiseDemo.vue
UNet 去噪过程演示组件
用途
展示 UNet/DiT 如何从噪声中逐步恢复图像理解扩散模型的核心去噪机制
交互功能
- 单步/自动播放去噪过程
- 可视化噪声预测
- 展示不同时间步的预测结果
- 对比有/无文本引导的生成
-->
<template>
<div class="unet-demo">
<el-card shadow="never">
<template #header>
<div class="header-controls">
<span class="title">🧠 UNet 去噪模型</span>
<div class="controls">
<el-button-group>
<el-button @click="stepBackward" :disabled="currentStep <= 0">
<el-icon><ArrowLeft /></el-icon>
</el-button>
<el-button @click="togglePlay">
<el-icon v-if="isPlaying"><VideoPause /></el-icon>
<el-icon v-else><VideoPlay /></el-icon>
</el-button>
<el-button @click="stepForward" :disabled="currentStep >= totalSteps">
<el-icon><ArrowRight /></el-icon>
</el-button>
</el-button-group>
<el-button @click="reset">重置</el-button>
</div>
</div>
</template>
<div class="demo-content">
<!-- 主展示区 -->
<div class="main-display">
<div class="display-section">
<div class="section-label">当前噪声图像 (Noisy Image)</div>
<canvas
ref="noisyCanvas"
width="256"
height="256"
class="display-canvas"
/>
<div class="timestep-info">
<el-tag type="info">Timestep: {{ currentStep }} / {{ totalSteps }}</el-tag>
<el-tag :type="getNoiseLevelType()">噪声强度: {{ getNoiseLevel() }}%</el-tag>
</div>
</div>
<div class="arrow-section">
<el-icon :size="24"><ArrowRight /></el-icon>
<div class="model-box">
<div class="model-name">UNet / DiT</div>
<div class="model-desc">预测噪声</div>
</div>
<el-icon :size="24"><ArrowRight /></el-icon>
</div>
<div class="display-section">
<div class="section-label">预测的噪声 (Predicted Noise)</div>
<canvas
ref="noiseCanvas"
width="256"
height="256"
class="display-canvas noise-preview"
/>
<div class="noise-stats">
<el-tag size="small" type="warning">噪声估计</el-tag>
</div>
</div>
<div class="arrow-section">
<el-icon :size="24"><ArrowRight /></el-icon>
<div class="operation-box">
<div class="op-name">减法</div>
<div class="op-formula">x - ε</div>
</div>
<el-icon :size="24"><ArrowRight /></el-icon>
</div>
<div class="display-section">
<div class="section-label">去噪结果 (Denoised)</div>
<canvas
ref="denoisedCanvas"
width="256"
height="256"
class="display-canvas"
/>
<div class="progress-info">
<el-progress
:percentage="(currentStep / totalSteps) * 100"
:status="currentStep === totalSteps ? 'success' : ''"
/>
</div>
</div>
</div>
<!-- 时间轴 -->
<div class="timeline-section">
<div class="timeline-label">去噪时间轴</div>
<el-slider
v-model="currentStep"
:min="0"
:max="totalSteps"
:step="1"
show-stops
:marks="marks"
@input="updateDisplay"
/>
</div>
<!-- 对比模式 -->
<div class="compare-section">
<el-switch
v-model="showComparison"
active-text="显示对比 (有/无文本引导)"
/>
<div v-if="showComparison" class="compare-display">
<div class="compare-item">
<div class="compare-label">无引导 (Unconditional)</div>
<canvas
ref="uncondCanvas"
width="200"
height="200"
class="compare-canvas"
/>
</div>
<div class="compare-item">
<div class="compare-label">有引导 (CFG Scale=7.5)</div>
<canvas
ref="condCanvas"
width="200"
height="200"
class="compare-canvas"
/>
</div>
</div>
</div>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>去噪原理</strong>
UNet 学习预测图像中的噪声然后用原图减去预测的噪声得到更清晰的结果重复这个过程直到从纯噪声恢复出清晰图像
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ArrowRight, ArrowLeft, VideoPlay, VideoPause } from '@element-plus/icons-vue'
const noisyCanvas = ref(null)
const noiseCanvas = ref(null)
const denoisedCanvas = ref(null)
const uncondCanvas = ref(null)
const condCanvas = ref(null)
const currentStep = ref(0)
const totalSteps = 20
const isPlaying = ref(false)
const showComparison = ref(false)
const marks = {
0: '纯噪声',
10: '中期',
20: '清晰图'
}
let animationId = null
//
const generateTargetImage = () => {
const canvas = document.createElement('canvas')
canvas.width = 256
canvas.height = 256
const ctx = canvas.getContext('2d')
//
const gradient = ctx.createLinearGradient(0, 0, 256, 256)
gradient.addColorStop(0, '#667eea')
gradient.addColorStop(1, '#764ba2')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, 256, 256)
//
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)'
for (let i = 0; i < 5; i++) {
ctx.beginPath()
ctx.arc(50 + i * 40, 100 + (i % 2) * 50, 30, 0, Math.PI * 2)
ctx.fill()
}
return ctx.getImageData(0, 0, 256, 256)
}
const targetImage = generateTargetImage()
//
const generateNoise = (width, height, intensity) => {
const data = new Uint8ClampedArray(width * height * 4)
for (let i = 0; i < data.length; i += 4) {
const noise = (Math.random() - 0.5) * intensity * 255
data[i] = 128 + noise
data[i + 1] = 128 + noise
data[i + 2] = 128 + noise
data[i + 3] = 255
}
return new ImageData(data, width, height)
}
//
const blendWithNoise = (imageData, noiseRatio) => {
const result = new Uint8ClampedArray(imageData.data)
for (let i = 0; i < result.length; i += 4) {
const noise = (Math.random() - 0.5) * noiseRatio * 255
result[i] = Math.max(0, Math.min(255, imageData.data[i] * (1 - noiseRatio) + 128 * noiseRatio + noise))
result[i + 1] = Math.max(0, Math.min(255, imageData.data[i + 1] * (1 - noiseRatio) + 128 * noiseRatio + noise))
result[i + 2] = Math.max(0, Math.min(255, imageData.data[i + 2] * (1 - noiseRatio) + 128 * noiseRatio + noise))
}
return new ImageData(result, imageData.width, imageData.height)
}
//
const predictNoise = (width, height, step) => {
const noiseRatio = 1 - (step / totalSteps)
return generateNoise(width, height, noiseRatio * 0.5)
}
//
const denoise = (noisyData, noiseData, step) => {
const result = new Uint8ClampedArray(noisyData.data)
const denoiseStrength = 0.1 + (step / totalSteps) * 0.4
for (let i = 0; i < result.length; i += 4) {
//
const targetR = targetImage.data[i]
const targetG = targetImage.data[i + 1]
const targetB = targetImage.data[i + 2]
const currentR = noisyData.data[i]
const currentG = noisyData.data[i + 1]
const currentB = noisyData.data[i + 2]
result[i] = currentR + (targetR - currentR) * denoiseStrength
result[i + 1] = currentG + (targetG - currentG) * denoiseStrength
result[i + 2] = currentB + (targetB - currentB) * denoiseStrength
}
return new ImageData(result, noisyData.width, noisyData.height)
}
//
const updateDisplay = () => {
const step = currentStep.value
const noiseRatio = 1 - (step / totalSteps)
//
const noisyCtx = noisyCanvas.value.getContext('2d')
const noisyData = blendWithNoise(targetImage, noiseRatio)
noisyCtx.putImageData(noisyData, 0, 0)
//
const noiseCtx = noiseCanvas.value.getContext('2d')
const noiseData = predictNoise(256, 256, step)
noiseCtx.putImageData(noiseData, 0, 0)
//
const denoisedCtx = denoisedCanvas.value.getContext('2d')
const denoisedData = denoise(noisyData, noiseData, step)
denoisedCtx.putImageData(denoisedData, 0, 0)
//
if (showComparison.value && uncondCanvas.value && condCanvas.value) {
//
const uncondCtx = uncondCanvas.value.getContext('2d')
const uncondData = blendWithNoise(targetImage, noiseRatio * 0.3)
uncondCtx.putImageData(uncondData, 0, 0)
//
const condCtx = condCanvas.value.getContext('2d')
condCtx.putImageData(denoisedData, 0, 0)
}
}
const getNoiseLevel = () => {
return Math.round((1 - currentStep.value / totalSteps) * 100)
}
const getNoiseLevelType = () => {
const level = getNoiseLevel()
if (level > 70) return 'danger'
if (level > 30) return 'warning'
return 'success'
}
const stepForward = () => {
if (currentStep.value < totalSteps) {
currentStep.value++
updateDisplay()
}
}
const stepBackward = () => {
if (currentStep.value > 0) {
currentStep.value--
updateDisplay()
}
}
const togglePlay = () => {
if (isPlaying.value) {
stopAnimation()
} else {
startAnimation()
}
}
const startAnimation = () => {
isPlaying.value = true
const animate = () => {
if (!isPlaying.value) return
if (currentStep.value >= totalSteps) {
currentStep.value = 0
} else {
currentStep.value++
}
updateDisplay()
animationId = setTimeout(() => {
requestAnimationFrame(animate)
}, 200)
}
animate()
}
const stopAnimation = () => {
isPlaying.value = false
if (animationId) {
clearTimeout(animationId)
animationId = null
}
}
const reset = () => {
stopAnimation()
currentStep.value = 0
updateDisplay()
}
onMounted(updateDisplay)
onUnmounted(stopAnimation)
</script>
<style scoped>
.unet-demo {
margin: 1rem 0;
}
.header-controls {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.title {
font-weight: 600;
}
.controls {
display: flex;
gap: 8px;
}
.demo-content {
display: flex;
flex-direction: column;
gap: 24px;
}
.main-display {
display: flex;
align-items: center;
justify-content: center;
gap: 16px;
flex-wrap: wrap;
padding: 16px 0;
}
.display-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.section-label {
font-size: 0.875rem;
font-weight: 500;
color: var(--vp-c-text-2);
}
.display-canvas {
width: 200px;
height: 200px;
background: var(--vp-c-bg-mute);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.noise-preview {
filter: grayscale(100%);
}
.timestep-info {
display: flex;
gap: 8px;
}
.arrow-section {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
color: var(--vp-c-text-3);
}
.model-box,
.operation-box {
background: var(--vp-c-bg-soft);
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
padding: 12px 16px;
text-align: center;
min-width: 80px;
}
.model-name,
.op-name {
font-weight: 600;
font-size: 0.875rem;
}
.model-desc,
.op-formula {
font-size: 0.75rem;
color: var(--vp-c-text-3);
margin-top: 4px;
}
.progress-info {
width: 100%;
max-width: 200px;
}
.timeline-section {
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.timeline-label {
font-weight: 500;
margin-bottom: 12px;
}
.compare-section {
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.compare-display {
display: flex;
justify-content: center;
gap: 24px;
margin-top: 16px;
flex-wrap: wrap;
}
.compare-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.compare-label {
font-size: 0.875rem;
color: var(--vp-c-text-2);
}
.compare-canvas {
width: 150px;
height: 150px;
background: var(--vp-c-bg-mute);
border-radius: 8px;
border: 2px solid var(--vp-c-divider);
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
@media (max-width: 768px) {
.main-display {
flex-direction: column;
}
.arrow-section {
transform: rotate(90deg);
margin: 8px 0;
}
}
</style>
@@ -0,0 +1,359 @@
<!--
VaeEncoderDemo.vue
VAE 编解码器演示组件
用途
展示 VAE 如何将高分辨率图像压缩到潜空间以及如何从潜空间还原图像
帮助用户理解 Latent Space 的概念
交互功能
- 编码/解码模式切换
- 可视化压缩过程
- 展示潜空间表示
- 对比原始图像和重建图像
-->
<template>
<div class="vae-demo">
<el-card shadow="never">
<template #header>
<div class="header-controls">
<span class="title">🔍 VAE 编解码器</span>
<el-radio-group v-model="mode" size="small">
<el-radio-button label="encode">
<el-icon><ArrowRight /></el-icon> 编码 (Encode)
</el-radio-button>
<el-radio-button label="decode">
<el-icon><ArrowLeft /></el-icon> 解码 (Decode)
</el-radio-button>
</el-radio-group>
</div>
</template>
<div class="vae-flow">
<!-- 输入侧 -->
<div class="stage">
<div class="stage-label">{{ mode === 'encode' ? '原始图像' : '潜空间表示' }}</div>
<div class="stage-visual">
<canvas
ref="inputCanvas"
width="200"
height="200"
class="stage-canvas"
/>
</div>
<div class="stage-info">
<el-tag size="small" type="info">
{{ mode === 'encode' ? '512 × 512 × 3 = 786,432 数值' : '64 × 64 × 4 = 16,384 数值' }}
</el-tag>
</div>
</div>
<!-- 箭头 -->
<div class="arrow-stage">
<el-icon class="flow-arrow" :size="32">
<component :is="mode === 'encode' ? ArrowRight : ArrowLeft" />
</el-icon>
<div class="compression-ratio">
<el-tag type="success" effect="dark">压缩率: 48×</el-tag>
</div>
</div>
<!-- 输出侧 -->
<div class="stage">
<div class="stage-label">{{ mode === 'encode' ? '潜空间表示' : '重建图像' }}</div>
<div class="stage-visual">
<canvas
ref="outputCanvas"
width="200"
height="200"
class="stage-canvas"
/>
</div>
<div class="stage-info">
<el-tag size="small" type="info">
{{ mode === 'encode' ? '64 × 64 × 4 = 16,384 数值' : '512 × 512 × 3 = 786,432 数值' }}
</el-tag>
</div>
</div>
</div>
<!-- 潜空间可视化 -->
<div class="latent-viz" v-if="mode === 'encode'">
<div class="latent-title">潜空间特征图 (4 个通道)</div>
<div class="latent-channels">
<div
v-for="i in 4"
:key="i"
class="channel-box"
:style="getChannelStyle(i)"
>
<span class="channel-label">Channel {{ i }}</span>
</div>
</div>
</div>
<div class="explanation">
<el-alert
:title="mode === 'encode' ? '编码:图像 → 潜空间' : '解码:潜空间 → 图像'"
:type="mode === 'encode' ? 'warning' : 'success'"
:description="mode === 'encode'
? 'VAE Encoder 将高维图像压缩到低维潜空间,保留关键语义信息,丢弃冗余细节。这就像把一本厚书浓缩成大纲。'
: 'VAE Decoder 从潜空间表示中重建图像。虽然无法完美还原每一个细节,但足以生成高质量的图像。这就像根据大纲重写一本书。'"
show-icon
:closable="false"
/>
</div>
<div class="info-box">
<p>
<span class="icon">💡</span>
<strong>为什么需要 VAE</strong>
直接在像素空间训练扩散模型计算量太大通过 VAE 压缩到潜空间计算效率提升约 48 同时保持图像质量
</p>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { ArrowRight, ArrowLeft } from '@element-plus/icons-vue'
const mode = ref('encode')
const inputCanvas = ref(null)
const outputCanvas = ref(null)
//
const drawSampleImage = (canvas) => {
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
//
//
const skyGradient = ctx.createLinearGradient(0, 0, 0, h * 0.6)
skyGradient.addColorStop(0, '#87CEEB')
skyGradient.addColorStop(1, '#E0F7FA')
ctx.fillStyle = skyGradient
ctx.fillRect(0, 0, w, h * 0.6)
//
ctx.beginPath()
ctx.arc(w * 0.75, h * 0.2, w * 0.1, 0, Math.PI * 2)
ctx.fillStyle = '#FFD700'
ctx.fill()
//
ctx.fillStyle = '#4CAF50'
ctx.beginPath()
ctx.moveTo(0, h * 0.6)
ctx.lineTo(w * 0.3, h * 0.3)
ctx.lineTo(w * 0.7, h * 0.5)
ctx.lineTo(w, h * 0.4)
ctx.lineTo(w, h)
ctx.lineTo(0, h)
ctx.fill()
//
ctx.fillStyle = '#8BC34A'
ctx.fillRect(0, h * 0.6, w, h * 0.4)
//
const colors = ['#FF69B4', '#FFD700', '#FF6347', '#9370DB']
for (let i = 0; i < 8; i++) {
const x = (i * w * 0.12) + 20
const y = h * 0.75 + (i % 2) * 30
ctx.fillStyle = colors[i % colors.length]
ctx.beginPath()
ctx.arc(x, y, 8, 0, Math.PI * 2)
ctx.fill()
}
}
//
const drawLatentRepresentation = (canvas) => {
const ctx = canvas.getContext('2d')
const w = canvas.width
const h = canvas.height
//
const imageData = ctx.createImageData(w, h)
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4
// 使
const value = Math.sin(x * 0.1) * Math.cos(y * 0.1) * 50 + 128
imageData.data[i] = value + Math.random() * 30
imageData.data[i + 1] = value + Math.random() * 30
imageData.data[i + 2] = value + Math.random() * 30
imageData.data[i + 3] = 255
}
}
ctx.putImageData(imageData, 0, 0)
}
//
const getChannelStyle = (channel) => {
const hues = [200, 120, 30, 280]
return {
background: `linear-gradient(135deg, hsl(${hues[channel - 1]}, 70%, 50%), hsl(${hues[channel - 1]}, 70%, 30%))`
}
}
//
const updateDisplay = () => {
if (!inputCanvas.value || !outputCanvas.value) return
if (mode.value === 'encode') {
drawSampleImage(inputCanvas.value)
drawLatentRepresentation(outputCanvas.value)
} else {
drawLatentRepresentation(inputCanvas.value)
drawSampleImage(outputCanvas.value)
}
}
onMounted(updateDisplay)
watch(mode, updateDisplay)
</script>
<style scoped>
.vae-demo {
margin: 1rem 0;
}
.header-controls {
display: flex;
justify-content: space-between;
align-items: center;
}
.title {
font-weight: 600;
}
.vae-flow {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
padding: 24px 0;
flex-wrap: wrap;
}
.stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.stage-label {
font-weight: 500;
color: var(--vp-c-text-2);
}
.stage-visual {
width: 200px;
height: 200px;
background: var(--vp-c-bg-mute);
border-radius: 8px;
overflow: hidden;
border: 2px solid var(--vp-c-divider);
}
.stage-canvas {
width: 100%;
height: 100%;
object-fit: cover;
}
.stage-info {
font-size: 0.75rem;
}
.arrow-stage {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.flow-arrow {
color: var(--vp-c-brand);
}
.compression-ratio {
font-size: 0.8rem;
}
.latent-viz {
margin-top: 16px;
padding: 16px;
background: var(--vp-c-bg-soft);
border-radius: 8px;
}
.latent-title {
font-weight: 500;
margin-bottom: 12px;
text-align: center;
}
.latent-channels {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 12px;
}
.channel-box {
aspect-ratio: 1;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
position: relative;
}
.channel-label {
position: absolute;
bottom: 4px;
left: 4px;
font-size: 0.7rem;
color: white;
background: rgba(0, 0, 0, 0.5);
padding: 2px 6px;
border-radius: 3px;
}
.explanation {
margin-top: 16px;
}
.info-box {
margin-top: 16px;
padding: 12px;
background: var(--vp-c-bg-mute);
border-radius: 6px;
font-size: 0.9rem;
line-height: 1.6;
}
.icon {
font-size: 1.2em;
}
@media (max-width: 640px) {
.vae-flow {
flex-direction: column;
}
.arrow-stage {
transform: rotate(90deg);
}
.latent-channels {
grid-template-columns: repeat(2, 1fr);
}
}
</style>
+22 -10
View File
@@ -223,13 +223,19 @@ import ContextCompressionDemo from './components/appendix/context-engineering/Co
// Agent Intro Components
import AgentWorkflowDemo from './components/appendix/agent-intro/AgentWorkflowDemo.vue'
// import AgentLevelDemo from './components/appendix/agent-intro/AgentLevelDemo.vue'
// import AgentArchitectureDemo from './components/appendix/agent-intro/AgentArchitectureDemo.vue'
// import AgentTaskFlowDemo from './components/appendix/agent-intro/AgentTaskFlowDemo.vue'
import AgentLevelDemo from './components/appendix/agent-intro/AgentLevelDemo.vue'
import AgentArchitectureDemo from './components/appendix/agent-intro/AgentArchitectureDemo.vue'
import AgentTaskFlowDemo from './components/appendix/agent-intro/AgentTaskFlowDemo.vue'
import FrameworkComparisonDemo from './components/appendix/agent-intro/FrameworkComparisonDemo.vue'
// import FrameworkSelectionDemo from './components/appendix/agent-intro/FrameworkSelectionDemo.vue'
// import AgentChallengesDemo from './components/appendix/agent-intro/AgentChallengesDemo.vue'
import FrameworkSelectionDemo from './components/appendix/agent-intro/FrameworkSelectionDemo.vue'
import AgentChallengesDemo from './components/appendix/agent-intro/AgentChallengesDemo.vue'
import AgentFutureDemo from './components/appendix/agent-intro/AgentFutureDemo.vue'
import AgentQuickStartDemo from './components/appendix/agent-intro/AgentQuickStartDemo.vue'
import AgentToolUseDemo from './components/appendix/agent-intro/AgentToolUseDemo.vue'
import AgentPlanningDemo from './components/appendix/agent-intro/AgentPlanningDemo.vue'
import AgentMemoryDemo from './components/appendix/agent-intro/AgentMemoryDemo.vue'
import AgentMultiToolPrinciple from './components/appendix/agent-intro/AgentMultiToolPrinciple.vue'
import AgentMemoryPrinciple from './components/appendix/agent-intro/AgentMemoryPrinciple.vue'
// Database Intro Components
import DatabaseIndexDemo from './components/appendix/database-intro/DatabaseIndexDemo.vue'
@@ -501,13 +507,19 @@ export default {
// Agent Intro Components Registration
app.component('AgentWorkflowDemo', AgentWorkflowDemo)
// app.component('AgentLevelDemo', AgentLevelDemo)
// app.component('AgentArchitectureDemo', AgentArchitectureDemo)
// app.component('AgentTaskFlowDemo', AgentTaskFlowDemo)
app.component('AgentLevelDemo', AgentLevelDemo)
app.component('AgentArchitectureDemo', AgentArchitectureDemo)
app.component('AgentTaskFlowDemo', AgentTaskFlowDemo)
app.component('FrameworkComparisonDemo', FrameworkComparisonDemo)
// app.component('FrameworkSelectionDemo', FrameworkSelectionDemo)
// app.component('AgentChallengesDemo', AgentChallengesDemo)
app.component('FrameworkSelectionDemo', FrameworkSelectionDemo)
app.component('AgentChallengesDemo', AgentChallengesDemo)
app.component('AgentFutureDemo', AgentFutureDemo)
app.component('AgentQuickStartDemo', AgentQuickStartDemo)
app.component('AgentToolUseDemo', AgentToolUseDemo)
app.component('AgentPlanningDemo', AgentPlanningDemo)
app.component('AgentMemoryDemo', AgentMemoryDemo)
app.component('AgentMultiToolPrinciple', AgentMultiToolPrinciple)
app.component('AgentMemoryPrinciple', AgentMemoryPrinciple)
// Database Intro Components Registration
app.component('DatabaseIndexDemo', DatabaseIndexDemo)