mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-21 14:39:34 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 577c8e2869 | |||
| 694a25822f |
@@ -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">
|
||||
|
||||
[](#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>
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
[
|
||||
{
|
||||
"children": {},
|
||||
"date": "2026-05-18",
|
||||
"version": "2.2.0"
|
||||
},
|
||||
{
|
||||
"children": {
|
||||
"features": [
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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
-358
@@ -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 }
|
||||
: {}),
|
||||
|
||||
Reference in New Issue
Block a user