mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
✨ feat(context-engine): inject referenced topic context into last user message (#13104)
* ✨ feat: inject referenced topic context into last user message When users @refer_topic in chat, inject the referenced topic's summary or recent messages directly into the context, reducing unnecessary tool calls. * 🐛 fix: include agentId and groupId in message retrieval for context engineering Signed-off-by: Innei <tukon479@gmail.com> * ✨ feat: skip topic reference resolution for messages with existing topic_reference_context Added logic to prevent double injection of topic references when messages already contain the topic_reference_context. Updated tests to verify the behavior for both cases: when topic references should be resolved and when they should be skipped. Signed-off-by: Innei <tukon479@gmail.com> --------- Signed-off-by: Innei <tukon479@gmail.com>
This commit is contained in:
@@ -4,3 +4,5 @@ export * from './skills';
|
||||
export * from './tools';
|
||||
// Messages Engine
|
||||
export * from './messages';
|
||||
// Topic Reference
|
||||
export * from './topicReference';
|
||||
|
||||
@@ -41,6 +41,7 @@ import {
|
||||
SystemRoleInjector,
|
||||
ToolDiscoveryProvider,
|
||||
ToolSystemRoleProvider,
|
||||
TopicReferenceContextInjector,
|
||||
UserMemoryInjector,
|
||||
} from '../../providers';
|
||||
import type { ContextProcessor } from '../../types';
|
||||
@@ -147,6 +148,7 @@ export class MessagesEngine {
|
||||
initialContext,
|
||||
stepContext,
|
||||
pageContentContext,
|
||||
topicReferences,
|
||||
enableSystemDate,
|
||||
timezone,
|
||||
} = this.params;
|
||||
@@ -316,6 +318,16 @@ export class MessagesEngine {
|
||||
// 17. GTD Todo injection (conditionally added, at end of last user message)
|
||||
...(isGTDTodoEnabled ? [new GTDTodoInjector({ enabled: true, todos: gtd.todos })] : []),
|
||||
|
||||
// 18. Topic Reference context injection (inject referenced topic summaries to last user message)
|
||||
...(topicReferences && topicReferences.length > 0
|
||||
? [
|
||||
new TopicReferenceContextInjector({
|
||||
enabled: true,
|
||||
topicReferences,
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
|
||||
// =============================================
|
||||
// Phase 4: Message Transformation
|
||||
// =============================================
|
||||
|
||||
@@ -18,6 +18,7 @@ export type {
|
||||
ToolDiscoveryConfig,
|
||||
ToolDiscoveryMeta,
|
||||
ToolsConfig,
|
||||
TopicReferenceItem,
|
||||
UIChatMessage,
|
||||
UserMemoryActivityItem,
|
||||
UserMemoryConfig,
|
||||
|
||||
@@ -19,6 +19,7 @@ import type { GTDPlan } from '../../providers/GTDPlanInjector';
|
||||
import type { GTDTodoList } from '../../providers/GTDTodoInjector';
|
||||
import type { SkillMeta } from '../../providers/SkillContextProvider';
|
||||
import type { ToolDiscoveryMeta } from '../../providers/ToolDiscoveryProvider';
|
||||
import type { TopicReferenceItem } from '../../providers/TopicReferenceContextInjector';
|
||||
import type { PipelineContextMetadata } from '../../types';
|
||||
import type { LobeToolManifest } from '../tools/types';
|
||||
|
||||
@@ -281,6 +282,10 @@ export interface MessagesEngineParams {
|
||||
/** User memory configuration */
|
||||
userMemory?: UserMemoryConfig;
|
||||
|
||||
// ========== Topic References ==========
|
||||
/** Topic reference summaries to inject into last user message */
|
||||
topicReferences?: TopicReferenceItem[];
|
||||
|
||||
// ========== Page Editor context ==========
|
||||
/**
|
||||
* Initial context captured at operation start (frontend runtime usage)
|
||||
@@ -330,5 +335,6 @@ export { type GTDPlan } from '../../providers/GTDPlanInjector';
|
||||
export { type GTDTodoItem, type GTDTodoList } from '../../providers/GTDTodoInjector';
|
||||
export { type SkillMeta } from '../../providers/SkillContextProvider';
|
||||
export { type ToolDiscoveryMeta } from '../../providers/ToolDiscoveryProvider';
|
||||
export { type TopicReferenceItem } from '../../providers/TopicReferenceContextInjector';
|
||||
export { type OpenAIChatMessage, type UIChatMessage } from '@/types/index';
|
||||
export { type FileContent, type KnowledgeBaseInfo } from '@lobechat/prompts';
|
||||
|
||||
@@ -0,0 +1,236 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { parseReferTopicTags, resolveTopicReferences } from '../resolveTopicReferences';
|
||||
|
||||
// ============ parseReferTopicTags ============
|
||||
|
||||
describe('parseReferTopicTags', () => {
|
||||
it('should parse refer_topic tags with name before id', () => {
|
||||
const result = parseReferTopicTags([
|
||||
{ content: '<refer_topic name="My Topic" id="topic-1" />' },
|
||||
]);
|
||||
expect(result).toEqual([{ topicId: 'topic-1', topicTitle: 'My Topic' }]);
|
||||
});
|
||||
|
||||
it('should parse refer_topic tags with id before name', () => {
|
||||
const result = parseReferTopicTags([
|
||||
{ content: '<refer_topic id="topic-2" name="Other Topic" />' },
|
||||
]);
|
||||
expect(result).toEqual([{ topicId: 'topic-2', topicTitle: 'Other Topic' }]);
|
||||
});
|
||||
|
||||
it('should deduplicate topic IDs across messages', () => {
|
||||
const result = parseReferTopicTags([
|
||||
{ content: '<refer_topic name="A" id="topic-1" />' },
|
||||
{ content: '<refer_topic name="A" id="topic-1" />' },
|
||||
]);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].topicId).toBe('topic-1');
|
||||
});
|
||||
|
||||
it('should parse multiple different topics', () => {
|
||||
const result = parseReferTopicTags([
|
||||
{ content: '<refer_topic name="A" id="t1" />\n<refer_topic name="B" id="t2" />' },
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((r) => r.topicId)).toEqual(['t1', 't2']);
|
||||
});
|
||||
|
||||
it('should return empty array when no tags found', () => {
|
||||
const result = parseReferTopicTags([{ content: 'Hello world' }]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should skip non-string content', () => {
|
||||
const result = parseReferTopicTags([{ content: ['array', 'content'] }]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should parse markdown-escaped refer_topic tags', () => {
|
||||
const result = parseReferTopicTags([
|
||||
{ content: 'hi \\<refer\\_topic name="新对话开始" id="tpc\\_867aVIS5Av2G" />' },
|
||||
]);
|
||||
expect(result).toEqual([{ topicId: 'tpc_867aVIS5Av2G', topicTitle: '新对话开始' }]);
|
||||
});
|
||||
|
||||
it('should parse markdown-escaped tags at start of content', () => {
|
||||
const result = parseReferTopicTags([
|
||||
{ content: '\\<refer\\_topic name="Greeting Message" id="tpc\\_EH0s0KrwpJN2" />讲了啥' },
|
||||
]);
|
||||
expect(result).toEqual([{ topicId: 'tpc_EH0s0KrwpJN2', topicTitle: 'Greeting Message' }]);
|
||||
});
|
||||
|
||||
it('should parse mixed escaped and non-escaped tags', () => {
|
||||
const result = parseReferTopicTags([
|
||||
{ content: '\\<refer\\_topic name="A" id="tpc\\_1" />' },
|
||||
{ content: '<refer_topic name="B" id="tpc_2" />' },
|
||||
]);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((r) => r.topicId)).toEqual(['tpc_1', 'tpc_2']);
|
||||
});
|
||||
});
|
||||
|
||||
// ============ resolveTopicReferences ============
|
||||
|
||||
describe('resolveTopicReferences', () => {
|
||||
it('should return undefined when no refer_topic tags found', async () => {
|
||||
const lookup = vi.fn();
|
||||
const result = await resolveTopicReferences([{ content: 'Hello' }], lookup);
|
||||
expect(result).toBeUndefined();
|
||||
expect(lookup).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should resolve topics with summaries', async () => {
|
||||
const lookup = vi.fn().mockResolvedValue({
|
||||
historySummary: 'Topic summary content',
|
||||
title: 'DB Title',
|
||||
});
|
||||
|
||||
const result = await resolveTopicReferences(
|
||||
[{ content: '<refer_topic name="UI Title" id="topic-1" />' }],
|
||||
lookup,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ summary: 'Topic summary content', topicId: 'topic-1', topicTitle: 'DB Title' },
|
||||
]);
|
||||
expect(lookup).toHaveBeenCalledWith('topic-1');
|
||||
});
|
||||
|
||||
it('should resolve topics without summaries', async () => {
|
||||
const lookup = vi.fn().mockResolvedValue({ historySummary: null, title: 'Some Topic' });
|
||||
|
||||
const result = await resolveTopicReferences(
|
||||
[{ content: '<refer_topic name="Fallback" id="topic-2" />' }],
|
||||
lookup,
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ summary: undefined, topicId: 'topic-2', topicTitle: 'Some Topic' }]);
|
||||
});
|
||||
|
||||
it('should fallback to parsed title when lookup returns null', async () => {
|
||||
const lookup = vi.fn().mockResolvedValue(null);
|
||||
|
||||
const result = await resolveTopicReferences(
|
||||
[{ content: '<refer_topic name="Parsed Title" id="topic-3" />' }],
|
||||
lookup,
|
||||
);
|
||||
|
||||
expect(result).toEqual([
|
||||
{ summary: undefined, topicId: 'topic-3', topicTitle: 'Parsed Title' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle lookup errors gracefully', async () => {
|
||||
const lookup = vi.fn().mockRejectedValue(new Error('DB error'));
|
||||
|
||||
const result = await resolveTopicReferences(
|
||||
[{ content: '<refer_topic name="Error Topic" id="topic-err" />' }],
|
||||
lookup,
|
||||
);
|
||||
|
||||
expect(result).toEqual([{ topicId: 'topic-err', topicTitle: 'Error Topic' }]);
|
||||
});
|
||||
|
||||
it('should handle mixed results', async () => {
|
||||
const lookup = vi.fn().mockImplementation(async (id: string) => {
|
||||
if (id === 't1') return { historySummary: 'Has summary', title: 'Topic 1' };
|
||||
return null;
|
||||
});
|
||||
|
||||
const result = await resolveTopicReferences(
|
||||
[{ content: '<refer_topic name="A" id="t1" />\n<refer_topic name="B" id="t2" />' }],
|
||||
lookup,
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result![0]).toEqual({ summary: 'Has summary', topicId: 't1', topicTitle: 'Topic 1' });
|
||||
expect(result![1]).toEqual({ summary: undefined, topicId: 't2', topicTitle: 'B' });
|
||||
});
|
||||
|
||||
it('should fetch recent messages as fallback when no summary and lookupMessages provided', async () => {
|
||||
const lookup = vi.fn().mockResolvedValue({ historySummary: null, title: 'No Summary Topic' });
|
||||
const lookupMessages = vi.fn().mockResolvedValue([
|
||||
{ content: 'Hello', role: 'user' },
|
||||
{ content: 'Hi there!', role: 'assistant' },
|
||||
{ content: '', role: 'tool' },
|
||||
{ content: 'Follow up', role: 'user' },
|
||||
]);
|
||||
|
||||
const result = await resolveTopicReferences(
|
||||
[{ content: '<refer_topic name="T" id="t1" />' }],
|
||||
lookup,
|
||||
lookupMessages,
|
||||
);
|
||||
|
||||
expect(lookupMessages).toHaveBeenCalledWith('t1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result![0].recentMessages).toHaveLength(3);
|
||||
expect(result![0].recentMessages).toEqual([
|
||||
{ content: 'Hello', role: 'user' },
|
||||
{ content: 'Hi there!', role: 'assistant' },
|
||||
{ content: 'Follow up', role: 'user' },
|
||||
]);
|
||||
expect(result![0].summary).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should truncate long message content to 300 chars', async () => {
|
||||
const longContent = 'x'.repeat(500);
|
||||
const lookup = vi.fn().mockResolvedValue({ historySummary: null, title: 'T' });
|
||||
const lookupMessages = vi.fn().mockResolvedValue([{ content: longContent, role: 'user' }]);
|
||||
|
||||
const result = await resolveTopicReferences(
|
||||
[{ content: '<refer_topic name="T" id="t1" />' }],
|
||||
lookup,
|
||||
lookupMessages,
|
||||
);
|
||||
|
||||
expect(result![0].recentMessages![0].content).toHaveLength(303); // 300 + '...'
|
||||
});
|
||||
|
||||
it('should take only last 5 messages', async () => {
|
||||
const lookup = vi.fn().mockResolvedValue({ historySummary: null, title: 'T' });
|
||||
const messages = Array.from({ length: 10 }, (_, i) => ({
|
||||
content: `msg ${i}`,
|
||||
role: i % 2 === 0 ? 'user' : 'assistant',
|
||||
}));
|
||||
const lookupMessages = vi.fn().mockResolvedValue(messages);
|
||||
|
||||
const result = await resolveTopicReferences(
|
||||
[{ content: '<refer_topic name="T" id="t1" />' }],
|
||||
lookup,
|
||||
lookupMessages,
|
||||
);
|
||||
|
||||
expect(result![0].recentMessages).toHaveLength(5);
|
||||
expect(result![0].recentMessages![0].content).toBe('msg 5');
|
||||
});
|
||||
|
||||
it('should skip lookupMessages when topic has summary', async () => {
|
||||
const lookup = vi.fn().mockResolvedValue({ historySummary: 'Has summary', title: 'T' });
|
||||
const lookupMessages = vi.fn();
|
||||
|
||||
const result = await resolveTopicReferences(
|
||||
[{ content: '<refer_topic name="T" id="t1" />' }],
|
||||
lookup,
|
||||
lookupMessages,
|
||||
);
|
||||
|
||||
expect(lookupMessages).not.toHaveBeenCalled();
|
||||
expect(result![0].summary).toBe('Has summary');
|
||||
});
|
||||
|
||||
it('should fall through to no-context when lookupMessages returns empty', async () => {
|
||||
const lookup = vi.fn().mockResolvedValue({ historySummary: null, title: 'T' });
|
||||
const lookupMessages = vi.fn().mockResolvedValue([]);
|
||||
|
||||
const result = await resolveTopicReferences(
|
||||
[{ content: '<refer_topic name="T" id="t1" />' }],
|
||||
lookup,
|
||||
lookupMessages,
|
||||
);
|
||||
|
||||
expect(result![0].summary).toBeUndefined();
|
||||
expect(result![0].recentMessages).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export type {
|
||||
ParsedTopicReference,
|
||||
TopicLookupResult,
|
||||
TopicMessageItem,
|
||||
} from './resolveTopicReferences';
|
||||
export { parseReferTopicTags, resolveTopicReferences } from './resolveTopicReferences';
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { TopicReferenceItem } from '../../providers/TopicReferenceContextInjector';
|
||||
|
||||
/**
|
||||
* Parsed refer_topic tag info
|
||||
*/
|
||||
export interface ParsedTopicReference {
|
||||
topicId: string;
|
||||
topicTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Topic lookup result (common interface for both client store and server DB)
|
||||
*/
|
||||
export interface TopicLookupResult {
|
||||
historySummary?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Message item returned by lookupMessages
|
||||
*/
|
||||
export interface TopicMessageItem {
|
||||
content?: string | null;
|
||||
role: string;
|
||||
}
|
||||
|
||||
/** Max recent messages to fetch as fallback */
|
||||
const MAX_RECENT_MESSAGES = 5;
|
||||
/** Max characters per message content */
|
||||
const MAX_MESSAGE_LENGTH = 300;
|
||||
|
||||
/**
|
||||
* Parse <refer_topic> tags from message contents
|
||||
*/
|
||||
export function parseReferTopicTags(
|
||||
messages: Array<{ content: string | unknown }>,
|
||||
): ParsedTopicReference[] {
|
||||
const topicIds = new Set<string>();
|
||||
const topicNames = new Map<string, string>();
|
||||
|
||||
for (const msg of messages) {
|
||||
// Strip markdown escape backslashes (e.g. \< → <, \_ → _) before parsing
|
||||
const raw = typeof msg.content === 'string' ? msg.content : '';
|
||||
const content = raw.replaceAll(/\\([<>_*[\]()#`~|])/g, '$1');
|
||||
const tagRegex = /<refer_topic\s([^>]*)>/g;
|
||||
let tagMatch;
|
||||
while ((tagMatch = tagRegex.exec(content)) !== null) {
|
||||
const attrs = tagMatch[1];
|
||||
const idMatch = /id="([^"]+)"/.exec(attrs);
|
||||
const nameMatch = /name="([^"]*)"/.exec(attrs);
|
||||
if (idMatch) {
|
||||
topicIds.add(idMatch[1]);
|
||||
if (nameMatch) topicNames.set(idMatch[1], nameMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...topicIds].map((topicId) => ({
|
||||
topicId,
|
||||
topicTitle: topicNames.get(topicId),
|
||||
}));
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
return text.slice(0, max) + '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve topic references by looking up each topic via the provided function.
|
||||
* Works with both sync (client store) and async (server DB) data sources.
|
||||
*
|
||||
* When a topic has no historySummary and lookupMessages is provided,
|
||||
* fetches the last 5 user/assistant messages as fallback context (each truncated to 300 chars).
|
||||
*/
|
||||
export async function resolveTopicReferences(
|
||||
messages: Array<{ content: string | unknown }>,
|
||||
lookupTopic: (topicId: string) => Promise<TopicLookupResult | null | undefined>,
|
||||
lookupMessages?: (topicId: string) => Promise<TopicMessageItem[]>,
|
||||
): Promise<TopicReferenceItem[] | undefined> {
|
||||
const parsed = parseReferTopicTags(messages);
|
||||
if (parsed.length === 0) return undefined;
|
||||
|
||||
const refs: TopicReferenceItem[] = [];
|
||||
|
||||
for (const { topicId, topicTitle } of parsed) {
|
||||
try {
|
||||
const topic = await lookupTopic(topicId);
|
||||
const title = topic?.title || topicTitle;
|
||||
|
||||
if (topic?.historySummary) {
|
||||
refs.push({ summary: topic.historySummary, topicId, topicTitle: title });
|
||||
continue;
|
||||
}
|
||||
|
||||
// Fallback: fetch recent messages
|
||||
if (lookupMessages) {
|
||||
try {
|
||||
const allMessages = await lookupMessages(topicId);
|
||||
|
||||
// Filter to user/assistant only, take last N, truncate content
|
||||
const recent = allMessages
|
||||
.filter((m) => m.role === 'user' || m.role === 'assistant')
|
||||
.filter((m) => m.content?.trim())
|
||||
.slice(-MAX_RECENT_MESSAGES)
|
||||
.map((m) => ({
|
||||
content: truncate(m.content!.trim(), MAX_MESSAGE_LENGTH),
|
||||
role: m.role,
|
||||
}));
|
||||
|
||||
if (recent.length > 0) {
|
||||
refs.push({ recentMessages: recent, topicId, topicTitle: title });
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// fallthrough to no-context
|
||||
}
|
||||
}
|
||||
|
||||
refs.push({ topicId, topicTitle: title });
|
||||
} catch {
|
||||
refs.push({ topicId, topicTitle });
|
||||
}
|
||||
}
|
||||
|
||||
return refs.length > 0 ? refs : undefined;
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import debug from 'debug';
|
||||
|
||||
import { BaseLastUserContentProvider } from '../base/BaseLastUserContentProvider';
|
||||
import type { PipelineContext, ProcessorOptions } from '../types';
|
||||
|
||||
declare module '../types' {
|
||||
interface PipelineContextMetadataOverrides {
|
||||
topicReferenceCount?: number;
|
||||
topicReferenceInjected?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
const log = debug('context-engine:provider:TopicReferenceContextInjector');
|
||||
|
||||
/**
|
||||
* Topic reference item
|
||||
*/
|
||||
export interface TopicReferenceItem {
|
||||
/** Recent messages as fallback when no summary available */
|
||||
recentMessages?: Array<{ content: string; role: string }>;
|
||||
/** The topic's history summary (undefined if no summary available) */
|
||||
summary?: string;
|
||||
/** The topic ID */
|
||||
topicId: string;
|
||||
/** The topic title */
|
||||
topicTitle?: string;
|
||||
}
|
||||
|
||||
export interface TopicReferenceContextInjectorConfig {
|
||||
/** Whether topic reference context injection is enabled */
|
||||
enabled?: boolean;
|
||||
/** Referenced topics (with or without summaries) */
|
||||
topicReferences?: TopicReferenceItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Format topic references for injection
|
||||
* Topics with summaries are inlined; topics without summaries prompt the agent to use the tool
|
||||
*/
|
||||
function formatRecentMessages(messages: Array<{ content: string; role: string }>): string {
|
||||
return messages
|
||||
.map((msg) => {
|
||||
const role = msg.role === 'user' ? 'User' : msg.role === 'assistant' ? 'Assistant' : msg.role;
|
||||
return `**${role}**: ${msg.content}`;
|
||||
})
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
function formatTopicReferences(items: TopicReferenceItem[]): string | null {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const withContext = items.filter((item) => item.summary || item.recentMessages?.length);
|
||||
const withoutContext = items.filter((item) => !item.summary && !item.recentMessages?.length);
|
||||
|
||||
const lines: string[] = [
|
||||
'<referred_topics hint="The following are brief summaries or excerpts of referenced topics. For complete conversation history, use the getTopicContext tool.">',
|
||||
];
|
||||
|
||||
for (const item of withContext) {
|
||||
const title = item.topicTitle ? ` title="${item.topicTitle}"` : '';
|
||||
const type = item.summary ? 'summary' : 'recent_messages';
|
||||
lines.push(`<topic id="${item.topicId}"${title} type="${type}">`);
|
||||
if (item.summary) {
|
||||
lines.push(item.summary);
|
||||
} else {
|
||||
lines.push(formatRecentMessages(item.recentMessages!));
|
||||
}
|
||||
lines.push('</topic>');
|
||||
}
|
||||
|
||||
if (withoutContext.length > 0) {
|
||||
lines.push(
|
||||
'<pending_topics hint="No context available for the following topics. Use the getTopicContext tool to retrieve their conversation history.">',
|
||||
);
|
||||
for (const item of withoutContext) {
|
||||
const title = item.topicTitle ? ` title="${item.topicTitle}"` : '';
|
||||
lines.push(`<topic id="${item.topicId}"${title} />`);
|
||||
}
|
||||
lines.push('</pending_topics>');
|
||||
}
|
||||
|
||||
lines.push('</referred_topics>');
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Topic Reference Context Injector
|
||||
*
|
||||
* When user messages contain <refer_topic> tags referencing other topics,
|
||||
* this injector appends the referenced topics' context to the last user message.
|
||||
* - Topics with summaries: summary is inlined directly
|
||||
* - Topics without summaries: a hint is added prompting the agent to call the getTopicContext tool
|
||||
*/
|
||||
export class TopicReferenceContextInjector extends BaseLastUserContentProvider {
|
||||
readonly name = 'TopicReferenceContextInjector';
|
||||
|
||||
constructor(
|
||||
private config: TopicReferenceContextInjectorConfig,
|
||||
options: ProcessorOptions = {},
|
||||
) {
|
||||
super(options);
|
||||
}
|
||||
|
||||
protected async doProcess(context: PipelineContext): Promise<PipelineContext> {
|
||||
log('doProcess called');
|
||||
log('config.enabled:', this.config.enabled);
|
||||
|
||||
const clonedContext = this.cloneContext(context);
|
||||
|
||||
if (
|
||||
!this.config.enabled ||
|
||||
!this.config.topicReferences ||
|
||||
this.config.topicReferences.length === 0
|
||||
) {
|
||||
log('Topic reference not enabled or no references, skipping injection');
|
||||
return this.markAsExecuted(clonedContext);
|
||||
}
|
||||
|
||||
const formattedContent = formatTopicReferences(this.config.topicReferences);
|
||||
|
||||
if (!formattedContent) {
|
||||
log('No topic reference content to inject');
|
||||
return this.markAsExecuted(clonedContext);
|
||||
}
|
||||
|
||||
log('Formatted content length:', formattedContent.length);
|
||||
|
||||
const lastUserIndex = this.findLastUserMessageIndex(clonedContext.messages);
|
||||
|
||||
log('Last user message index:', lastUserIndex);
|
||||
|
||||
if (lastUserIndex === -1) {
|
||||
log('No user messages found, skipping injection');
|
||||
return this.markAsExecuted(clonedContext);
|
||||
}
|
||||
|
||||
const hasExistingWrapper = this.hasExistingSystemContext(clonedContext);
|
||||
const contentToAppend = hasExistingWrapper
|
||||
? this.createContextBlock(formattedContent, 'topic_reference_context')
|
||||
: this.wrapWithSystemContext(formattedContent, 'topic_reference_context');
|
||||
|
||||
this.appendToLastUserMessage(clonedContext, contentToAppend);
|
||||
|
||||
clonedContext.metadata.topicReferenceInjected = true;
|
||||
clonedContext.metadata.topicReferenceCount = this.config.topicReferences.length;
|
||||
|
||||
log('Topic reference context appended to last user message');
|
||||
|
||||
return this.markAsExecuted(clonedContext);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { PipelineContext } from '../../types';
|
||||
import { TopicReferenceContextInjector } from '../TopicReferenceContextInjector';
|
||||
|
||||
const createContext = (messages: any[] = []): PipelineContext => ({
|
||||
initialState: {
|
||||
messages: [],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
},
|
||||
isAborted: false,
|
||||
messages,
|
||||
metadata: {
|
||||
maxTokens: 4096,
|
||||
model: 'gpt-4',
|
||||
},
|
||||
});
|
||||
|
||||
describe('TopicReferenceContextInjector', () => {
|
||||
it('should skip when not enabled', async () => {
|
||||
const injector = new TopicReferenceContextInjector({ enabled: false });
|
||||
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
|
||||
const result = await injector.process(context);
|
||||
|
||||
expect(result.messages[0].content).toBe('Hello');
|
||||
expect(result.metadata.topicReferenceInjected).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should skip when no topic references provided', async () => {
|
||||
const injector = new TopicReferenceContextInjector({ enabled: true, topicReferences: [] });
|
||||
const context = createContext([{ content: 'Hello', id: 'user-1', role: 'user' }]);
|
||||
const result = await injector.process(context);
|
||||
|
||||
expect(result.messages[0].content).toBe('Hello');
|
||||
expect(result.metadata.topicReferenceInjected).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should inject topic summaries into the last user message', async () => {
|
||||
const injector = new TopicReferenceContextInjector({
|
||||
enabled: true,
|
||||
topicReferences: [
|
||||
{
|
||||
summary: 'This topic discussed React hooks and state management.',
|
||||
topicId: 'topic-1',
|
||||
topicTitle: 'React Hooks Discussion',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context = createContext([
|
||||
{ content: 'Earlier message', id: 'user-1', role: 'user' },
|
||||
{ content: 'Reply', id: 'assistant-1', role: 'assistant' },
|
||||
{
|
||||
content: '<refer_topic name="React Hooks Discussion" id="topic-1" />\nTell me more',
|
||||
id: 'user-2',
|
||||
role: 'user',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await injector.process(context);
|
||||
|
||||
expect(result.messages).toHaveLength(3);
|
||||
const lastContent = result.messages[2].content as string;
|
||||
expect(lastContent).toContain('<referred_topics hint=');
|
||||
expect(lastContent).toContain('getTopicContext tool');
|
||||
expect(lastContent).toContain(
|
||||
'<topic id="topic-1" title="React Hooks Discussion" type="summary">',
|
||||
);
|
||||
expect(lastContent).toContain('This topic discussed React hooks and state management.');
|
||||
expect(lastContent).toContain('</referred_topics>');
|
||||
expect(result.metadata.topicReferenceInjected).toBe(true);
|
||||
expect(result.metadata.topicReferenceCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should inject pending hint for topics without summaries', async () => {
|
||||
const injector = new TopicReferenceContextInjector({
|
||||
enabled: true,
|
||||
topicReferences: [
|
||||
{
|
||||
topicId: 'topic-no-summary',
|
||||
topicTitle: 'Old Chat',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context = createContext([{ content: 'Check this topic', id: 'user-1', role: 'user' }]);
|
||||
|
||||
const result = await injector.process(context);
|
||||
const lastContent = result.messages[0].content as string;
|
||||
|
||||
expect(lastContent).toContain('<pending_topics');
|
||||
expect(lastContent).toContain('getTopicContext');
|
||||
expect(lastContent).toContain('<topic id="topic-no-summary" title="Old Chat" />');
|
||||
expect(lastContent).not.toContain('</topic>');
|
||||
expect(result.metadata.topicReferenceInjected).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle mixed topics (some with summaries, some without)', async () => {
|
||||
const injector = new TopicReferenceContextInjector({
|
||||
enabled: true,
|
||||
topicReferences: [
|
||||
{
|
||||
summary: 'Summary of topic A.',
|
||||
topicId: 'topic-a',
|
||||
topicTitle: 'Topic A',
|
||||
},
|
||||
{
|
||||
topicId: 'topic-b',
|
||||
topicTitle: 'Topic B',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context = createContext([
|
||||
{ content: 'Refer to both topics', id: 'user-1', role: 'user' },
|
||||
]);
|
||||
|
||||
const result = await injector.process(context);
|
||||
const lastContent = result.messages[0].content as string;
|
||||
|
||||
expect(lastContent).toContain('<topic id="topic-a" title="Topic A" type="summary">');
|
||||
expect(lastContent).toContain('Summary of topic A.');
|
||||
expect(lastContent).toContain('<pending_topics');
|
||||
expect(lastContent).toContain('<topic id="topic-b" title="Topic B" />');
|
||||
expect(result.metadata.topicReferenceCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should reuse existing system context wrapper', async () => {
|
||||
const injector = new TopicReferenceContextInjector({
|
||||
enabled: true,
|
||||
topicReferences: [
|
||||
{
|
||||
summary: 'Topic summary.',
|
||||
topicId: 'topic-1',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context = createContext([
|
||||
{
|
||||
content: `My question
|
||||
|
||||
<!-- SYSTEM CONTEXT (NOT PART OF USER QUERY) -->
|
||||
<existing_context>
|
||||
some context
|
||||
</existing_context>
|
||||
<!-- END SYSTEM CONTEXT -->`,
|
||||
id: 'user-1',
|
||||
role: 'user',
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await injector.process(context);
|
||||
const content = result.messages[0].content as string;
|
||||
|
||||
expect(content.match(/<!-- SYSTEM CONTEXT \(NOT PART OF USER QUERY\) -->/g)).toHaveLength(1);
|
||||
expect(content).toContain('<existing_context>');
|
||||
expect(content).toContain('<topic_reference_context>');
|
||||
});
|
||||
|
||||
it('should inject recent messages when no summary available', async () => {
|
||||
const injector = new TopicReferenceContextInjector({
|
||||
enabled: true,
|
||||
topicReferences: [
|
||||
{
|
||||
recentMessages: [
|
||||
{ content: 'What is React?', role: 'user' },
|
||||
{ content: 'React is a JS library.', role: 'assistant' },
|
||||
],
|
||||
topicId: 'topic-recent',
|
||||
topicTitle: 'React Q&A',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const context = createContext([
|
||||
{ content: 'Tell me about that topic', id: 'user-1', role: 'user' },
|
||||
]);
|
||||
|
||||
const result = await injector.process(context);
|
||||
const lastContent = result.messages[0].content as string;
|
||||
|
||||
expect(lastContent).toContain(
|
||||
'<topic id="topic-recent" title="React Q&A" type="recent_messages">',
|
||||
);
|
||||
expect(lastContent).toContain('**User**: What is React?');
|
||||
expect(lastContent).toContain('**Assistant**: React is a JS library.');
|
||||
expect(lastContent).not.toContain('<pending_topics');
|
||||
});
|
||||
|
||||
it('should skip injection when no user messages exist', async () => {
|
||||
const injector = new TopicReferenceContextInjector({
|
||||
enabled: true,
|
||||
topicReferences: [{ summary: 'Summary', topicId: 'topic-1' }],
|
||||
});
|
||||
|
||||
const context = createContext([
|
||||
{ content: 'Assistant only', id: 'assistant-1', role: 'assistant' },
|
||||
]);
|
||||
|
||||
const result = await injector.process(context);
|
||||
|
||||
expect(result.messages[0].content).toBe('Assistant only');
|
||||
expect(result.metadata.topicReferenceInjected).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -18,6 +18,7 @@ export { SystemDateProvider } from './SystemDateProvider';
|
||||
export { SystemRoleInjector } from './SystemRoleInjector';
|
||||
export { ToolDiscoveryProvider } from './ToolDiscoveryProvider';
|
||||
export { ToolSystemRoleProvider } from './ToolSystemRole';
|
||||
export { TopicReferenceContextInjector } from './TopicReferenceContextInjector';
|
||||
export { UserMemoryInjector } from './UserMemoryInjector';
|
||||
|
||||
// Re-export types
|
||||
@@ -58,4 +59,8 @@ export type { SystemDateProviderConfig } from './SystemDateProvider';
|
||||
export type { SystemRoleInjectorConfig } from './SystemRoleInjector';
|
||||
export type { ToolDiscoveryMeta, ToolDiscoveryProviderConfig } from './ToolDiscoveryProvider';
|
||||
export type { ToolSystemRoleConfig } from './ToolSystemRole';
|
||||
export type {
|
||||
TopicReferenceContextInjectorConfig,
|
||||
TopicReferenceItem,
|
||||
} from './TopicReferenceContextInjector';
|
||||
export type { MemoryContext, UserMemoryInjectorConfig } from './UserMemoryInjector';
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
type LobeToolManifest,
|
||||
type OperationToolSet,
|
||||
type ResolvedToolSet,
|
||||
resolveTopicReferences,
|
||||
ToolNameResolver,
|
||||
ToolResolver,
|
||||
} from '@lobechat/context-engine';
|
||||
@@ -24,7 +25,8 @@ import { type ChatToolPayload, type MessageToolCall, type UIChatMessage } from '
|
||||
import { serializePartsForStorage } from '@lobechat/utils';
|
||||
import debug from 'debug';
|
||||
|
||||
import { type MessageModel } from '@/database/models/message';
|
||||
import { type MessageModel, MessageModel as MessageModelClass } from '@/database/models/message';
|
||||
import { TopicModel } from '@/database/models/topic';
|
||||
import { type LobeChatDatabase } from '@/database/type';
|
||||
import { serverMessagesEngine } from '@/server/modules/Mecha/ContextEngineering';
|
||||
import { type EvalContext } from '@/server/modules/Mecha/ContextEngineering/types';
|
||||
@@ -178,6 +180,33 @@ export const createRuntimeExecutors = (
|
||||
if (agentConfig) {
|
||||
const { LOBE_DEFAULT_MODEL_LIST } = await import('model-bank');
|
||||
|
||||
// Extract <refer_topic> tags from messages and fetch summaries.
|
||||
// Skip if messages already contain injected topic_reference_context
|
||||
// (e.g., from client-side contextEngineering preprocessing) to avoid double injection.
|
||||
let topicReferences;
|
||||
const alreadyHasTopicRefs = (
|
||||
llmPayload.messages as Array<{ content: string | unknown }>
|
||||
).some(
|
||||
(m) => typeof m.content === 'string' && m.content.includes('topic_reference_context'),
|
||||
);
|
||||
|
||||
if (!alreadyHasTopicRefs && ctx.serverDB && ctx.userId) {
|
||||
const topicModel = new TopicModel(ctx.serverDB, ctx.userId);
|
||||
const messageModel = new MessageModelClass(ctx.serverDB, ctx.userId);
|
||||
topicReferences = await resolveTopicReferences(
|
||||
llmPayload.messages as Array<{ content: string | unknown }>,
|
||||
async (topicId) => topicModel.findById(topicId),
|
||||
async (topicId) => {
|
||||
const topic = await topicModel.findById(topicId);
|
||||
return messageModel.query({
|
||||
agentId: topic?.agentId ?? undefined,
|
||||
groupId: topic?.groupId ?? undefined,
|
||||
topicId,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const contextEngineInput = {
|
||||
additionalVariables: state.metadata?.deviceSystemInfo,
|
||||
userTimezone: ctx.userTimezone,
|
||||
@@ -235,6 +264,9 @@ export const createRuntimeExecutors = (
|
||||
...(state.metadata?.skillMetas?.length && {
|
||||
skillsConfig: { enabledSkills: state.metadata.skillMetas },
|
||||
}),
|
||||
|
||||
// Topic reference summaries
|
||||
...(topicReferences && { topicReferences }),
|
||||
};
|
||||
|
||||
processedMessages = await serverMessagesEngine(contextEngineInput);
|
||||
|
||||
@@ -1100,6 +1100,61 @@ describe('RuntimeExecutors', () => {
|
||||
name: 'Enabled KB',
|
||||
});
|
||||
});
|
||||
|
||||
it('should skip topic reference resolution when messages already contain topic_reference_context', async () => {
|
||||
const ctxWithConfig: RuntimeExecutorContext = {
|
||||
...ctx,
|
||||
agentConfig: { plugins: [], systemRole: 'test' },
|
||||
};
|
||||
const executors = createRuntimeExecutors(ctxWithConfig);
|
||||
const state = createMockState();
|
||||
|
||||
const instruction = {
|
||||
payload: {
|
||||
messages: [
|
||||
{
|
||||
content:
|
||||
'<refer_topic name="Old topic" id="topic-abc" />\nHello\n<system_context>\n<context type="topic_reference_context">\n<referred_topics>...</referred_topics>\n</context>\n</system_context>',
|
||||
role: 'user',
|
||||
},
|
||||
],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
},
|
||||
type: 'call_llm' as const,
|
||||
};
|
||||
|
||||
await executors.call_llm!(instruction, state);
|
||||
|
||||
expect(engineSpy).toHaveBeenCalledTimes(1);
|
||||
const callArgs = engineSpy.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty('topicReferences');
|
||||
});
|
||||
|
||||
it('should resolve topic references when messages do not contain topic_reference_context', async () => {
|
||||
const ctxWithConfig: RuntimeExecutorContext = {
|
||||
...ctx,
|
||||
agentConfig: { plugins: [], systemRole: 'test' },
|
||||
};
|
||||
const executors = createRuntimeExecutors(ctxWithConfig);
|
||||
const state = createMockState();
|
||||
|
||||
const instruction = {
|
||||
payload: {
|
||||
messages: [{ content: 'Just a normal message without any topic refs', role: 'user' }],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
},
|
||||
type: 'call_llm' as const,
|
||||
};
|
||||
|
||||
await executors.call_llm!(instruction, state);
|
||||
|
||||
expect(engineSpy).toHaveBeenCalledTimes(1);
|
||||
// resolveTopicReferences ran but found no <refer_topic> tags → topicReferences is undefined
|
||||
const callArgs = engineSpy.mock.calls[0][0];
|
||||
expect(callArgs).not.toHaveProperty('topicReferences');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ export const serverMessagesEngine = async ({
|
||||
evalContext,
|
||||
agentManagementContext,
|
||||
pageContentContext,
|
||||
topicReferences,
|
||||
additionalVariables,
|
||||
userTimezone,
|
||||
}: ServerMessagesEngineParams): Promise<OpenAIChatMessage[]> => {
|
||||
@@ -140,6 +141,9 @@ export const serverMessagesEngine = async ({
|
||||
// Skills configuration
|
||||
...(skillsConfig?.enabledSkills && skillsConfig.enabledSkills.length > 0 && { skillsConfig }),
|
||||
|
||||
// Topic references
|
||||
...(topicReferences && topicReferences.length > 0 && { topicReferences }),
|
||||
|
||||
// Extended contexts
|
||||
...(agentBuilderContext && { agentBuilderContext }),
|
||||
...(discordContext && { discordContext }),
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
KnowledgeBaseInfo,
|
||||
LobeToolManifest,
|
||||
SkillMeta,
|
||||
TopicReferenceItem,
|
||||
UserMemoryData,
|
||||
} from '@lobechat/context-engine';
|
||||
import type { PageContentContext } from '@lobechat/prompts';
|
||||
@@ -120,6 +121,9 @@ export interface ServerMessagesEngineParams {
|
||||
// ========== Tools ==========
|
||||
/** Tools configuration */
|
||||
toolsConfig?: ServerToolsConfig;
|
||||
// ========== Topic References ==========
|
||||
/** Topic reference summaries to inject into last user message */
|
||||
topicReferences?: TopicReferenceItem[];
|
||||
// ========== User memory ==========
|
||||
/** User memory configuration */
|
||||
userMemory?: ServerUserMemoryConfig;
|
||||
@@ -134,6 +138,7 @@ export {
|
||||
type EvalContext,
|
||||
type FileContent,
|
||||
type KnowledgeBaseInfo,
|
||||
type TopicReferenceItem,
|
||||
type UserMemoryData,
|
||||
} from '@lobechat/context-engine';
|
||||
export type { PageContentContext } from '@lobechat/prompts';
|
||||
|
||||
@@ -16,7 +16,7 @@ import type {
|
||||
ToolDiscoveryConfig,
|
||||
UserMemoryData,
|
||||
} from '@lobechat/context-engine';
|
||||
import { MessagesEngine } from '@lobechat/context-engine';
|
||||
import { MessagesEngine, resolveTopicReferences } from '@lobechat/context-engine';
|
||||
import { historySummaryPrompt } from '@lobechat/prompts';
|
||||
import type {
|
||||
OpenAIChatMessage,
|
||||
@@ -35,6 +35,7 @@ import { getChatGroupStoreState } from '@/store/agentGroup';
|
||||
import { agentGroupSelectors } from '@/store/agentGroup/selectors';
|
||||
import { getAiInfraStoreState } from '@/store/aiInfra';
|
||||
import { getChatStoreState } from '@/store/chat';
|
||||
import { topicSelectors } from '@/store/chat/selectors';
|
||||
import { getToolStoreState } from '@/store/tool';
|
||||
import {
|
||||
builtinToolSelectors,
|
||||
@@ -515,6 +516,23 @@ export const contextEngineering = async ({
|
||||
log('mentionedAgents injected: %d agents', initialContext!.mentionedAgents!.length);
|
||||
}
|
||||
|
||||
// Resolve topic references from messages containing <refer_topic> tags
|
||||
const topicReferences = await resolveTopicReferences(
|
||||
messages,
|
||||
async (topicId: string) => {
|
||||
const topic = topicSelectors.getTopicById(topicId)(getChatStoreState());
|
||||
return topic ?? null;
|
||||
},
|
||||
async (topicId: string) => {
|
||||
const { messageService } = await import('@/services/message');
|
||||
const msgs = await messageService.getMessages({ agentId, groupId, topicId });
|
||||
return msgs.map((m) => ({
|
||||
content: typeof m.content === 'string' ? m.content : '',
|
||||
role: m.role,
|
||||
}));
|
||||
},
|
||||
);
|
||||
|
||||
// Create MessagesEngine with injected dependencies
|
||||
const engine = new MessagesEngine({
|
||||
// Agent configuration
|
||||
@@ -582,6 +600,7 @@ export const contextEngineering = async ({
|
||||
...((isAgentManagementEnabled || hasMentionedAgents) && { agentManagementContext }),
|
||||
...(agentGroup && { agentGroup }),
|
||||
...(gtdConfig && { gtd: gtdConfig }),
|
||||
...(topicReferences && topicReferences.length > 0 && { topicReferences }),
|
||||
});
|
||||
|
||||
log('Input messages count: %d', messages.length);
|
||||
|
||||
Reference in New Issue
Block a user