Compare commits

..

2 Commits

Author SHA1 Message Date
Innei 577c8e2869 🗃️ feat(database): add page shares schema 2026-05-20 16:00:00 +08:00
lobehubbot 694a25822f 🔖 chore(release): release version v2.2.0 [skip ci] 2026-05-18 04:43:53 +00:00
24 changed files with 16042 additions and 895 deletions
+33
View File
@@ -2,6 +2,39 @@
# Changelog
### [Version 2.2.0](https://github.com/lobehub/lobe-chat/compare/v2.1.59-canary.27...v2.2.0)
<sup>Released on **2026-05-18**</sup>
#### 💄 Styles
- **pricing**: restore DeepSeek models to official pricing.
#### 🐛 Bug Fixes
- **conversation**: animate only the last markdown block + drop clearMessages hotkey.
<br/>
<details>
<summary><kbd>Improvements and Fixes</kbd></summary>
#### Styles
- **pricing**: restore DeepSeek models to official pricing, closes [#14911](https://github.com/lobehub/lobe-chat/issues/14911) ([e566688](https://github.com/lobehub/lobe-chat/commit/e566688))
#### What's fixed
- **conversation**: animate only the last markdown block + drop clearMessages hotkey, closes [#14906](https://github.com/lobehub/lobe-chat/issues/14906) ([469a8e6](https://github.com/lobehub/lobe-chat/commit/469a8e6))
</details>
<div align="right">
[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
</div>
## [Version 2.1.58](https://github.com/lobehub/lobe-chat/compare/v2.1.57...v2.1.58)
<sup>Released on **2026-05-13**</sup>
+5
View File
@@ -1,4 +1,9 @@
[
{
"children": {},
"date": "2026-05-18",
"version": "2.2.0"
},
{
"children": {
"features": [
+20
View File
@@ -1212,6 +1212,22 @@ table oidc_sessions {
}
}
table page_shares {
id text [pk, not null]
document_id text [not null]
user_id text [not null]
visibility text [not null, default: 'private']
page_view_count integer [not null, default: 0]
accessed_at "timestamp with time zone" [not null, default: `now()`]
created_at "timestamp with time zone" [not null, default: `now()`]
updated_at "timestamp with time zone" [not null, default: `now()`]
indexes {
document_id [name: 'page_shares_document_id_unique', unique]
user_id [name: 'page_shares_user_id_idx']
}
}
table chunks {
id uuid [pk, not null, default: `gen_random_uuid()`]
text text
@@ -2167,6 +2183,10 @@ ref: threads.source_message_id - messages.id
ref: messages.message_group_id > message_groups.id
ref: page_shares.document_id > documents.id
ref: page_shares.user_id - users.id
ref: sessions.group_id - session_groups.id
ref: topic_documents.document_id > documents.id
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@lobehub/lobehub",
"version": "2.1.58",
"version": "2.2.0",
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
"keywords": [
"framework",
@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS "page_shares" (
"id" text PRIMARY KEY NOT NULL,
"document_id" text NOT NULL,
"user_id" text NOT NULL,
"visibility" text DEFAULT 'private' NOT NULL,
"page_view_count" integer DEFAULT 0 NOT NULL,
"accessed_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "page_shares" DROP CONSTRAINT IF EXISTS "page_shares_document_id_documents_id_fk";
--> statement-breakpoint
ALTER TABLE "page_shares" ADD CONSTRAINT "page_shares_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
ALTER TABLE "page_shares" DROP CONSTRAINT IF EXISTS "page_shares_user_id_users_id_fk";
--> statement-breakpoint
ALTER TABLE "page_shares" ADD CONSTRAINT "page_shares_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
CREATE UNIQUE INDEX IF NOT EXISTS "page_shares_document_id_unique" ON "page_shares" USING btree ("document_id");
--> statement-breakpoint
CREATE INDEX IF NOT EXISTS "page_shares_user_id_idx" ON "page_shares" USING btree ("user_id");
File diff suppressed because it is too large Load Diff
@@ -721,6 +721,13 @@
"when": 1778602304603,
"tag": "0102_add_agent_operations_table",
"breakpoints": true
},
{
"idx": 103,
"version": "7",
"when": 1779263513902,
"tag": "0103_add_page_shares",
"breakpoints": true
}
],
"version": "6"
+1
View File
@@ -19,6 +19,7 @@ export * from './messengerInstallation';
export * from './nextauth';
export * from './notification';
export * from './oidc';
export * from './pageShare';
export * from './rag';
export * from './ragEvals';
export * from './rbac';
@@ -0,0 +1,39 @@
import { index, integer, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { createNanoId } from '../utils/idGenerator';
import { timestamps } from './_helpers';
import { documents } from './file';
import { users } from './user';
/**
* Page sharing table - Manages public sharing links for documents/pages.
*/
export const pageShares = pgTable(
'page_shares',
{
id: text('id')
.$defaultFn(() => createNanoId(8)())
.primaryKey(),
documentId: text('document_id')
.notNull()
.references(() => documents.id, { onDelete: 'cascade' }),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
visibility: text('visibility').default('private').notNull(), // 'private' | 'link'
pageViewCount: integer('page_view_count').default(0).notNull(),
...timestamps,
},
(t) => [
uniqueIndex('page_shares_document_id_unique').on(t.documentId),
index('page_shares_user_id_idx').on(t.userId),
],
);
export type NewPageShare = typeof pageShares.$inferInsert;
export type PageShareItem = typeof pageShares.$inferSelect;
@@ -16,6 +16,7 @@ import { documentHistories } from './documentHistory';
import { documents, files, knowledgeBases } from './file';
import { generationBatches, generations, generationTopics } from './generation';
import { messageGroups, messages, messagesFiles, messageTranslates } from './message';
import { pageShares } from './pageShare';
import { chunks, documentChunks, unstructuredChunks } from './rag';
import { sessionGroups, sessions } from './session';
import { threads, topicDocuments, topics } from './topic';
@@ -246,10 +247,22 @@ export const documentsRelations = relations(documents, ({ one, many }) => ({
relationName: 'fileDocuments',
}),
topics: many(topicDocuments),
shares: many(pageShares),
chunks: many(documentChunks),
histories: many(documentHistories),
}));
export const pageSharesRelations = relations(pageShares, ({ one }) => ({
document: one(documents, {
fields: [pageShares.documentId],
references: [documents.id],
}),
user: one(users, {
fields: [pageShares.userId],
references: [users.id],
}),
}));
export const documentHistoriesRelations = relations(documentHistories, ({ one }) => ({
document: one(documents, {
fields: [documentHistories.documentId],
@@ -1,58 +0,0 @@
/**
* @vitest-environment happy-dom
*/
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAgentStore } from '@/store/agent';
import { initialState as initialAgentState } from '@/store/agent/initialState';
import { useChatStore } from '@/store/chat';
import { initialState as initialChatState } from '@/store/chat/initialState';
import { syncTaskAgentContext } from './TaskAgentContextSync';
vi.hoisted(() => {
const storage = {
clear: vi.fn(),
getItem: vi.fn(() => null),
removeItem: vi.fn(),
setItem: vi.fn(),
};
Object.defineProperty(globalThis, 'localStorage', {
configurable: true,
value: storage,
});
});
describe('syncTaskAgentContext', () => {
beforeEach(() => {
useAgentStore.setState(initialAgentState, false);
useChatStore.setState(
{
...initialChatState,
activeAgentId: 'agent-a',
activeGroupId: 'group-a',
activeThreadId: 'thread-a',
activeTopicId: 'topic-a',
},
false,
);
});
it('syncs task assignee agent into agent and chat contexts', () => {
syncTaskAgentContext('agent-b');
expect(useAgentStore.getState().activeAgentId).toBe('agent-b');
expect(useChatStore.getState().activeAgentId).toBe('agent-b');
expect(useChatStore.getState().activeGroupId).toBeUndefined();
expect(useChatStore.getState().activeThreadId).toBeUndefined();
expect(useChatStore.getState().activeTopicId).toBeUndefined();
});
it('clears stale agent context for an unassigned task', () => {
syncTaskAgentContext(null);
expect(useAgentStore.getState().activeAgentId).toBeUndefined();
expect(useChatStore.getState().activeAgentId).toBeUndefined();
});
});
@@ -1,44 +0,0 @@
'use client';
import { memo, useEffect } from 'react';
import { useAgentStore } from '@/store/agent';
import { useChatStore } from '@/store/chat';
import { useTaskStore } from '@/store/task';
import { taskDetailSelectors } from '@/store/task/selectors';
import { useUserStore } from '@/store/user';
import { authSelectors } from '@/store/user/selectors';
export const syncTaskAgentContext = (agentId?: string | null) => {
const nextAgentId = agentId ?? undefined;
useAgentStore.getState().setActiveAgentId(nextAgentId);
useChatStore.setState(
{
activeAgentId: nextAgentId,
activeGroupId: undefined,
activeThreadId: undefined,
activeTopicId: undefined,
},
false,
'TaskAgentContextSync/syncAgentId',
);
};
const TaskAgentContextSync = memo(() => {
const isLogin = useUserStore(authSelectors.isLogin);
const agentId = useTaskStore(taskDetailSelectors.activeTaskAgentId);
const useFetchAgentConfig = useAgentStore((s) => s.useFetchAgentConfig);
useEffect(() => {
syncTaskAgentContext(agentId);
}, [agentId]);
useFetchAgentConfig(isLogin, agentId ?? '');
return null;
});
TaskAgentContextSync.displayName = 'TaskAgentContextSync';
export default TaskAgentContextSync;
@@ -14,7 +14,6 @@ import { taskDetailSelectors } from '@/store/task/selectors';
import Breadcrumb from '../shared/Breadcrumb';
import TaskActivities from './TaskActivities';
import TaskAgentContextSync from './TaskAgentContextSync';
import TaskArtifacts from './TaskArtifacts';
import TaskDetailAssignee from './TaskDetailAssignee';
import TaskDetailHeaderActions from './TaskDetailHeaderActions';
@@ -50,7 +49,6 @@ const TaskDetailPage = memo<TaskDetailPageProps>(({ taskId }) => {
return (
<Flexbox flex={1} height={'100%'} style={{ minHeight: 0 }}>
<TaskAgentContextSync />
<NavHeader
left={
<>
@@ -1,4 +1,3 @@
import { LayersEnum } from '@lobechat/types';
import { fireEvent, render, screen } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
@@ -263,37 +262,4 @@ describe('AgentSignalReceiptList', () => {
expect(mocks.navigate).toHaveBeenCalledWith('/memory');
});
it('opens memory receipts on their layer detail route when target metadata is available', () => {
render(
<AgentSignalReceiptList
receipts={[
{
agentId: 'agent-1',
createdAt: 1,
detail: 'Saved this for future replies',
id: 'receipt-1',
kind: 'memory',
sourceId: 'source-1',
sourceType: 'client.gateway.runtime_end',
status: 'applied',
target: {
id: 'preference-1',
memoryId: 'memory-1',
memoryLayer: LayersEnum.Preference,
title: 'Decision-first PR review preference',
type: 'memory',
},
title: 'Memory saved',
topicId: 'topic-1',
userId: 'user-1',
},
]}
/>,
);
fireEvent.click(screen.getByText('Open'));
expect(mocks.navigate).toHaveBeenCalledWith('/memory/preferences?preferenceId=preference-1');
});
});
@@ -1,6 +1,5 @@
'use client';
import { LayersEnum } from '@lobechat/types';
import { Icon } from '@lobehub/ui';
import { createStaticStyles } from 'antd-style';
import type { LucideIcon } from 'lucide-react';
@@ -15,13 +14,6 @@ import { useChatStore } from '@/store/chat';
import type { AgentSignalReceiptView } from '../hooks/useAgentSignalReceipts';
const PAGE_ROUTE_PATTERN = /^\/agent\/([^/]+)\/([^/]+)\/page(?:\/[^/?#]+)?/;
const MEMORY_ROUTE_BY_LAYER = {
[LayersEnum.Activity]: { idParam: 'activityId', path: '/memory/activities' },
[LayersEnum.Context]: { idParam: 'contextId', path: '/memory/contexts' },
[LayersEnum.Experience]: { idParam: 'experienceId', path: '/memory/experiences' },
[LayersEnum.Identity]: { idParam: 'identityId', path: '/memory/identities' },
[LayersEnum.Preference]: { idParam: 'preferenceId', path: '/memory/preferences' },
} satisfies Record<LayersEnum, { idParam: string; path: string }>;
const styles = createStaticStyles(({ css, cssVar }) => ({
agentSignalDescription: css`
@@ -66,17 +58,6 @@ interface AgentSignalReceiptItemProps {
receipt: AgentSignalReceiptView;
}
const getMemoryRoute = (target?: AgentSignalReceiptView['target']) => {
if (target?.type !== 'memory') return;
if (!target.memoryLayer) return '/memory';
const route = MEMORY_ROUTE_BY_LAYER[target.memoryLayer];
if (!route) return '/memory';
return target.id ? `${route.path}?${route.idParam}=${encodeURIComponent(target.id)}` : route.path;
};
const AgentSignalReceiptItem = memo<AgentSignalReceiptItemProps>(({ receipt }) => {
const { t } = useTranslation(['chat', 'common']);
const navigate = useStableNavigate();
@@ -103,11 +84,10 @@ const AgentSignalReceiptItem = memo<AgentSignalReceiptItemProps>(({ receipt }) =
);
const target = receipt.target;
const documentId = target?.type === 'skill' ? (target.documentId ?? target.id) : undefined;
const memoryRoute = getMemoryRoute(target);
const canOpen = Boolean(memoryRoute) || Boolean(documentId);
const canOpen = target?.type === 'memory' || Boolean(documentId);
const handleOpen = useCallback(() => {
if (memoryRoute) {
navigate(memoryRoute);
if (target?.type === 'memory') {
navigate('/memory');
return;
}
@@ -123,7 +103,7 @@ const AgentSignalReceiptItem = memo<AgentSignalReceiptItemProps>(({ receipt }) =
}
openDocument(documentId);
}, [documentId, memoryRoute, navigate, openDocument, target]);
}, [documentId, navigate, openDocument, target]);
return (
<PortalResourceCard
@@ -132,6 +112,7 @@ const AgentSignalReceiptItem = memo<AgentSignalReceiptItemProps>(({ receipt }) =
openLabel={canOpen ? t('common:cmdk.toOpen', 'Open') : undefined}
title={title}
tooltip={tooltip}
// TODO: Replace memory fallback with category/id-aware routes when Agent Signal receipts expose them.
onOpen={canOpen ? handleOpen : undefined}
/>
);
@@ -14,10 +14,6 @@ const switchTopicMock = vi.hoisted(() => vi.fn());
const toggleCommandMenuMock = vi.hoisted(() => vi.fn());
const useParamsMock = vi.hoisted(() => vi.fn());
const usePathnameMock = vi.hoisted(() => vi.fn());
const agentStoreMock = vi.hoisted(() => ({
activeAgentId: undefined as string | undefined,
heterogeneousProviderType: undefined as string | undefined,
}));
vi.mock('@lobehub/ui', () => ({
Flexbox: ({ children, ...props }: { children?: ReactNode; [key: string]: unknown }) => (
@@ -88,13 +84,12 @@ vi.mock('@/libs/swr', () => ({
}));
vi.mock('@/store/agent', () => ({
useAgentStore: (selector: (state: typeof agentStoreMock) => unknown) => selector(agentStoreMock),
useAgentStore: (selector: (state: unknown) => unknown) => selector({}),
}));
vi.mock('@/store/agent/selectors', () => ({
agentSelectors: {
currentAgentHeterogeneousProviderType: (state: typeof agentStoreMock) =>
state.heterogeneousProviderType,
currentAgentHeterogeneousProviderType: () => undefined,
},
}));
@@ -133,8 +128,6 @@ describe('Agent sidebar header nav', () => {
toggleCommandMenuMock.mockReset();
useParamsMock.mockReset();
usePathnameMock.mockReset();
agentStoreMock.activeAgentId = undefined;
agentStoreMock.heterogeneousProviderType = undefined;
useParamsMock.mockReturnValue({ aid: 'agt_eH4zL98zBx5u', topicId: 'tpc_2FCHvjS7d4CA' });
});
@@ -162,42 +155,4 @@ describe('Agent sidebar header nav', () => {
expect(pushMock).toHaveBeenCalledWith('/agent/agt_eH4zL98zBx5u');
expect(mutateMock).toHaveBeenCalledTimes(1);
});
it('falls back to the active agent when retained on a task route without an agent param', () => {
agentStoreMock.activeAgentId = 'agt_from_task';
useParamsMock.mockReturnValue({});
usePathnameMock.mockReturnValue('/task/T-1');
render(<Nav />);
fireEvent.click(screen.getByRole('button', { name: 'actions.addNewTopic' }));
fireEvent.click(screen.getByRole('button', { name: 'tab.profile' }));
fireEvent.click(screen.getByRole('button', { name: 'tab.integration' }));
expect(pushMock).toHaveBeenNthCalledWith(1, '/agent/agt_from_task');
expect(pushMock).toHaveBeenNthCalledWith(2, '/agent/agt_from_task/profile');
expect(pushMock).toHaveBeenNthCalledWith(3, '/agent/agt_from_task/channel');
expect(mutateMock).toHaveBeenCalledTimes(1);
expect(switchTopicMock).toHaveBeenCalledTimes(2);
expect(switchTopicMock).toHaveBeenNthCalledWith(1, null, { skipRefreshMessage: true });
expect(switchTopicMock).toHaveBeenNthCalledWith(2, null, { skipRefreshMessage: true });
});
it('keeps channel visible for Claude Code heterogeneous agents', () => {
agentStoreMock.heterogeneousProviderType = 'claude-code';
usePathnameMock.mockReturnValue('/agent/agt_eH4zL98zBx5u');
render(<Nav />);
expect(screen.getByRole('button', { name: 'tab.integration' })).toBeInTheDocument();
});
it('hides channel for non-Claude Code heterogeneous agents', () => {
agentStoreMock.heterogeneousProviderType = 'codex';
usePathnameMock.mockReturnValue('/agent/agt_eH4zL98zBx5u');
render(<Nav />);
expect(screen.queryByRole('button', { name: 'tab.integration' })).not.toBeInTheDocument();
});
});
@@ -22,8 +22,7 @@ const Nav = memo(() => {
const { t } = useTranslation('chat');
const { t: tTopic } = useTranslation('topic');
const params = useParams();
const activeAgentId = useAgentStore((s) => s.activeAgentId);
const agentId = params.aid ?? activeAgentId;
const agentId = params.aid;
const pathname = usePathname();
const isProfileActive = pathname.includes('/profile');
const isChannelActive = pathname.includes('/channel');
@@ -33,7 +32,7 @@ const Nav = memo(() => {
const heterogeneousProviderType = useAgentStore(
agentSelectors.currentAgentHeterogeneousProviderType,
);
const hideProfile = !isAgentEditable || !agentId;
const hideProfile = !isAgentEditable;
// Claude Code agents can use message channels; other hetero providers (e.g. codex) still hide it.
const hideChannel =
hideProfile || (!!heterogeneousProviderType && heterogeneousProviderType !== 'claude-code');
@@ -41,10 +41,8 @@ const List = memo<SessionListProps>(
// Empty custom/default groups always show the Create button so the user can populate them.
// Non-empty lists only show it at the bottom of the default group; custom groups rely on
// the group header dropdown for further additions. When the default list overflows and we
// already render the "More" entry, hide the Create button to keep the footer compact —
// creation is still reachable from the group header dropdown.
const showCreateButton = isEmpty ? groupId !== undefined : isDefaultList && !hasMore;
// the group header dropdown for further additions.
const showCreateButton = isEmpty ? groupId !== undefined : isDefaultList;
if (isEmpty) {
return showCreateButton ? (
@@ -1,9 +1,8 @@
// @vitest-environment node
import { LayersEnum } from '@lobechat/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { RuntimeProcessorContext } from '../../../../runtime/context';
import { defineUserMemoryActionHandler, resolveMemoryActionTargetFromState } from '../userMemory';
import { defineUserMemoryActionHandler } from '../userMemory';
const memoryActionRunner = vi.fn();
@@ -74,64 +73,6 @@ describe('defineUserMemoryActionHandler', () => {
expect(context.runtimeState.touchGuardState).toHaveBeenCalledTimes(1);
});
it('returns the applied memory target from the memory agent runner', async () => {
memoryActionRunner.mockResolvedValue({
detail: 'Arvin Xu 希望助手在输出时每个段落/模块都写得更长、更展开。',
status: 'applied',
target: {
id: 'mem_td3XirTeX4f7',
memoryId: 'mem_8gISOK6BhxGP',
memoryLayer: LayersEnum.Preference,
summary: 'Arvin Xu 希望助手在输出时每个段落/模块都写得更长、更展开。',
title: '偏好更详细、更长的回答段落',
type: 'memory',
},
});
const handler = defineUserMemoryActionHandler({
db: {} as never,
memoryActionRunner,
userId: 'user_1',
});
const result = await handler.handle(
{
actionId: 'act_memory_target',
actionType: 'action.user-memory.handle',
chain: { chainId: 'chain_1', rootSourceId: 'source_1' },
payload: {
agentId: 'agent_1',
idempotencyKey: 'source_1:memory:msg_1',
message:
'<speaker id="833816919" username="nivra2000" nickname="Aa T" />\n每一块都有点太短了?能否长一点呢',
topicId: 'topic_1',
},
signal: {
signalId: 'sig_1',
signalType: 'signal.feedback.domain.memory',
},
source: { sourceId: 'source_1', sourceType: 'agent.user.message' },
timestamp: 1,
},
context,
);
expect(result).toMatchObject({
detail: 'Arvin Xu 希望助手在输出时每个段落/模块都写得更长、更展开。',
output: {
target: {
id: 'mem_td3XirTeX4f7',
memoryId: 'mem_8gISOK6BhxGP',
memoryLayer: LayersEnum.Preference,
summary: 'Arvin Xu 希望助手在输出时每个段落/模块都写得更长、更展开。',
title: '偏好更详细、更长的回答段落',
type: 'memory',
},
},
status: 'applied',
});
});
it('skips memory actions when the feedback message is missing', async () => {
const handler = defineUserMemoryActionHandler({
db: {} as never,
@@ -300,301 +241,3 @@ describe('defineUserMemoryActionHandler', () => {
expect(memoryActionRunner).toHaveBeenCalledTimes(2);
});
});
describe('resolveMemoryActionTargetFromState', () => {
it('extracts the successful memory title from runtime tool calls', () => {
const target = resolveMemoryActionTargetFromState({
messages: [
{
id: 'msg_bad',
role: 'assistant',
tool_calls: [
{
function: {
arguments: '{,',
name: 'lobe-user-memory____addPreferenceMemory',
},
id: 'call_bad',
type: 'function',
},
],
},
{
content: 'The tool call arguments string is not valid JSON.',
role: 'tool',
tool_call_id: 'call_bad',
},
{
id: 'msg_good',
role: 'assistant',
tool_calls: [
{
function: {
arguments: JSON.stringify({
details: '用户反馈当前回复的每个模块都太短,希望后续展开得更充分。',
summary: 'Arvin Xu 希望助手在输出时每个段落/模块都写得更长、更展开。',
title: '偏好更详细、更长的回答段落',
withPreference: {
conclusionDirectives: '回答时展开每个段落和模块。',
},
}),
name: 'lobe-user-memory____addPreferenceMemory',
},
id: 'call_good',
type: 'function',
},
],
},
{
content:
'Preference memory "偏好更详细、更长的回答段落" saved with memoryId: "mem_8gISOK6BhxGP" and preferenceId: "mem_td3XirTeX4f7"',
role: 'tool',
tool_call_id: 'call_good',
},
],
} as never);
expect(target).toEqual({
id: 'mem_td3XirTeX4f7',
memoryId: 'mem_8gISOK6BhxGP',
memoryLayer: LayersEnum.Preference,
summary: 'Arvin Xu 希望助手在输出时每个段落/模块都写得更长、更展开。',
title: '偏好更详细、更长的回答段落',
type: 'memory',
});
});
it('resolves update identity targets from nested set arguments', () => {
const target = resolveMemoryActionTargetFromState({
messages: [
{
id: 'msg_update_identity',
role: 'assistant',
tool_calls: [
{
function: {
arguments: JSON.stringify({
id: 'identity-existing',
mergeStrategy: 'replace',
set: {
details: 'The user clarified that they maintain LobeHub Agent Signal code.',
summary: 'The user maintains Agent Signal memory receipt behavior.',
title: 'Maintains Agent Signal receipts',
},
}),
name: 'lobe-user-memory____updateIdentityMemory',
},
id: 'call_update_identity',
type: 'function',
},
],
},
{
content: 'Identity memory updated: identity-existing',
pluginState: { identityId: 'identity-existing' },
role: 'tool',
tool_call_id: 'call_update_identity',
},
],
} as never);
expect(target).toEqual({
id: 'identity-existing',
memoryLayer: LayersEnum.Identity,
summary: 'The user maintains Agent Signal memory receipt behavior.',
title: 'Maintains Agent Signal receipts',
type: 'memory',
});
});
it('resolves receipt targets from persisted tool snapshots', () => {
const target = resolveMemoryActionTargetFromState({
messages: [
{
id: 'msg_persisted_tool',
role: 'assistant',
tools: [
null,
{
apiName: 'addPreferenceMemory',
arguments: {
title: 'Persisted preference title',
withPreference: {
conclusionDirectives: 'Use persisted tool metadata for receipt targets.',
},
},
id: 'call_persisted',
identifier: 'lobe-user-memory',
},
],
},
{
content: 'Preference memory saved',
plugin: { id: 'call_persisted' },
pluginState: { memoryId: 'mem_persisted', preferenceId: 'pref_persisted' },
role: 'tool',
},
],
} as never);
expect(target).toEqual({
id: 'pref_persisted',
memoryId: 'mem_persisted',
memoryLayer: LayersEnum.Preference,
summary: 'Use persisted tool metadata for receipt targets.',
title: 'Persisted preference title',
type: 'memory',
});
});
it('skips confirmed memory tool calls with invalid arguments', () => {
const target = resolveMemoryActionTargetFromState({
messages: [
{
id: 'msg_confirmed',
role: 'assistant',
tool_calls: [
{
function: {
arguments: JSON.stringify({
details: 'Fallback details for a valid confirmed target.',
title: 'Valid confirmed preference',
}),
name: 'lobe-user-memory____addPreferenceMemory',
},
id: 'call_confirmed',
type: 'function',
},
],
},
{
content:
'Preference memory "Valid confirmed preference" saved with memoryId: "mem_confirmed" and preferenceId: "pref_confirmed"',
role: 'tool',
tool_call_id: 'call_confirmed',
},
{
id: 'msg_invalid',
role: 'assistant',
tool_calls: [
{
function: {
arguments: '{,',
name: 'lobe-user-memory____addPreferenceMemory',
},
id: 'call_invalid',
type: 'function',
},
],
},
{
content:
'Preference memory "Invalid latest preference" saved with memoryId: "mem_invalid" and preferenceId: "pref_invalid"',
role: 'tool',
tool_call_id: 'call_invalid',
},
],
} as never);
expect(target).toEqual({
id: 'pref_confirmed',
memoryId: 'mem_confirmed',
memoryLayer: LayersEnum.Preference,
summary: 'Fallback details for a valid confirmed target.',
title: 'Valid confirmed preference',
type: 'memory',
});
});
it('ignores unconfirmed memory write tool calls when resolving receipt targets', () => {
const target = resolveMemoryActionTargetFromState({
messages: [
{
id: 'msg_confirmed',
role: 'assistant',
tool_calls: [
{
function: {
arguments: JSON.stringify({
summary: 'The user prefers longer, more developed answers.',
title: 'Confirmed preference title',
}),
name: 'lobe-user-memory____addPreferenceMemory',
},
id: 'call_confirmed',
type: 'function',
},
],
},
{
content:
'Preference memory "Confirmed preference title" saved with memoryId: "mem_confirmed" and preferenceId: "pref_confirmed"',
role: 'tool',
tool_call_id: 'call_confirmed',
},
{
id: 'msg_unconfirmed',
role: 'assistant',
tool_calls: [
{
function: {
arguments: JSON.stringify({
summary: 'This write was not confirmed by a successful tool result.',
title: 'Unconfirmed preference title',
}),
name: 'lobe-user-memory____addPreferenceMemory',
},
id: 'call_unconfirmed',
type: 'function',
},
],
},
{
content: 'addPreferenceMemory with error detail: database timeout',
role: 'tool',
tool_call_id: 'call_unconfirmed',
},
],
} as never);
expect(target).toEqual({
id: 'pref_confirmed',
memoryId: 'mem_confirmed',
memoryLayer: LayersEnum.Preference,
summary: 'The user prefers longer, more developed answers.',
title: 'Confirmed preference title',
type: 'memory',
});
});
it('does not resolve a target when no memory write has a successful tool result', () => {
const target = resolveMemoryActionTargetFromState({
messages: [
{
id: 'msg_unconfirmed',
role: 'assistant',
tool_calls: [
{
function: {
arguments: JSON.stringify({
summary: 'This write was not confirmed by a successful tool result.',
title: 'Unconfirmed preference title',
}),
name: 'lobe-user-memory____addPreferenceMemory',
},
id: 'call_unconfirmed',
type: 'function',
},
],
},
{
content: 'addPreferenceMemory with error detail: database timeout',
role: 'tool',
tool_call_id: 'call_unconfirmed',
},
],
} as never);
expect(target).toBeUndefined();
});
});
@@ -11,7 +11,7 @@ import {
createAgentSignalMemoryWriterPrompt,
createAgentSignalMemoryWriterSystemRole,
} from '@lobechat/prompts';
import { LayersEnum, RequestTrigger } from '@lobechat/types';
import { RequestTrigger } from '@lobechat/types';
import { nanoid } from '@lobechat/utils';
import { PluginModel } from '@/database/models/plugin';
@@ -44,45 +44,21 @@ import { AGENT_SIGNAL_POLICY_ACTION_TYPES } from '../../types';
const MEMORY_AGENT_MAX_STEPS = 8;
const MEMORY_WRITE_API_NAMES = [
MemoryApiName.addActivityMemory,
MemoryApiName.addContextMemory,
MemoryApiName.addExperienceMemory,
MemoryApiName.addIdentityMemory,
MemoryApiName.addPreferenceMemory,
MemoryApiName.removeIdentityMemory,
MemoryApiName.updateIdentityMemory,
] as const;
const MEMORY_WRITE_TOOL_NAMES = new Set(
MEMORY_WRITE_API_NAMES.map((apiName) => `${MemoryIdentifier}/${apiName}`),
[
MemoryApiName.addActivityMemory,
MemoryApiName.addContextMemory,
MemoryApiName.addExperienceMemory,
MemoryApiName.addIdentityMemory,
MemoryApiName.addPreferenceMemory,
MemoryApiName.removeIdentityMemory,
MemoryApiName.updateIdentityMemory,
].map((apiName) => `${MemoryIdentifier}/${apiName}`),
);
const MEMORY_WRITE_API_NAME_SET = new Set<string>(MEMORY_WRITE_API_NAMES);
const MEMORY_WRITE_TARGET_BY_API_NAME: Record<string, { idKey: string; layer: LayersEnum }> = {
[MemoryApiName.addActivityMemory]: { idKey: 'activityId', layer: LayersEnum.Activity },
[MemoryApiName.addContextMemory]: { idKey: 'contextId', layer: LayersEnum.Context },
[MemoryApiName.addExperienceMemory]: { idKey: 'experienceId', layer: LayersEnum.Experience },
[MemoryApiName.addIdentityMemory]: { idKey: 'identityId', layer: LayersEnum.Identity },
[MemoryApiName.addPreferenceMemory]: { idKey: 'preferenceId', layer: LayersEnum.Preference },
[MemoryApiName.removeIdentityMemory]: { idKey: 'identityId', layer: LayersEnum.Identity },
[MemoryApiName.updateIdentityMemory]: { idKey: 'identityId', layer: LayersEnum.Identity },
};
const TOOL_NAME_SEPARATOR = '____';
export interface MemoryActionTarget {
id?: string;
memoryId?: string;
memoryLayer?: LayersEnum;
summary?: string;
title: string;
type: 'memory';
}
export interface MemoryAgentActionResult {
detail?: string;
status: 'applied' | 'failed' | 'skipped';
target?: MemoryActionTarget;
}
export interface UserMemoryActionHandlerOptions {
@@ -177,196 +153,6 @@ const hasFailedMemoryWrite = (state: AgentState) => {
);
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);
const getString = (value: unknown) => {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
};
const parseToolArguments = (value: unknown): Record<string, unknown> | undefined => {
if (isRecord(value)) return value;
if (typeof value !== 'string') return;
try {
const parsed: unknown = JSON.parse(value);
return isRecord(parsed) ? parsed : undefined;
} catch {
return;
}
};
interface MemoryToolCallSnapshot {
apiName?: string;
arguments?: unknown;
id?: string;
identifier?: string;
}
const getToolCallsFromMessage = (message: unknown): MemoryToolCallSnapshot[] => {
if (!isRecord(message)) return [];
const toolCalls: MemoryToolCallSnapshot[] = [];
const persistedTools = Array.isArray(message.tools) ? message.tools : [];
for (const tool of persistedTools) {
if (!isRecord(tool)) continue;
toolCalls.push({
apiName: getString(tool.apiName),
arguments: tool.arguments,
id: getString(tool.id),
identifier: getString(tool.identifier),
});
}
const rawToolCalls = Array.isArray(message.tool_calls) ? message.tool_calls : [];
for (const toolCall of rawToolCalls) {
if (!isRecord(toolCall)) continue;
const fn = isRecord(toolCall.function) ? toolCall.function : undefined;
const name = getString(fn?.name);
if (!name) continue;
const [identifier, apiName] = name.split(TOOL_NAME_SEPARATOR);
toolCalls.push({
apiName: apiName || name,
arguments: fn?.arguments,
id: getString(toolCall.id),
identifier: apiName ? identifier : undefined,
});
}
return toolCalls;
};
const isMemoryWriteToolCall = (
toolCall: MemoryToolCallSnapshot,
): toolCall is MemoryToolCallSnapshot & { apiName: string } => {
if (!toolCall.apiName || !MEMORY_WRITE_API_NAME_SET.has(toolCall.apiName)) return false;
return !toolCall.identifier || toolCall.identifier === MemoryIdentifier;
};
const getToolMessageCallId = (message: unknown) => {
if (!isRecord(message)) return;
const plugin = isRecord(message.plugin) ? message.plugin : undefined;
return getString(message.tool_call_id) ?? getString(plugin?.id);
};
const getMemoryIdsFromToolMessage = (message: unknown) => {
if (!isRecord(message)) return;
const ids: Record<string, string> = {};
const addId = (key: string, value: unknown) => {
if (!key.endsWith('Id')) return;
const id = getString(value);
if (id) ids[key] = id;
};
const pluginState = isRecord(message.pluginState) ? message.pluginState : undefined;
if (pluginState) {
for (const [key, value] of Object.entries(pluginState)) {
addId(key, value);
}
}
const content = getString(message.content);
if (content) {
for (const match of content.matchAll(/([A-Za-z]\w*Id):\s*"([^"]+)"/g)) {
addId(match[1], match[2]);
}
}
return Object.keys(ids).length > 0 ? ids : undefined;
};
const getMemoryToolResultIds = (state: AgentState) => {
const resultIds = new Map<string, Record<string, string>>();
for (const message of state.messages ?? []) {
const callId = getToolMessageCallId(message);
const ids = getMemoryIdsFromToolMessage(message);
if (callId && ids) resultIds.set(callId, ids);
}
return resultIds;
};
const getNestedString = (payload: Record<string, unknown>, keys: string[]) => {
let current: unknown = payload;
for (const key of keys) {
if (!isRecord(current)) return;
current = current[key];
}
return getString(current);
};
const getToolArgumentString = (args: Record<string, unknown>, key: string) => {
return getString(args[key]) ?? getNestedString(args, ['set', key]);
};
const createTargetFromToolArguments = (
args: Record<string, unknown>,
toolCall: MemoryToolCallSnapshot & { apiName: string },
resultIds?: Record<string, string>,
): MemoryActionTarget | undefined => {
const title = getToolArgumentString(args, 'title');
if (!title) return;
const targetConfig = MEMORY_WRITE_TARGET_BY_API_NAME[toolCall.apiName];
const id = targetConfig ? resultIds?.[targetConfig.idKey] : undefined;
const memoryId = resultIds?.memoryId;
const summary =
getToolArgumentString(args, 'summary') ??
getToolArgumentString(args, 'details') ??
getNestedString(args, ['withPreference', 'conclusionDirectives']);
return {
...((id ?? memoryId) ? { id: id ?? memoryId } : {}),
...(memoryId ? { memoryId } : {}),
...(targetConfig ? { memoryLayer: targetConfig.layer } : {}),
...(summary ? { summary } : {}),
title,
type: 'memory',
};
};
export const resolveMemoryActionTargetFromState = (
state: AgentState,
): MemoryActionTarget | undefined => {
const resultIds = getMemoryToolResultIds(state);
for (const message of [...(state.messages ?? [])].reverse()) {
const toolCalls = getToolCallsFromMessage(message).reverse();
for (const toolCall of toolCalls) {
if (!isMemoryWriteToolCall(toolCall)) continue;
if (!toolCall.id) continue;
const confirmedResultIds = resultIds.get(toolCall.id);
if (!confirmedResultIds) continue;
const args = parseToolArguments(toolCall.arguments);
if (!args) continue;
const target = createTargetFromToolArguments(args, toolCall, confirmedResultIds);
if (target) return target;
}
}
};
export const runMemoryActionAgent = async (
input: {
agentId?: string;
@@ -503,13 +289,7 @@ export const runMemoryActionAgent = async (
}
if (hasSuccessfulMemoryWrite(finalState)) {
const target = resolveMemoryActionTargetFromState(finalState);
return {
...(target?.summary ? { detail: target.summary } : {}),
status: 'applied',
...(target ? { target } : {}),
};
return { status: 'applied' };
}
if (hasFailedMemoryWrite(finalState)) {
@@ -592,15 +372,13 @@ export const handleUserMemoryAction = async (
topicId: typeof action.payload.topicId === 'string' ? action.payload.topicId : undefined,
};
const runner = options.memoryActionRunner ?? ((input) => runMemoryActionAgent(input, options));
let memoryActionResult: MemoryAgentActionResult | undefined;
const memoryService = createMemoryService({
writeMemory: async () => {
const result = await runner(runnerInput);
memoryActionResult = result;
if (result.status === 'applied') {
return {
memoryId: result.target?.id ?? idempotencyKey ?? action.actionId,
memoryId: idempotencyKey ?? action.actionId,
summary: result.detail,
};
}
@@ -624,7 +402,6 @@ export const handleUserMemoryAction = async (
.then<MemoryAgentActionResult>((writeResult) => ({
detail: writeResult.summary,
status: 'applied',
...(memoryActionResult?.target ? { target: memoryActionResult.target } : {}),
}))
.catch((error: unknown): MemoryAgentActionResult => {
if (error instanceof MemoryActionError) {
@@ -644,7 +421,6 @@ export const handleUserMemoryAction = async (
actionId: action.actionId,
attempt: finalizeAttempt(startedAt, 'succeeded'),
detail: result.detail,
...(result.target ? { output: { target: result.target } } : {}),
status: 'applied',
};
}
@@ -1,7 +1,6 @@
// @vitest-environment node
import type { BaseAction, ExecutorResult } from '@lobechat/agent-signal';
import { createSource } from '@lobechat/agent-signal';
import { LayersEnum } from '@lobechat/types';
import { describe, expect, it, vi } from 'vitest';
import { AGENT_SIGNAL_POLICY_ACTION_TYPES } from '../../policies/types';
@@ -53,7 +52,7 @@ const result = (input: {
});
describe('projectAgentSignalReceipts', () => {
it('projects applied memory action results without unstructured feedback as target', () => {
it('projects applied memory action results', () => {
expect(
projectAgentSignalReceipts({
actions: [
@@ -79,6 +78,10 @@ describe('projectAgentSignalReceipts', () => {
sourceId: 'source-1',
sourceType: 'client.gateway.runtime_end',
status: 'applied',
target: {
title: 'Remember that future PR reviews should be decision-first.',
type: 'memory',
},
title: 'Memory saved',
topicId: 'topic-1',
userId: 'user-1',
@@ -86,53 +89,6 @@ describe('projectAgentSignalReceipts', () => {
]);
});
it('prefers the memory target title from action output over the feedback message', () => {
expect(
projectAgentSignalReceipts({
actions: [
action({
actionId: 'action-memory-1',
actionType: AGENT_SIGNAL_POLICY_ACTION_TYPES.userMemoryHandle,
payload: {
message:
'<speaker id="833816919" username="nivra2000" nickname="Aa T" />\nEvery section is too short. Can it be longer?',
},
}),
],
results: [
result({
actionId: 'action-memory-1',
output: {
target: {
id: 'preference_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
summary: 'The user prefers longer, more developed answer sections.',
title: 'Preference for detailed answer sections',
type: 'memory',
},
},
status: 'applied',
}),
],
source,
userId: 'user-1',
}),
).toMatchObject([
{
kind: 'memory',
target: {
id: 'preference_1',
memoryId: 'mem_1',
memoryLayer: LayersEnum.Preference,
summary: 'The user prefers longer, more developed answer sections.',
title: 'Preference for detailed answer sections',
type: 'memory',
},
},
]);
});
it('projects applied skill-management results as updated skill receipts', () => {
expect(
projectAgentSignalReceipts({
@@ -1,5 +1,4 @@
import type { AgentSignalSource, BaseAction, ExecutorResult } from '@lobechat/agent-signal';
import { LayersEnum } from '@lobechat/types';
import { AGENT_SIGNAL_DEFAULTS } from '../constants';
import { AGENT_SIGNAL_POLICY_ACTION_TYPES } from '../policies/types';
@@ -94,10 +93,6 @@ export interface AgentSignalReceipt {
documentId?: string;
/** Backing resource id for future navigation when still available. Skill ids use `documents.id`. */
id?: string;
/** User memory base id for audit and fallback lookup. */
memoryId?: string;
/** User memory layer used to route memory receipts to their detail page. */
memoryLayer?: LayersEnum;
/** Short summary captured at write time. */
summary?: string;
/** Human-readable resource title captured at write time. */
@@ -241,10 +236,6 @@ const getPayloadString = (payload: Record<string, unknown>, key: string) => {
const getClampedString = (value: string, maxLength = 96) =>
value.length > maxLength ? `${value.slice(0, maxLength - 1)}...` : value;
const isMemoryLayer = (value: unknown): value is LayersEnum => {
return Object.values(LayersEnum).includes(value as LayersEnum);
};
const getReceiptTarget = (
action: BaseAction,
result: ExecutorResult,
@@ -271,12 +262,6 @@ const getReceiptTarget = (
? { documentId: payload.documentId }
: {}),
...(typeof payload.id === 'string' && payload.id.length > 0 ? { id: payload.id } : {}),
...(type === 'memory' && typeof payload.memoryId === 'string' && payload.memoryId.length > 0
? { memoryId: payload.memoryId }
: {}),
...(type === 'memory' && isMemoryLayer(payload.memoryLayer)
? { memoryLayer: payload.memoryLayer }
: {}),
...(typeof payload.summary === 'string' && payload.summary.length > 0
? { summary: payload.summary }
: {}),
@@ -287,6 +272,14 @@ const getReceiptTarget = (
}
if (kind !== 'memory') return;
const message = getPayloadString(action.payload, 'message')?.trim();
if (!message) return;
return {
title: getClampedString(message),
type: 'memory',
};
};
const toReceiptKind = (
@@ -1,5 +1,4 @@
// @vitest-environment node
import { LayersEnum } from '@lobechat/types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
@@ -28,9 +27,6 @@ const receipt = {
sourceType: 'client.gateway.runtime_end',
status: 'applied' as const,
target: {
id: 'preference-1',
memoryId: 'memory-1',
memoryLayer: LayersEnum.Preference,
summary: 'Use short answers in future chats',
title: 'Short answer preference',
type: 'memory' as const,
@@ -65,9 +61,6 @@ describe('redis receipt store', () => {
sourceType: 'client.gateway.runtime_end',
status: 'applied',
target: JSON.stringify({
id: 'preference-1',
memoryId: 'memory-1',
memoryLayer: LayersEnum.Preference,
summary: 'Use short answers in future chats',
title: 'Short answer preference',
type: 'memory',
@@ -1,5 +1,3 @@
import { LayersEnum } from '@lobechat/types';
import { AGENT_SIGNAL_KEYS } from '../../../constants';
import type { AgentSignalReceipt } from '../../../services/receiptService';
import type { AgentSignalReceiptStore } from '../../types';
@@ -23,9 +21,6 @@ const toReceiptHash = (receipt: AgentSignalReceipt): Record<string, string> => (
userId: receipt.userId,
});
const isMemoryLayer = (value: unknown): value is LayersEnum =>
Object.values(LayersEnum).includes(value as LayersEnum);
const parseReceiptTarget = (value?: string): AgentSignalReceipt['target'] | undefined => {
if (!value) return;
@@ -43,14 +38,6 @@ const parseReceiptTarget = (value?: string): AgentSignalReceipt['target'] | unde
? { documentId: target.documentId }
: {}),
...(typeof target.id === 'string' && target.id.length > 0 ? { id: target.id } : {}),
...(target.type === 'memory' &&
typeof target.memoryId === 'string' &&
target.memoryId.length > 0
? { memoryId: target.memoryId }
: {}),
...(target.type === 'memory' && isMemoryLayer(target.memoryLayer)
? { memoryLayer: target.memoryLayer }
: {}),
...(typeof target.summary === 'string' && target.summary.length > 0
? { summary: target.summary }
: {}),