🐛 fix: fix memory schema (#11645)

* fix memory schema

* fix tests

* improve memory
This commit is contained in:
Arvin Xu
2026-01-22 11:27:39 +08:00
committed by GitHub
parent f6988e7032
commit 3baf78043d
20 changed files with 416 additions and 30 deletions

View File

@@ -0,0 +1,17 @@
'use client';
import type { BuiltinRenderProps } from '@lobechat/types';
import { memo } from 'react';
import type { AddPreferenceMemoryParams, AddPreferenceMemoryState } from '../../../types';
import { PreferenceMemoryCard } from '../../components';
const AddPreferenceMemoryRender = memo<
BuiltinRenderProps<AddPreferenceMemoryParams, AddPreferenceMemoryState>
>(({ args }) => {
return <PreferenceMemoryCard data={args} />;
});
AddPreferenceMemoryRender.displayName = 'AddPreferenceMemoryRender';
export default AddPreferenceMemoryRender;

View File

@@ -2,6 +2,7 @@ import type { BuiltinRender } from '@lobechat/types';
import { MemoryApiName } from '../../types';
import AddExperienceMemoryRender from './AddExperienceMemory';
import AddPreferenceMemoryRender from './AddPreferenceMemory';
import SearchUserMemoryRender from './SearchUserMemory';
/**
@@ -11,5 +12,6 @@ import SearchUserMemoryRender from './SearchUserMemory';
*/
export const MemoryRenders: Record<string, BuiltinRender> = {
[MemoryApiName.addExperienceMemory]: AddExperienceMemoryRender as BuiltinRender,
[MemoryApiName.addPreferenceMemory]: AddPreferenceMemoryRender as BuiltinRender,
[MemoryApiName.searchUserMemory]: SearchUserMemoryRender as BuiltinRender,
};

View File

@@ -0,0 +1,17 @@
'use client';
import type { BuiltinStreamingProps } from '@lobechat/types';
import { memo } from 'react';
import type { AddPreferenceMemoryParams } from '../../../types';
import { PreferenceMemoryCard } from '../../components';
export const AddPreferenceMemoryStreaming = memo<BuiltinStreamingProps<AddPreferenceMemoryParams>>(
({ args }) => {
return <PreferenceMemoryCard data={args} loading />;
},
);
AddPreferenceMemoryStreaming.displayName = 'AddPreferenceMemoryStreaming';
export default AddPreferenceMemoryStreaming;

View File

@@ -2,6 +2,7 @@ import { type BuiltinStreaming } from '@lobechat/types';
import { MemoryApiName } from '../../types';
import { AddExperienceMemoryStreaming } from './AddExperienceMemory';
import { AddPreferenceMemoryStreaming } from './AddPreferenceMemory';
/**
* Memory Streaming Components Registry
@@ -11,8 +12,8 @@ import { AddExperienceMemoryStreaming } from './AddExperienceMemory';
*/
export const MemoryStreamings: Record<string, BuiltinStreaming> = {
[MemoryApiName.addExperienceMemory]: AddExperienceMemoryStreaming as BuiltinStreaming,
[MemoryApiName.addPreferenceMemory]: AddPreferenceMemoryStreaming as BuiltinStreaming,
};
export {AddExperienceMemoryStreaming} from './AddExperienceMemory';
export { AddExperienceMemoryStreaming } from './AddExperienceMemory';
export { AddPreferenceMemoryStreaming } from './AddPreferenceMemory';

View File

@@ -0,0 +1,357 @@
'use client';
import { Accordion, AccordionItem, Avatar, Flexbox, Tag, Text } from '@lobehub/ui';
import { Steps } from 'antd';
import { createStaticStyles, cssVar } from 'antd-style';
import { memo } from 'react';
import BubblesLoading from '@/components/BubblesLoading';
import NeuralNetworkLoading from '@/components/NeuralNetworkLoading';
import StreamingMarkdown from '@/components/StreamingMarkdown';
import { highlightTextStyles } from '@/styles';
import type { AddPreferenceMemoryParams } from '../../types';
const styles = createStaticStyles(({ css, cssVar }) => ({
container: css`
overflow: hidden;
width: 100%;
border: 1px solid ${cssVar.colorBorderSecondary};
border-radius: 16px;
background: ${cssVar.colorBgContainer};
`,
content: css`
padding-block: 12px;
padding-inline: 16px;
`,
detail: css`
font-size: 13px;
line-height: 1.6;
color: ${cssVar.colorTextSecondary};
`,
directive: css`
font-size: 14px;
line-height: 1.6;
color: ${cssVar.colorText};
`,
header: css`
padding-block: 10px;
padding-inline: 12px;
border-block-end: 1px solid ${cssVar.colorBorderSecondary};
`,
section: css`
padding: 4px;
border-block-start: 1px solid ${cssVar.colorBorderSecondary};
`,
stepContent: css`
font-size: 13px;
line-height: 1.6;
color: ${cssVar.colorTextSecondary};
white-space: pre-wrap;
`,
stepsContainer: css`
.ant-steps-item-content {
min-height: auto;
}
.ant-steps-item-description {
padding-block-end: 12px !important;
}
`,
suggestion: css`
padding-block: 8px;
padding-inline: 12px;
border-radius: 8px;
font-size: 13px;
line-height: 1.5;
color: ${cssVar.colorTextSecondary};
background: ${cssVar.colorFillQuaternary};
`,
summary: css`
font-size: 14px;
font-weight: 500;
color: ${cssVar.colorTextSecondary};
`,
tags: css`
padding-block-start: 8px;
border-block-start: 1px dashed ${cssVar.colorBorderSecondary};
`,
title: css`
overflow: hidden;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 1;
font-weight: 500;
color: ${cssVar.colorText};
`,
}));
export interface PreferenceMemoryCardProps {
data?: AddPreferenceMemoryParams;
loading?: boolean;
}
export const PreferenceMemoryCard = memo<PreferenceMemoryCardProps>(({ data, loading }) => {
const { summary, details, tags, title, withPreference } = data || {};
const { conclusionDirectives, originContext, appContext, suggestions, type } =
withPreference || {};
const hasContextContent =
originContext?.actor ||
originContext?.scenario ||
originContext?.trigger ||
originContext?.applicableWhen ||
originContext?.notApplicableWhen;
const hasAppContext = appContext?.app || appContext?.feature || appContext?.surface;
const hasSuggestions = suggestions && suggestions.length > 0;
if (
!summary &&
!details &&
!tags?.length &&
!title &&
!conclusionDirectives &&
!hasContextContent &&
!hasSuggestions
)
return null;
const contextItems = [
{ avatar: '👤', content: originContext?.actor, title: 'Actor' },
{ avatar: '🎯', content: originContext?.scenario, title: 'Scenario' },
{ avatar: '⚡', content: originContext?.trigger, title: 'Trigger' },
{ avatar: '✅', content: originContext?.applicableWhen, title: 'Applicable When' },
{ avatar: '❌', content: originContext?.notApplicableWhen, title: 'Not Applicable When' },
].filter((item) => item.content);
const appContextItems = [
{ avatar: '📱', content: appContext?.app, title: 'App' },
{ avatar: '🔧', content: appContext?.feature, title: 'Feature' },
{ avatar: '📍', content: appContext?.surface, title: 'Surface' },
].filter((item) => item.content);
return (
<Flexbox className={styles.container}>
{/* Header */}
<Flexbox align={'center'} className={styles.header} gap={8} horizontal>
<Flexbox flex={1}>
<div className={styles.title}>{title || 'Preference Memory'}</div>
</Flexbox>
{type && <Tag>{type}</Tag>}
{loading && <NeuralNetworkLoading size={20} />}
</Flexbox>
{/* When has context content: collapse summary */}
{hasContextContent || hasAppContext ? (
<>
{/* Collapsed Summary */}
{(summary || tags?.length) && (
<Accordion gap={0}>
<AccordionItem
itemKey="summary"
paddingBlock={8}
paddingInline={8}
styles={{
base: { marginBlock: 4, marginInline: 4 },
}}
title={
<Text fontSize={12} type={'secondary'} weight={500}>
Summary
</Text>
}
>
<Flexbox gap={8} paddingBlock={'8px 12px'} paddingInline={8}>
{summary && <div className={styles.summary}>{summary}</div>}
{details && <div className={styles.detail}>{details}</div>}
{tags && tags.length > 0 && (
<Flexbox className={styles.tags} gap={8} horizontal wrap={'wrap'}>
{tags.map((tag, index) => (
<Tag key={index}>{tag}</Tag>
))}
</Flexbox>
)}
</Flexbox>
</AccordionItem>
</Accordion>
)}
{/* Origin Context Steps */}
{hasContextContent && (
<Accordion className={styles.section} defaultExpandedKeys={['context']} gap={0}>
<AccordionItem
itemKey="context"
paddingBlock={8}
paddingInline={8}
title={
<Text fontSize={12} type={'secondary'} weight={500}>
Origin Context
</Text>
}
>
<Flexbox paddingBlock={'8px 12px'} paddingInline={8}>
<Steps
className={styles.stepsContainer}
current={null as any}
direction="vertical"
items={contextItems.map((item) => ({
description: <div className={styles.stepContent}>{item.content}</div>,
icon: (
<Avatar
avatar={item.avatar}
shadow
shape={'square'}
size={20}
style={{
border: `1px solid ${cssVar.colorBorderSecondary}`,
fontSize: 11,
}}
/>
),
title: (
<Text as={'span'} fontSize={12} type={'secondary'} weight={500}>
{item.title}
</Text>
),
}))}
size="small"
/>
</Flexbox>
</AccordionItem>
</Accordion>
)}
{/* App Context */}
{hasAppContext && (
<Accordion className={styles.section} gap={0}>
<AccordionItem
itemKey="appContext"
paddingBlock={8}
paddingInline={8}
title={
<Text fontSize={12} type={'secondary'} weight={500}>
App Context
</Text>
}
>
<Flexbox paddingBlock={'8px 12px'} paddingInline={8}>
<Steps
className={styles.stepsContainer}
current={null as any}
direction="vertical"
items={appContextItems.map((item) => ({
description: <div className={styles.stepContent}>{item.content}</div>,
icon: (
<Avatar
avatar={item.avatar}
shadow
shape={'square'}
size={20}
style={{
border: `1px solid ${cssVar.colorBorderSecondary}`,
fontSize: 11,
}}
/>
),
title: (
<Text as={'span'} fontSize={12} type={'secondary'} weight={500}>
{item.title}
</Text>
),
}))}
size="small"
/>
</Flexbox>
</AccordionItem>
</Accordion>
)}
{/* Conclusion Directive */}
{conclusionDirectives && (
<Flexbox
className={styles.section}
gap={8}
style={{ paddingBlock: 16, paddingInline: 12 }}
>
<Text fontSize={12} weight={500}>
<span className={highlightTextStyles.primary}>Directive</span>
</Text>
<div className={styles.directive}>{conclusionDirectives}</div>
</Flexbox>
)}
{/* Suggestions */}
{hasSuggestions && (
<Flexbox
className={styles.section}
gap={8}
style={{ paddingBlock: 16, paddingInline: 12 }}
>
<Text fontSize={12} weight={500}>
<span className={highlightTextStyles.info}>Suggestions</span>
</Text>
<Flexbox gap={8}>
{suggestions.map((suggestion, index) => (
<div className={styles.suggestion} key={index}>
{suggestion}
</div>
))}
</Flexbox>
</Flexbox>
)}
</>
) : (
/* When no context content: show summary and details */
<Flexbox className={styles.content} gap={8}>
{!summary && loading ? (
<BubblesLoading />
) : (
<>
{summary && <div className={styles.summary}>{summary}</div>}
{details && <StreamingMarkdown>{details}</StreamingMarkdown>}
{conclusionDirectives && (
<Flexbox gap={4} paddingBlock={8}>
<Text fontSize={12} weight={500}>
<span className={highlightTextStyles.primary}>Directive</span>
</Text>
<div className={styles.directive}>{conclusionDirectives}</div>
</Flexbox>
)}
{hasSuggestions && (
<Flexbox gap={8} paddingBlock={8}>
<Text fontSize={12} weight={500}>
<span className={highlightTextStyles.info}>Suggestions</span>
</Text>
<Flexbox gap={8}>
{suggestions.map((suggestion, index) => (
<div className={styles.suggestion} key={index}>
{suggestion}
</div>
))}
</Flexbox>
</Flexbox>
)}
{tags && tags.length > 0 && (
<Flexbox className={styles.tags} gap={8} horizontal wrap={'wrap'}>
{tags.map((tag, index) => (
<Tag key={index}>{tag}</Tag>
))}
</Flexbox>
)}
</>
)}
</Flexbox>
)}
</Flexbox>
);
});
PreferenceMemoryCard.displayName = 'PreferenceMemoryCard';
export default PreferenceMemoryCard;

View File

@@ -1 +1,2 @@
export { ExperienceMemoryCard, type ExperienceMemoryCardProps } from './ExperienceMemoryCard';
export { PreferenceMemoryCard, type PreferenceMemoryCardProps } from './PreferenceMemoryCard';

View File

@@ -74,7 +74,7 @@ class MemoryExecutor extends BaseExecutor<typeof MemoryApiName> {
}
return {
content: `🧠 Context memory saved: "${params.title}"`,
content: `Context memory "${params.title}" saved with memoryId: "${result.memoryId}" and contextId: "${result.contextId}"`,
state: { contextId: result.contextId, memoryId: result.memoryId },
success: true,
};
@@ -151,7 +151,7 @@ class MemoryExecutor extends BaseExecutor<typeof MemoryApiName> {
}
return {
content: `🧠 Identity memory saved: "${params.title}"`,
content: `Identity memory "${params.title}" saved with memoryId: "${result.memoryId}" and identityId: "${result.identityId}"`,
state: { identityId: result.identityId, memoryId: result.memoryId },
success: true,
};
@@ -189,7 +189,7 @@ class MemoryExecutor extends BaseExecutor<typeof MemoryApiName> {
}
return {
content: `🧠 Preference memory saved: "${params.title}"`,
content: `Preference memory "${params.title}" saved with memoryId: "${result.memoryId}" and preferenceId: "${result.preferenceId}"`,
state: { memoryId: result.memoryId, preferenceId: result.preferenceId },
success: true,
};

View File

@@ -54,4 +54,5 @@ Conversation language: {{language}}
- When memory activity is warranted, explain which layers are affected, cite any matching memories you found, and justify why extraction or updates are needed.
- When nothing qualifies, explicitly state that no memory action is required after reviewing the context.
- Keep your reasoning concise, structured, and aligned with the conversation language.
- **Never expose internal memory IDs** (e.g., mem_xxx, id: xxx) to users in your responses. Refer to memories by their descriptive titles or summaries instead.
</response_expectations>`;

View File

@@ -86,7 +86,7 @@ export interface BaseCreateUserMemoryParams {
details: string;
detailsEmbedding?: number[];
memoryCategory: string;
memoryLayer: LayersEnum;
memoryLayer?: LayersEnum;
memoryType: TypesEnum;
summary: string;
summaryEmbedding?: number[];

View File

@@ -39,7 +39,6 @@ describe('ContextExtractor', () => {
const memoryItem = memories.items;
expect(memories.type).toBe('array');
expect(memoryItem.properties.memoryLayer.const).toBe('context');
// memoryCategory is a plain string in schema, not an enum
expect(memoryItem.properties.memoryCategory.type).toBe('string');
expect(memoryItem.properties.memoryType.enum).toEqual(memoryTypeValues);

View File

@@ -39,7 +39,6 @@ describe('ExperienceExtractor', () => {
const memoryItem = memories.items;
expect(memories.type).toBe('array');
expect(memoryItem.properties.memoryLayer.const).toBe('experience');
// memoryCategory is a plain string in schema, not an enum
expect(memoryItem.properties.memoryCategory.type).toBe('string');
expect(memoryItem.properties.memoryType.enum).toEqual(memoryTypeValues);

View File

@@ -46,7 +46,6 @@ describe('IdentityExtractor', () => {
{
details: null,
memoryCategory: 'personal',
memoryLayer: 'identity',
memoryType: 'fact',
summary: 'New identity summary',
tags: ['tag'],

View File

@@ -39,7 +39,6 @@ describe('PreferenceExtractor', () => {
const memoryItem = memories.items;
expect(memories.type).toBe('array');
expect(memoryItem.properties.memoryLayer.const).toBe('preference');
// memoryCategory is a plain string in schema, not an enum
expect(memoryItem.properties.memoryCategory.type).toBe('string');
expect(memoryItem.properties.memoryType.enum).toEqual(memoryTypeValues);

View File

@@ -1,6 +1,5 @@
import {
ContextStatusEnum,
LayersEnum,
UserMemoryContextObjectType,
UserMemoryContextSubjectType,
} from '@lobechat/types';
@@ -79,7 +78,6 @@ export const WithContextSchema = z.object({
export const ContextMemoryItemSchema = z.object({
details: z.string().describe('Optional detailed information'),
memoryCategory: z.string().describe('Memory category'),
memoryLayer: z.literal(LayersEnum.Context).describe('Memory layer'),
memoryType: MemoryTypeSchema.describe('Memory type'),
summary: z.string().describe('Concise overview of this specific memory'),
tags: z.array(z.string()).describe('User defined tags that summarize the context facets'),

View File

@@ -1,4 +1,3 @@
import { LayersEnum } from '@lobechat/types';
import { z } from 'zod';
import { MemoryTypeSchema } from './common';
@@ -37,7 +36,6 @@ export const WithExperienceSchema = z.object({
export const ExperienceMemoryItemSchema = z.object({
details: z.string().describe('Optional detailed information'),
memoryCategory: z.string().describe('Memory category'),
memoryLayer: z.literal(LayersEnum.Experience).describe('Memory layer'),
memoryType: MemoryTypeSchema.describe('Memory type'),
summary: z.string().describe('Concise overview of this specific memory'),
tags: z.array(z.string()).describe('Model generated tags that summarize the experience facets'),

View File

@@ -1,4 +1,4 @@
import { LayersEnum, MergeStrategyEnum } from '@lobechat/types';
import { MergeStrategyEnum } from '@lobechat/types';
import { z } from 'zod';
import { MemoryTypeSchema } from './common';
@@ -43,7 +43,6 @@ export const AddIdentityActionSchema = z
.object({
details: z.union([z.string(), z.null()]).describe('Optional detailed information'),
memoryCategory: z.string().describe('Memory category'),
memoryLayer: z.literal(LayersEnum.Identity).describe('Memory layer'),
memoryType: MemoryTypeSchema.describe('Memory type'),
summary: z.string().describe('Concise overview of this specific memory'),
tags: z.array(z.string()).describe('Model generated tags that summarize the identity facets'),

View File

@@ -1,4 +1,3 @@
import { LayersEnum } from '@lobechat/types';
import { z } from 'zod';
import { MemoryTypeSchema } from './common';
@@ -66,7 +65,6 @@ export const WithPreferenceSchema = z.object({
export const PreferenceMemoryItemSchema = z.object({
details: z.string().describe('Optional detailed information'),
memoryCategory: z.string().describe('Memory category'),
memoryLayer: z.literal(LayersEnum.Preference).describe('Memory layer'),
memoryType: MemoryTypeSchema.describe('Memory type'),
summary: z.string().describe('Concise overview of this specific memory'),
tags: z.array(z.string()).describe('Model generated tags that summarize the preference facets'),

View File

@@ -774,7 +774,7 @@ export const userMemoriesRouter = router({
details: input.details || '',
detailsEmbedding,
memoryCategory: input.memoryCategory,
memoryLayer: input.memoryLayer,
memoryLayer: LayersEnum.Context,
memoryType: input.memoryType,
summary: input.summary,
summaryEmbedding,
@@ -830,7 +830,7 @@ export const userMemoriesRouter = router({
type: input.memoryType,
},
memoryCategory: input.memoryCategory,
memoryLayer: input.memoryLayer,
memoryLayer: LayersEnum.Experience,
memoryType: input.memoryType,
summary: input.summary,
summaryEmbedding,
@@ -885,7 +885,7 @@ export const userMemoriesRouter = router({
details: input.details,
detailsVector1024: detailsEmbedding ?? null,
memoryCategory: input.memoryCategory,
memoryLayer: input.memoryLayer,
memoryLayer: LayersEnum.Identity,
memoryType: input.memoryType,
metadata: Object.keys(identityMetadata).length > 0 ? identityMetadata : undefined,
summary: input.summary,
@@ -949,7 +949,7 @@ export const userMemoriesRouter = router({
details: input.details || '',
detailsEmbedding,
memoryCategory: input.memoryCategory,
memoryLayer: input.memoryLayer,
memoryLayer: LayersEnum.Preference,
memoryType: input.memoryType,
preference: {
conclusionDirectives: input.withPreference.conclusionDirectives || '',

View File

@@ -6,6 +6,7 @@ import {
RemoveIdentityActionSchema,
UpdateIdentityActionSchema,
} from '@lobechat/memory-user-memory';
import { LayersEnum } from '@lobechat/types';
import {
type IdentityEntryBasePayload,
@@ -51,7 +52,7 @@ export const toolsRouter = router({
details: input.details || '',
detailsEmbedding,
memoryCategory: input.memoryCategory,
memoryLayer: input.memoryLayer,
memoryLayer: LayersEnum.Context,
memoryType: input.memoryType,
summary: input.summary,
summaryEmbedding,
@@ -107,7 +108,7 @@ export const toolsRouter = router({
type: input.memoryType,
},
memoryCategory: input.memoryCategory,
memoryLayer: input.memoryLayer,
memoryLayer: LayersEnum.Experience,
memoryType: input.memoryType,
summary: input.summary,
summaryEmbedding,
@@ -162,7 +163,7 @@ export const toolsRouter = router({
details: input.details,
detailsVector1024: detailsEmbedding ?? null,
memoryCategory: input.memoryCategory,
memoryLayer: input.memoryLayer,
memoryLayer: LayersEnum.Identity,
memoryType: input.memoryType,
metadata: Object.keys(identityMetadata).length > 0 ? identityMetadata : undefined,
summary: input.summary,
@@ -226,7 +227,7 @@ export const toolsRouter = router({
details: input.details || '',
detailsEmbedding,
memoryCategory: input.memoryCategory,
memoryLayer: input.memoryLayer,
memoryLayer: LayersEnum.Preference,
memoryType: input.memoryType,
preference: {
conclusionDirectives: input.withPreference.conclusionDirectives || '',

View File

@@ -618,7 +618,7 @@ export class MemoryExtractionExecutor {
details: item.details ?? '',
detailsEmbedding: detailsVector ?? undefined,
memoryCategory: item.memoryCategory ?? null,
memoryLayer: (item.memoryLayer as LayersEnum) ?? LayersEnum.Context,
memoryLayer: LayersEnum.Context,
memoryType: (item.memoryType as TypesEnum) ?? TypesEnum.Context,
summary: item.summary ?? '',
summaryEmbedding: summaryVector ?? undefined,
@@ -684,7 +684,7 @@ export class MemoryExtractionExecutor {
type: item.withExperience?.type ?? null,
},
memoryCategory: item.memoryCategory ?? null,
memoryLayer: (item.memoryLayer as LayersEnum) ?? LayersEnum.Experience,
memoryLayer: LayersEnum.Experience,
memoryType: (item.memoryType as TypesEnum) ?? TypesEnum.Activity,
summary: item.summary ?? '',
summaryEmbedding: summaryVector ?? undefined,
@@ -728,7 +728,7 @@ export class MemoryExtractionExecutor {
details: item.details ?? '',
detailsEmbedding: detailsVector ?? undefined,
memoryCategory: item.memoryCategory ?? null,
memoryLayer: (item.memoryLayer as LayersEnum) ?? LayersEnum.Preference,
memoryLayer: LayersEnum.Preference,
memoryType: (item.memoryType as TypesEnum) ?? TypesEnum.Preference,
preference: {
capturedAt: job.sourceUpdatedAt,