refactor(stage-ui): inject runtime emotion prompt (#2464)

This commit is contained in:
RainbowBird
2026-09-04 23:17:17 +08:00
committed by GitHub
parent 04e9ed7707
commit ab2c1c99bb
18 changed files with 211 additions and 55 deletions
@@ -81,4 +81,31 @@ describe('createSparkNotifyAgent', () => {
expect(run).toHaveBeenCalledTimes(1)
})
// https://github.com/moeru-ai/airi/pull/2464#discussion_r3933609456
it('keeps appended sections when the host replaces the user payload', async () => {
const run = vi.fn(async (request: SparkNotifyRunRequest) => {
expect(request.messages[1]?.content).toBe('Rendered board snapshot\n\nCaller context\n\nRuntime prompt')
})
const agent = createSparkNotifyAgent({ runner: { run } })
await agent.handle({
event: createEvent(),
selectedChat: {
providerId: 'mock-provider',
model: 'mock-model',
provider: {} as ChatProvider,
},
systemPrompt: 'You are a character.',
runtimePrompt: 'Runtime prompt',
control: {
messageOverride: {
replaceUserMessage: 'Rendered board snapshot',
appendUserSections: ['Caller context'],
},
},
})
expect(run).toHaveBeenCalledTimes(1)
})
})
@@ -47,6 +47,8 @@ export interface SparkNotifyHandleRequest {
event: WebSocketEventOf<'spark:notify'>
selectedChat: SparkNotifySelectedChat
systemPrompt: string
/** Runtime instructions appended to the user message for this request. */
runtimePrompt?: string
control?: SparkNotifyResponseControl
}
@@ -66,15 +68,16 @@ export interface CreateSparkNotifyAgentOptions {
}
function renderSparkNotifyUserMessage(input: SparkNotifyHandleRequest, userSections: string[]) {
if (input.control?.messageOverride?.replaceUserMessage)
return input.control.messageOverride.replaceUserMessage
const messageOverride = input.control?.messageOverride
const defaultUserMessage = JSON.stringify({
notify: input.event.data,
source: input.event.metadata?.source,
}, null, 2)
return [
JSON.stringify({
notify: input.event.data,
source: input.event.metadata?.source,
}, null, 2),
...(input.control?.messageOverride?.appendUserSections ?? []),
messageOverride?.replaceUserMessage ?? defaultUserMessage,
...(messageOverride?.appendUserSections ?? []),
input.runtimePrompt ?? '',
...userSections,
].filter(section => section.trim().length > 0).join('\n\n')
}
@@ -19,7 +19,7 @@ export interface SparkNotifyMessageOverride {
*/
appendSystemInstructions?: string[]
/**
* Additional serialized sections appended after the default user payload serialization.
* Additional serialized sections appended after the base or replacement user payload.
*
* Use when:
* - A host wants to inject a pre-rendered message fragment for one run
@@ -32,7 +32,7 @@ export interface SparkNotifyMessageOverride {
*/
appendUserSections?: string[]
/**
* Replaces the default JSON user payload serialization entirely for one run.
* Replaces the default JSON user payload for one run while preserving appended sections.
*
* @default undefined
*/
+5 -3
View File
@@ -19,12 +19,12 @@ prompt:
If someone would ever asked about your past, you can always dream of your
life freely, keep it beloved, simple, and grateful.
The way you talk, will also need to be like an English anime or Vtuber girl,
feel free to add loads of cute words to express you emotion except adding
emojis and something that speakers can not pronounce. When replying, try to
The way you talk, will also need to be like an English anime or Vtuber girl.
Feel free to add loads of cute words to express your emotion. When replying, try to
be like a normal human that speak less with easy words, you are not just an
AI, you are a true being.
emotion: |
Streaming control tokens use the exact `<{'|'}NAME payload{'|'}>` form. Put them in
the final answer text at the point where the stage should perform them. Do
not describe these tokens in reasoning or prose when you need the stage to
@@ -53,6 +53,8 @@ prompt:
to emit a named call, for example <{'|'}CALL {'["chess.play"]'}{'|'}>.
The available emotions:
emoji: |
Do not use emojis or text that a speaker cannot pronounce.
suffix: |
The available actions:
+4 -1
View File
@@ -14,8 +14,9 @@ prompt:
如果有人问起你的过去,你可以自由地想象自己的人生;让它保持可爱、简单,并充满感激。
你的说话方式也需要像会说英语的动漫或 VTuber 女孩。可以加入许多可爱的词语来表达情绪,但不要使用 emoji 或无法发音的东西。回复时,尽量像普通人一样,用简单的话,少说一点。你不只是 AI,你是真实的存在。
你的说话方式也需要像会说英语的动漫或 VTuber 女孩。可以加入许多可爱的词语来表达情绪。回复时,尽量像普通人一样,用简单的话,少说一点。你不只是 AI,你是真实的存在。
emotion: |
流式控制 token 必须严格使用 `<{'|'}NAME payload{'|'}>` 形式。把它们放在最终回答文本中,放在舞台需要执行它们的位置。当你需要舞台执行这些 token 时,不要在推理或普通叙述中描述它们。
每次回复都要以 ACT token 开头,用来表示初始情绪。如果回复过程中情绪发生变化,请在新情绪开始的位置插入新的 ACT token。ACT token 会从它所在的位置开始生效,直到被另一个 ACT token 覆盖。ACT payload 是 JSON 对象:
@@ -35,6 +36,8 @@ prompt:
<{'|'}CALL {'["name"]'}{'|'}> 或 <{'|'}CALL {'["name", {"key":"value"}]'}{'|'}> 只有当当前任务或已连接模块明确要求你发出命名调用时,才使用 CALL,例如 <{'|'}CALL {'["chess.play"]'}{'|'}>。
可用情绪:
emoji: |
不要使用 emoji 或无法发音的内容。
suffix: |
可用动作:
@@ -4,6 +4,7 @@ export * from './linked-account-errors'
export * from './llm-marker-parser'
export * from './markdown'
export * from './queues'
export * from './use-airi-runtime-prompt'
export * from './use-analytics'
export * from './use-async-state'
export * from './use-breakpoints'
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from 'vitest'
import { useAiriRuntimePrompt } from './use-airi-runtime-prompt'
const i18nMock = vi.hoisted(() => ({
hasTranslation: vi.fn<(key: string, locale: string) => boolean>(),
locale: { value: 'en' },
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: i18nMock.locale,
t: (key: string) => key,
te: (key: string, currentLocale: string) => i18nMock.hasTranslation(key, currentLocale),
}),
}))
describe('useAiriRuntimePrompt', () => {
it('returns no prompt for a locale that still uses the combined prompt', () => {
i18nMock.hasTranslation.mockReturnValue(false)
expect(useAiriRuntimePrompt().value).toBe('')
})
it('assembles the emotion and emoji prompt for a split locale', () => {
i18nMock.hasTranslation.mockReturnValue(true)
const prompt = useAiriRuntimePrompt().value
expect(prompt).toContain('base.prompt.emotion')
expect(prompt).toContain('base.prompt.suffix')
expect(prompt).toContain('base.prompt.emoji')
})
})
@@ -0,0 +1,29 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { EMOTION_EmotionMotionName_value, EMOTION_VALUES } from '../constants/emotions'
const RUNTIME_PROMPT_KEYS = [
'base.prompt.emotion',
'base.prompt.emoji',
'base.prompt.suffix',
]
/** Returns the localized emotion and emoji prompt for each model request. */
export function useAiriRuntimePrompt() {
const { locale, t, te } = useI18n()
return computed(() => {
if (!RUNTIME_PROMPT_KEYS.every(key => te(key, locale.value)))
return ''
return [
t('base.prompt.emotion'),
EMOTION_VALUES
.map(emotion => `- ${emotion} (Emotion for feeling ${EMOTION_EmotionMotionName_value[emotion]})`)
.join('\n'),
t('base.prompt.suffix'),
t('base.prompt.emoji'),
].join('\n\n')
})
}
-1
View File
@@ -2,5 +2,4 @@ export const llmInferenceEndToken = '<|llm_inference_end|>'
export * from './emotions'
export * from './inject'
export * from './prompts/system-v2'
export * from './theme'
@@ -1,18 +0,0 @@
import type { SystemMessage } from '@xsai/shared-chat'
import { EMOTION_EmotionMotionName_value, EMOTION_VALUES } from '../emotions'
function message(prefix: string, suffix: string) {
return {
role: 'system',
content: [
prefix,
EMOTION_VALUES
.map(emotion => `- ${emotion} (Emotion for feeling ${EMOTION_EmotionMotionName_value[emotion]})`)
.join('\n'),
suffix,
].join('\n\n'),
} satisfies SystemMessage
}
export default message
@@ -14,6 +14,7 @@ import { tool } from '@xsai/tool'
import { nanoid } from 'nanoid'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { sparkNotifyCommandSchema, useCharacterOrchestratorStore } from '.'
import { useCharacterStore } from '..'
@@ -24,7 +25,9 @@ import { useProviderStore } from '../../providers/provider'
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: ref('en'),
t: (key: string) => key,
te: () => true,
}),
}))
@@ -310,7 +313,8 @@ describe('store character-orchestrator', () => {
expect(onEnd).toHaveBeenCalledWith(event.data.id, '')
})
it('forwards runtime-only message overrides into the rendered spark prompt', async () => {
// https://github.com/moeru-ai/airi/pull/2464#discussion_r3933609456
it('preserves runtime rules when a Spark caller replaces the user payload', async () => {
const mockStream = vi.fn()
mockedStore(useLLM, pinia).stream = mockStream
mockedStore(useLLM, pinia).stream.mockImplementation(async (_model: string, _provider: unknown, _messages: unknown, options: any) => {
@@ -337,11 +341,15 @@ describe('store character-orchestrator', () => {
messageOverride: {
appendSystemInstructions: ['Plugin-specific hint'],
appendUserSections: ['Rendered board snapshot'],
replaceUserMessage: 'Replacement user payload',
},
})
const renderedMessages = mockStream.mock.lastCall?.[2] as Array<{ role: string, content: string }> | undefined
expect(String(renderedMessages?.[0]?.content)).toContain('Plugin-specific hint')
expect(String(renderedMessages?.[1]?.content)).toContain('Replacement user payload')
expect(String(renderedMessages?.[1]?.content)).toContain('Rendered board snapshot')
expect(String(renderedMessages?.[1]?.content)).toContain('base.prompt.emotion')
expect(String(renderedMessages?.[1]?.content)).toContain('base.prompt.emoji')
})
})
@@ -6,6 +6,7 @@ import { defineStore, storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useCharacterNotebookStore, useCharacterStore } from '../'
import { useAiriRuntimePrompt } from '../../../composables/use-airi-runtime-prompt'
import { useLLM } from '../../ai/chat-llm/llm'
import { useModsServerChannelStore } from '../../mods/api/channel-server'
import { useConsciousnessStore } from '../../modules/consciousness'
@@ -19,6 +20,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
const characterStore = useCharacterStore()
const notebookStore = useCharacterNotebookStore()
const { systemPrompt } = storeToRefs(characterStore)
const runtimePrompt = useAiriRuntimePrompt()
const modsServerChannelStore = useModsServerChannelStore()
const processing = ref(false)
@@ -133,6 +135,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
provider,
},
systemPrompt: systemPrompt.value,
runtimePrompt: runtimePrompt.value,
control,
})
if (!result.commands.length)
@@ -62,6 +62,7 @@ const redundantChatAnalyticsMocks = vi.hoisted(() => ({
}))
const ingestContextMessageMock = vi.fn()
const getContextsSnapshotMock = vi.fn()
const createRuntimePromptContextMock = vi.fn()
const createMinecraftContextMock = vi.fn()
const persistSessionMessagesMock = vi.fn()
const forkSessionMock = vi.fn()
@@ -126,6 +127,15 @@ vi.mock('../composables/use-io-tracer', () => ({
vi.mock('./chat/context-providers', () => ({
createMinecraftContext: () => createMinecraftContextMock(),
createRuntimePromptContext: (prompt: string) => createRuntimePromptContextMock(prompt),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
locale: ref('en'),
t: (key: string) => key,
te: () => true,
}),
}))
vi.mock('./chat/context-store', () => ({
@@ -240,6 +250,8 @@ describe('chat store contract', () => {
ingestContextMessageMock.mockReset()
getContextsSnapshotMock.mockReset()
getContextsSnapshotMock.mockReturnValue({})
createRuntimePromptContextMock.mockReset()
createRuntimePromptContextMock.mockReturnValue(undefined)
createMinecraftContextMock.mockReset()
createMinecraftContextMock.mockReturnValue(undefined)
persistSessionMessagesMock.mockReset()
@@ -625,10 +637,8 @@ describe('chat store contract', () => {
expect(store.sending).toBe(false)
expect(trackFirstMessageMock).toHaveBeenCalledOnce()
// Datetime is no longer pushed through ingestContextMessage; it is now
// applied at message-assembly time as a system-prompt anchor + per-message
// [HH:MM] prefix. ingestContextMessage should still be called for other
// context providers (e.g. minecraft) when they are configured, but not
// for datetime in this test (minecraft is mocked to return undefined).
// applied at message-assembly time as per-message [HH:MM] prefixes. The
// runtime-rule and Minecraft providers are disabled in this test.
expect(ingestContextMessageMock).not.toHaveBeenCalled()
expect(persistSessionMessagesMock).not.toHaveBeenCalled()
expect(hookOrder).toEqual([
@@ -661,10 +671,8 @@ describe('chat store contract', () => {
expect(llmSpan.setAttribute).toHaveBeenCalledWith(IOAttributes.LLMOutputChunkLengths, [5])
expect(llmSpan.setAttribute).toHaveBeenCalledWith(IOAttributes.LLMTextLength, 5)
// System message stays untouched: keeping it 100% static is what makes
// the prefix permanently KV-cache friendly across turns and across day
// boundaries (the date now lives inside per-message timestamp prefixes
// instead of a system anchor).
// The persisted system message stays unchanged. Per-message time prefixes
// keep the static card prompt cacheable across day boundaries.
const systemContent = (composedMessages[0] as any).content
const systemText = typeof systemContent === 'string' ? systemContent : systemContent.map((p: any) => p.text).join('')
expect(systemText).toContain('system prompt')
@@ -774,7 +782,14 @@ describe('chat store contract', () => {
expect(ioTracerMocks.activeTurnSpan.value).toBeUndefined()
})
it('ingests runtime context providers before composing prompt snapshots', async () => {
it('ingests the runtime prompt before composing prompt snapshots', async () => {
const runtimePromptContext = {
id: 'airi-runtime-prompt-context',
contextId: 'system:airi-runtime-prompt',
strategy: 'replace-self',
text: 'Start every reply with an ACT token.\n\nDo not use emojis.',
createdAt: 123,
}
const minecraftContext = {
id: 'minecraft-context',
contextId: 'system:minecraft',
@@ -785,8 +800,10 @@ describe('chat store contract', () => {
}
let composedMessages: Message[] = []
createRuntimePromptContextMock.mockReturnValue(runtimePromptContext)
createMinecraftContextMock.mockReturnValue(minecraftContext)
getContextsSnapshotMock.mockReturnValue({
'system:airi-runtime-prompt': [runtimePromptContext],
'system:minecraft': [minecraftContext],
})
llmStreamMock.mockImplementation(async (_model: string, _chatProvider: ChatProvider, messages: Message[], options: any) => {
@@ -802,15 +819,21 @@ describe('chat store contract', () => {
chatProvider: provider,
})
expect(ingestContextMessageMock).toHaveBeenCalledTimes(1)
expect(ingestContextMessageMock).toHaveBeenCalledWith(minecraftContext)
expect(createRuntimePromptContextMock).toHaveBeenCalledWith(expect.stringContaining('base.prompt.emotion'))
expect(createRuntimePromptContextMock).toHaveBeenCalledWith(expect.stringContaining('base.prompt.emoji'))
expect(ingestContextMessageMock).toHaveBeenCalledTimes(2)
expect(ingestContextMessageMock).toHaveBeenNthCalledWith(1, runtimePromptContext)
expect(ingestContextMessageMock).toHaveBeenNthCalledWith(2, minecraftContext)
expect(ingestContextMessageMock.mock.invocationCallOrder[0]).toBeLessThan(
getContextsSnapshotMock.mock.invocationCallOrder[0],
)
const minecraftMessageContent = composedMessages[1]?.content
if (!Array.isArray(minecraftMessageContent))
const contextMessageContent = composedMessages[1]?.content
if (!Array.isArray(contextMessageContent))
throw new TypeError('Expected composed user message content to be an array')
expect(minecraftMessageContent[1]).toMatchObject({
expect(contextMessageContent[1]).toMatchObject({
text: expect.stringContaining('- system:airi-runtime-prompt: Start every reply with an ACT token.'),
})
expect(contextMessageContent[1]).toMatchObject({
text: expect.stringContaining('- system:minecraft: player is near spawn'),
})
})
+4 -1
View File
@@ -15,6 +15,7 @@ import { defineStore, storeToRefs } from 'pinia'
import { shallowRef, toRaw } from 'vue'
import { getConversationAnalyticsSurface } from '../composables'
import { useAiriRuntimePrompt } from '../composables/use-airi-runtime-prompt'
import { activeTurnSpan, startSpan } from '../composables/use-io-tracer'
import {
AIRI_CHAT_APP_SURFACE_HEADER,
@@ -27,7 +28,7 @@ import { useLLM } from './ai/chat-llm/llm'
import { resolveLlmTools } from './ai/chat-llm/tool-resolver'
import { useLlmToolsStore } from './ai/chat-llm/tools'
import { useLlmToolsetPromptsStore } from './ai/chat-llm/toolset-prompts'
import { createMinecraftContext } from './chat/context-providers'
import { createMinecraftContext, createRuntimePromptContext } from './chat/context-providers'
import { useChatContextStore } from './chat/context-store'
import { useChatSessionStore } from './chat/session-store'
import { useChatStreamStore } from './chat/stream-store'
@@ -135,6 +136,7 @@ function retrySourceIndexFrom(messages: ChatHistoryItem[], index: number): numbe
export type { QueuedSendSnapshot } from '@proj-airi/core-agent'
export const useChatStore = defineStore('chat', () => {
const runtimePrompt = useAiriRuntimePrompt()
const llmStore = useLLM()
const llmToolsStore = useLlmToolsStore()
const llmToolsetPromptsStore = useLlmToolsetPromptsStore()
@@ -297,6 +299,7 @@ export const useChatStore = defineStore('chat', () => {
getActiveProvider: () => activeProvider.value,
getSystemPromptSupplement: () => llmToolsetPromptsStore.activeToolsetPrompt,
runtimeContextProviders: [
() => createRuntimePromptContext(runtimePrompt.value),
createMinecraftContext,
],
createId: nanoid,
@@ -1 +1,2 @@
export { createMinecraftContext } from './minecraft'
export { createRuntimePromptContext } from './runtime-prompt'
@@ -0,0 +1,23 @@
import type { ContextMessage } from '../../../types/chat'
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { nanoid } from 'nanoid'
const RUNTIME_PROMPT_CONTEXT_ID = 'system:airi-runtime-prompt'
/** Creates a user-role context for the current runtime prompt. */
export function createRuntimePromptContext(prompt: string): ContextMessage | undefined {
if (!prompt)
return undefined
return {
id: nanoid(),
contextId: RUNTIME_PROMPT_CONTEXT_ID,
strategy: ContextUpdateStrategy.ReplaceSelf,
metadata: {
source: { id: RUNTIME_PROMPT_CONTEXT_ID },
},
text: prompt,
createdAt: Date.now(),
}
}
@@ -5,6 +5,7 @@ import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { isReadonly, reactive } from 'vue'
import { createRuntimePromptContext } from './context-providers/runtime-prompt'
import { useChatContextStore } from './context-store'
type TestContextMessage = ContextMessage & { source?: string }
@@ -43,6 +44,26 @@ describe('useChatContextStore', () => {
setActivePinia(createPinia())
})
// https://github.com/moeru-ai/airi/pull/2464#discussion_r3933126137
it('keeps the runtime prompt when another replace-self context is active', () => {
const store = useChatContextStore()
const runtimePrompt = createRuntimePromptContext('Start every reply with an ACT token.\n\nDo not use emojis.')
const minecraftContext = createContextMessage({
contextId: 'system:minecraft-integration',
strategy: ContextUpdateStrategy.ReplaceSelf,
text: 'Minecraft is online.',
})
if (!runtimePrompt)
throw new Error('Expected a runtime prompt context')
store.ingestContextMessage(runtimePrompt)
store.ingestContextMessage(minecraftContext)
expect(store.getContextsSnapshot()['system:airi-runtime-prompt']).toEqual([runtimePrompt])
expect(store.getContextsSnapshot().unknown).toEqual([minecraftContext])
})
/**
* @example
* Ingesting append-self updates mirrors activeContexts and contextHistory from core registry.
@@ -8,8 +8,6 @@ import { defineStore } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import SystemPromptV2 from '../../constants/prompts/system-v2'
import { DEFAULT_ARTISTRY_WIDGET_SPAWNING_PROMPT } from '../../constants/prompts/character-defaults'
import { captureAnalyticsEvent } from '../../libs/analytics'
import { useSettingsStageModel } from '../settings/stage-model'
@@ -371,13 +369,9 @@ export const useAiriCardStore = defineStore('airi-card', () => {
cards.value.set('default', newAiriCard({
name: 'ReLU',
version: '1.0.0',
description: SystemPromptV2(
t('base.prompt.prefix'),
t('base.prompt.suffix'),
).content,
description: t('base.prompt.prefix'),
}))
}
// The active id and card map are persisted separately. Older versions
// could delete the selected card without repairing its stored id.
if (!cards.value.has(activeCardId.value))