feat: airi card (#98)

This commit is contained in:
RainbowBird
2025-03-30 03:16:53 +08:00
committed by GitHub
parent 36fdb20235
commit 1e5481f0a6
18 changed files with 870 additions and 77 deletions
+21
View File
@@ -70,6 +70,27 @@ settings:
microphone: Microphone
models: Model
pages:
card:
title: Airi Card
description: Use Airi character card presets
upload: Upload
delete: Delete
active: Active
activate: Activate
delete_card: Delete Card
delete_confirmation: Are you sure you want to delete this card?
card_not_found: Card not found
created_by: created by
creator_notes: Creator Notes
description_label: Description
character: Character
personality: Personality
scenario: Scenario
systemprompt: System Prompt
posthistoryinstructions: Post-History Instructions
modules: Modules
voice_id: Voice ID
cancel: Cancel
memory:
description: Where memories got stored, and organized
title: Memory
+60 -38
View File
@@ -56,6 +56,27 @@ settings:
microphone: 麦克风
models: 模型
pages:
card:
title: Airi 角色卡
description: 使用 Airi 角色卡预设
voice_id: 声音 ID
upload: 上传
delete: 删除
active: 已激活
activate: 激活
delete_card: 删除角色卡
delete_confirmation: 确定要删除这张角色卡吗?
card_not_found: 未找到角色卡
created_by: 创建者
creator_notes: 创建者笔记
description_label: 描述
character: 角色设定
personality: 性格
scenario: 场景
systemprompt: 系统提示词
posthistoryinstructions: 历史提示指令
modules: 模块
cancel: 取消
memory:
description: 存放记忆的地方,以及策略
title: 记忆体
@@ -294,51 +315,51 @@ settings:
presets:
- colors:
- Airi 绿
description: The default greenish theme color, brought by Airi to you!
description: 默认的绿色主题,由 Airi 为您带来!
title: 默认颜色
- colors:
- Taupe
- Beige
- Ash Grey
- Light Taupe
- Ivory
- Olive Grey
- Sand
- Warm Grey
description: Soft, muted tones inspired by Giorgio Morandi's paintings
- 灰褐色
- 米色
- 灰白色
- 浅灰褐色
- 象牙色
- 橄榄灰
- 沙色
- 暖灰色
description: 受乔治·莫兰迪绘画启发的柔和、低调的色调
title: Morandi 颜色
- colors:
- Sky Blue
- Mist
- Sand
- Moss Green
- Water Lily
- Wheat
- Slate Blue
- Sage
description: Impressionist palette inspired by Claude Monet's works
- 天蓝色
- 薄雾色
- 沙色
- 苔藓绿
- 睡莲色
- 小麦色
- 板岩蓝
- 鼠尾草色
description: 受克劳德·莫奈作品启发的印象派调色板
title: 莫奈颜色
- colors:
- Tan
- Warm Taupe
- Umber
- Coffee
- Bronze
- Gold
- Mustard
- Amber
description: Traditional Japanese color palette
- 棕褐色
- 暖灰褐色
- 赭色
- 咖啡色
- 青铜色
- 金色
- 芥末色
- 琥珀色
description: 传统日本色彩调色板
title: 日本颜色
- colors:
- Nordic Blue
- Ice
- Fjord
- Steel
- Glacier
- Slate
- Cloud
- Stone
description: Scandinavian minimalist color scheme
- 北欧蓝
- 冰色
- 峡湾色
- 钢铁色
- 冰川色
- 板岩色
- 云色
- 石头色
description: 北欧极简主义配色方案
title: 北欧颜色
- colors:
- 霞光红
@@ -349,7 +370,8 @@ settings:
- 缃色
- 青冥
- 赭石
description: Traditional Chinese colors, derived from ancient textiles, porcelain and paintings
description: >-
中国传统色彩,源自古代纺织品、瓷器和绘画
title: 中国传统颜色
title: 预设
title: 外观
+1
View File
@@ -36,6 +36,7 @@
"@pixiv/three-vrm": "^3.3.6",
"@pixiv/three-vrm-animation": "^3.3.6",
"@pixiv/three-vrm-core": "^3.3.6",
"@proj-airi/ccc": "workspace:^",
"@proj-airi/drizzle-duckdb-wasm": "workspace:^",
"@proj-airi/elevenlabs": "workspace:^",
"@proj-airi/provider-transformers": "workspace:^",
@@ -1,15 +1,18 @@
<script setup lang="ts">
import { Collapsable } from '@proj-airi/stage-ui/components'
defineProps<{
withDefaults(defineProps<{
title: string
icon: string
innerClass?: string
}>()
expand?: boolean
}>(), {
expand: true,
})
</script>
<template>
<Collapsable default>
<Collapsable :default="expand">
<template #trigger="slotProps">
<button
class="setting-bar"
@@ -0,0 +1,296 @@
<script setup lang="ts">
import type { AiriCard } from '@proj-airi/stage-ui/stores'
import { Button, Section } from '@proj-airi/stage-ui/components'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import {
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogOverlay,
AlertDialogPortal,
AlertDialogRoot,
AlertDialogTitle,
AlertDialogTrigger,
} from 'radix-vue'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
interface Props {
cardId: string
}
interface Emits {
(e: 'activate'): void
(e: 'delete'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const { t } = useI18n()
const cardStore = useAiriCardStore()
const { getCard, removeCard } = cardStore
const { activeCardId } = storeToRefs(cardStore)
// Get current card
const card = computed<AiriCard | undefined>(() => getCard(props.cardId))
// Get module settings
const moduleSettings = computed(() => {
const airiExt = card.value?.extensions?.airi?.modules
return {
consciousness: airiExt?.consciousness?.model || '',
speech: airiExt?.speech?.model || '',
voice: airiExt?.speech?.voice_id || '',
}
})
// Get character settings
const characterSettings = computed(() => {
if (!card.value)
return {}
return {
personality: card.value.personality,
scenario: card.value.scenario,
systemPrompt: card.value.systemPrompt,
postHistoryInstructions: card.value.postHistoryInstructions,
}
})
// Check if card is active
const isActive = computed(() => props.cardId === activeCardId.value)
// Activate card
function activateCard() {
activeCardId.value = props.cardId
emit('activate')
}
// Animation control for card activation
const isActivating = ref(false)
function handleActivate() {
isActivating.value = true
setTimeout(() => {
activateCard()
isActivating.value = false
activeCardId.value = 'default'
}, 300)
}
// Delete card confirmation
const showDeleteConfirm = ref(false)
function handleDeleteConfirm() {
if (card.value) {
removeCard(props.cardId)
emit('delete')
activateCard()
}
showDeleteConfirm.value = false
}
function hightlightTagToHtml(text: string) {
return text?.replace(/\{\{(.*?)\}\}/g, '<span class="bg-primary-500/20 inline-block">{{ $1 }}</span>').trim()
}
</script>
<template>
<div
v-if="card"
bg="neutral-50 dark:[rgba(0,0,0,0.3)]"
rounded-xl p-5 flex="~ col gap-5"
border="~ neutral-200/50 dark:neutral-700/30"
shadow="sm dark:md"
transition="all duration-300"
class="backdrop-blur-sm"
>
<!-- Header -->
<div flex="~ col" gap-3>
<div flex="~ row" items-center justify-between>
<div>
<h1 text-2xl font-bold class="from-primary-500 to-primary-400 bg-gradient-to-r bg-clip-text text-transparent">
{{ card.name }}
</h1>
<div mt-1 text-sm text-neutral-500 dark:text-neutral-400>
v{{ card.version }}
<template v-if="card.creator">
· {{ t('settings.pages.card.created_by') }} <span font-medium>{{ card.creator }}</span>
</template>
</div>
</div>
<!-- Action buttons -->
<div flex="~ row" gap-2>
<!-- Delete button -->
<AlertDialogRoot v-if="props.cardId !== 'default'" v-model:open="showDeleteConfirm">
<AlertDialogTrigger as-child>
<Button
variant="danger"
:label="t('settings.pages.card.delete')"
/>
</AlertDialogTrigger>
<AlertDialogPortal>
<AlertDialogOverlay class="fixed inset-0 z-50 bg-black/50" />
<AlertDialogContent
class="fixed left-1/2 top-1/2 z-50 max-w-md w-full border border-neutral-200 rounded-xl bg-white p-6 shadow-xl -translate-x-1/2 -translate-y-1/2 dark:border-neutral-700 dark:bg-neutral-800"
>
<AlertDialogTitle class="mb-4 text-xl font-bold">
{{ t('settings.pages.card.delete_card') }}
</AlertDialogTitle>
<AlertDialogDescription class="mb-6">
{{ t('settings.pages.card.delete_confirmation') }} <b>"{{ card.name }}"</b>
</AlertDialogDescription>
<div class="flex flex-row justify-end gap-3">
<AlertDialogCancel as-child>
<Button
variant="secondary"
:label="t('settings.pages.card.cancel')"
@click="() => showDeleteConfirm = false"
/>
</AlertDialogCancel>
<AlertDialogAction as-child>
<Button
variant="danger"
:label="t('settings.pages.card.delete')"
@click="handleDeleteConfirm"
/>
</AlertDialogAction>
</div>
</AlertDialogContent>
</AlertDialogPortal>
</AlertDialogRoot>
<!-- Activation button -->
<Button
variant="primary"
:label="isActive ? t('settings.pages.card.active') : t('settings.pages.card.activate')"
:disabled="isActive"
:class="{ 'animate-pulse': isActivating }"
@click="handleActivate"
/>
</div>
</div>
<!-- Creator notes -->
<Section v-if="card.notes" :title="t('settings.pages.card.creator_notes')" icon="i-solar:notes-bold-duotone">
<div
bg="white/60 dark:black/30"
whitespace-pre-line rounded-lg p-4
text-neutral-700 dark:text-neutral-300
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
v-html="hightlightTagToHtml(card.notes)"
/>
</Section>
<!-- Description section -->
<Section v-if="card.description" :title="t('settings.pages.card.description_label')" icon="i-solar:document-text-bold-duotone">
<div
bg="white/60 dark:black/30"
whitespace-pre-line
rounded-lg
p-4
text="neutral-600 dark:neutral-300"
border="~ neutral-200/50 dark:neutral-700/30"
v-html="hightlightTagToHtml(card.description)"
/>
</Section>
<!-- Character -->
<template v-if="Object.values(characterSettings).some(value => !!value)">
<Section :title="t('settings.pages.card.character')" icon="i-solar:user-rounded-bold-duotone">
<div flex="~ col" gap-4>
<template v-for="(value, key) in characterSettings" :key="key">
<div v-if="value" flex="~ col" gap-2>
<h2 text-lg text-neutral-500 font-medium dark:text-neutral-400>
{{ t(`settings.pages.card.${key.toLowerCase()}`) }}
</h2>
<div
bg="white/60 dark:black/30"
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
max-h-60 overflow-auto whitespace-pre-line rounded-lg p-3 text-neutral-700 dark:text-neutral-300
v-html="hightlightTagToHtml(value)"
/>
</div>
</template>
</div>
</Section>
</template>
<!-- Modules -->
<Section :title="t('settings.pages.card.modules')" icon="i-solar:tuning-square-bold-duotone">
<div grid="~ cols-1 sm:cols-3" gap-4>
<div
flex="~ col"
bg="white/60 dark:black/30"
gap-1 rounded-lg p-3
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
>
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
<div i-lucide:ghost />
{{ t('settings.pages.modules.consciousness.title') }}
</span>
<div truncate font-medium>
{{ moduleSettings.consciousness }}
</div>
</div>
<div
flex="~ col"
bg="white/60 dark:black/30"
gap-2 rounded-lg p-3
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
>
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
<div i-lucide:mic />
{{ t('settings.pages.modules.speech.title') }}
</span>
<div truncate font-medium>
{{ moduleSettings.speech }}
</div>
</div>
<div
flex="~ col"
bg="white/60 dark:black/30"
gap-2 rounded-lg p-3
border="~ neutral-200/50 dark:neutral-700/30"
transition="all duration-200"
hover="bg-white/80 dark:bg-black/40"
>
<span flex="~ row" items-center gap-2 text-sm text-neutral-500 dark:text-neutral-400>
<div i-solar:music-notes-bold-duotone />
{{ t('settings.pages.card.voice_id') }}
</span>
<div truncate font-medium>
{{ moduleSettings.voice }}
</div>
</div>
</div>
</Section>
</div>
</div>
<div
v-else
bg="neutral-50/50 dark:neutral-900/50"
rounded-xl p-8 text-center
border="~ neutral-200/50 dark:neutral-700/30"
shadow="sm"
>
<div i-solar:card-search-broken mx-auto mb-3 text-6xl text-neutral-400 />
{{ t('settings.pages.card.card_not_found') }}
</div>
</template>
@@ -0,0 +1,153 @@
<script setup lang="ts">
import type { ccv3 } from '@proj-airi/ccc'
import { Button } from '@proj-airi/stage-ui/components/Button'
import { RadioCardDetailManySelect } from '@proj-airi/stage-ui/components/Form'
import { useAiriCardStore } from '@proj-airi/stage-ui/stores'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import AiriCardView from './components/AiriCardView.vue'
const router = useRouter()
const { t } = useI18n()
const cardStore = useAiriCardStore()
const { addCard } = cardStore
const { cards, activeCardId } = storeToRefs(cardStore)
// Currently selected card ID (different from active card ID)
const selectedCardId = ref<string>(activeCardId.value || '')
// Card list data structure
interface CardListItem {
id: string
name: string
description?: string
deprecated?: boolean
customizable?: boolean
}
// Transform cards Map to array for display
const cardsArray = computed<CardListItem[]>(() =>
Array.from(cards.value.entries()).map(([id, card]) => ({
id,
name: card.name,
description: card.description,
})),
)
/**
* Handles card file upload and processing
*/
async function handleUpload() {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.json'
fileInput.onchange = async (event: Event) => {
const file = (event.target as HTMLInputElement).files?.[0]
if (!file)
return
try {
const content = await file.text()
const cardJSON = JSON.parse(content) as ccv3.CharacterCardV3
// Add card and select it
selectedCardId.value = addCard(cardJSON)
}
catch (error) {
console.error('Error processing card file:', error)
}
}
fileInput.click()
}
/**
* Sets the selected card as active
*/
function activateSelectedCard() {
if (selectedCardId.value)
activeCardId.value = selectedCardId.value
}
/**
* Handles card deletion
*/
function handleCardDelete() {
// Reset selected card ID if it's the one being deleted
selectedCardId.value = cardsArray.value.length > 0 ? cardsArray.value[0].id : ''
}
</script>
<template>
<div
v-motion
flex="~ row" items-center gap-2
:initial="{ opacity: 0, x: 10 }"
:enter="{ opacity: 1, x: 0 }"
:leave="{ opacity: 0, x: -10 }"
:duration="250"
>
<button @click="router.back()">
<div i-solar:alt-arrow-left-line-duotone text-2xl />
</button>
<h1 relative>
<div absolute left-0 top-0 translate-y="[-80%]">
<span text="neutral-300 dark:neutral-500" text-nowrap>{{ t('settings.title') }}</span>
</div>
<div text-nowrap text-3xl font-semibold>
{{ t('settings.pages.card.title') }}
</div>
</h1>
</div>
<div bg="neutral-50 dark:[rgba(0,0,0,0.3)]" rounded-xl p-4 flex="~ col gap-4">
<!-- Toolbar -->
<div flex="~ col" gap-6>
<div flex="~ row gap-2 flex-wrap">
<!-- Upload button -->
<Button
variant="primary"
icon="i-solar:upload-line-duotone"
:label="t('settings.pages.card.upload')"
@click="handleUpload"
/>
</div>
<!-- Card selection -->
<template v-if="cards.size > 0">
<RadioCardDetailManySelect
v-model="selectedCardId"
:items="cardsArray"
:expand-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.expand')"
:collapse-button-text="t('settings.pages.modules.consciousness.sections.section.provider-model-selection.collapse')"
:show-more="false"
/>
</template>
</div>
<!-- Card content area -->
<div v-if="selectedCardId && cards.size > 0" mt-6>
<AiriCardView
:card-id="selectedCardId"
@activate="activateSelectedCard"
@delete="handleCardDelete"
/>
</div>
<!-- Background decoration -->
<div text="neutral-200/50 dark:neutral-600/20" pointer-events-none fixed bottom-0 right-0 z--1 translate-x-10 translate-y-10>
<div text="40" i-lucide:id-card />
</div>
</div>
</template>
<route lang="yaml">
meta:
stageTransition:
name: slide
</route>
@@ -60,6 +60,12 @@ const removeBeforeEach = router.beforeEach(async (_, __, next) => {
})
const settings = computed(() => [
{
title: t('settings.pages.card.title'),
description: t('settings.pages.card.description'),
icon: 'i-lucide:card',
to: '/settings/airi-card',
},
{
title: t('settings.pages.modules.title'),
description: t('settings.pages.modules.description'),
+1 -1
View File
@@ -1,4 +1,4 @@
# @moeru-ai/ccc
# @proj-airi/ccc
Create Character Card in a modular way.
+39 -1
View File
@@ -74,10 +74,48 @@ interface CardDescription {
description?: string
}
interface CardExtra {
/**
* Character's personality traits and behavioral patterns
* Used to define how the character should act and respond
*/
personality?: string
/**
* Background context and setting for the character
* Provides the environment and situation the character exists in
*/
scenario?: string
/**
* Core system instructions for the character's behavior
* Defines fundamental rules and context for the AI model
*/
systemPrompt?: string
/**
* Instructions to process after chat history
* Helps maintain character consistency across conversations
*/
postHistoryInstructions?: string
/**
* Categorization labels for the character
* Used for filtering and organization
*/
tags?: string[]
/**
* Sample conversation snippets showing character interactions
* Demonstrates expected conversation patterns
*/
messageExample?: Message[][]
}
/**
* Moeru-AI Character Card
*/
export type Card = CardAdditional & CardCore & CardDescription & CardMeta
export type Card = CardAdditional & CardCore & CardDescription & CardMeta & CardExtra
export type CardFn<T extends Record<string, unknown> = Record<string, unknown>> = (data: T) => Card
+6 -6
View File
@@ -23,9 +23,9 @@ function createCardData(data: Card): CharacterCardV3['data'] {
return {
name: data.name,
nickname: data.nickname,
description: data.description ?? '', // TODO: improve description // FIXME: important
personality: '', // TODO: add personality
scenario: '', // TODO: add scenario
description: data.description ?? '',
personality: data.personality ?? '',
scenario: data.scenario ?? '',
first_mes: data.greetings?.[0] ?? '',
alternate_greetings: data.greetings?.slice(1) ?? [],
group_only_greetings: data.greetingsGroupOnly ?? [],
@@ -33,10 +33,10 @@ function createCardData(data: Card): CharacterCardV3['data'] {
creator: data.creator ?? '',
creator_notes: data.notes ?? '',
creator_notes_multilingual: data.notesMultilingual,
system_prompt: '', // TODO: add system_prompt
post_history_instructions: '', // TODO: add post_history_instructions
system_prompt: data.systemPrompt ?? '',
post_history_instructions: data.postHistoryInstructions ?? '',
mes_example: formatMessageExample(data.messageExample),
tags: [], // TODO: add tags
tags: data.tags ?? [],
extensions: createExtensions(data),
}
}
+2
View File
@@ -57,8 +57,10 @@
},
"dependencies": {
"@formkit/auto-animate": "^0.8.2",
"@proj-airi/ccc": "workspace:^",
"@proj-airi/server-sdk": "workspace:^",
"@vueuse/motion": "^3.0.3",
"radix-vue": "^1.9.17",
"reka-ui": "^2.1.1",
"unist-builder": "^4.0.0",
"xast-util-to-xml": "^4.0.0",
@@ -12,7 +12,6 @@ interface ButtonProps {
label?: string // Button text label
disabled?: boolean // Disabled state
loading?: boolean // Loading state
onClick?: () => void // Click handler
variant?: ButtonVariant // Button style variant
size?: ButtonSize // Button size variant
block?: boolean // Full width button
@@ -59,7 +58,6 @@ const baseClasses = computed(() => [
<button
:disabled="isDisabled"
:class="baseClasses"
@click="onClick"
>
<div class="flex flex-row items-center justify-center gap-2">
<div v-if="loading" class="i-lucide:loader-circle animate-spin" />
@@ -98,7 +98,7 @@ function toggleExpansion() {
<!-- Description with ellipsis (limited to 2 lines) -->
<TransitionVertical>
<div
v-if="!isExpanded"
v-if="!isExpanded || !showExpandCollapse"
class="line-clamp-2 cursor-pointer text-xs"
:class="[
modelValue === value
@@ -21,6 +21,7 @@ interface Props {
customInputPlaceholder?: string
expandButtonText?: string
collapseButtonText?: string
showMore?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@@ -32,6 +33,7 @@ const props = withDefaults(defineProps<Props>(), {
customInputPlaceholder: 'Enter custom value',
expandButtonText: 'Show more',
collapseButtonText: 'Show less',
showMore: true,
})
const emit = defineEmits<{
@@ -120,7 +122,7 @@ function updateCustomValue(value: string) {
:title="item.name"
:description="item.description"
:deprecated="item.deprecated"
:show-expand-collapse="true"
:show-expand-collapse="showMore"
:expand-collapse-threshold="100"
:show-custom-input="item.customizable"
:custom-input-value="customValue"
+8 -9
View File
@@ -1,14 +1,13 @@
import type { ChatProvider } from '@xsai-ext/shared-providers'
import type { AssistantMessage, Message } from '@xsai/shared-chat'
import type { AssistantMessage, Message, SystemMessage } from '@xsai/shared-chat'
import { defineStore } from 'pinia'
import { defineStore, storeToRefs } from 'pinia'
import { ref, toRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import { useLlmmarkerParser } from '../composables/llmmarkerParser'
import SystemPromptV2 from '../constants/prompts/system-v2'
import { useLLM } from '../stores/llm'
import { asyncIteratorFromReadableStream } from '../utils/iterator'
import { useAiriCardStore } from './modules'
export interface ErrorMessage {
role: 'error'
@@ -17,7 +16,7 @@ export interface ErrorMessage {
export const useChatStore = defineStore('chat', () => {
const { stream } = useLLM()
const { t } = useI18n()
const { systemPrompt } = storeToRefs(useAiriCardStore())
const sending = ref(false)
@@ -63,10 +62,10 @@ export const useChatStore = defineStore('chat', () => {
}
const messages = ref<Array<Message | ErrorMessage>>([
SystemPromptV2(
t('prompt.prefix'),
t('prompt.suffix'),
),
{
role: 'system',
content: systemPrompt.value.replace(/\{\{user\}\}/g, 'user'),
} satisfies SystemMessage,
])
const streamingMessage = ref<AssistantMessage>({ role: 'assistant', content: '' })
@@ -0,0 +1,242 @@
import type { Card, ccv3 } from '@proj-airi/ccc'
import { useLocalStorage } from '@vueuse/core'
import { defineStore, storeToRefs } from 'pinia'
import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import SystemPromptV2 from '../../constants/prompts/system-v2'
import { useConsciousnessStore } from './consciousness'
import { useSpeechStore } from './speech'
export interface AiriExtension {
modules: {
consciousness: {
model: string // Example: "gpt-4o"
}
speech: {
model: string // Example: "eleven_multilingual_v2"
voice_id: string // Example: "alloy"
pitch?: number
rate?: number
ssml?: boolean
language?: string
}
vrm?: {
source?: 'file' | 'url'
file?: string // Example: "vrm/model.vrm"
url?: string // Example: "https://example.com/vrm/model.vrm"
}
live2d?: {
source?: 'file' | 'url'
file?: string // Example: "live2d/model.json"
url?: string // Example: "https://example.com/live2d/model.json"
}
}
agents: {
[key: string]: { // example: minecraft
prompt: string
}
}
}
export interface AiriCard extends Card {
extensions: {
airi: AiriExtension
} & Card['extensions']
}
export const useAiriCardStore = defineStore('airi-card', () => {
const cards = useLocalStorage<Map<string, AiriCard>>('airi-cards', new Map())
const activeCardId = useLocalStorage('airi-card-active-id', 'default')
const activeCard = computed(() => cards.value.get(activeCardId.value))
const consciousnessStore = useConsciousnessStore()
const speechStore = useSpeechStore()
const {
activeModel: activeConsciousnessModel,
} = storeToRefs(consciousnessStore)
const {
activeSpeechVoiceId,
activeSpeechModel,
} = storeToRefs(speechStore)
const addCard = (card: AiriCard | Card | ccv3.CharacterCardV3) => {
const newCardId = crypto.randomUUID()
cards.value.set(newCardId, newAiriCard(card))
return newCardId
}
const removeCard = (id: string) => {
cards.value.delete(id)
}
const getCard = (id: string) => {
return cards.value.get(id)
}
function resolveAiriExtension(card: Card | ccv3.CharacterCardV3): AiriExtension {
// Get existing extension if available
const existingExtension = ('data' in card
? card.data?.extensions?.airi
: card.extensions?.airi) as AiriExtension
// Create default modules config
const defaultModules = {
consciousness: {
model: activeConsciousnessModel.value,
},
speech: {
model: activeSpeechModel.value,
voice_id: activeSpeechVoiceId.value,
},
}
// Return default if no extension exists
if (!existingExtension) {
return {
modules: defaultModules,
agents: {},
}
}
// Merge existing extension with defaults
return {
modules: {
consciousness: {
model: existingExtension.modules?.consciousness?.model ?? defaultModules.consciousness.model,
},
speech: {
model: existingExtension.modules?.speech?.model ?? defaultModules.speech.model,
voice_id: existingExtension.modules?.speech?.voice_id ?? defaultModules.speech.voice_id,
pitch: existingExtension.modules?.speech?.pitch,
rate: existingExtension.modules?.speech?.rate,
ssml: existingExtension.modules?.speech?.ssml,
language: existingExtension.modules?.speech?.language,
},
vrm: existingExtension.modules?.vrm,
live2d: existingExtension.modules?.live2d,
},
agents: existingExtension.agents ?? {},
}
}
function newAiriCard(card: Card | ccv3.CharacterCardV3): AiriCard {
// Handle ccv3 format if needed
if ('data' in card) {
const ccv3Card = card as ccv3.CharacterCardV3
return {
name: ccv3Card.data.name,
version: ccv3Card.data.character_version ?? '1.0.0',
description: ccv3Card.data.description ?? '',
creator: ccv3Card.data.creator ?? '',
notes: ccv3Card.data.creator_notes ?? '',
notesMultilingual: ccv3Card.data.creator_notes_multilingual,
personality: ccv3Card.data.personality ?? '',
scenario: ccv3Card.data.scenario ?? '',
greetings: [
ccv3Card.data.first_mes,
...(ccv3Card.data.alternate_greetings ?? []),
],
greetingsGroupOnly: ccv3Card.data.group_only_greetings ?? [],
systemPrompt: ccv3Card.data.system_prompt ?? '',
postHistoryInstructions: ccv3Card.data.post_history_instructions ?? '',
messageExample: ccv3Card.data.mes_example
? ccv3Card.data.mes_example
.split('<START>\n')
.filter(Boolean)
.map(example => example.split('\n')
.map((line) => {
if (line.startsWith('{{char}}:') || line.startsWith('{{user}}:'))
return line as `{{char}}: ${string}` | `{{user}}: ${string}`
throw new Error(`Invalid message example format: ${line}`)
}))
: [],
tags: ccv3Card.data.tags ?? [],
extensions: {
airi: resolveAiriExtension(ccv3Card),
...ccv3Card.data.extensions,
},
}
}
return {
...card,
extensions: {
airi: resolveAiriExtension(card),
...card.extensions,
},
}
}
onMounted(() => {
const { t } = useI18n()
cards.value.set('default', newAiriCard({
name: 'ReLU',
version: '1.0.0',
// description: 'ReLU is a simple and effective activation function that is used in many neural networks.',
description: SystemPromptV2(
t('prompt.prefix'),
t('prompt.suffix'),
).content,
}))
})
watch(activeCard, (newCard: AiriCard | undefined) => {
if (!newCard)
return
// TODO: live2d, vrm
// TODO: Minecraft Agent, etc
const extension = resolveAiriExtension(newCard)
if (!extension)
return
activeConsciousnessModel.value = extension?.modules?.consciousness?.model
activeSpeechModel.value = extension?.modules?.speech?.model
activeSpeechVoiceId.value = extension?.modules?.speech?.voice_id
})
return {
cards,
activeCard,
activeCardId,
addCard,
removeCard,
getCard,
currentModels: computed(() => {
return {
consciousness: {
model: activeConsciousnessModel.value,
},
speech: {
model: activeSpeechModel.value,
voice_id: activeSpeechVoiceId.value,
},
} satisfies AiriExtension['modules']
}),
systemPrompt: computed(() => {
const card = activeCard.value
if (!card)
return ''
const components = [
card.systemPrompt,
card.description,
card.personality,
].filter(Boolean)
return components.join('\n')
}),
}
})
@@ -1,2 +1,3 @@
export * from './airi-card'
export * from './consciousness'
export * from './speech'
+24 -15
View File
@@ -418,7 +418,7 @@ importers:
version: 2.3.0
'@intlify/unplugin-vue-i18n':
specifier: ^6.0.5
version: 6.0.5(@vue/compiler-dom@3.5.13)(eslint@9.23.0(jiti@2.4.2))(rollup@2.79.1)(typescript@5.8.2)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
version: 6.0.5(@vue/compiler-dom@3.5.13)(eslint@9.23.0(jiti@2.4.2))(rollup@4.38.0)(typescript@5.8.2)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
'@proj-airi/drizzle-duckdb-wasm':
specifier: workspace:^
version: link:../../packages/drizzle-duckdb-wasm
@@ -469,7 +469,7 @@ importers:
version: 3.0.0-beta.7(typescript@5.8.2)(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
'@vueuse/motion':
specifier: ^3.0.3
version: 3.0.3(magicast@0.3.5)(rollup@2.79.1)(vue@3.5.13(typescript@5.8.2))
version: 3.0.3(magicast@0.3.5)(rollup@4.38.0)(vue@3.5.13(typescript@5.8.2))
electron:
specifier: ^34.4.1
version: 34.4.1
@@ -490,13 +490,13 @@ importers:
version: 3.2.0(unocss@66.1.0-beta.7(postcss@8.5.3)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2)))
unplugin-auto-import:
specifier: ^19.1.2
version: 19.1.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))
version: 19.1.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.38.0))(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))
unplugin-vue-components:
specifier: ^28.4.1
version: 28.4.1(@babel/parser@7.26.10)(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(vue@3.5.13(typescript@5.8.2))
version: 28.4.1(@babel/parser@7.26.10)(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.38.0))(vue@3.5.13(typescript@5.8.2))
unplugin-vue-macros:
specifier: ^2.14.5
version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.19.12)(rollup@2.79.1)(typescript@5.8.2)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.25.0)(rollup@4.38.0)(typescript@5.8.2)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
unplugin-vue-markdown:
specifier: ^28.3.1
version: 28.3.1(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
@@ -505,13 +505,13 @@ importers:
version: 0.12.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
vite-bundle-visualizer:
specifier: ^1.2.1
version: 1.2.1(rollup@2.79.1)
version: 1.2.1(rollup@4.38.0)
vite-plugin-pwa:
specifier: ^0.21.2
version: 0.21.2(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0)
vite-plugin-vue-devtools:
specifier: ^7.7.2
version: 7.7.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(rollup@2.79.1)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
version: 7.7.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.38.0))(rollup@4.38.0)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
vite-plugin-vue-layouts:
specifier: ^0.11.0
version: 0.11.0(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
@@ -906,6 +906,9 @@ importers:
'@pixiv/three-vrm-core':
specifier: ^3.3.6
version: 3.3.6(three@0.175.0)
'@proj-airi/ccc':
specifier: workspace:^
version: link:../../packages/ccc
'@proj-airi/drizzle-duckdb-wasm':
specifier: workspace:^
version: link:../../packages/drizzle-duckdb-wasm
@@ -1101,7 +1104,7 @@ importers:
version: 2.3.0
'@intlify/unplugin-vue-i18n':
specifier: ^6.0.5
version: 6.0.5(@vue/compiler-dom@3.5.13)(eslint@9.23.0(jiti@2.4.2))(rollup@4.38.0)(typescript@5.8.2)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
version: 6.0.5(@vue/compiler-dom@3.5.13)(eslint@9.23.0(jiti@2.4.2))(rollup@2.79.1)(typescript@5.8.2)(vue-i18n@11.1.2(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
'@proj-airi/lobe-icons':
specifier: ^1.0.4
version: 1.0.4
@@ -1140,7 +1143,7 @@ importers:
version: 3.0.0-beta.7(typescript@5.8.2)(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
'@vueuse/motion':
specifier: ^3.0.3
version: 3.0.3(magicast@0.3.5)(rollup@4.38.0)(vue@3.5.13(typescript@5.8.2))
version: 3.0.3(magicast@0.3.5)(rollup@2.79.1)(vue@3.5.13(typescript@5.8.2))
hfup:
specifier: workspace:^
version: link:../../packages/hfup
@@ -1152,13 +1155,13 @@ importers:
version: 4.0.1
unplugin-auto-import:
specifier: ^19.1.2
version: 19.1.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.38.0))(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))
version: 19.1.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))
unplugin-vue-components:
specifier: ^28.4.1
version: 28.4.1(@babel/parser@7.26.10)(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.38.0))(vue@3.5.13(typescript@5.8.2))
version: 28.4.1(@babel/parser@7.26.10)(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(vue@3.5.13(typescript@5.8.2))
unplugin-vue-macros:
specifier: ^2.14.5
version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.25.0)(rollup@4.38.0)(typescript@5.8.2)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
version: 2.14.5(@vueuse/core@13.0.0(vue@3.5.13(typescript@5.8.2)))(esbuild@0.19.12)(rollup@2.79.1)(typescript@5.8.2)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-tsc@3.0.0-alpha.2(typescript@5.8.2))(vue@3.5.13(typescript@5.8.2))
unplugin-vue-markdown:
specifier: ^28.3.1
version: 28.3.1(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))
@@ -1167,13 +1170,13 @@ importers:
version: 0.12.0(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
vite-bundle-visualizer:
specifier: ^1.2.1
version: 1.2.1(rollup@4.38.0)
version: 1.2.1(rollup@2.79.1)
vite-plugin-pwa:
specifier: ^0.21.2
version: 0.21.2(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(workbox-build@7.3.0(@types/babel__core@7.20.5))(workbox-window@7.3.0)
vite-plugin-vue-devtools:
specifier: ^7.7.2
version: 7.7.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@4.38.0))(rollup@4.38.0)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
version: 7.7.2(@nuxt/kit@3.14.1592(magicast@0.3.5)(rollup@2.79.1))(rollup@2.79.1)(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue@3.5.13(typescript@5.8.2))
vite-plugin-vue-layouts:
specifier: ^0.11.0
version: 0.11.0(vite@6.2.3(@types/node@22.13.14)(jiti@2.4.2)(less@4.2.2)(terser@5.17.6)(tsx@4.19.3)(yaml@2.7.0))(vue-router@4.5.0(vue@3.5.13(typescript@5.8.2)))(vue@3.5.13(typescript@5.8.2))
@@ -1473,12 +1476,18 @@ importers:
'@formkit/auto-animate':
specifier: ^0.8.2
version: 0.8.2
'@proj-airi/ccc':
specifier: workspace:^
version: link:../ccc
'@proj-airi/server-sdk':
specifier: workspace:^
version: link:../server-sdk
'@vueuse/motion':
specifier: ^3.0.3
version: 3.0.3(magicast@0.3.5)(rollup@4.38.0)(vue@3.5.13(typescript@5.8.2))
radix-vue:
specifier: ^1.9.17
version: 1.9.17(vue@3.5.13(typescript@5.8.2))
reka-ui:
specifier: ^2.1.1
version: 2.1.1(typescript@5.8.2)(vue@3.5.13(typescript@5.8.2))
@@ -23364,7 +23373,7 @@ snapshots:
aria-hidden: 1.2.4
defu: 6.1.4
fast-deep-equal: 3.1.3
nanoid: 5.1.3
nanoid: 5.1.5
vue: 3.5.13(typescript@5.8.2)
transitivePeerDependencies:
- '@vue/composition-api'