Files
test-repo/docs/.vitepress/theme/components/appendix/vlm-intro/VlmQuickStartDemo.vue
T
sanbuphy 0eba9e87e9 fix(eslint): reduce warnings in GitHub Actions deployment
- Disable formatting rules (handled by Prettier)
- Relaxed strict Vue/JS rules for demo code compatibility
- Fix syntax errors in ApiPlayground and VoiceCloningDemo
- Fix duplicate else-if condition in ApiPlayground
- Fix Promise executor async pattern in AutoregressiveAudioDemo
- Add TypeScript file support to ESLint config

Warnings reduced from 295 to 251 problems.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-18 17:38:10 +08:00

888 lines
18 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="vlm-quick-start">
<div class="header">
<div class="title">
👁 VLM 初体验不只是看图说话
</div>
<div class="subtitle">
选择不同场景体验多模态模型的多种能力
</div>
</div>
<div class="scenario-tabs">
<button
v-for="s in scenarios"
:key="s.id"
class="tab-btn"
:class="{ active: currentScenario === s.id }"
@click="switchScenario(s.id)"
>
{{ s.name }}
</button>
</div>
<div class="demo-container">
<!-- Image Area -->
<div class="image-area">
<div
class="image-placeholder"
:class="{ loaded: hasImage, 'receipt-bg': currentScenario === 'ocr' }"
>
<div
v-if="!hasImage"
class="upload-prompt"
>
<div class="icon">
🖼
</div>
<button
class="upload-btn"
@click="loadImage"
>
上传图片 (模拟)
</button>
</div>
<div
v-else
class="image-content"
>
<!-- Chat: Landscape -->
<div
v-if="currentScenario === 'chat'"
class="real-image-container landscape"
>
<div class="real-image">
🏔
</div>
<div class="sun">
</div>
<div class="tree">
🌲
</div>
</div>
<!-- Detection: Fruits -->
<div
v-else-if="currentScenario === 'detection'"
class="real-image-container fruits"
>
<div class="real-image">
<span class="fruit apple">🍎</span>
<span class="fruit banana">🍌</span>
<span class="fruit grape">🍇</span>
</div>
<div
v-if="showBoundingBox"
class="bounding-box apple-box"
title="Apple"
>
<span class="box-label">apple: 0.98</span>
</div>
<div
v-if="showBoundingBox"
class="bounding-box banana-box"
title="Banana"
>
<span class="box-label">banana: 0.95</span>
</div>
</div>
<!-- Analysis: Factory Safety -->
<div
v-else-if="currentScenario === 'analysis'"
class="factory-image"
>
<div class="safety-sign">
安全生产
</div>
<div class="worker-container">
<span class="worker">👷</span>
<span
v-if="true"
class="helmet"
></span>
</div>
<div class="machinery">
</div>
</div>
<!-- OCR: Receipt -->
<div
v-else
class="receipt-image"
>
<div class="receipt-header">
🧾 RECEIPT
</div>
<div class="receipt-body">
<div class="line">
<span>Coffee</span><span>$4.50</span>
</div>
<div class="line">
<span>Bagel</span><span>$3.00</span>
</div>
<div class="line total">
<span>TOTAL</span><span>$7.50</span>
</div>
<div class="line date">
<span>2023-10-24</span>
</div>
</div>
</div>
<div class="image-label">
{{ getImageLabel() }}
</div>
</div>
</div>
</div>
<!-- Chat Area -->
<div class="chat-area">
<div
ref="messagesRef"
class="messages"
>
<div
v-if="messages.length === 0"
class="empty-text"
>
{{ hasImage ? '图片已就绪,请选择指令' : '请先上传图片' }}
</div>
<div
v-for="(msg, index) in messages"
:key="index"
class="message"
:class="msg.role"
>
<div class="content">
<div
v-if="msg.isJson"
class="json-content"
>
<pre>{{ msg.content }}</pre>
</div>
<span v-else>{{ msg.content }}</span>
<span
v-if="
msg.role === 'assistant' &&
isGenerating &&
index === messages.length - 1
"
class="cursor"
>|</span>
</div>
</div>
</div>
<div class="input-area">
<div
v-if="hasImage && !isGenerating"
class="quick-actions"
>
<button
v-for="q in currentQuestions"
:key="q"
class="action-btn"
@click="ask(q)"
>
{{ q }}
</button>
</div>
<div
v-else-if="isGenerating"
class="status-text"
>
AI 正在观察图片并思考...
</div>
<div
v-else
class="status-text"
>
等待图片上传...
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, nextTick } from 'vue'
const scenarios = [
{ id: 'chat', name: '通用对话' },
{ id: 'detection', name: '目标检测' },
{ id: 'ocr', name: 'OCR 提取' },
{ id: 'analysis', name: '业务风控' }
]
const currentScenario = ref('chat')
const hasImage = ref(false)
const isGenerating = ref(false)
const showBoundingBox = ref(false)
const messages = ref([])
const messagesRef = ref(null)
const questionsMap = {
chat: ['这里是哪里?', '描述一下天气', '写首关于这座山的诗'],
detection: ['检测图中的水果', '数数有几个苹果', '输出检测框坐标'],
ocr: ['提取所有文字', '总金额是多少?', '消费日期是哪天?'],
analysis: ['工人是否佩戴安全帽?', '检测现场安全隐患', '输出风险评估报告']
}
const answersMap = {
chat: {
'这里是哪里?':
'这是一张高山风景照。远处是覆盖着皑皑白雪的山峰,可能是阿尔卑斯山或喜马拉雅山脉。山脚下有郁郁葱葱的松树林。',
描述一下天气:
'天气看起来非常晴朗,阳光明媚(☀️),能见度很高。蓝天白云,是一个适合登山或滑雪的好天气。',
写首关于这座山的诗:
'🏔️ 雪岭插云天,\n🌲 松涛响翠烟。\n☀️ 金阳融冷色,\n🏞️ 壮丽入心田。'
},
detection: {
检测图中的水果: {
type: 'json',
text: JSON.stringify(
{ objects: ['apple', 'banana', 'grape'], count: 3 },
null,
2
),
action: 'showBox'
},
数数有几个苹果: '图中检测到 1 个苹果(🍎)。',
输出检测框坐标: {
type: 'json',
text: JSON.stringify(
{
objects: [
{ label: 'apple', box: [15, 15, 85, 85] },
{ label: 'banana', box: [95, 15, 165, 85] }
]
},
null,
2
),
action: 'showBox'
}
},
ocr: {
提取所有文字: {
type: 'json',
text: JSON.stringify(
{
lines: [
'RECEIPT',
'Coffee $4.50',
'Bagel $3.00',
'TOTAL $7.50',
'2023-10-24'
]
},
null,
2
)
},
'总金额是多少?': '这张小票的总金额是 $7.50。',
'消费日期是哪天?': '消费日期是 2023年10月24日。'
},
analysis: {
'工人是否佩戴安全帽?':
'检测到画面中有一名工人(👷),已正确佩戴红色安全帽(⛑️)。',
检测现场安全隐患: {
type: 'json',
text: JSON.stringify(
{ hazards: [], safety_score: 100, status: 'SAFE' },
null,
2
)
},
输出风险评估报告:
'✅ **安全合规**\n- 人员:1人\n- 防护装备:齐全\n- 机械设备:正常运行中\n- 风险等级:低'
}
}
const getImageLabel = () => {
const map = {
chat: '已上传:雪山风景.jpg',
detection: '已上传:水果果盘.jpg',
ocr: '已上传:购物小票.jpg',
analysis: '已上传:车间监控.jpg'
}
return map[currentScenario.value]
}
const currentQuestions = computed(
() => questionsMap[currentScenario.value] || []
)
const switchScenario = (id) => {
currentScenario.value = id
hasImage.value = false
messages.value = []
showBoundingBox.value = false
}
const loadImage = () => {
hasImage.value = true
messages.value = [] // Clear history
showBoundingBox.value = false
}
const ask = async (question) => {
messages.value.push({ role: 'user', content: question })
isGenerating.value = true
await wait(800) // Simulate vision encoding time
const scenarioAnswers = answersMap[currentScenario.value]
const rawAnswer = scenarioAnswers[question] || '我还在学习这个任务...'
let content = ''
let isJson = false
let action = null
if (typeof rawAnswer === 'object') {
content = rawAnswer.text
isJson = rawAnswer.type === 'json'
action = rawAnswer.action
} else {
content = rawAnswer
}
messages.value.push({ role: 'assistant', content: '', isJson })
const answerIdx = messages.value.length - 1
// Streaming effect
const stepSize = isJson ? 5 : 1 // JSON types faster
for (let i = 0; i < content.length; i += stepSize) {
messages.value[answerIdx].content += content.slice(i, i + stepSize)
scrollToBottom()
await wait(20)
}
if (action === 'showBox') {
showBoundingBox.value = true
}
isGenerating.value = false
}
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
const scrollToBottom = () => {
nextTick(() => {
if (messagesRef.value) {
messagesRef.value.scrollTop = messagesRef.value.scrollHeight
}
})
}
</script>
<style scoped>
.vlm-quick-start {
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: 20px;
}
.title {
font-size: 18px;
font-weight: bold;
margin-bottom: 5px;
}
.subtitle {
font-size: 13px;
color: var(--vp-c-text-2);
}
.scenario-tabs {
display: flex;
justify-content: center;
gap: 10px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.tab-btn {
padding: 6px 16px;
border-radius: 20px;
border: 1px solid transparent;
background: var(--vp-c-bg);
color: var(--vp-c-text-2);
font-size: 13px;
cursor: pointer;
transition: all 0.2s;
}
.tab-btn.active {
background: var(--vp-c-brand);
color: white;
font-weight: bold;
}
.tab-btn:hover:not(.active) {
background: var(--vp-c-bg-mute);
}
.demo-container {
display: flex;
gap: 20px;
height: 340px;
}
/* Image Area */
.image-area {
flex: 1;
background: var(--vp-c-bg);
border: 1px dashed var(--vp-c-divider);
border-radius: 6px;
overflow: hidden;
position: relative;
}
.image-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.image-placeholder.loaded {
background: #fff4e6;
border: none;
}
.image-placeholder.receipt-bg {
background: #f0f0f0;
}
.upload-prompt .icon {
font-size: 48px;
margin-bottom: 10px;
text-align: center;
}
.upload-btn {
background: var(--vp-c-brand);
color: white;
border: none;
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
transition: opacity 0.2s;
}
.upload-btn:hover {
opacity: 0.9;
}
.image-content {
text-align: center;
position: relative;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.real-image-container {
position: relative;
display: inline-block;
}
/* Landscape Style */
.real-image-container.landscape {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(to bottom, #87ceeb 50%, #e0e0e0 50%);
border-radius: 6px;
overflow: hidden;
position: absolute;
top: 0;
left: 0;
}
.landscape .real-image {
font-size: 80px;
z-index: 2;
margin-top: 20px;
}
.landscape .sun {
position: absolute;
top: 20px;
right: 20px;
font-size: 40px;
animation: spin 10s linear infinite;
}
.landscape .tree {
position: absolute;
bottom: 20px;
left: 20px;
font-size: 40px;
z-index: 3;
}
/* Fruits Style */
.real-image-container.fruits {
padding: 20px;
}
.real-image-container.fruits .real-image {
display: flex;
gap: 20px;
}
.real-image-container.fruits .fruit {
font-size: 60px;
display: inline-block;
animation: popIn 0.5s ease;
}
.bounding-box.apple-box {
left: 15px;
top: 15px;
width: 70px;
height: 75px;
right: auto;
bottom: auto;
}
.bounding-box.banana-box {
left: 95px;
top: 15px;
width: 70px;
height: 75px;
right: auto;
bottom: auto;
}
/* Factory Style */
.factory-image {
background: #f8f9fa;
border: 2px solid #e9ecef;
border-radius: 6px;
padding: 20px;
width: 260px;
height: 180px;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
animation: slideUp 0.5s ease;
}
.safety-sign {
position: absolute;
top: 10px;
left: 10px;
font-size: 12px;
background: #ffeb3b;
color: #000;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #fbc02d;
font-weight: bold;
}
.worker-container {
font-size: 80px;
position: relative;
z-index: 2;
}
.worker-container .helmet {
position: absolute;
top: -15px;
left: 15px;
font-size: 40px;
z-index: 3;
}
.machinery {
position: absolute;
bottom: 10px;
right: 10px;
font-size: 50px;
opacity: 0.8;
animation: spin 5s linear infinite;
}
.real-image {
font-size: 80px;
margin-bottom: 10px;
animation: popIn 0.5s cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
.bounding-box {
position: absolute;
top: -10px;
left: -10px;
right: -10px;
bottom: 0px;
border: 2px solid #ef4444;
background: rgba(239, 68, 68, 0.1);
border-radius: 4px;
animation: fadeIn 0.3s ease;
}
.box-label {
position: absolute;
top: -20px;
left: -2px;
background: #ef4444;
color: white;
font-size: 10px;
padding: 2px 4px;
border-radius: 2px;
}
/* Receipt Style */
.receipt-image {
background: white;
padding: 15px;
width: 160px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
font-family: 'Courier New', Courier, monospace;
font-size: 11px;
text-align: left;
margin-bottom: 10px;
animation: slideUp 0.5s ease;
}
.receipt-header {
text-align: center;
font-weight: bold;
border-bottom: 1px dashed #ccc;
padding-bottom: 8px;
margin-bottom: 8px;
}
.receipt-body .line {
display: flex;
justify-content: space-between;
margin-bottom: 4px;
}
.receipt-body .total {
border-top: 1px dashed #ccc;
padding-top: 4px;
margin-top: 4px;
font-weight: bold;
}
.receipt-body .date {
margin-top: 8px;
justify-content: center;
color: #888;
font-size: 10px;
}
.image-label {
font-size: 12px;
color: #666;
background: rgba(255, 255, 255, 0.8);
padding: 4px 8px;
border-radius: 4px;
position: absolute;
bottom: 10px;
}
/* Chat Area */
.chat-area {
flex: 1.2;
display: flex;
flex-direction: column;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 6px;
}
.messages {
flex: 1;
padding: 15px;
display: flex;
flex-direction: column;
gap: 12px;
}
.empty-text {
text-align: center;
color: var(--vp-c-text-3);
margin-top: 40px;
font-size: 13px;
}
.message {
max-width: 90%;
padding: 10px;
border-radius: 10px;
font-size: 13px;
line-height: 1.5;
}
.message.user {
align-self: flex-end;
background: var(--vp-c-brand);
color: white;
border-bottom-right-radius: 2px;
}
.message.assistant {
align-self: flex-start;
background: var(--vp-c-bg-mute);
color: var(--vp-c-text-1);
border-bottom-left-radius: 2px;
}
.json-content pre {
margin: 0;
white-space: pre-wrap;
font-family: monospace;
font-size: 11px;
}
.input-area {
padding: 15px;
border-top: 1px solid var(--vp-c-divider);
min-height: 60px;
display: flex;
align-items: center;
justify-content: center;
}
.quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.action-btn {
padding: 6px 12px;
background: var(--vp-c-bg);
border: 1px solid var(--vp-c-divider);
border-radius: 16px;
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.action-btn:hover {
border-color: var(--vp-c-brand);
color: var(--vp-c-brand);
background: var(--vp-c-bg-mute);
}
.status-text {
font-size: 12px;
color: var(--vp-c-text-3);
}
.cursor {
display: inline-block;
width: 2px;
height: 14px;
background: currentColor;
animation: blink 1s infinite;
vertical-align: middle;
}
@keyframes popIn {
from {
transform: scale(0);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
@keyframes slideUp {
from {
transform: translateY(20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (max-width: 600px) {
.demo-container {
flex-direction: column;
height: auto;
}
.image-area {
height: 200px;
}
.chat-area {
height: 300px;
}
.scenario-tabs {
overflow-x: auto;
justify-content: flex-start;
padding-bottom: 5px;
}
.tab-btn {
white-space: nowrap;
}
}
</style>