Compare commits

..

14 Commits

Author SHA1 Message Date
7167f5bbd8 fix image path 2025-06-07 22:32:00 +08:00
Kirigaya Kazuto
e07840fd02
Merge pull request #2 from reliedejuedouzhe2/main
add FAQ for vnev
2025-06-07 22:24:06 +08:00
268eb54ee3 完成「OpenMCP 使用手册」所有章节 2025-06-06 20:55:05 +08:00
cybermanhao
535ca9bf56 add FAQ for vnev 2025-06-06 20:07:53 +08:00
0d6bb121fc 完成「用大模型测试您的 mcp」章节 2025-06-06 19:11:14 +08:00
65adc6f8d5 完成「连接大模型」章节 2025-06-06 18:14:39 +08:00
0883aa9d64 test rebase policy 2025-06-06 17:33:46 +08:00
229297cfac 更加优雅的首页加载动画 2025-06-06 17:26:34 +08:00
f5683dd52a 更加优雅的首页加载动画 2025-06-06 17:24:32 +08:00
f6c410a231 merge 2025-06-06 14:38:17 +08:00
b6c81342a9 新的 gsap 片头入场动画 2025-06-05 22:43:11 +08:00
644d537ec3 add some animation 2025-06-05 04:19:07 +08:00
48b60b059d add some animation 2025-06-05 03:37:21 +08:00
ea0779e0ec update 2025-06-04 22:22:47 +08:00
33 changed files with 6709 additions and 6450 deletions

2
.gitignore vendored
View File

@ -126,7 +126,7 @@ dist
.tern-port .tern-port
# Stores VSCode versions used for testing VSCode extensions # Stores VSCode versions used for testing VSCode extensions
.vscode-test .openmcp-test
# yarn v2 # yarn v2
.yarn/cache .yarn/cache

View File

