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
+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'