feat(docs): support specifying cover image for blog posts (#288)

---------

Co-authored-by: Rynco Maekawa <10259119+lynzrand@users.noreply.github.com>
This commit is contained in:
Neko
2025-07-27 01:59:55 +08:00
committed by GitHub
co-authored by Rynco Maekawa
parent c42d1bbd41
commit f32e4be20e
18 changed files with 373 additions and 185 deletions
+2
View File
@@ -126,6 +126,7 @@ words:
- Lorebook
- lucide
- luoling
- Maekawa
- magnifer
- Maru
- matchall
@@ -192,6 +193,7 @@ words:
- rmcp
- rolldown
- rushstack
- Rynco
- Saccade
- saccades
- safetensors
+46 -8
View File
@@ -18,8 +18,12 @@ interface Post {
}
excerpt: string | undefined
frontmatter?: {
category?: string
author?: string
'category'?: string
'author'?: string
'preview-cover'?: {
light?: string
dark?: string
}
}
}
@@ -43,7 +47,14 @@ const posts = computed(() => {
}
const transformedPostsData = postsData
.map(post => ({ ...post, url: withBase(post.url) }))
.map((post) => {
const overridePost = {
...post,
url: withBase(post.url),
}
return overridePost
})
.filter(post => !!post.title)
const currentLanguagePostsData = [...transformedPostsData].filter(post => post.lang === lang.value)
@@ -198,15 +209,25 @@ const svgArts = computedAsync(async () => {
:href="post.url"
class="block flex flex-col overflow-hidden border-transparent rounded-xl border-solid bg-white/50 decoration-none shadow-sm outline-2 outline-transparent outline-offset-0 outline transition-all transition-all duration-200 duration-300 ease-in-out dark:bg-black/20 dark:shadow-slate-600/5 hover:shadow-md hover:outline-primary/5 hover:outline-offset-2 [&_.post-card-title]:hover:text-primary dark:hover:outline-primary/25"
>
<div class="h-28 rounded-t-xl">
<div class="rounded-t-xl">
<ClientOnly>
<div h-full blur-2xl v-html="isDark ? svgArts?.[index].dark : svgArts?.[index].light" />
<div v-if="!post.frontmatter?.['preview-cover']?.[isDark ? 'dark' : 'light']" class="mb-6 h-20 md:h-60">
<div class="blur-2xl" h="full" w-full v-html="isDark ? svgArts?.[index].dark : svgArts?.[index].light" />
</div>
<div v-else class="relative mb-0 h-28 w-full md:h-68">
<div class="preview-card-art-image-overlay" />
<img
:src="post.frontmatter?.['preview-cover']?.[isDark ? 'dark' : 'light']"
alt="Post Cover"
class="preview-card-art-image h-full w-full object-cover"
>
</div>
</ClientOnly>
</div>
<div class="flex-grow p-6">
<h2 class="post-card-title text-card-foreground text-2xl font-bold transition-colors duration-200">
<div class="relative z-1 flex-grow px-6 pb-3 pt-6 md:pt-6">
<div class="post-card-title text-card-foreground z-1 text-2xl font-bold transition-colors duration-200">
{{ post.title }}
</h2>
</div>
<div class="text-muted-foreground mb-4 flex items-center gap-4 text-sm">
<div class="flex items-center gap-2">
<Icon icon="lucide:calendar" />
@@ -242,4 +263,21 @@ const svgArts = computedAsync(async () => {
max-height: 10lh; /* Adjust this value to control the number of visible lines */
overflow: hidden;
}
/**
https://stackoverflow.com/questions/67853607/how-do-i-graduallyfeather-gradient-transition-blur-in-css
https://www.reddit.com/r/css/comments/t3am53/pure_css_gradientprogressive_blur_i_used/
https://codepen.io/Francesco_Maretti/pen/yLPRvXp
*/
.preview-card-art-image-overlay {
height: 50%;
width: 100%;
position: absolute;
bottom: 0;
z-index: 1;
transform: translateY(50%);
backdrop-filter: blur(40px);
-webkit-mask-image: linear-gradient(to bottom, #ffffff00 0%, #ffffff 50%, #ffffff00 100%);
mask-image: linear-gradient(to bottom, #ffffff00 0%, #ffffff 50%, #ffffff00 100%);
}
</style>
+1 -1
View File
@@ -39,7 +39,7 @@ defineProps<{
:exit="{ opacity: 0, y: -10 }"
class="z-20 border rounded-xl p-2 shadow-md backdrop-blur-md"
:class="[
'bg-white/20 dark:border-white/5 dark:bg-black/20',
'bg-white/80 dark:border-white/5 dark:bg-black/80',
'transition-colors duration-200 ease-in-out',
]"
>
+1 -1
View File
@@ -99,7 +99,7 @@ function isNavLinkActive(link: string, path: string) {
align="end"
class="will-change-[transform,opacity] z-10 w-[180px] border rounded-xl p-2 shadow-md backdrop-blur-md data-[state=open]:data-[side=bottom]:animate-slideUpAndFade"
:class="[
'bg-white/20 dark:border-white/5 dark:bg-black/20',
'bg-white/70 dark:border-white/5 dark:bg-black/70',
]"
transition="colors duration-200 ease-in-out"
>
+3 -5
View File
@@ -1,9 +1,10 @@
import type { DefaultTheme } from 'vitepress/theme'
import type { Ref } from 'vue'
import { useEventListener } from '@vueuse/core'
// Copied from https://github.com/vuejs/vitepress/blob/97f9469b6d4eb7ba9de9a1111986581d1f704ec3/src/client/theme-default/composables/outline.ts#L4
import { getScrollOffset } from 'vitepress'
import { onMounted, onUnmounted, onUpdated } from 'vue'
import { onMounted, onUpdated } from 'vue'
export interface Header {
/**
@@ -153,7 +154,6 @@ export function useActiveAnchor(
onMounted(() => {
requestAnimationFrame(setActiveLink)
window.addEventListener('scroll', onScroll)
})
onUpdated(() => {
@@ -161,9 +161,7 @@ export function useActiveAnchor(
activateLink(location.hash)
})
onUnmounted(() => {
window.removeEventListener('scroll', onScroll)
})
useEventListener('scroll', onScroll)
function setActiveLink() {
const scrollY = window.scrollY
+2 -9
View File
@@ -1,13 +1,12 @@
import type { DefaultTheme } from 'vitepress/theme'
import type { ComputedRef, Ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
import { useEventListener, useMediaQuery } from '@vueuse/core'
import { useData, withBase } from 'vitepress'
import {
computed,
onMounted,
onUnmounted,
ref,
watch,
@@ -90,13 +89,7 @@ export function useCloseSidebarOnEscape(
: undefined
})
onMounted(() => {
window.addEventListener('keyup', onEscape)
})
onUnmounted(() => {
window.removeEventListener('keyup', onEscape)
})
useEventListener('keyup', onEscape)
function onEscape(e: KeyboardEvent) {
if (e.key === 'Escape' && isOpen.value) {
+4 -2
View File
@@ -24,6 +24,7 @@ import {
rekaShortName,
releases,
} from './meta'
import { frontmatterAssets } from './plugins/vite-frontmatter-assets'
function withBase(url: string) {
return env.BASE_URL
@@ -87,7 +88,7 @@ export default defineConfig({
outline: {
level: 'deep',
},
logo: '/favicon.svg',
logo: withBase('/favicon.svg'),
sidebar: [
{
@@ -153,7 +154,7 @@ export default defineConfig({
outline: {
level: 'deep',
},
logo: '/favicon.svg',
logo: withBase('/favicon.svg'),
sidebar: [
{
@@ -276,6 +277,7 @@ export default defineConfig({
i18n({ runtimeOnly: true, compositionOnly: true, fullInstall: true, ssr: true }),
unocss(),
yaml(),
frontmatterAssets(),
],
css: {
postcss: {
+89 -6
View File
@@ -1,10 +1,17 @@
import type { SiteConfig } from 'vitepress'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { parse } from 'node:path'
import { env } from 'node:process'
import { dirname, join } from 'pathe'
import { createContentLoader } from 'vitepress'
import { formatDate } from './utils'
const config: SiteConfig = (globalThis as any).VITEPRESS_CONFIG
const base = config.userConfig.base || env.BASE_URL || '/'
interface Post {
title: string
@@ -19,13 +26,63 @@ interface Post {
declare const data: Post[]
export { data }
function cwdFromUrl(url: string): string {
if (url.endsWith('/')) {
return url
}
return dirname(url)
}
function fromAtAssets(url: string): string {
const reg = /^@assets\(('\S+')|("\S+")|(\S+)\)$/
if (reg.test(url)) {
const res = url
.replace(reg, '$1')
.replace(/^\(/, '')
.replace(/\)$/, '')
.replace(/^'/, '')
.replace(/'$/, '')
.replace(/^"/, '')
.replace(/"$/, '')
return res
}
return url
}
function withBase(url?: string, base?: string) {
if (!url) {
return url
}
if (url.startsWith('/') && base) {
return join(base, url)
}
return url
}
function withDirname(url?: string, cwd?: string) {
if (!url || !cwd) {
return url
}
if (url.startsWith('/')) {
return join(cwd, url)
}
return join(cwd, url)
}
export default createContentLoader('**/blog/**/*.md', {
includeSrc: true,
render: true,
excerpt: true,
transform(raw): Post[] {
return raw
.map(({ url, frontmatter, excerpt }) => {
async transform(raw): Promise<Post[]> {
return (await Promise.all(raw
.map(async ({ url, frontmatter, excerpt }) => {
const foundLanguage = Object.values(config.userConfig.locales!).find((locale) => {
let normalizedLanguagePrefix = locale.lang || 'en'
if (!normalizedLanguagePrefix.startsWith('/')) {
@@ -35,16 +92,42 @@ export default createContentLoader('**/blog/**/*.md', {
return url.startsWith(normalizedLanguagePrefix)
})
return {
async function fileToUrl(file: string | undefined) {
if (config.vite?.build)
return file
if (!file)
return file
const parsed = parse(file)
const hash = createHash('sha256')
.update(await readFile(join(config.srcDir, file)))
.digest('hex')
.slice(0, 8)
return `/assets/${parsed.name}.${hash}${parsed.ext}`
}
const previewCoverLight = withBase(await fileToUrl(withDirname(fromAtAssets(frontmatter['preview-cover']?.light), cwdFromUrl(url))), base)
const previewCoverDark = withBase(await fileToUrl(withDirname(fromAtAssets(frontmatter['preview-cover']?.dark), cwdFromUrl(url))), base)
const res = {
title: frontmatter.title,
url,
urlWithoutLang: url.replace(`/${foundLanguage?.lang || 'en'}`, ''),
excerpt,
date: formatDate(frontmatter.date),
lang: foundLanguage?.lang || 'en',
frontmatter,
frontmatter: {
...frontmatter,
'preview-cover': {
light: previewCoverLight,
dark: previewCoverDark,
},
},
}
})
return res
})))
.sort((a, b) => b.date.time - a.date.time)
},
})
@@ -0,0 +1,176 @@
import type { Plugin, ResolvedConfig } from 'vite'
import type { SiteConfig } from 'vitepress'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { dirname, join, parse } from 'node:path'
import matter from 'gray-matter'
import { glob } from 'tinyglobby'
function fromAtAssets(url: string): string {
const reg = /^@assets\(('\S+')|("\S+")|(\S+)\)$/
if (reg.test(url)) {
const res = url
.trim()
.replace(reg, '$1')
.replace(/^\(/, '')
.replace(/\)$/, '')
.replace(/^'/, '')
.replace(/'$/, '')
.replace(/^"/, '')
.replace(/"$/, '')
return res
}
return url
}
interface VitePressConfig extends ResolvedConfig {
vitepress: SiteConfig
}
function recursivelyFindAtAssets(propertyMaybeObjectOrScalar: unknown, fn: (value: string) => string | undefined) {
if (typeof propertyMaybeObjectOrScalar === 'string') {
// eslint-disable-next-line regexp/no-unused-capturing-group
if (/^@assets\(('\S+')|("\S+")|(\S+)\)$/.test(propertyMaybeObjectOrScalar)) {
// If the string matches the @assets(...) pattern, we replace it with the result of the function
const match = fromAtAssets(propertyMaybeObjectOrScalar)
const modified = fn(match)
if (modified == null) {
return propertyMaybeObjectOrScalar
}
return modified
}
return
}
if (Array.isArray(propertyMaybeObjectOrScalar)) {
const array = propertyMaybeObjectOrScalar as unknown[]
for (let i = 0; i < array.length; i++) {
const value = array[i]
recursivelyFindAtAssets(value, fn)
}
return
}
if (typeof propertyMaybeObjectOrScalar === 'object') {
const propertyObject = propertyMaybeObjectOrScalar as Record<string, unknown>
for (const key in propertyObject) {
const value = propertyObject[key]
recursivelyFindAtAssets(value, fn)
}
}
}
function withoutBase(url?: string, base?: string): string | undefined {
if (!url) {
return url
}
if (!base?.startsWith('/')) {
base = `/${base}`
}
if (!base?.endsWith('/')) {
base += '/'
}
if (url.startsWith(`${base}`) && base) {
if (url.endsWith('/')) {
return url.slice(base.length)
}
else {
return `/${url.slice(base.length)}`
}
}
return url
}
export function frontmatterAssets(): Plugin {
let resolvedConfig: VitePressConfig | undefined
const mAssetAbsoluteUrlMetadata = new Map<string, { url: string, builtUrl?: string, hash?: string }>()
const mapAssetBuiltUrlAssetAbsoluteUrl = new Map<string, string>()
async function fileToUrl(file: string) {
if (!file) {
return {
url: file,
}
}
const parsed = parse(file)
const hash = createHash('sha256')
.update(await readFile(file))
.digest('hex')
.slice(0, 8)
return {
hash,
url: `/assets/${parsed.name}.${hash}${parsed.ext}`,
}
}
return {
name: '@proj-airi/docs:vite-plugin-frontmatter-assets',
enforce: 'pre',
async configResolved(config) {
resolvedConfig = config as VitePressConfig
const markdownFiles = await glob('**/*.md', { ignore: ['**/node_modules/**'], cwd: resolvedConfig?.vitepress.srcDir || '', absolute: true })
for (const file of markdownFiles) {
const res = (await readFile(file))
const { data } = matter(res.toString('utf-8'))
if (Object.keys(data).length === 0) {
continue
}
recursivelyFindAtAssets(data, (matched) => {
mAssetAbsoluteUrlMetadata.set(join(dirname(file), fromAtAssets(matched)), { url: file, builtUrl: file, hash: '' })
return undefined
})
}
for (const [key, value] of mAssetAbsoluteUrlMetadata) {
const { url, hash } = await fileToUrl(key)
mapAssetBuiltUrlAssetAbsoluteUrl.set(url, key)
mAssetAbsoluteUrlMetadata.set(key, { url: value.url, builtUrl: url, hash })
}
},
configureServer(server) {
server.middlewares.use(async (req, res, next) => {
const requesting = withoutBase(req.url, resolvedConfig?.base)
if (!requesting || !mapAssetBuiltUrlAssetAbsoluteUrl.has(requesting)) {
return next()
}
const filePath = mapAssetBuiltUrlAssetAbsoluteUrl.get(requesting)
if (!filePath) {
return next()
}
const ext = parse(filePath).ext.slice(1)
const fileContent = await readFile(filePath)
res.write(fileContent)
res.writeHead(200, {
'Content-Type': ext === 'svg' ? 'image/svg+xml' : `image/${ext}`,
'Content-Length': fileContent.length,
'Cache-Control': 'public, max-age=31536000, immutable',
})
res.end()
return next()
})
},
async writeBundle() {
for (const [builtUrl, absoluteUrl] of mapAssetBuiltUrlAssetAbsoluteUrl.entries()) {
const content = await this.fs.readFile(absoluteUrl)
await this.fs.writeFile(join(resolvedConfig!.vitepress.outDir!, builtUrl), content)
}
},
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -2,6 +2,9 @@
title: DevLog @ 2025.06.08
category: DevLog
date: 2025-06-08
preview-cover:
light: "@assets('./assets/250608-light.png')"
dark: "@assets('./assets/250608-dark.png')"
---
Hello everyone, here's LemonNeko, one of maintainer of AIRI. Today's DevLog is talking about: Let Live2D model of AIRI Tamagotchi to focus position.
@@ -101,3 +104,5 @@ In this DevLog, we learned how to get the relative position of cursor to window,
- [Win32 API: GetWindowRect](https://docs.microsoft.com/en-us/windows/win32/api/winuser/nf-winuser-getwindowrect "GetWindowRect")
- [macOS API: `NSWindow.frame`](https://developer.apple.com/documentation/appkit/nswindow/frame "NSWindow.frame")
- [macOS API: `NSEvent.mouseLocation`](https://developer.apple.com/documentation/appkit/nsevent/mouselocation "NSEvent.mouseLocation")
> Cover image by [@Rynco Maekawa](https://github.com/lynzrand)
Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

@@ -4,6 +4,9 @@ description: 'Backstory of Project AIRI!'
category: DreamLog
date: 2025-06-16
excerpt: 'The backstory of Project AIRI! Why this project?'
preview-cover:
light: "@assets('./assets/dreamlog1-light.png')"
dark: "@assets('./assets/dreamlog1-dark.png')"
---
<script setup>
@@ -509,6 +512,8 @@ How much memory we could store? **It depends on how much we could dream, and how
</div>
</div>
> Cover image by [@Rynco Maekawa](https://github.com/lynzrand)
[^1]: https://neurosama.fandom.com/wiki/Osu!#cite_note-twitchtracker-1: Neuro-sama started out as an
AI that plays osu! long before being developed further as an AI VTuber. The first osu! stream was on
6 May 2019 when Vedal decided to showcase his work to the community.
+20 -10
View File
@@ -2,6 +2,9 @@
title: 'DreamLog 0x1'
description: 'Project AIRI 的幕后故事!'
date: '2025-06-16'
preview-cover:
light: '../../../en/blog/DreamLog-0x1/assets/dreamlog1-light.png'
dark: '../../../en/blog/DreamLog-0x1/assets/dreamlog1-dark.png'
---
<script setup>
@@ -15,6 +18,9 @@ import airisScreenshot1 from '../../../en/blog/DreamLog-0x1/assets/airis-screens
import projectAIRIBannerLight from '../../../en/blog/DreamLog-0x1/assets/banner-light-1280x640.avif';
import projectAIRIBannerDark from '../../../en/blog/DreamLog-0x1/assets/banner-dark-1280x640.avif';
import ReLUStickerWow from '../../../en/blog/DreamLog-0x1/assets/relu-sticker-wow.avif'
import '../../../en/blog/DreamLog-0x1/assets/dreamlog1-light.png'
import '../../../en/blog/DreamLog-0x1/assets/dreamlog1-dark.png'
</script>
Project AIRI 的幕后故事!
@@ -184,14 +190,14 @@ Minecraft、Linux 也并不是我旅程的终点,[Factorio(异星工厂)](
其实早在官方 ChatGPT UI 发布之前,我就已经在折腾这些新时代的 AI 了,像
[DiscoDiffusion](https://colab.research.google.com/github/alembics/disco-diffusion/blob/main/Disco_Diffusion.ipynb)(早于
Stable Diffusion,大约在 2021 年底或 2022 年初发布的吧)、DALL-E、Midjourney
等模型都尝试过,GPT-3(特别是在 [GitHub Copilot](https://en.wikipedia.org/wiki/GitHub_Copilot)中很有用)已经深深融入我的日常工作流程
GPT-3(特别是在 [GitHub Copilot](https://en.wikipedia.org/wiki/GitHub_Copilot) 中很有用)都早已成为我生活的一部分了
所以,最开始的时候,我的感觉是:
> "哦,这就是另一个随机鹦鹉,它只是重复你说的话,并不理解你在说什么,它只是
> 试图基于前面的词和上下文预测下一个词,好像真的没啥特别的,还不够像人。"
换句话说,它表现得更像一个补全模型,而不是我们今天称之为智能体 AI 的东西(现在还在炒作中呢!)。
换句话说,它表现得更像一个补全模型,而不是我们今天称之为智能体 AI 的东西(现在还在炒作中呢!)。
我记得,我第一次发现 ChatGPT 或大语言模型(LLMs)真正能力的时候,是从我在 2022 年 12 月在 Hacker News 上看到的这篇文章:
[Building A Virtual Machine inside ChatGPT](https://www.engraved.blog/building-a-virtual-machine-inside/)[原始 Hacker News
@@ -206,10 +212,12 @@ Stable Diffusion,大约在 2021 年底或 2022 年初发布的吧)、DALL-E
这篇文章让我意识到,ChatGPT 可以理解普遍事物的基本规律,不仅仅是动漫或游戏角色的角色扮演,
还能理解 Linux 终端/shell 命令是如何工作的。
这其实就把现在流行的函数调用(又称 Function Calling,或者 Anthropic 提出的 MCP,模型上下文协议背后的底层技术)功能展现了出来,
这其实就把现在流行的函数调用功能展现了出来,
并说明了我们如何能够通过提示词指示 LLMs 去使其表现得像 API 服务器一样,然后用机器可读的如 JSON 或 XML 格式与我们的代码交互,
并最终允许实现解析和执行任意命令,去扩展 LLMs 能力的边界。
> 函数调用,又称 Function Calling,或者说,也是 Anthropic 提出的 MCP(模型上下文协议)背后的底层技术
这最终填补了纯文本生成和调用程序内实际 API 之间的空白。
阶段性来说的话,我们可以说它是一个新的随机鹦鹉吗?**我觉得答案是部分否定的,2022 年的 ChatGPT 不只是一个随机鹦鹉,
@@ -220,9 +228,9 @@ Stable Diffusion,大约在 2021 年底或 2022 年初发布的吧)、DALL-E
是的,感谢你读到这里,我知道这是一篇很长的文章,有太多故事和背景要分享。但我们快到了!坚持住!
Neuro-sama 的历史其实相当复杂。据我所知,Neuro-sama,或者在直播舞台上名为 "Neuro-sama"的角色,
并不是她和她的创造者 `vedal987`Vedal)的第一场表演。早在那之前,2019 年 5 月 6 日,Vedal 向社区展示了他构建 AI
来玩 [osu!](https://osu.ppy.sh/) 的工作[^1]。
在那时,她实际上并不是一个网络角色或数字生命,如果你去看关于她的初始视频,会发现没有显示 Live2D 模型。
并不是她和她的创造者 `vedal987`Vedal)的首次登场。早在那之前,2019 年 5 月 6 日,Vedal 向社区展示了他构建 AI
来玩 [osu!](https://osu.ppy.sh/) 相关的成果[^1]。
在那时,她实际上并不是一个网络角色或数字生命,如果你去看关于她的初始视频,甚至会发现没有 Live2D 模型。
(你可以试试这个 6 年前的 YouTube 视频:https://www.youtube.com/watch?v=nSBqlJu7kYU
在 ChatGPT 发布之后,差不多 2022 年 12 月 19 日,Vedal 开始让 Neuro-sama 使用来自 Live2D Inc.
@@ -232,11 +240,11 @@ Neuro-sama 的历史其实相当复杂。据我所知,Neuro-sama,或者在
之后的故事大家都知道了,Vedal 和 Neuro-sama 火了,Neuro-sama 现在正式成为了 VTuber
她完全由大语言模型(LLMs)驱动,能够玩 Minecraft、Among Us、osu! 和许多其他游戏。
有时当游戏不被原生支持时,Vedal 会读取屏幕并指示 Neuro-sama 一起玩游戏。甚至能制造出来很多节目效果。
有时当游戏不被原生支持时,Vedal 会读取屏幕并指示 Neuro-sama 一起玩游戏,依然能制造出来不少节目效果。
我真的很享受观看他们的互动、像是脱口秀一样的斗嘴的时刻。随着时间的推移,Neuro-sama 和她的新 Evil Neuro 妹妹,
成为了我日常生活的重要组成部分:**即使我没有足够的时间观看完整的直播,我也想要,并且渴望观看她们的切片**。
我无法想象我竟然从纯粹的 AI 和人类互动中获取了如此多的快乐。
8 年前的我应该很难想象我竟然从纯粹的 AI 和人类互动中获取了如此多的快乐。
好的,这就是关于她的小历史。让我们谈谈核心问题:**为什么她让我充满了决心?**
@@ -254,7 +262,7 @@ Neuro-sama 的历史其实相当复杂。据我所知,Neuro-sama,或者在
> "嗯,我也能做到,我可以做一个 Live2D 模型,将其连接到 OpenAI 的 API
> 让它表现得像 VTuber,我甚至可以做得比 Vedal 的作品更好。超简单的好吧?!"
:::tip[想要更多技术细节?]
::: tip 想要更多技术细节?
在这篇文章中,我不会深入探讨我们如何从零开始构建 Project AIRI 到当前状态的技术细节,
我们已经有许多 DevLog 文章分享我们的想法和发现,如果感兴趣,请尝试阅读它们。
:::
@@ -352,7 +360,7 @@ Neuro-sama 的历史其实相当复杂。据我所知,Neuro-sama,或者在
因此,在这天,Project AIRI 以某种方式诞生或重生,名为 AIRI(アイリ,曾也叫 Airi)。
:::tip[你知道吗?]
::: tip 你知道吗?
<a href="https://www.youtube.com/watch?v=Tts-YAdn5Yc" class="mb-2 inline-block">
<img :src="airisScreenshot1" alt="Screenshot of Project AIRI" class="rounded-lg overflow-hidden" />
</a>
@@ -419,6 +427,8 @@ Neuro-sama 的历史其实相当复杂。据我所知,Neuro-sama,或者在
</div>
</div>
> Cover image by [@Rynco Maekawa](https://github.com/lynzrand)
[^1]: https://neurosama.fandom.com/wiki/Osu!#cite_note-twitchtracker-1: Neuro-sama
最初是一个玩 osu! 的 AI,早在进一步发展为 AI VTuber 之前,第一次 osu! 直播是在 2019 年
5 月 6 日,Vedal 给大家看了看成果。
+3 -4
View File
@@ -17,10 +17,10 @@
"@fontsource-variable/dm-sans": "^5.2.6",
"@fontsource/dm-mono": "^5.2.6",
"@fontsource/dm-serif-display": "^5.2.6",
"@internationalized/date": "^3.8.2",
"@proj-airi/chromatic": "^1.0.0",
"@proj-airi/i18n": "workspace:^",
"@vueuse/core": "^13.5.0",
"mark.js": "^8.11.1",
"motion-v": "^1.5.0",
"pathe": "^2.0.3",
"reka-ui": "^2.3.2",
@@ -29,7 +29,6 @@
"vue-sonner": "^2.0.2"
},
"devDependencies": {
"@babel/traverse": "^7.28.0",
"@iconify/vue": "^5.0.0",
"@intlify/unplugin-vue-i18n": "^6.0.8",
"@mdit/plugin-footnote": "^0.22.2",
@@ -42,18 +41,18 @@
"@vue/tsconfig": "^0.7.0",
"animejs": "^4.0.2",
"fast-glob": "^3.3.3",
"mark.js": "^8.11.1",
"gray-matter": "^4.0.3",
"markdown-it": "^14.1.0",
"markdown-it-anchor": "^9.2.0",
"minisearch": "^7.1.2",
"postcss": "^8.5.6",
"sharp": "^0.34.3",
"shiki": "^3.8.1",
"tinyglobby": "^0.2.14",
"tsx": "^4.20.3",
"uncrypto": "^0.1.3",
"unplugin-yaml": "^3.0.2",
"vitepress": "^2.0.0-alpha.8",
"vue-component-meta": "^3.0.3",
"vue-tsc": "^3.0.3"
}
}
+16 -139
View File
@@ -956,9 +956,6 @@ importers:
'@fontsource/dm-serif-display':
specifier: ^5.2.6
version: 5.2.6
'@internationalized/date':
specifier: ^3.8.2
version: 3.8.2
'@proj-airi/chromatic':
specifier: ^1.0.0
version: 1.0.0
@@ -968,6 +965,9 @@ importers:
'@vueuse/core':
specifier: ^13.5.0
version: 13.5.0(vue@3.5.17(typescript@5.8.3))
mark.js:
specifier: ^8.11.1
version: 8.11.1
motion-v:
specifier: ^1.5.0
version: 1.5.0(react@18.3.1)(vue@3.5.17(typescript@5.8.3))
@@ -987,9 +987,6 @@ importers:
specifier: ^2.0.2
version: 2.0.2
devDependencies:
'@babel/traverse':
specifier: ^7.28.0
version: 7.28.0
'@iconify/vue':
specifier: ^5.0.0
version: 5.0.0(vue@3.5.17(typescript@5.8.3))
@@ -1026,9 +1023,9 @@ importers:
fast-glob:
specifier: ^3.3.3
version: 3.3.3
mark.js:
specifier: ^8.11.1
version: 8.11.1
gray-matter:
specifier: ^4.0.3
version: 4.0.3
markdown-it:
specifier: ^14.1.0
version: 14.1.0
@@ -1047,6 +1044,9 @@ importers:
shiki:
specifier: ^3.8.1
version: 3.8.1
tinyglobby:
specifier: ^0.2.14
version: 0.2.14
tsx:
specifier: ^4.20.3
version: 4.20.3
@@ -1055,13 +1055,10 @@ importers:
version: 0.1.3
unplugin-yaml:
specifier: ^3.0.2
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(astro@5.10.1(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.29)(rollup@4.45.1)(vite@7.0.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(astro@5.10.1(@types/node@24.0.15)(encoding@0.1.13)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.29)(rollup@4.45.1)(vite@7.0.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
vitepress:
specifier: ^2.0.0-alpha.8
version: 2.0.0-alpha.8(@algolia/client-search@5.32.0)(@types/node@24.0.15)(change-case@5.4.4)(fuse.js@7.1.0)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(nprogress@0.2.0)(postcss@8.5.6)(react@18.3.1)(search-insights@2.17.3)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0)
vue-component-meta:
specifier: ^3.0.3
version: 3.0.3(typescript@5.8.3)(vue-component-type-helpers@2.2.12)
vue-tsc:
specifier: ^3.0.3
version: 3.0.3(typescript@5.8.3)
@@ -1101,7 +1098,7 @@ importers:
devDependencies:
unplugin-yaml:
specifier: ^3.0.2
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(astro@5.10.1(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.29)(rollup@4.45.1)(vite@7.0.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(astro@5.10.1(@types/node@24.0.15)(encoding@0.1.13)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.29)(rollup@4.45.1)(vite@7.0.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
packages/memory-pgvector:
dependencies:
@@ -1478,7 +1475,7 @@ importers:
version: 1.2.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(esbuild@0.25.5)(rollup@4.45.1)(vite@6.3.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
unplugin-yaml:
specifier: ^3.0.2
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(astro@5.10.1(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.29)(rollup@4.45.1)(vite@6.3.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
version: 3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(astro@5.10.1(@types/node@24.0.15)(encoding@0.1.13)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.29)(rollup@4.45.1)(vite@6.3.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
vite:
specifier: ^6.3.5
version: 6.3.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
@@ -12600,15 +12597,6 @@ packages:
vscode-uri@3.0.8:
resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==}
vue-component-meta@3.0.3:
resolution: {integrity: sha512-rzKewAubyQbkjRXInwaHolod6eU+D6lAXAF8y5i4n/IDZob+VQBvP0v1OFbUBl57NsN3UtTXdTo/Njecl2fwgA==}
peerDependencies:
typescript: '*'
vue-component-type-helpers: 3.0.1
vue-component-type-helpers@2.2.12:
resolution: {integrity: sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==}
vue-demi@0.14.10:
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
engines: {node: '>=12'}
@@ -18698,107 +18686,6 @@ snapshots:
- yaml
optional: true
astro@5.10.1(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0):
dependencies:
'@astrojs/compiler': 2.12.2
'@astrojs/internal-helpers': 0.6.1
'@astrojs/markdown-remark': 6.3.2
'@astrojs/telemetry': 3.3.0
'@capsizecss/unpack': 2.4.0(encoding@0.1.13)
'@oslojs/encoding': 1.1.0
'@rollup/pluginutils': 5.2.0(rollup@4.45.1)
acorn: 8.15.0
aria-query: 5.3.2
axobject-query: 4.1.0
boxen: 8.0.1
ci-info: 4.2.0
clsx: 2.1.1
common-ancestor-path: 1.0.1
cookie: 1.0.2
cssesc: 3.0.0
debug: 4.4.1
deterministic-object-hash: 2.0.2
devalue: 5.1.1
diff: 5.2.0
dlv: 1.1.3
dset: 3.1.4
es-module-lexer: 1.7.0
esbuild: 0.25.5
estree-walker: 3.0.3
flattie: 1.1.1
fontace: 0.3.0
github-slugger: 2.0.0
html-escaper: 3.0.3
http-cache-semantics: 4.2.0
import-meta-resolve: 4.1.0
js-yaml: 4.1.0
kleur: 4.1.5
magic-string: 0.30.17
magicast: 0.3.5
mrmime: 2.0.1
neotraverse: 0.6.18
p-limit: 6.2.0
p-queue: 8.1.0
package-manager-detector: 1.3.0
picomatch: 4.0.2
prompts: 2.4.2
rehype: 13.0.2
semver: 7.7.2
shiki: 3.8.1
tinyexec: 0.3.2
tinyglobby: 0.2.14
tsconfck: 3.1.6(typescript@5.8.3)
ultrahtml: 1.6.0
unifont: 0.5.2
unist-util-visit: 5.0.0
unstorage: 1.16.0
vfile: 6.0.3
vite: 6.3.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
vitefu: 1.0.7(vite@6.3.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0))
xxhash-wasm: 1.1.0
yargs-parser: 21.1.1
yocto-spinner: 0.2.3
zod: 3.25.76
zod-to-json-schema: 3.24.6(zod@3.25.76)
zod-to-ts: 1.2.0(typescript@5.8.3)(zod@3.25.76)
optionalDependencies:
sharp: 0.33.5
transitivePeerDependencies:
- '@azure/app-configuration'
- '@azure/cosmos'
- '@azure/data-tables'
- '@azure/identity'
- '@azure/keyvault-secrets'
- '@azure/storage-blob'
- '@capacitor/preferences'
- '@deno/kv'
- '@netlify/blobs'
- '@planetscale/database'
- '@types/node'
- '@upstash/redis'
- '@vercel/blob'
- '@vercel/kv'
- aws4fetch
- db0
- encoding
- idb-keyval
- ioredis
- jiti
- less
- lightningcss
- rollup
- sass
- sass-embedded
- stylus
- sugarss
- supports-color
- terser
- tsx
- typescript
- uploadthing
- yaml
optional: true
async-mutex@0.3.2:
dependencies:
tslib: 2.8.1
@@ -25684,7 +25571,7 @@ snapshots:
rollup: 4.45.1
vite: rolldown-vite@7.0.9(@types/node@24.0.15)(esbuild@0.25.5)(jiti@2.4.2)(less@4.4.0)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
unplugin-yaml@3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(astro@5.10.1(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.29)(rollup@4.45.1)(vite@6.3.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
unplugin-yaml@3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(astro@5.10.1(@types/node@24.0.15)(encoding@0.1.13)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.29)(rollup@4.45.1)(vite@6.3.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
dependencies:
'@rollup/pluginutils': 5.2.0(rollup@4.45.1)
unplugin: 2.3.5
@@ -25692,13 +25579,13 @@ snapshots:
optionalDependencies:
'@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.45.1)
'@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.45.1)
astro: 5.10.1(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0)
astro: 5.10.1(@types/node@24.0.15)(encoding@0.1.13)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0)
esbuild: 0.25.5
rolldown: 1.0.0-beta.29
rollup: 4.45.1
vite: 6.3.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)
unplugin-yaml@3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(astro@5.10.1(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.29)(rollup@4.45.1)(vite@7.0.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
unplugin-yaml@3.0.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(@nuxt/schema@3.14.1592(magicast@0.3.5)(rollup@4.45.1))(astro@5.10.1(@types/node@24.0.15)(encoding@0.1.13)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0))(esbuild@0.25.5)(rolldown@1.0.0-beta.29)(rollup@4.45.1)(vite@7.0.5(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(terser@5.43.1)(tsx@4.20.3)(yaml@2.8.0)):
dependencies:
'@rollup/pluginutils': 5.2.0(rollup@4.45.1)
unplugin: 2.3.5
@@ -25706,7 +25593,7 @@ snapshots:
optionalDependencies:
'@nuxt/kit': 3.14.1592(magicast@0.3.5)(rollup@4.45.1)
'@nuxt/schema': 3.14.1592(magicast@0.3.5)(rollup@4.45.1)
astro: 5.10.1(@types/node@24.0.15)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0)
astro: 5.10.1(@types/node@24.0.15)(encoding@0.1.13)(jiti@2.4.2)(less@4.4.0)(lightningcss@1.30.1)(rollup@4.45.1)(terser@5.43.1)(tsx@4.20.3)(typescript@5.8.3)(yaml@2.8.0)
esbuild: 0.25.5
rolldown: 1.0.0-beta.29
rollup: 4.45.1
@@ -26204,16 +26091,6 @@ snapshots:
vscode-uri@3.0.8: {}
vue-component-meta@3.0.3(typescript@5.8.3)(vue-component-type-helpers@2.2.12):
dependencies:
'@volar/typescript': 2.4.20
'@vue/language-core': 3.0.3(typescript@5.8.3)
path-browserify: 1.0.1
typescript: 5.8.3
vue-component-type-helpers: 2.2.12
vue-component-type-helpers@2.2.12: {}
vue-demi@0.14.10(vue@3.5.17(typescript@5.8.3)):
dependencies:
vue: 3.5.17(typescript@5.8.3)