@ -111,11 +111,12 @@ function makeHomeAnimation() {
const elements = [ const elements = [
{ selector: ".bilibili-player-container", start: "top 65%", end: "top 65%" }, { selector: "#home-0", trigger: '#home-0', start: "top 65%", end: "top 65%" },
{ selector: "#openmcp-为谁准备", trigger: '#openmcp-为谁准备', start: "top 65%", end: "top 65%" }, { selector: ".bilibili-player-container", trigger: '#home-0', start: "top 65%", end: "top 65%" },
{ selector: ".k-tabs", trigger: '#openmcp-为谁准备', start: "top 65%", end: "top 65%" }, { selector: "#home-1", trigger: '#home-1', start: "top 65%", end: "top 65%" },
{ selector: "#问题解答-faq", trigger: '#问题解答-faq', start: "top 65%", end: "top 65%" }, { selector: ".k-tabs", trigger: '#home-1', start: "top 65%", end: "top 65%" },
{ selector: ".el-collapse", trigger: '#问题解答-faq', start: "top 65%", end: "top 65%" }, { selector: "#home-2", trigger: '#home-2', start: "top 65%", end: "top 65%" },
{ selector: ".el-collapse", trigger: '#home-2', start: "top 65%", end: "top 65%" },
]; ];
elements.forEach(element => { elements.forEach(element => {

View File

@ -1,9 +1,11 @@
<template> <template>
<div class="k-tabs" :style="panelStyle"> <div class="k-tabs">
<div class="k-tabs-tags"> <div class="k-tabs-tags">
<div class="k-tabs-tag-item" v-for="pane of tabsContainer.paneInfos" :key="pane.id" <div class="k-tabs-tag-item" v-for="pane of tabsContainer.paneInfos" :key="pane.id"
:ref="el => tabsContainer.getPanes(el, pane.id)" @click="tabsContainer.switchLabel(pane.id)" :ref="el => tabsContainer.getPanes(el, pane.id)" @click="tabsContainer.switchLabel(pane.id)" :class="{
:class="{ 'active-tab': tabsContainer.lastPaneId === pane.id }"> 'active-tab': tabsContainer.lastPaneId === pane.id,
'prev-tab': pane.id === prevActiveIndex
}">
<span :class="pane.labelClass"></span> <span :class="pane.labelClass"></span>
<span>{{ pane.label }}</span> <span>{{ pane.label }}</span>
</div> </div>
@ -15,7 +17,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { reactive, useSlots, provide, nextTick, computed, onMounted } from 'vue'; import { reactive, useSlots, nextTick, onMounted, ref } from 'vue';
import gsap from 'gsap';
type PaneInfo = { type PaneInfo = {
id: number; id: number;
@ -23,116 +26,96 @@ type PaneInfo = {
labelClass: string; labelClass: string;
}; };
interface TabsContainer {
paneInfos: PaneInfo[];
panes: HTMLElement[];
lastPaneId?: number;
activeLabel: string;
panelContainer?: any;
height: string;
getPanes: (el: any, id: string | number) => void;
switchLabel: (id: number) => void;
updateLabels: () => void;
}
const slots = useSlots(); const slots = useSlots();
let maxChildHeight = 0; const prevActiveIndex = ref<number | null>(null);
function resizeTab(id: number) { const tabsContainer = reactive({
const container = tabsContainer.panelContainer; paneInfos: [] as PaneInfo[],
if (container) { panes: [] as HTMLElement[],
const panels = Array.from(container.children) as HTMLElement[];
const currentPanel = panels[id];
if (currentPanel) {
maxChildHeight = Math.max(maxChildHeight, currentPanel.clientHeight);
tabsContainer.height = maxChildHeight + 'px';
}
}
}
const panelStyle = computed(() => ({
height: tabsContainer.height
}));
const tabsContainer: TabsContainer = reactive({
paneInfos: [],
panes: [],
lastPaneId: 0, lastPaneId: 0,
activeLabel: '', panelContainer: undefined as HTMLElement | undefined,
hoverBar: null,
panelContainer: undefined, getPanes(el: HTMLElement | null, id: number) {
height: '0', if (el) this.panes[id] = el;
getPanes(el: HTMLElement | null, id: string | number) {
if (el) {
this.panes[id] = el;
}
}, },
switchLabel(id: number) {
if (this.lastPaneId === id) { async switchLabel(id: number) {
return; if (this.lastPaneId === id) return;
}
const oldId = this.lastPaneId;
prevActiveIndex.value = oldId;
this.lastPaneId = id; this.lastPaneId = id;
this.activeLabel = this.paneInfos[id]?.label || '';
const container = tabsContainer.panelContainer; const container = this.panelContainer;
if (!container) return;
const panels = Array.from(container.children) as HTMLElement[]; const panels = Array.from(container.children) as HTMLElement[];
const oldPanel = panels[oldId];
const newPanel = panels[id];
// // 1. First shrink the old panel to 0.3 scale
if (oldPanel) {
await gsap.to(oldPanel, {
scale: 0.3,
opacity: 0,
duration: 0.3,
ease: "power2.in"
});
}
// 2. Hide all other panels
panels.forEach((panel, index) => { panels.forEach((panel, index) => {
console.log('index', index); if (index !== id) {
console.log('id', id); gsap.set(panel, {
display: 'none',
panel.style.transition = 'opacity 0.3s ease'; opacity: 0,
if (index === id) { scale: 1
panel.style.display = 'block'; });
setTimeout(() => {
panel.style.opacity = '1';
}, 150);
} else {
panel.style.opacity = '0';
setTimeout(() => {
panel.style.display = 'none';
}, 300);
} }
}); });
// 3. Show and animate in the new panel
gsap.set(newPanel, {
display: 'block',
opacity: 0,
scale: 1.2
});
nextTick(() => { gsap.to(newPanel, {
resizeTab(id); opacity: 1,
scale: 1,
duration: 0.4,
ease: "back.out(1.2)"
}); });
}, },
updateLabels() {
const defaultChildren = slots.default?.() || [];
this.paneInfos = []; updateLabels() {
for (const index in defaultChildren) { this.paneInfos = (slots.default?.() || []).map((vnode, index) => ({
const vnode = defaultChildren[index]; id: index,
this.paneInfos.push({
id: Number(index),
label: vnode.props?.label || '', label: vnode.props?.label || '',
labelClass: vnode.props?.labelClass || '', labelClass: vnode.props?.labelClass || '',
}); }));
}
if (this.paneInfos.length > 0) {
this.activeLabel = this.paneInfos[0]?.label || '';
nextTick(() => {
resizeTab(0);
});
}
} }
}); });
// Initialize
tabsContainer.updateLabels(); tabsContainer.updateLabels();
onMounted(() => { onMounted(() => {
if (tabsContainer.panelContainer) { if (tabsContainer.panelContainer) {
const panels = Array.from(tabsContainer.panelContainer.children) as HTMLElement[]; const panels = Array.from(tabsContainer.panelContainer.children) as HTMLElement[];
panels.forEach((panel, index) => { panels.forEach((panel, index) => {
panel.style.position = 'absolute'; panel.style.position = 'absolute';
panel.style.width = '100%';
if (index != tabsContainer.lastPaneId) { if (index !== 0) {
panel.style.display = 'none'; panel.style.display = 'none';
panel.style.opacity = '0'; } else {
gsap.from(panel, {
scale: 1.2,
opacity: 0,
duration: 0.5
});
} }
}); });
} }
@ -142,58 +125,43 @@ onMounted(() => {
<style scoped> <style scoped>
.k-tabs { .k-tabs {
position: relative; position: relative;
height: fit-content;
} }
.k-tabs-tags { .k-tabs-tags {
display: flex; display: flex;
margin-bottom: 20px; margin-bottom: 20px;
width: 100%;
position: relative; position: relative;
z-index: 10;
gap: 8px;
} }
.k-tabs-tag-item { .k-tabs-tag-item {
background-color: var(--vp-button-alt-bg); background-color: var(--vp-button-alt-bg);
border-radius: .5em; border-radius: .5em;
margin-right: 10px;
padding: 2px 8px;
font-size: 0.8rem;
display: inline-block;
text-decoration: none;
cursor: pointer;
white-space: nowrap;
user-select: none;
transition: background-color 0.3s ease, transform 0.2s ease;
}
@media (min-width: 768px) {
.k-tabs-tag-item {
padding: 6px 16px; padding: 6px 16px;
font-size: 1rem; cursor: pointer;
} transition: all 0.3s ease;
} transform-origin: center;
will-change: transform;
.k-tabs-tag-item:hover {
background-color: var(--vp-c-brand-2);
} }
.k-tabs-tag-item.active-tab { .k-tabs-tag-item.active-tab {
background-color: var(--vp-c-brand-3); background-color: var(--vp-c-brand-3);
transform: scale(1.05);
} }
@media screen and (max-width: 414px) { .k-tabs-content {
.k-tabs-tags { position: relative;
} min-height: 200px;
}
.hover-bar {
background-color: var(--vp-c-brand-3);
border-radius: .9em .9em 0 0;
transition: .35s ease-in-out;
position: absolute;
} }
.k-tabs-content>* { .k-tabs-content>* {
transition: opacity 0.3s ease; position: absolute;
top: 0;
left: 0;
width: 100%;
will-change: transform, opacity;
backface-visibility: hidden;
transform-origin: center center;
} }
</style> </style>

View File

@ -1,22 +1,24 @@
<template> <template>
<div class="wrapper"> <div class="wrapper">
<div class="bilibili-player-container"> <div class="bilibili-player-container" @mouseenter="onHover" @mouseleave="onHoverEnd">
<iframe v-if="isPlaying" :src="playerUrl" frameborder="0" allowfullscreen></iframe> <iframe v-if="isPlaying" :src="playerUrl" frameborder="0" allowfullscreen></iframe>
<div v-else class="cover-container" @click="playVideo"> <div v-else class="cover-container" @click="playVideo">
<img :src="props.cover" class="cover-image" /> <img :src="props.cover" class="cover-image" ref="coverImage" />
<button class="play-button"> <button class="play-button" ref="playButton">
<svg viewBox="0 0 24 24" width="48" height="48"> <svg viewBox="0 0 24 24" width="48" height="48">
<path fill="currentColor" d="M8 5v14l11-7z" /> <path fill="currentColor" d="M8 5v14l11-7z" />
</svg> </svg>
</button> </button>
<div class="hover-overlay"></div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import gsap from 'gsap'
const props = defineProps({ const props = defineProps({
url: { url: {
@ -31,10 +33,56 @@ const props = defineProps({
const isPlaying = ref(false); const isPlaying = ref(false);
const playerUrl = ref(props.url); const playerUrl = ref(props.url);
const coverImage = ref(null);
const playButton = ref(null);
function playVideo() { function playVideo() {
isPlaying.value = true isPlaying.value = true
} }
function onHover() {
if (!isPlaying.value) {
gsap.to(coverImage.value, {
filter: 'brightness(0.7) saturate(1.2)',
duration: 0.3,
ease: 'power2.out'
});
gsap.to(playButton.value, {
scale: 1.15,
duration: 0.3,
ease: 'elastic.out(1, 0.5)'
});
gsap.to('.hover-overlay', {
opacity: 1,
duration: 0.3
});
}
}
function onHoverEnd() {
if (!isPlaying.value) {
gsap.to(coverImage.value, {
filter: 'brightness(1) saturate(1)',
duration: 0.3,
ease: 'power2.out'
});
gsap.to(playButton.value, {
scale: 1,
duration: 0.3,
ease: 'power2.out'
});
gsap.to('.hover-overlay', {
opacity: 0,
duration: 0.3
});
}
}
onMounted(() => {
//
gsap.set(coverImage.value, { filter: 'brightness(1) saturate(1)' });
gsap.set('.hover-overlay', { opacity: 0 });
});
</script> </script>
<style scoped> <style scoped>
@ -54,7 +102,13 @@ function playVideo() {
aspect-ratio: 16/9; aspect-ratio: 16/9;
border-radius: .5em; border-radius: .5em;
overflow: hidden; overflow: hidden;
border: 3px solid var(--vp-c-brand-2); transition: box-shadow 0.3s ease, border-color 0.3s ease;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.bilibili-player-container:hover {
border-color: var(--vp-c-brand-1);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
} }
.cover-container { .cover-container {
@ -68,6 +122,19 @@ function playVideo() {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
transition: filter 0.3s ease;
will-change: filter;
}
.hover-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: linear-gradient(135deg, rgba(var(--vp-c-brand-2-rgb), 0.2) 0%, rgba(var(--vp-c-brand-1-rgb), 0.1) 100%);
pointer-events: none;
will-change: opacity;
} }
.play-button { .play-button {
@ -85,16 +152,24 @@ function playVideo() {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
transition: transform 0.2s ease, background-color 0.2s ease; transition: transform 0.3s ease, background-color 0.3s ease;
will-change: transform;
z-index: 2;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
} }
.play-button:hover { .play-button:hover {
transform: translate(-50%, -50%) scale(1.1); background-color: var(--vp-c-brand-1);
background-color: var(--vp-c-brand-2); transform: translate(-50%, -50%) scale(1.2);
} }
.play-button svg { .play-button svg {
margin-left: 4px; margin-left: 4px;
transition: transform 0.2s ease;
}
.play-button:hover svg {
transform: scale(1.1);
} }
iframe { iframe {
@ -102,4 +177,24 @@ iframe {
height: 100%; height: 100%;
border: none; border: none;
} }
/* 响应式调整 */
@media (max-width: 768px) {
.bilibili-player-container {
min-width: 90vw;
min-height: calc(90vw * 9 / 16);
width: 90vw;
height: calc(90vw * 9 / 16);
}
.play-button {
width: 48px;
height: 48px;
}
.play-button svg {
width: 36px;
height: 36px;
}
}
</style> </style>

View File

@ -1,6 +1,7 @@
<template> <template>
<div style="height: fit-content; width: fit-content; position: relative;" ref="container"> <div style="height: fit-content; width: fit-content; position: relative;" ref="container">
<svg class="VPImage image-src" viewBox="0 0 612 612" fill="none" xmlns="http://www.w3.org/2000/svg" ref="svgElement"> <svg class="VPImage image-src" viewBox="0 0 612 612" fill="none" xmlns="http://www.w3.org/2000/svg"
ref="svgElement">
<defs> <defs>
<linearGradient id="gradient_1" gradientUnits="userSpaceOnUse" x1="300" y1="0" x2="300" y2="600"> <linearGradient id="gradient_1" gradientUnits="userSpaceOnUse" x1="300" y1="0" x2="300" y2="600">
<stop offset="0" stop-color="#A1A7F6" /> <stop offset="0" stop-color="#A1A7F6" />
@ -58,6 +59,11 @@
<feBlend mode="normal" in2="BackgroundImageFix_1" result="Shadow_2" /> <feBlend mode="normal" in2="BackgroundImageFix_1" result="Shadow_2" />
<feBlend mode="normal" in="SourceGraphic" in2="Shadow_2" result="Shape_3" /> <feBlend mode="normal" in="SourceGraphic" in2="Shadow_2" result="Shape_3" />
</filter> </filter>
<!-- 新增描边渐变色定义 -->
<linearGradient id="strokeGradient" gradientUnits="userSpaceOnUse" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#A48BF7" />
<stop offset="80%" stop-color="#F7F6FD" />
</linearGradient>
</defs> </defs>
<g transform="translate(6 2)"> <g transform="translate(6 2)">
<g> <g>
@ -87,67 +93,45 @@
</div> </div>
</template> </template>
<script setup> <script setup lang="ts">
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { gsap } from 'gsap' import { gsap } from 'gsap'
const container = ref(null) const container = ref < any > (null);
const svgElement = ref(null) const svgElement = ref < any > (null);
onMounted(() => { onMounted(() => {
const paths = svgElement.value.querySelectorAll('path') const paths = svgElement.value.querySelectorAll('path');
//
const nameClip = document.querySelector('.VPHero .heading .name.clip');
const headingText = document.querySelector('.VPHero .heading .text');
const tagline = document.querySelector('.VPHero .tagline');
const actions = document.querySelector('.VPHero .actions');
//
gsap.set(nameClip, {
transform: 'translate(50px, 80px)',
scale: 1.8,
});
gsap.set([headingText, tagline, actions], {
opacity: 0,
y: 20
});
//
gsap.set(paths, { gsap.set(paths, {
strokeDasharray: (i, target) => { strokeDasharray: (_, target) => {
const length = target.getTotalLength() const length = target.getTotalLength()
target.style.strokeDasharray = length target.style.strokeDasharray = length
return length return length
}, },
strokeDashoffset: (i, target) => target.getTotalLength(), strokeDashoffset: (_, target) => target.getTotalLength(),
stroke: 'var(--vp-c-brand-2)', stroke: 'url(#strokeGradient)', // 使
strokeWidth: 3, strokeWidth: 3.5,
fillOpacity: 0 fillOpacity: 0
}) })
// 线
const master = gsap.timeline()
// 线
const drawTimeline = gsap.timeline({
defaults: { duration: 1.5, ease: "power2.inOut" }
})
//
paths.forEach(path => {
drawTimeline.to(path, {
strokeDashoffset: 0,
}, "<+=0.3")
})
// 线
const reverseTimeline = gsap.timeline({
defaults: { duration: 1.5, ease: "power2.inOut" }
})
//
paths.forEach(path => {
reverseTimeline.to(path, {
fillOpacity: 1,
strokeDashoffset: (i, target) => target.getTotalLength()
}, "<")
})
// 线
const wiggleTimeline = gsap.timeline({
repeat: -1,
yoyo: true,
defaults: {
duration: 2,
ease: "sine.inOut"
}
})
// //
const wiggleElements = [ const wiggleElements = [
{ {
@ -166,7 +150,55 @@ onMounted(() => {
el: svgElement.value.querySelector('[transform="translate(388 129)"]'), el: svgElement.value.querySelector('[transform="translate(388 129)"]'),
x: "+=9", y: "+=4", rotation: "-=2" x: "+=9", y: "+=4", rotation: "-=2"
} }
] ];
// 线
const master = gsap.timeline();
// 线
const drawTimeline = gsap.timeline({
defaults: { duration: 1.5, ease: "power2.inOut" }
});
// OpenMCP
if (!nameClip || !headingText || !tagline || !actions) {
console.error("One or more required elements are missing.");
return;
}
drawTimeline.to(nameClip, {
transform: 'translate(50px, 80px)',
}, "<+=0.3");
// OpenMCP
paths.forEach((path: gsap.TweenTarget) => {
drawTimeline.to(path, {
strokeDashoffset: 0,
}, "<+=0.3");
});
// 线
const reverseTimeline = gsap.timeline({
defaults: { duration: 1.5, ease: "power2.inOut" }
});
//
paths.forEach(path => {
reverseTimeline.to(path, {
fillOpacity: 1,
strokeDashoffset: (i, target) => target.getTotalLength()
}, "<")
});
// 线
const wiggleTimeline = gsap.timeline({
repeat: -1,
yoyo: true,
defaults: {
duration: 2,
ease: "sine.inOut"
}
});
// //
wiggleElements.forEach(item => { wiggleElements.forEach(item => {
@ -174,15 +206,90 @@ onMounted(() => {
x: item.x, x: item.x,
y: item.y, y: item.y,
rotation: item.rotation, rotation: item.rotation,
scale: item.scale,
transformOrigin: "center center" transformOrigin: "center center"
}, "<") }, "<")
}) });
// 线
const heroTimeline = gsap.timeline({
defaults: {
duration: 0.8,
ease: "power3.out"
}
});
//
heroTimeline
.to(nameClip, {
transform: 'translate(0, 0)',
scale: 1,
duration: 1.2,
ease: "back.out(1.7)"
})
.to([headingText, tagline, actions], {
opacity: 1,
y: 0,
stagger: 0.15,
duration: 0.6
}, "-=0.4"); // 0.4
// feature
const boxTimeline = gsap.timeline({
defaults: {
duration: 0.8,
ease: "power3.out"
}
});
// box
const boxes = document.querySelectorAll('.VPFeatures .item.grid-3');
//
gsap.set(boxes, {
opacity: 0,
y: 30,
scale: 0.9
});
// 线
boxTimeline.to(boxes, {
opacity: 1,
y: 0,
scale: 1,
stagger: 0.15,
onComplete: () => {
gsap.set(boxes, { clearProps: "all" });
}
});
// heroTimeline
heroTimeline
.to(nameClip, {
transform: 'translate(0, 0)',
scale: 1,
duration: 1.0, //
ease: "elastic.out(1, 0.5)" //
})
.to([headingText, tagline], {
opacity: 1,
y: 0,
stagger: 0.2, //
duration: 0.8,
ease: "back.out(1.5)" //
}, "-=0.3")
.to(actions, {
opacity: 1,
y: 0,
duration: 0.6,
ease: "power2.out" //
}, "-=0.2");
//
master.add(drawTimeline) master.add(drawTimeline)
.add(reverseTimeline, "+=0.5") .add(reverseTimeline, "+=0.3") //
.add(wiggleTimeline) // .add(heroTimeline, "-=0.3")
.add(boxTimeline, "<+=1.3")
.add(wiggleTimeline, "<+=0.2");
}) })
</script> </script>

View File

@ -59,6 +59,7 @@
background-color: var(--vp-c-brand-3); background-color: var(--vp-c-brand-3);
} }
.VPFeatures .item:hover .box .title,
.VPFeatures .item:hover .box .details { .VPFeatures .item:hover .box .details {
color: var(--vp-c-white); color: var(--vp-c-white);
} }

View File

@ -266,7 +266,52 @@ iframe {
color: white; color: white;
} }
.VPHero .name.clip { .VPHero .heading .name.clip {
color: var(--vp-c-brand-2) !important; background: var(--vp-home-hero-name-background);
background-color: var(--vp-c-brand-2) !important; -webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: var(--vp-home-hero-name-color);
}
.VPHero .heading .text {
}
.VPHero .tagline {
}
.VPHero .actions {
}
#home-0 {
font-size: 40px;
line-height: 1.1;
}
#home-0 > span {
font-size: 20px;
opacity: 0.5;
line-height: 0.8;
}
#home-1 {
font-size: 40px;
line-height: 1.1;
}
#home-1 > span {
font-size: 20px;
opacity: 0.5;
line-height: 0.8;
}
#home-2 {
font-size: 40px;
line-height: 1.1;
}
#home-2 > span {
font-size: 20px;
opacity: 0.5;
line-height: 0.8;
} }

View File

@ -1,31 +0,0 @@
{
"items": [
[],
[],
[],
[
{
"connectionType": "SSE",
"name": "SSE-1748530644202",
"version": "1.0",
"url": "http://localhost:8000/mcp",
"oauth": ""
}
],
[],
[],
[],
[],
[],
[
{
"connectionType": "SSE",
"name": "SSE-1748531219080",
"version": "1.0",
"url": "http://localhost:8000/sse",
"oauth": ""
}
],
[]
]
}

View File

@ -1,215 +0,0 @@
{
"clientId": "81b3b1d6-b1d6-53b1d63c156-83b1d63c156a678-6a678e36",
"currentIndex": 0,
"tabs": [
{
"name": "交互测试",
"icon": "icon-robot",
"type": "blank",
"componentIndex": 3,
"storage": {
"activeNames": [
0
],
"messages": [
{
"role": "user",
"content": "使用 ffmpeg 把视频 (0, 65) 到 (1173, 769) 的位置截取出来,视频的格式是 mov我希望导出为 mp4",
"extraInfo": {
"created": 1749111492698,
"state": "success",
"serverName": "deepseek"
}
},
{
"role": "assistant",
"content": "你可以使用 `ffmpeg` 的 `crop` 滤镜来截取视频的指定区域,并将 `.mov` 转换为 `.mp4` 格式。以下是具体的命令:\n\n### 命令解析:\n1. **输入文件**`input.mov`(替换为你的文件名)\n2. **裁剪参数**\n - `crop=w:h:x:y`:从坐标 `(x,y)` 开始,截取宽度 `w`、高度 `h` 的区域。\n - 你的需求是从 `(0, 65)` 到 `(1173, 769)`,因此:\n - 宽度 `w = 1173 - 0 = 1173`\n - 高度 `h = 769 - 65 = 704`\n - 起始坐标 `(x,y) = (0, 65)`\n3. **输出格式**`mp4`(默认使用 H.264 编码)\n\n### 完整命令:\n```bash\nffmpeg -i input.mov -filter:v \"crop=1173:704:0:65\" -c:a copy output.mp4\n```\n\n### 参数说明:\n- `-i input.mov`:指定输入文件。\n- `-filter:v \"crop=1173:704:0:65\"`:视频裁剪滤镜。\n- `-c:a copy`:直接复制音频流(无需重新编码,节省时间)。\n- `output.mp4`:输出文件名。\n\n### 其他选项:\n- 如果需要控制视频质量,可以添加 `-crf` 参数(默认 23值越小质量越高\n ```bash\n ffmpeg -i input.mov -filter:v \"crop=1173:704:0:65\" -crf 18 -c:a copy output.mp4\n ```\n- 如果需要硬件加速(如 NVIDIA GPU可以添加 `-hwaccel cuda` 等参数。\n\n### 注意:\n1. 确保 `ffmpeg` 已安装并添加到系统路径。\n2. 如果视频有旋转/缩放等元数据,可能需要先处理(如用 `-vf \"transpose=1\"` 旋转)。\n\n运行命令后`output.mp4` 就是你需要的裁剪后视频。",
"extraInfo": {
"created": 1749111520497,
"state": "success",
"serverName": "deepseek",
"usage": {
"prompt_tokens": 42,
"completion_tokens": 487,
"total_tokens": 529,
"prompt_tokens_details": {
"cached_tokens": 0
},
"prompt_cache_hit_tokens": 0,
"prompt_cache_miss_tokens": 42
}
}
}
],
"settings": {
"modelIndex": 0,
"enableTools": [
{
"name": "k_navigate",
"description": "Navigate to a URL",
"inputSchema": {
"type": "object",
"properties": {
"url": {
"type": "string",
"description": "URL to navigate to"
},
"launchOptions": {
"type": "object",
"description": "PuppeteerJS LaunchOptions. Default null. If changed and not null, browser restarts. Example: { headless: true, args: ['--no-sandbox'] }"
},
"allowDangerous": {
"type": "boolean",
"description": "Allow dangerous LaunchOptions that reduce security. When false, dangerous args like --no-sandbox will throw errors. Default false."
}
},
"required": [
"url"
]
},
"enabled": false
},
{
"name": "k_screenshot",
"description": "Take a screenshot of the current page or a specific element",
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "Name for the screenshot"
},
"selector": {
"type": "string",
"description": "CSS selector for element to screenshot"
},
"width": {
"type": "number",
"description": "Width in pixels (default: 800)"
},
"height": {
"type": "number",
"description": "Height in pixels (default: 600)"
}
},
"required": [
"name"
]
},
"enabled": false
},
{
"name": "k_click",
"description": "Click an element on the page",
"inputSchema": {
"type": "object",
"properties": {
"selector": {
"type": "string",
"description": "CSS selector for element to click"
}
},
"required": [
"selector"
]
},
"enabled": false
},
{
"name": "k_fill",
"description": "Fill out an input field",
"inputSchema": {
"type": "object",
"properties": {
"selector": {
"type": "string",
"description": "CSS selector for input field"
},
"value": {
"type": "string",
"description": "Value to fill"
}
},
"required": [
"selector",
"value"
]
},
"enabled": false
},
{
"name": "k_select",
"description": "Select an element on the page with Select tag",
"inputSchema": {
"type": "object",
"properties": {
"selector": {
"type": "string",
"description": "CSS selector for element to select"
},
"value": {
"type": "string",
"description": "Value to select"
}
},
"required": [
"selector",
"value"
]
},
"enabled": false
},
{
"name": "k_hover",
"description": "Hover an element on the page",
"inputSchema": {
"type": "object",
"properties": {
"selector": {
"type": "string",
"description": "CSS selector for element to hover"
}
},
"required": [
"selector"
]
},
"enabled": false
},
{
"name": "k_evaluate",
"description": "Execute JavaScript in the browser console",
"inputSchema": {
"type": "object",
"properties": {
"script": {
"type": "string",
"description": "JavaScript code to execute"
}
},
"required": [
"script"
]
},
"enabled": false
},
{
"name": "k_get_full_page_text",
"description": "获取页面所有文本内容",
"inputSchema": {
"type": "object",
"properties": {}
},
"enabled": false
}
],
"enableWebSearch": false,
"temperature": 0.6,
"contextLength": 20,
"systemPrompt": "",
"parallelToolCalls": true
}
}
}
]
}

View File

@ -40,6 +40,14 @@ features:
<br><br> <br><br>
<h2 id="home-0">
为您的 MCP Agent 开发排忧解难
<br>
<span>Providing Fun and Convenience for Your MCP Agent Development</span>
</h2>
<BiliPlayer <BiliPlayer
url="//player.bilibili.com/player.html?isOutside=true&aid=114445745397200&bvid=BV1zYGozgEHcautoplay=false" url="//player.bilibili.com/player.html?isOutside=true&aid=114445745397200&bvid=BV1zYGozgEHcautoplay=false"
cover="https://picx.zhimg.com/80/v2-8c1f5d99066ed272554146ed8caf7cc3_1440w.png" cover="https://picx.zhimg.com/80/v2-8c1f5d99066ed272554146ed8caf7cc3_1440w.png"
@ -47,7 +55,12 @@ features:
<br> <br>
## OpenMCP 为谁准备?
<h2 id="home-1">
OpenMCP 为谁准备?
<br>
<span>The Development of OpenMCP is for ...</span>
</h2>
<br> <br>
@ -88,8 +101,11 @@ features:
<br> <br>
<h2 id="home-2">
## 问题解答 FAQ 问题解答
<br>
<span>Waiting for Your Questions</span>
</h2>
<el-collapse> <el-collapse>
<el-collapse-item title="OpenMCP 适合做什么?" name="1"> <el-collapse-item title="OpenMCP 适合做什么?" name="1">

View File

@ -0,0 +1,27 @@
---
layout: doc
---
# 常见问题解答
## 错误代码说明
### 32000 - MCP 连接失败
MCP 连接失败可能有多种原因,以下是一些常见情况:
• **虚拟环境路径不匹配**
虚拟环境venv与入口文件路径不匹配是导致连接失败的常见原因之一。
详细的解决方案请参考:[配置说明](./venv-not-same-path/venv-not-same-path.md)
---
• **其他可能的原因**
- 端口被占用
- 环境变量配置错误
- 依赖库未正确安装
> 如果您遇到以上问题,请先查看错误日志获取详细信息。如果问题仍然存在,可以在 [GitHub Issues](https://github.com/LSTM-Kirigaya/openmcp-client/issues) 中寻求帮助。

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@ -0,0 +1,29 @@
# 虚拟环境与入口文件不在同一目录时的配置方式
## 问题描述
在使用 OpenMCP 时有时会遇到虚拟环境venv与 Python 文件不在同一目录的情况,甚至虚拟环境可能位于项目文件夹之外。这种情况下,点击右上角连接按钮可能会出现 MCP 连接失败错误代码32000的问题。
## 解决方案
### 1. 调整执行目录
在连接选项中,您需要调整执行目录到虚拟环境所在的位置:
![MCP 连接选项界面](./image-2.png)
### 2. 修改执行命令
同时,需要相应地修改执行命令:
![修改执行命令示例](./image.png)
### 3. 直接指定解释器路径
对于特定情况,您可以直接在命令中指定 Python 解释器的完整路径,例如:
```bash
C:\code\ygo-chat\.venv\Scripts\python.exe example.py
```
> 注意:此方法同样适用于 node或者mcp指令的【命令】以及其它mcp client的mcp配置文件。

View File

@ -1 +1,69 @@
# 连接大模型 # 连接大模型
如果需要使用「交互测试」来在和大模型的交互中测试 MCP 工具的性能,你需要首先需要在 OpenMCP 配置大模型。
:::warning 协议兼容性警告
目前 OpenMCP 只支持符合 OpenAI 接口规范的 大模型服务,其他大模型的调用需要请通过 [newApi](https://github.com/QuantumNous/new-api) 进行转发或者自行实现。
目前市面上主流的如下模型我们都是支持的,如果遇到大模型连接的问题,请随时 [[channel|联系我们]]。
:::
在 「设置」-「API」 可以进入大模型的连接配置界面。
![](./images/setting-api.png)
## 默认支持的模型
OpenMCP 默认填充了市面上常见的大模型,下面是支持的模型
| 大模型 Name | 提供商 | baseUrl | 默认模型 |
|----------------------|---------------------------|---------------------------------------------|-----------------------|
| DeepSeek | DeepSeek | `https://api.deepseek.com/v1` | `deepseek-chat` |
| OpenAI | OpenAI | `https://api.openai.com/v1` | `gpt-4-turbo` |
| 通义千问 Qwen | Alibaba | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `qwen-plus` |
| 豆包 Seed | ByteDance | `https://ark.cn-beijing.volces.com/api/v3` | `doubao-1.5-pro-32k` |
| Gemini | Google | `https://generativelanguage.googleapis.com/v1beta/openai/` | `gemini-2.0-flash` |
| Grok | xAI | `https://api.x.ai/v1` | `grok-3-mini` |
| Mistral | Mistral AI | `https://api.mistral.ai/v1` | `mistral-tiny` |
| Groq | Groq | `https://api.groq.com/openai/v1` | `mixtral-8x7b-32768` |
| Perplexity | Perplexity AI | `https://api.perplexity.ai/v1` | `pplx-7b-online` |
| Kimi Chat | 月之暗面 (Moonshot AI) | `https://api.moonshot.cn/v1` | `moonshot-v1-8k` |
## 配置大模型
你需要做的只是把对应服务商的 apiToken 填入 openmcp 中即可。然后点击「测试」,看到下面的响应说明连接成功。您就可以在交互测试里面使用大模型了!
![](./images/setting-api-test.png)
:::warning
有些用户会遇到无法访问的问题,请确保你的 baseUrl 填写正确。如果在国内使用某些国外厂商的服务,比如 geminiopenai请确保你的网络环境可以访问到这些服务。在 「设置」-「通用」中你可以设置代理服务器。
:::
## 添加模型
如果你想使用的指定服务商的模型不在默认支持的模型中,有两种方法可以添加它们。
### 方法一:更新模型列表
此处以通义千问为例子,确保在 apitoken 填写正确的情况下,点击「更新模型列表」,如果服务商严格实现了 openai 标准,那么就能看到所有更新的模型了。
![](./images/setting-update-models.png)
### 方法二:手动添加模型
如果你的服务器没有支持 openai 标准,你将无法使用「方法一」,你可以如此手动添加模型列表。此处以 Grok 为例,在服务商中找到 grok点击图中所示的编辑
![](./images/setting-api-edit.png)
点击模型,输入模型名称,回车,点击确认:
![](./images/setting-api-edit-1.png)
回到 api 页面,再点击保存。
## 添加服务
如果你要的服务商没有出现我们的列表中(云服务商的服务,或者自己部署的服务),可以通过「添加服务」按钮来添加自定义模型,使用方法和「添加模型」「方法二:手动添加模型」类似,就不赘述了。

View File

@ -66,10 +66,10 @@ MCP 客户端是指能够通过 MCP 协议进行通信的大模型对话客户
![](./images/openmcp-control-panel.png) ![](./images/openmcp-control-panel.png)
当前工作区曾经连接过的 mcp 服务器会出现在这里,这是因为 openmcp 默认将工作区启动的 mcp 的连接信息存储在了 `.vscode/tabs.{server-name}.json` 中,其中 `{server-name}` 就是 mcp 服务器连接成功的服务器名称。 当前工作区曾经连接过的 mcp 服务器会出现在这里,这是因为 openmcp 默认将工作区启动的 mcp 的连接信息存储在了 `.openmcp/tabs.{server-name}.json` 中,其中 `{server-name}` 就是 mcp 服务器连接成功的服务器名称。
:::warning :::warning
注意,同一个项目中,你不应该有两个名字完全相同的 mcp 服务器,这会导致 `.vscode/tabs.{server-name}.json` 连接信息存储冲突,发生未知错误。 注意,同一个项目中,你不应该有两个名字完全相同的 mcp 服务器,这会导致 `.openmcp/tabs.{server-name}.json` 连接信息存储冲突,发生未知错误。
::: :::
如果你想要在任意工作区都能使用同一个 mcp 服务器,可以考虑在「安装的 MCP 服务器」中添加成熟耐用的 mcp 服务器,这个位置添加的 mcp 服务器全局可用。 如果你想要在任意工作区都能使用同一个 mcp 服务器,可以考虑在「安装的 MCP 服务器」中添加成熟耐用的 mcp 服务器,这个位置添加的 mcp 服务器全局可用。

View File

@ -11,10 +11,10 @@ openmcp 具备自动保存测试结果的功能。如下的行为会触发 openm
- 创建标签页,并选择一个有效的调试项目 - 创建标签页,并选择一个有效的调试项目
- 在调试页进行调试行为(选择工具,执行工具,询问大模型等) - 在调试页进行调试行为(选择工具,执行工具,询问大模型等)
当前 mcp 项目的测试数据会被保存在 `.vscode/tabs.{server-name}.json` 中,其中 `{server-name}` 就是 mcp 服务器连接成功的服务器名称。 当前 mcp 项目的测试数据会被保存在 `.openmcp/tabs.{server-name}.json` 中,其中 `{server-name}` 就是 mcp 服务器连接成功的服务器名称。
:::warning :::warning
注意,同一个项目中,你不应该有两个名字完全相同的 mcp 服务器,这会导致 `.vscode/tabs.{server-name}.json` 连接信息存储冲突,发生未知错误。 注意,同一个项目中,你不应该有两个名字完全相同的 mcp 服务器,这会导致 `.openmcp/tabs.{server-name}.json` 连接信息存储冲突,发生未知错误。
::: :::
## 快速调试 ## 快速调试

View File

@ -1 +1,44 @@
# 分发您的实验结果 # 分发您的实验结果
## 标签页恢复
openmcp 默认会实时保存您的实验结果,每一个在工作区开启的服务器默认会将结果存储在 `.openmcp/tabs.{server-name}.json` 中,其中 `{server-name}` 就是 mcp 服务器连接成功的服务器名称。
请确保您的 `.gitignore` 文件中没有包含匹配到 .openmcp 文件夹的规则。这样,当您通过 git 提交你的代码,对 agent 的代码进行管理时,当你在别的电脑上 clone 或者他人 clone 你的项目时,就能快速恢复你上一次的实验内容,继续进行实验或者开发调试。
## 连接恢复
每一个 mcp 服务器的连接信息会被保存在 `.openmcp/connection.json` 中,下面是一个例子:
```json
{
"items": [
[
{
"connectionType": "STDIO",
"command": "mcp",
"args": [
"run",
"main.py"
],
"url": "",
"cwd": "{workspace}/simple-mcp",
"oauth": "",
"clientName": "openmcp.connect.STDIO",
"clientVersion": "0.0.1",
"env": {},
"serverInfo": {
"name": "锦恢的 MCP Server",
"version": "1.9.2"
},
"filePath": "{workspace}/simple-mcp/main.py",
"name": "锦恢的 MCP Server",
"version": "1.9.2"
}
]
]
}
```
当您打开左侧的控制面板或者打开一个过去打开过的 mcp 服务器时, mcp 默认会根据上面的信息来获取工作区的服务器列表或者尝试进行自动连接。如果 openmcp 在连接 mcp 时发生了初始化错误或者保存错误,除了向 openmcp 官方求助外,您还可以尝试手动管理 `.openmcp/connection.json` 文件。

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 411 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

View File

@ -1 +1,35 @@
# 连接多个 MCP 服务器 # 连接多个 MCP 服务器
openmcp 支持连接多个 MCP 服务器。
假设你现在想要实现一个可以自动查阅资料并且整理成 word 文档的 agent你可以这样做
1. 找到能进行网络搜索的 mcp[crawl4ai mcp](https://github.com/LSTM-Kirigaya/openmcp-tutorial/tree/main/crawl4ai-mcp)
2. 找到能进行 word 操作的 mcp[Office-Word-MCP-Server](https://github.com/GongRzhe/Office-Word-MCP-Server)
3. 在 openmcp 中把它们组合起来。
4. 自动完成你的任务!
假设,我们已经连接了第一个 mcp也就是 crawl4ai mcp我们可以添加额外的 mcp 服务器:
![](./images/add-new-mcp.png)
## 添加方法一:拖拽
直接把需要加入的 mcp 服务器的文件,按住 shift 点击拖拽进入 openmcp 界面就能自动完成参数的填充。
![](./images/drag-to-fill.png)
:::warning
自动填充的命令不一定总是准确的,在 [STDIO 连接的启动](http://localhost:5173/openmcp/plugin-tutorial/usage/connect-mcp.html#stdio-%E8%BF%9E%E6%8E%A5%E7%9A%84%E5%90%AF%E5%8A%A8) 中我们说过这一点。具体的连接方法请阅读 [附录:关于 uv 启动 mcp 你必须知道的](http://localhost:5173/openmcp/plugin-tutorial/quick-start/first-mcp.html#%E9%99%84%E5%BD%95-%E5%85%B3%E4%BA%8E-uv-%E5%90%AF%E5%8A%A8-mcp-%E4%BD%A0%E5%BF%85%E9%A1%BB%E7%9F%A5%E9%81%93%E7%9A%84) 后自行判断。
:::
## 添加方法二:手动填写参数
手动填写参数,没啥好说的。
## 使用多服务器
多服务器连接后的使用和单服务器没有太大的区别openmcp 内部会自动完成工具的调度和选择。唯一需要注意的是,多服务器的 tool name 一定不要重名,否则会出现冲突。
如果您认为 tool 重名有存在的必要性,请通过 [issue](https://github.com/LSTM-Kirigaya/openmcp-client/issues) 让我们知道您的场景和想法,根据讨论,我们会支持。

View File

@ -1 +1,72 @@
# 用大模型测试您的 mcp # 用大模型测试您的 mcp
如果您完成了 [[connect-llm|连接 mcp 服务器]] 这一步,那么您就可以开始测试您的 mcp 了。
在 [[put-into-llm|扔进大模型里面测测好坏!]] 中,我们已经通过一个简单的例子来展示来如何使用大模型测试您的 mcp。因此这篇文章更多是讲解不便在「快速开始」中赘述的细节。
和大模型交互时,有一些参数可以选择,也就是输入框下面那一排按钮,我来简单介绍一下。
## 选择模型
顾名思义你可以在这里切换你的模型。值得一提的是openmcp 会以单条对话为粒度来记录每一条对话使用的模型。您可以利用这一特性来进行混合模型测试。
如果你没有找到你想要的模型,或是想要添加额外的模型,请移步 [[connect-llm|连接 mcp 服务器]] 来了解如何添加模型。
## 系统提示词
您可以在这里选择和添加系统提示词。
![](./images/system-prompt.png)
openmcp 默认将您的系统提示词保存在 `~/.openmcp/nedb/systemPrompt.db` 中。您可以通过 nedb 来进行反序列化和拷贝。
## 提词
您可以利用该模块来调用 mcp 服务器提供的 prompt 功能,生成的 prompt 字段会作为富文本被插入您的对话中。
![](./images/prompt.png)
## 资源
您可以利用该模块来调用 mcp 服务器提供的 resource 功能,生成的 resource 字段会作为富文本被插入您的对话中。
![](./images/resource.png)
:::warning openmcp 不负责 resource 的数据持久化!
请注意!每次对话完成后 resource 是否会被保存到磁盘完全由 mcp server 作者决定openmcp 不负责 resource 的数据持久化!如果您发现关闭 openmcp 再打开resource 彩蛋为空,这不是 openmcp 的 bug而是 mcp server 作者没有支持数据持久化!
:::
## 允许模型在单轮回复中调用多个工具
大模型在进行工具调用时有时候会将在一次回复中要求调用多次工具比如你想要同时获取三个网页的内容翻译大模型可能会同时调用三次「网络搜索」工具如果你提供了的话。多次工具使用时openmcp 会如此渲染调用执行过程:
![](./images/parallel-tool-call.png)
openmcp 输入框的按钮中的「允许模型在单轮回复中调用多个工具」默认是点亮的。也就是允许大模型可以在一次回复中调用多次工具。
有的时候,我们希望命令一条条执行,就可以选择把这个按钮熄灭。
:::warning 协议兼容性警告
有的厂商(比如 gemini不一定严格支持了 openai 协议,对于不支持 「允许模型在单轮回复中调用多个工具」的厂商openmcp 的后端会自动将该选项关闭。
:::
## 温度参数
温度参数越高,生成内容的随机性越强,对于通用大模型,个人建议参数为 0.6 ~ 0.7会是一个适用于通用型任务的数值。OpenMCP 提供的默认值为 0.6。
## 上下文长度
上下文长度代表了大模型的最大上下文轮数,默认值为 20。举个例子如果你和大模型对话产生了 40 轮数据(工具调用条数+你的问题数量+大模型回答的次数总共 40 个),那么下一次对话 openmcp 只会发送最后 20 条数据给大模型。
:::warning 上下文长度不要太小!
我们强烈不推荐你将这个数值设置低于 20因为大模型接受工具调用的结果需要和之前的调用请求一一对应。如果不对应大模型会返回注入 400 这样的错误。遇到这样的错误,请从最初一句话重启或者新开一个「交互测试」。
:::
## MCP 服务器超时
MCP 服务器默认超时时间为 30 秒,如果您的 mcp 服务器需要更长的时间才能完成任务,您可以通过「设置」「通用」来设置超时时间,单位为秒,该设置全局生效。

View File

@ -2,7 +2,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
// 要排除的文件夹 // 要排除的文件夹
const excludedDirs = ['node_modules', '.vitepress', '.vscode', 'scripts']; const excludedDirs = ['node_modules', '.vitepress', '.openmcp', 'scripts'];
// 递归搜索图片文件夹 // 递归搜索图片文件夹
function findImageDirs(rootDir, currentDir = '', results = []) { function findImageDirs(rootDir, currentDir = '', results = []) {