refactor(stage-ui): inject runtime emotion prompt (#2464)
This commit is contained in:
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
}
|
||||
@@ -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'),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user