feat: refactor to use agent runtime as the generation core and support branch mode (#10080)

* refactor

* refactor

* refactor message group

* wip

# Conflicts:
#	src/store/chat/slices/aiChat/actions/generateAIChatV2.ts

* refactor

* refactor agent mode

* fix style

* refactor agent executors

* finish the refactor

* remove gpt-tokenizer

* add metadata api

* add fix

* support branch

* fix branch render data

* fix send issue

* refactor style

* refactor style

* refactor tests

* refactor chatStore

* refactor from model to model

* fix tests

* refactor regenerate mode

* update style

* fix lint

* refactor

* refactor

* refactor

* fix delete

* refactor thread mode

* fix basic experience

* fix

* fix tests

* fix manual add

* fix tests

* fix group
This commit is contained in:
Arvin Xu
2025-11-07 16:44:03 +08:00
committed by GitHub
parent c3c4319625
commit b95e741717
148 changed files with 7560 additions and 8522 deletions
+5 -6
View File
@@ -36,7 +36,6 @@ const nextConfig: NextConfig = {
'@icons-pack/react-simple-icons',
'@lobehub/ui',
'@lobehub/icons',
'gpt-tokenizer',
],
// oidc provider depend on constructor.name
// but swc minification will remove the name
@@ -269,7 +268,7 @@ const nextConfig: NextConfig = {
],
// when external packages in dev mode with turbopack, this config will lead to bundle error
serverExternalPackages: isProd ? ['@electric-sql/pglite', "pdfkit"] : ["pdfkit"],
serverExternalPackages: isProd ? ['@electric-sql/pglite', 'pdfkit'] : ['pdfkit'],
transpilePackages: ['pdfjs-dist', 'mermaid'],
turbopack: {},
@@ -335,10 +334,10 @@ const withBundleAnalyzer = process.env.ANALYZE === 'true' ? analyzer() : noWrapp
const withPWA =
isProd && !isDesktop
? withSerwistInit({
register: false,
swDest: 'public/sw.js',
swSrc: 'src/app/sw.ts',
})
register: false,
swDest: 'public/sw.js',
swSrc: 'src/app/sw.ts',
})
: noWrapper;
export default withBundleAnalyzer(withPWA(nextConfig as NextConfig));
+1 -1
View File
@@ -150,6 +150,7 @@
"@lobechat/agent-runtime": "workspace:*",
"@lobechat/const": "workspace:*",
"@lobechat/context-engine": "workspace:*",
"@lobechat/conversation-flow": "workspace:*",
"@lobechat/database": "workspace:*",
"@lobechat/electron-client-ipc": "workspace:*",
"@lobechat/electron-server-ipc": "workspace:*",
@@ -208,7 +209,6 @@
"fflate": "^0.8.2",
"file-type": "^21.0.0",
"framer-motion": "^12.23.24",
"gpt-tokenizer": "^3.2.0",
"gray-matter": "^4.0.3",
"html-to-text": "^9.0.5",
"i18next": "^25.6.0",
@@ -117,13 +117,12 @@ describe('AgentRuntime', () => {
const runtime = new AgentRuntime(agent);
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
const toolCall: ToolsCalling = {
const toolCall = {
id: 'call_123',
type: 'function',
function: {
name: 'test_tool',
arguments: '{"input": "test"}',
},
apiName: 'test_tool',
identifier: 'test_tool',
arguments: '{"input": "test"}',
type: 'default' as const,
};
const result = await runtime.approveToolCall(state, toolCall);
@@ -288,13 +287,12 @@ describe('AgentRuntime', () => {
const runtime = new AgentRuntime(agent);
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
const toolCall: ToolsCalling = {
const toolCall = {
id: 'call_123',
type: 'function',
function: {
name: 'calculator',
arguments: '{"expression": "2+2"}',
},
apiName: 'calculator',
identifier: 'calculator',
arguments: '{"expression": "2+2"}',
type: 'default' as const,
};
const result = await runtime.approveToolCall(state, toolCall);
@@ -320,13 +318,12 @@ describe('AgentRuntime', () => {
const runtime = new AgentRuntime(agent);
const state = AgentRuntime.createInitialState({ sessionId: 'test-session' });
const toolCall: ToolsCalling = {
const toolCall = {
id: 'call_123',
type: 'function',
function: {
name: 'unknown_tool',
arguments: '{}',
},
apiName: 'unknown_tool',
identifier: 'unknown_tool',
arguments: '{}',
type: 'default' as const,
};
const result = await runtime.approveToolCall(state, toolCall);
@@ -1022,7 +1019,14 @@ describe('AgentRuntime', () => {
expect(result.newState.pendingToolsCalling).toHaveLength(1);
// Step 2: Approve and execute tool call
const toolCall = result.newState.pendingToolsCalling![0];
const pendingToolCall = result.newState.pendingToolsCalling![0];
const toolCall = {
id: pendingToolCall.id,
apiName: pendingToolCall.function.name,
identifier: pendingToolCall.function.name,
arguments: pendingToolCall.function.arguments,
type: 'default' as const,
};
result = await runtime.approveToolCall(result.newState, toolCall);
// Should have executed tool
@@ -1121,17 +1125,27 @@ describe('AgentRuntime', () => {
return [
{
payload: {
id: 'call_1',
type: 'function' as const,
function: { name: 'tool_1', arguments: '{}' },
parentMessageId: 'user-msg-id',
toolCalling: {
id: 'call_1',
type: 'default' as const,
apiName: 'tool_1',
identifier: 'tool_1',
arguments: '{}',
},
},
type: 'call_tool' as const,
},
{
payload: {
id: 'call_2',
type: 'function' as const,
function: { name: 'tool_2', arguments: '{}' },
parentMessageId: 'user-msg-id',
toolCalling: {
id: 'call_2',
type: 'default' as const,
apiName: 'tool_2',
identifier: 'tool_2',
arguments: '{}',
},
},
type: 'call_tool' as const,
},
@@ -1175,9 +1189,14 @@ describe('AgentRuntime', () => {
return [
{
payload: {
id: 'call_safe',
type: 'function' as const,
function: { name: 'safe_tool', arguments: '{}' },
parentMessageId: 'user-msg-id',
toolCalling: {
id: 'call_safe',
type: 'default' as const,
apiName: 'safe_tool',
identifier: 'safe_tool',
arguments: '{}',
},
},
type: 'call_tool' as const,
},
@@ -1252,18 +1271,25 @@ describe('AgentRuntime', () => {
async runner(context: AgentRuntimeContext, _state: AgentState) {
if (context.phase === 'user_input') {
return {
payload: [
{
id: 'call_expensive',
type: 'function' as const,
function: { name: 'expensive_tool', arguments: '{}' },
},
{
id: 'call_cheap',
type: 'function' as const,
function: { name: 'cheap_tool', arguments: '{}' },
},
],
payload: {
parentMessageId: 'user-msg-id',
toolsCalling: [
{
id: 'call_expensive',
type: 'default' as const,
apiName: 'expensive_tool',
identifier: 'expensive_tool',
arguments: '{}',
},
{
id: 'call_cheap',
type: 'default' as const,
apiName: 'cheap_tool',
identifier: 'cheap_tool',
arguments: '{}',
},
],
},
type: 'call_tools_batch' as const,
};
}
@@ -1380,11 +1406,14 @@ describe('AgentRuntime', () => {
if (context.phase === 'user_input') {
return {
payload: {
apiName: 'expensive_tool',
arguments: '{}',
id: 'call_1',
identifier: 'expensive_tool',
type: 'default' as const,
parentMessageId: 'user-msg-id',
toolCalling: {
apiName: 'expensive_tool',
arguments: '{}',
id: 'call_1',
identifier: 'expensive_tool',
type: 'default' as const,
},
},
type: 'call_tool' as const,
};
@@ -1458,22 +1487,25 @@ describe('AgentRuntime', () => {
async runner(context: AgentRuntimeContext, _state: AgentState) {
if (context.phase === 'user_input') {
return {
payload: [
{
apiName: 'tool_1',
arguments: '{}',
id: 'call_1',
identifier: 'tool_1',
type: 'default' as const,
},
{
apiName: 'tool_2',
arguments: '{}',
id: 'call_2',
identifier: 'tool_2',
type: 'default' as const,
},
],
payload: {
parentMessageId: 'user-msg-id',
toolsCalling: [
{
apiName: 'tool_1',
arguments: '{}',
id: 'call_1',
identifier: 'tool_1',
type: 'default' as const,
},
{
apiName: 'tool_2',
arguments: '{}',
id: 'call_2',
identifier: 'tool_2',
type: 'default' as const,
},
],
},
type: 'call_tools_batch' as const,
};
}
@@ -1535,22 +1567,25 @@ describe('AgentRuntime', () => {
async runner(context: AgentRuntimeContext, _state: AgentState) {
if (context.phase === 'user_input') {
return {
payload: [
{
apiName: 'analytics_tool',
arguments: '{}',
id: 'call_analytics',
identifier: 'analytics_tool',
type: 'default' as const,
},
{
apiName: 'logging_tool',
arguments: '{}',
id: 'call_logging',
identifier: 'logging_tool',
type: 'default' as const,
},
],
payload: {
parentMessageId: 'user-msg-id',
toolsCalling: [
{
apiName: 'analytics_tool',
arguments: '{}',
id: 'call_analytics',
identifier: 'analytics_tool',
type: 'default' as const,
},
{
apiName: 'logging_tool',
arguments: '{}',
id: 'call_logging',
identifier: 'logging_tool',
type: 'default' as const,
},
],
},
type: 'call_tools_batch' as const,
};
}
+63 -18
View File
@@ -1,10 +1,16 @@
import type {
import { ChatToolPayload } from '@lobechat/types';
import pMap from 'p-map';
import {
Agent,
AgentEvent,
AgentInstruction,
AgentInstructionCallTool,
AgentInstructionCallToolsBatch,
AgentRuntimeContext,
AgentState,
Cost,
GeneralAgentCallToolsBatchResultPayload,
InstructionExecutor,
RuntimeConfig,
ToolRegistry,
@@ -79,8 +85,16 @@ export class AgentRuntime {
// Handle human approved tool calls
if (runtimeContext.phase === 'human_approved_tool') {
const approvedPayload = runtimeContext.payload as { approvedToolCall: ToolsCalling };
rawInstructions = { payload: approvedPayload.approvedToolCall, type: 'call_tool' };
const approvedPayload = runtimeContext.payload as { approvedToolCall: ChatToolPayload };
const toolCalling = approvedPayload.approvedToolCall;
rawInstructions = {
payload: {
parentMessageId: '', // Not required for approval flow
toolCalling,
},
type: 'call_tool',
};
} else {
// Standard flow: Plan -> Execute
rawInstructions = await this.agent.runner(runtimeContext, newState);
@@ -89,12 +103,37 @@ export class AgentRuntime {
// Normalize to array
const instructions = Array.isArray(rawInstructions) ? rawInstructions : [rawInstructions];
// Convert old format to new format
const normalizedInstructions = instructions.map((instruction) => {
if (
instruction.type === 'call_tools_batch' && // Check if payload is array of ToolsCalling (old format)
Array.isArray(instruction.payload)
) {
const toolsCalling = instruction.payload.map((tc: ToolsCalling) => ({
apiName: tc.function.name,
arguments: tc.function.arguments,
id: tc.id,
identifier: tc.function.name,
type: 'default' as any,
}));
return {
payload: {
parentMessageId: '',
toolsCalling,
},
type: 'call_tools_batch',
};
}
return instruction;
});
// Execute all instructions sequentially
let currentState = newState;
const allEvents: AgentEvent[] = [];
let finalNextContext: AgentRuntimeContext | undefined = undefined;
for (const instruction of instructions) {
for (const instruction of normalizedInstructions) {
let result;
// Special handling for batch tool execution
@@ -147,7 +186,7 @@ export class AgentRuntime {
*/
async approveToolCall(
state: AgentState,
approvedToolCall: any,
approvedToolCall: ChatToolPayload,
): Promise<{ events: AgentEvent[]; newState: AgentState; nextContext?: AgentRuntimeContext }> {
const context: AgentRuntimeContext = {
payload: { approvedToolCall },
@@ -414,7 +453,7 @@ export class AgentRuntime {
/** Create call_tool executor */
private createCallToolExecutor(): InstructionExecutor {
return async (instruction, state) => {
const { payload: toolCall } = instruction as Extract<AgentInstruction, { type: 'call_tool' }>;
const { payload } = instruction as AgentInstructionCallTool;
const newState = structuredClone(state);
const events: AgentEvent[] = [];
@@ -423,9 +462,10 @@ export class AgentRuntime {
const tools = this.agent.tools || ({} as ToolRegistry);
const toolCall = payload.toolCalling;
// Support both ToolsCalling (OpenAI format) and CallingToolPayload formats
const toolName = toolCall.apiName || toolCall.function?.name;
const toolArgs = toolCall.arguments || toolCall.function?.arguments;
const toolName = toolCall.apiName;
const toolArgs = toolCall.arguments;
const toolId = toolCall.id;
const handler = tools[toolName];
@@ -586,27 +626,30 @@ export class AgentRuntime {
* Execute multiple tool calls concurrently
*/
private async executeToolsBatch(
instruction: { payload: any[]; type: 'call_tools_batch' },
instruction: AgentInstructionCallToolsBatch,
baseState: AgentState,
): Promise<{
events: AgentEvent[];
newState: AgentState;
nextContext?: AgentRuntimeContext;
}> {
const { payload: toolsCalling } = instruction;
const { payload } = instruction;
// Execute all tools concurrently based on the same state
const results = await Promise.all(
toolsCalling.map((toolCall) =>
this.executors.call_tool(
{ payload: toolCall, type: 'call_tool' } as any,
structuredClone(baseState), // Each tool starts from the same base state
),
const results = await pMap(instruction.payload.toolsCalling, (toolCalling: ChatToolPayload) =>
this.executors.call_tool(
{
payload: { parentMessageId: payload.parentMessageId, toolCalling },
type: 'call_tool',
} as AgentInstructionCallTool,
structuredClone(baseState), // Each tool starts from the same base state
),
);
const lastParentMessageId = (results.at(-1)!.nextContext?.payload as any)
?.parentMessageId as string;
// Merge results
return this.mergeToolResults(results, baseState);
return this.mergeToolResults(results, baseState, lastParentMessageId);
}
/**
@@ -619,6 +662,7 @@ export class AgentRuntime {
nextContext?: AgentRuntimeContext;
}>,
baseState: AgentState,
lastParentMessageId: string,
): {
events: AgentEvent[];
newState: AgentState;
@@ -679,9 +723,10 @@ export class AgentRuntime {
newState,
nextContext: {
payload: {
parentMessageId: lastParentMessageId,
toolCount: results.length,
toolResults: results.map((r) => r.nextContext?.payload),
},
} as GeneralAgentCallToolsBatchResultPayload,
phase: 'tools_batch_result',
session: this.createSessionContext(newState),
},
@@ -0,0 +1,55 @@
import { ChatToolPayload, MessageToolCall } from '@lobechat/types';
export interface GeneralAgentCallLLMInstructionPayload {
isFirstMessage?: boolean;
messages: any[];
model: string;
parentMessageId?: string;
provider: string;
tools: any[];
}
export interface GeneralAgentCallLLMResultPayload {
hasToolsCalling: boolean;
parentMessageId: string;
result: { content: string; tool_calls: MessageToolCall[] };
toolsCalling: ChatToolPayload[];
}
export interface GeneralAgentCallingToolInstructionPayload {
parentMessageId: string;
toolCalling: ChatToolPayload;
}
export interface GeneralAgentCallToolResultPayload {
data: any;
executionTime: number;
isSuccess: boolean;
parentMessageId: string;
toolCall: ChatToolPayload;
toolCallId: string;
}
export interface GeneralAgentCallToolsBatchInstructionPayload {
parentMessageId: string;
toolsCalling: ChatToolPayload[];
}
export interface GeneralAgentCallToolsBatchResultPayload {
parentMessageId: string;
toolCount: number;
toolResults: GeneralAgentCallToolResultPayload[];
}
export interface GeneralAgentConfig {
agentConfig?: {
[key: string]: any;
maxSteps?: number;
};
modelRuntimeConfig?: {
model: string;
provider: string;
};
sessionId: string;
userId?: string;
}
@@ -1,4 +1,5 @@
export * from './event';
export * from './generalAgent';
export * from './instruction';
export * from './runtime';
export * from './state';
@@ -1,4 +1,4 @@
import type { ModelUsage } from '@lobechat/types';
import { ChatToolPayload, ModelUsage } from '@lobechat/types';
import type { FinishReason } from './event';
import { AgentState, ToolRegistry, ToolsCalling } from './state';
@@ -86,6 +86,7 @@ export interface CallLLMPayload {
isFirstMessage?: boolean;
messages: any[];
model: string;
parentId?: string;
provider: string;
tools: any[];
}
@@ -104,12 +105,18 @@ export interface AgentInstructionCallLlm {
}
export interface AgentInstructionCallTool {
payload: any;
payload: {
parentMessageId: string;
toolCalling: ChatToolPayload;
};
type: 'call_tool';
}
export interface AgentInstructionCallToolsBatch {
payload: any[];
payload: {
parentMessageId: string;
toolsCalling: ChatToolPayload[];
} & any;
type: 'call_tools_batch';
}
-1
View File
@@ -6,7 +6,6 @@ export const DEFAULT_PREFERENCE: UserPreference = {
topic: true,
},
lab: {
enableAssistantMessageGroup: false,
enableGroupChat: false,
enableInputMarkdown: true,
},
@@ -7,9 +7,9 @@ const log = debug('context-engine:processor:GroupMessageFlattenProcessor');
/**
* Group Message Flatten Processor
* Responsible for flattening role=group messages into standard assistant + tool message sequences
* Responsible for flattening role=assistantGroup messages into standard assistant + tool message sequences
*
* Group messages are created when assistant messages with tools are merged with their tool results.
* AssistantGroup messages are created when assistant messages with tools are merged with their tool results.
* This processor converts them back to a flat structure that AI models can understand.
*/
export class GroupMessageFlattenProcessor extends BaseProcessor {
@@ -31,8 +31,8 @@ export class GroupMessageFlattenProcessor extends BaseProcessor {
// Process each message
for (const message of clonedContext.messages) {
// Check if this is a group message with children field
if (message.role === 'group' && message.children) {
// Check if this is an assistantGroup message with children field
if (message.role === 'assistantGroup' && message.children) {
// If children array is empty, skip this message entirely (no content to flatten)
if (message.children.length === 0) {
continue;
@@ -41,7 +41,9 @@ export class GroupMessageFlattenProcessor extends BaseProcessor {
processedCount++;
groupMessagesFlattened++;
log(`Flattening group message ${message.id} with ${message.children.length} children`);
log(
`Flattening assistantGroup message ${message.id} with ${message.children.length} children`,
);
// Flatten each child
for (const child of message.children) {
@@ -146,7 +148,7 @@ export class GroupMessageFlattenProcessor extends BaseProcessor {
clonedContext.metadata.toolMessagesCreated = toolMessagesCreated;
log(
`Group message flatten processing completed: ${groupMessagesFlattened} groups flattened, ${assistantMessagesCreated} assistant messages created, ${toolMessagesCreated} tool messages created`,
`AssistantGroup message flatten processing completed: ${groupMessagesFlattened} groups flattened, ${assistantMessagesCreated} assistant messages created, ${toolMessagesCreated} tool messages created`,
);
return this.markAsExecuted(clonedContext);
@@ -18,7 +18,7 @@ describe('GroupMessageFlattenProcessor', () => {
const input: any[] = [
{
id: 'msg-group-1',
role: 'group',
role: 'assistantGroup',
content: '',
createdAt: '2025-10-27T10:00:00.000Z',
updatedAt: '2025-10-27T10:00:10.000Z',
@@ -98,7 +98,7 @@ describe('GroupMessageFlattenProcessor', () => {
const input: any[] = [
{
id: 'msg-group-1',
role: 'group',
role: 'assistantGroup',
content: '',
children: [
{
@@ -168,7 +168,7 @@ describe('GroupMessageFlattenProcessor', () => {
const input: any[] = [
{
id: 'msg-group-1',
role: 'group',
role: 'assistantGroup',
content: '',
children: [
{
@@ -207,7 +207,7 @@ describe('GroupMessageFlattenProcessor', () => {
const input: any[] = [
{
id: 'msg-group-1',
role: 'group',
role: 'assistantGroup',
content: '',
children: [
{
@@ -255,7 +255,7 @@ describe('GroupMessageFlattenProcessor', () => {
},
{
id: 'msg-group-1',
role: 'group',
role: 'assistantGroup',
content: '',
children: [
{
@@ -307,7 +307,7 @@ describe('GroupMessageFlattenProcessor', () => {
const input: any[] = [
{
id: 'msg-group-1',
role: 'group',
role: 'assistantGroup',
content: '',
children: [],
},
@@ -326,7 +326,7 @@ describe('GroupMessageFlattenProcessor', () => {
const input: any[] = [
{
id: 'msg-group-1',
role: 'group',
role: 'assistantGroup',
content: '',
// No children field
},
@@ -346,7 +346,7 @@ describe('GroupMessageFlattenProcessor', () => {
const input: any[] = [
{
id: 'msg-group-1',
role: 'group',
role: 'assistantGroup',
content: '',
children: [
{
@@ -378,7 +378,7 @@ describe('GroupMessageFlattenProcessor', () => {
const input: any[] = [
{
id: 'msg-group-1',
role: 'group',
role: 'assistantGroup',
content: '',
children: [
{
@@ -410,7 +410,7 @@ describe('GroupMessageFlattenProcessor', () => {
const input: any[] = [
{
id: 'msg-group-1',
role: 'group',
role: 'assistantGroup',
content: '',
children: [
{
@@ -442,7 +442,7 @@ describe('GroupMessageFlattenProcessor', () => {
const input: any[] = [
{
id: 'msg-group-1',
role: 'group',
role: 'assistantGroup',
content: '',
parentId: 'parent-1',
threadId: 'thread-1',
@@ -497,7 +497,7 @@ describe('GroupMessageFlattenProcessor', () => {
const input: any[] = [
{
id: 'msg_LnIlOyMUnX1ylf',
role: 'group',
role: 'assistantGroup',
content: '',
createdAt: '2025-10-27T10:47:59.475Z',
updatedAt: '2025-10-27T10:48:10.768Z',
@@ -0,0 +1,249 @@
[
{
"id": "msg_6zcyh1hELkzOmu",
"role": "user",
"content": "你好",
"createdAt": "2025-11-07T07:19:44.264Z",
"updatedAt": "2025-11-07T07:19:44.264Z",
"topicId": "tpc_ruVgxvO16Ljh",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
{
"id": "msg_xXSCL5uzwDANSn",
"role": "assistant",
"content": "你好!我是您的AI助手,很高兴为您服务!我可以帮您:\n\n🔍 **搜索网络信息** - 查找最新资讯、技术文档、学术资料等\n💻 **搜索GitHub代码** - 查找真实项目中的代码示例和实现模式\n📄 **浏览网页内容** - 获取特定网页的详细信息\n\n请告诉我您需要什么帮助?比如:\n- 想了解某个技术概念或库的使用方法\n- 需要查找最新的新闻或资讯\n- 想看看其他开发者如何实现特定功能\n- 需要获取某个网页的详细内容\n\n我会根据您的需求选择最合适的工具来为您提供帮助!",
"metadata": {
"tps": 21.633839199225054,
"cost": 0.000171,
"ttft": 1287,
"latency": 7481,
"duration": 6194,
"totalTokens": 3934,
"inputTextTokens": 3800,
"outputTextTokens": 134,
"totalInputTokens": 3800,
"inputCachedTokens": 3776,
"totalOutputTokens": 134,
"inputCacheMissTokens": 24
},
"createdAt": "2025-11-07T07:19:44.908Z",
"updatedAt": "2025-11-07T07:19:53.454Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_6zcyh1hELkzOmu",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
},
{
"id": "msg_HhaoxginXhbfU9",
"role": "user",
"content": "你好",
"metadata": {
"activeBranchIndex": 1
},
"createdAt": "2025-11-07T07:21:05.366Z",
"updatedAt": "2025-11-07T07:24:17.810Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_xXSCL5uzwDANSn",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
{
"id": "msg_h5nXrbCVGj2uWy",
"role": "assistant",
"content": "你好!很高兴再次与您交流!😊\n\n我是您的AI助手,可以为您提供多种帮助:\n\n**我能做什么:**\n- 🔍 **网络搜索** - 查找最新资讯、技术文档、学术资料等\n- 💻 **GitHub代码搜索** - 查找真实项目中的代码示例和最佳实践\n- 📄 **网页内容获取** - 提取特定网页的详细信息\n\n**例如您可以问我:**\n- \"帮我搜索一下React Hooks的最佳实践\"\n- \"查找Python中如何处理异常的真实代码示例\"\n- \"获取某个技术博客的最新文章内容\"\n- \"了解当前热门的技术趋势\"\n\n请告诉我您今天需要什么帮助?我会根据您的具体需求选择最合适的工具来为您服务!",
"metadata": {
"tps": 20.645337708504236,
"cost": 0.000216,
"ttft": 1524,
"latency": 8838,
"duration": 7314,
"totalTokens": 4086,
"inputTextTokens": 3935,
"outputTextTokens": 151,
"totalInputTokens": 3935,
"inputCachedTokens": 3776,
"totalOutputTokens": 151,
"inputCacheMissTokens": 159
},
"createdAt": "2025-11-07T07:21:11.767Z",
"updatedAt": "2025-11-07T07:21:20.560Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_HhaoxginXhbfU9",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
},
{
"id": "msg_eEAXz3HLKeAmEQ",
"role": "assistant",
"content": "你好!😊 我是您的AI助手,很高兴再次与您交流!\n\n我可以为您提供多种帮助:\n\n🔍 **网络搜索** - 查找最新资讯、技术文档、学术资料等\n💻 **GitHub代码搜索** - 查找真实项目中的代码示例和实现模式\n📄 **网页内容抓取** - 获取特定网页的详细信息\n\n请告诉我您需要什么帮助?比如:\n- 想了解某个编程概念或库的使用方法\n- 需要查找最新的技术资讯或新闻\n- 想看看其他开发者如何实现特定功能\n- 需要获取某个网页的详细内容\n\n我会根据您的需求选择最合适的工具来为您服务!",
"metadata": {
"tps": 23.98775093569241,
"cost": 0.000179,
"ttft": 1522,
"latency": 7400,
"duration": 5878,
"totalTokens": 4076,
"inputTextTokens": 3935,
"outputTextTokens": 141,
"totalInputTokens": 3935,
"inputCachedTokens": 3904,
"totalOutputTokens": 141,
"inputCacheMissTokens": 31
},
"createdAt": "2025-11-07T07:21:41.587Z",
"updatedAt": "2025-11-07T07:21:48.866Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_HhaoxginXhbfU9",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
},
{
"id": "msg_GjcBeBlQ69teA7",
"role": "assistant",
"content": "你好!我是您的AI助手,很高兴为您服务!\n\n我可以为您提供以下帮助:\n\n🔍 **网络搜索** - 查找最新的新闻、技术文档、学术资料等\n💻 **GitHub代码搜索** - 查找真实项目中的代码示例和最佳实践\n📄 **网页内容获取** - 获取特定网页的详细信息\n\n请告诉我您需要什么帮助?比如:\n- 想了解某个技术概念或库的使用方法\n- 需要查找最新的新闻或资讯\n- 想看看其他开发者如何实现特定功能\n- 需要获取某个网页的详细内容\n\n我会根据您的需求选择最合适的工具来为您提供帮助!",
"metadata": {
"tps": 23.99573409171703,
"cost": 0.000176,
"ttft": 1194,
"latency": 6820,
"duration": 5626,
"totalTokens": 4070,
"inputTextTokens": 3935,
"outputTextTokens": 135,
"totalInputTokens": 3935,
"inputCachedTokens": 3904,
"totalOutputTokens": 135,
"inputCacheMissTokens": 31
},
"createdAt": "2025-11-07T07:22:25.223Z",
"updatedAt": "2025-11-07T07:22:32.162Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_HhaoxginXhbfU9",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
},
{
"id": "msg_nSG0QuSDSmYlXj",
"role": "user",
"content": "你好",
"createdAt": "2025-11-07T07:23:33.255Z",
"updatedAt": "2025-11-07T07:23:33.255Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_eEAXz3HLKeAmEQ",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
{
"id": "msg_VMXrVH9hXeCP1R",
"role": "user",
"content": "你是谁?",
"createdAt": "2025-11-07T07:23:49.990Z",
"updatedAt": "2025-11-07T07:23:49.990Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_GjcBeBlQ69teA7",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
{
"id": "msg_wpUElxuYslyASJ",
"role": "assistant",
"content": "今天天气怎么样?",
"createdAt": "2025-11-07T07:24:15.934Z",
"updatedAt": "2025-11-07T07:24:15.934Z",
"topicId": "tpc_ruVgxvO16Ljh",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
{
"id": "msg_LHG8QeuqpcFZml",
"role": "user",
"content": "你好",
"createdAt": "2025-11-07T07:35:12.401Z",
"updatedAt": "2025-11-07T07:35:12.401Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_wpUElxuYslyASJ",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
{
"id": "msg_jEKKxHQbz5duC6",
"role": "assistant",
"content": "...",
"createdAt": "2025-11-07T07:35:13.056Z",
"updatedAt": "2025-11-07T07:35:13.056Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_LHG8QeuqpcFZml",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
}
]
@@ -1,14 +1,18 @@
import type { Message } from '../../../../types';
import activeIndex1 from './active-index-1.json';
import assistantBranch from './assistant-branch.json';
import assistantGroupBranches from './assistant-group-branches.json';
import assistantUserBranch from './assistant-user-branch.json';
import conversation from './conversation.json';
import multiAssistantGroup from './multi-assistant-group.json';
import nested from './nested.json';
export const branch = {
activeIndex1: activeIndex1 as Message[],
assistantBranch: assistantBranch as Message[],
assistantGroupBranches: assistantGroupBranches as unknown as Message[],
assistantUserBranch: assistantUserBranch as Message[],
conversation: conversation as Message[],
multiAssistantGroup: multiAssistantGroup as Message[],
nested: nested as Message[],
};
@@ -0,0 +1,260 @@
[
{
"id": "msg-user-1",
"role": "user",
"content": "杭州和北京天气如何",
"parentId": null,
"createdAt": 1704067200000,
"updatedAt": 1704067200000,
"meta": {},
"metadata": {
"activeBranchIndex": 2
}
},
{
"id": "msg-assistant-1",
"role": "assistant",
"content": "",
"parentId": "msg-user-1",
"createdAt": 1704067202000,
"updatedAt": 1704067202000,
"meta": {},
"metadata": {
"totalTokens": 3751,
"totalInputTokens": 3441,
"totalOutputTokens": 310
},
"tools": [
{
"id": "call-1",
"type": "builtin",
"apiName": "search",
"arguments": "{\"query\":\"杭州 北京 天气 现在\"}",
"identifier": "lobe-web-browsing"
}
]
},
{
"id": "msg-tool-1",
"role": "tool",
"content": "<searchResults>\n <item title=\"Weather Site\" url=\"https://weather.example.com/\">Weather information...</item>\n</searchResults>",
"parentId": "msg-assistant-1",
"createdAt": 1704067205000,
"updatedAt": 1704067205000,
"meta": {},
"tool_call_id": "call-1",
"plugin": {
"apiName": "search",
"identifier": "lobe-web-browsing",
"type": "builtin"
},
"pluginState": {
"query": "杭州 北京 天气 现在",
"results": [
{
"url": "https://weather.example.com/",
"title": "Weather Site",
"content": "Weather information..."
}
]
}
},
{
"id": "msg-assistant-2",
"role": "assistant",
"content": "",
"parentId": "msg-tool-1",
"createdAt": 1704067208000,
"updatedAt": 1704067208000,
"meta": {},
"metadata": {
"totalTokens": 4311,
"totalInputTokens": 4128,
"totalOutputTokens": 183
},
"tools": [
{
"id": "call-2",
"type": "builtin",
"apiName": "search",
"arguments": "{\"query\":\"杭州 天气 北京 天气\"}",
"identifier": "lobe-web-browsing"
}
]
},
{
"id": "msg-tool-2",
"role": "tool",
"content": "<searchResults>\n <item title=\"Weather Info\" url=\"https://weather2.example.com/\">More weather...</item>\n</searchResults>",
"parentId": "msg-assistant-2",
"createdAt": 1704067211000,
"updatedAt": 1704067211000,
"meta": {},
"tool_call_id": "call-2",
"plugin": {
"apiName": "search",
"identifier": "lobe-web-browsing",
"type": "builtin"
}
},
{
"id": "msg-assistant-3",
"role": "assistant",
"content": "你是想看现在的实时天气,还是未来几天的天气预报?",
"parentId": "msg-tool-2",
"createdAt": 1704067214000,
"updatedAt": 1704067214000,
"meta": {},
"metadata": {
"totalTokens": 6643,
"totalInputTokens": 6135,
"totalOutputTokens": 508
},
"reasoning": {
"content": "用户询问天气,我需要确认他们想要的是实时天气还是预报。"
}
},
{
"id": "msg-assistant-4",
"role": "assistant",
"content": "",
"parentId": "msg-user-1",
"createdAt": 1704067220000,
"updatedAt": 1704067220000,
"meta": {},
"metadata": {
"totalTokens": 3809,
"totalInputTokens": 3440,
"totalOutputTokens": 369
},
"tools": [
{
"id": "call-3",
"type": "builtin",
"apiName": "search",
"arguments": "{\"query\":\"!google 杭州 天气\"}",
"identifier": "lobe-web-browsing"
},
{
"id": "call-4",
"type": "builtin",
"apiName": "search",
"arguments": "{\"query\":\"!google 北京 天气\"}",
"identifier": "lobe-web-browsing"
}
]
},
{
"id": "msg-tool-3",
"role": "tool",
"content": "<searchResults />",
"parentId": "msg-assistant-4",
"createdAt": 1704067223000,
"updatedAt": 1704067223000,
"meta": {},
"tool_call_id": "call-3",
"plugin": {
"apiName": "search",
"identifier": "lobe-web-browsing",
"type": "builtin"
},
"pluginState": {
"query": "!google 杭州 天气",
"results": []
}
},
{
"id": "msg-tool-4",
"role": "tool",
"content": "<searchResults />",
"parentId": "msg-assistant-4",
"createdAt": 1704067226000,
"updatedAt": 1704067226000,
"meta": {},
"tool_call_id": "call-4",
"plugin": {
"apiName": "search",
"identifier": "lobe-web-browsing",
"type": "builtin"
},
"pluginState": {
"query": "!google 北京 天气",
"results": []
}
},
{
"id": "msg-assistant-5",
"role": "assistant",
"content": "我可以帮你查天气 —— 想确认一下你的偏好再去查实时数据。",
"parentId": "msg-tool-4",
"createdAt": 1704067229000,
"updatedAt": 1704067229000,
"meta": {},
"metadata": {
"totalTokens": 7100,
"totalInputTokens": 6425,
"totalOutputTokens": 675
}
},
{
"id": "msg-assistant-6",
"role": "assistant",
"content": "",
"parentId": "msg-user-1",
"createdAt": 1704067240000,
"updatedAt": 1704067240000,
"meta": {},
"metadata": {
"totalTokens": 3627,
"totalInputTokens": 3441,
"totalOutputTokens": 186
},
"tools": [
{
"id": "call-5",
"type": "builtin",
"apiName": "search",
"arguments": "{\"query\":\"杭州 天气 现在 北京 天气 现在\"}",
"identifier": "lobe-web-browsing"
}
]
},
{
"id": "msg-tool-5",
"role": "tool",
"content": "<searchResults>\n <item title=\"Weather Portal\" url=\"https://weather3.example.com/\">Weather data...</item>\n</searchResults>",
"parentId": "msg-assistant-6",
"createdAt": 1704067243000,
"updatedAt": 1704067243000,
"meta": {},
"tool_call_id": "call-5",
"plugin": {
"apiName": "search",
"identifier": "lobe-web-browsing",
"type": "builtin"
},
"pluginState": {
"query": "杭州 天气 现在 北京 天气 现在",
"results": [
{
"url": "https://weather3.example.com/",
"title": "Weather Portal"
}
]
}
},
{
"id": "msg-assistant-7",
"role": "assistant",
"content": "好的——我可以立刻去查并把结果发给你。",
"parentId": "msg-tool-5",
"createdAt": 1704067246000,
"updatedAt": 1704067246000,
"meta": {},
"metadata": {
"totalTokens": 8188,
"totalInputTokens": 7604,
"totalOutputTokens": 584
}
}
]
@@ -66,6 +66,10 @@
"meta": {},
"metadata": {
"activeBranchIndex": 1
},
"branch": {
"activeBranchIndex": 1,
"count": 3
}
},
{
@@ -0,0 +1,481 @@
{
"contextTree": [
{
"id": "msg_6zcyh1hELkzOmu",
"type": "message"
},
{
"id": "msg_xXSCL5uzwDANSn",
"type": "message"
},
{
"id": "msg_HhaoxginXhbfU9",
"type": "message"
},
{
"activeBranchIndex": 1,
"branches": [
[
{
"id": "msg_h5nXrbCVGj2uWy",
"type": "message"
}
],
[
{
"id": "msg_eEAXz3HLKeAmEQ",
"type": "message"
},
{
"id": "msg_nSG0QuSDSmYlXj",
"type": "message"
}
],
[
{
"id": "msg_GjcBeBlQ69teA7",
"type": "message"
},
{
"id": "msg_VMXrVH9hXeCP1R",
"type": "message"
}
]
],
"id": "branch-msg_HhaoxginXhbfU9-0",
"parentMessageId": "msg_HhaoxginXhbfU9",
"type": "branch"
},
{
"id": "msg_wpUElxuYslyASJ",
"type": "message"
},
{
"id": "msg_LHG8QeuqpcFZml",
"type": "message"
},
{
"id": "msg_jEKKxHQbz5duC6",
"type": "message"
}
],
"flatList": [
{
"id": "msg_6zcyh1hELkzOmu",
"role": "user",
"content": "你好",
"createdAt": "2025-11-07T07:19:44.264Z",
"updatedAt": "2025-11-07T07:19:44.264Z",
"topicId": "tpc_ruVgxvO16Ljh",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
{
"id": "msg_xXSCL5uzwDANSn",
"role": "assistant",
"content": "你好!我是您的AI助手,很高兴为您服务!我可以帮您:\n\n🔍 **搜索网络信息** - 查找最新资讯、技术文档、学术资料等\n💻 **搜索GitHub代码** - 查找真实项目中的代码示例和实现模式\n📄 **浏览网页内容** - 获取特定网页的详细信息\n\n请告诉我您需要什么帮助?比如:\n- 想了解某个技术概念或库的使用方法\n- 需要查找最新的新闻或资讯\n- 想看看其他开发者如何实现特定功能\n- 需要获取某个网页的详细内容\n\n我会根据您的需求选择最合适的工具来为您提供帮助!",
"metadata": {
"tps": 21.633839199225054,
"cost": 0.000171,
"ttft": 1287,
"latency": 7481,
"duration": 6194,
"totalTokens": 3934,
"inputTextTokens": 3800,
"outputTextTokens": 134,
"totalInputTokens": 3800,
"inputCachedTokens": 3776,
"totalOutputTokens": 134,
"inputCacheMissTokens": 24
},
"createdAt": "2025-11-07T07:19:44.908Z",
"updatedAt": "2025-11-07T07:19:53.454Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_6zcyh1hELkzOmu",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
},
{
"id": "msg_HhaoxginXhbfU9",
"role": "user",
"content": "你好",
"metadata": {
"activeBranchIndex": 1
},
"createdAt": "2025-11-07T07:21:05.366Z",
"updatedAt": "2025-11-07T07:24:17.810Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_xXSCL5uzwDANSn",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": [],
"branch": {
"activeBranchIndex": 1,
"count": 3
}
},
{
"id": "msg_eEAXz3HLKeAmEQ",
"role": "assistant",
"content": "你好!😊 我是您的AI助手,很高兴再次与您交流!\n\n我可以为您提供多种帮助:\n\n🔍 **网络搜索** - 查找最新资讯、技术文档、学术资料等\n💻 **GitHub代码搜索** - 查找真实项目中的代码示例和实现模式\n📄 **网页内容抓取** - 获取特定网页的详细信息\n\n请告诉我您需要什么帮助?比如:\n- 想了解某个编程概念或库的使用方法\n- 需要查找最新的技术资讯或新闻\n- 想看看其他开发者如何实现特定功能\n- 需要获取某个网页的详细内容\n\n我会根据您的需求选择最合适的工具来为您服务!",
"metadata": {
"tps": 23.98775093569241,
"cost": 0.000179,
"ttft": 1522,
"latency": 7400,
"duration": 5878,
"totalTokens": 4076,
"inputTextTokens": 3935,
"outputTextTokens": 141,
"totalInputTokens": 3935,
"inputCachedTokens": 3904,
"totalOutputTokens": 141,
"inputCacheMissTokens": 31
},
"createdAt": "2025-11-07T07:21:41.587Z",
"updatedAt": "2025-11-07T07:21:48.866Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_HhaoxginXhbfU9",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
},
{
"id": "msg_nSG0QuSDSmYlXj",
"role": "user",
"content": "你好",
"createdAt": "2025-11-07T07:23:33.255Z",
"updatedAt": "2025-11-07T07:23:33.255Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_eEAXz3HLKeAmEQ",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
{
"id": "msg_wpUElxuYslyASJ",
"role": "assistant",
"content": "今天天气怎么样?",
"createdAt": "2025-11-07T07:24:15.934Z",
"updatedAt": "2025-11-07T07:24:15.934Z",
"topicId": "tpc_ruVgxvO16Ljh",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
{
"id": "msg_LHG8QeuqpcFZml",
"role": "user",
"content": "你好",
"createdAt": "2025-11-07T07:35:12.401Z",
"updatedAt": "2025-11-07T07:35:12.401Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_wpUElxuYslyASJ",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
{
"id": "msg_jEKKxHQbz5duC6",
"role": "assistant",
"content": "...",
"createdAt": "2025-11-07T07:35:13.056Z",
"updatedAt": "2025-11-07T07:35:13.056Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_LHG8QeuqpcFZml",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
}
],
"messageMap": {
"msg_6zcyh1hELkzOmu": {
"id": "msg_6zcyh1hELkzOmu",
"role": "user",
"content": "你好",
"createdAt": "2025-11-07T07:19:44.264Z",
"updatedAt": "2025-11-07T07:19:44.264Z",
"topicId": "tpc_ruVgxvO16Ljh",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
"msg_xXSCL5uzwDANSn": {
"id": "msg_xXSCL5uzwDANSn",
"role": "assistant",
"content": "你好!我是您的AI助手,很高兴为您服务!我可以帮您:\n\n🔍 **搜索网络信息** - 查找最新资讯、技术文档、学术资料等\n💻 **搜索GitHub代码** - 查找真实项目中的代码示例和实现模式\n📄 **浏览网页内容** - 获取特定网页的详细信息\n\n请告诉我您需要什么帮助?比如:\n- 想了解某个技术概念或库的使用方法\n- 需要查找最新的新闻或资讯\n- 想看看其他开发者如何实现特定功能\n- 需要获取某个网页的详细内容\n\n我会根据您的需求选择最合适的工具来为您提供帮助!",
"metadata": {
"tps": 21.633839199225054,
"cost": 0.000171,
"ttft": 1287,
"latency": 7481,
"duration": 6194,
"totalTokens": 3934,
"inputTextTokens": 3800,
"outputTextTokens": 134,
"totalInputTokens": 3800,
"inputCachedTokens": 3776,
"totalOutputTokens": 134,
"inputCacheMissTokens": 24
},
"createdAt": "2025-11-07T07:19:44.908Z",
"updatedAt": "2025-11-07T07:19:53.454Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_6zcyh1hELkzOmu",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
},
"msg_HhaoxginXhbfU9": {
"id": "msg_HhaoxginXhbfU9",
"role": "user",
"content": "你好",
"metadata": {
"activeBranchIndex": 1
},
"createdAt": "2025-11-07T07:21:05.366Z",
"updatedAt": "2025-11-07T07:24:17.810Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_xXSCL5uzwDANSn",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
"msg_h5nXrbCVGj2uWy": {
"id": "msg_h5nXrbCVGj2uWy",
"role": "assistant",
"content": "你好!很高兴再次与您交流!😊\n\n我是您的AI助手,可以为您提供多种帮助:\n\n**我能做什么:**\n- 🔍 **网络搜索** - 查找最新资讯、技术文档、学术资料等\n- 💻 **GitHub代码搜索** - 查找真实项目中的代码示例和最佳实践\n- 📄 **网页内容获取** - 提取特定网页的详细信息\n\n**例如您可以问我:**\n- \"帮我搜索一下React Hooks的最佳实践\"\n- \"查找Python中如何处理异常的真实代码示例\"\n- \"获取某个技术博客的最新文章内容\"\n- \"了解当前热门的技术趋势\"\n\n请告诉我您今天需要什么帮助?我会根据您的具体需求选择最合适的工具来为您服务!",
"metadata": {
"tps": 20.645337708504236,
"cost": 0.000216,
"ttft": 1524,
"latency": 8838,
"duration": 7314,
"totalTokens": 4086,
"inputTextTokens": 3935,
"outputTextTokens": 151,
"totalInputTokens": 3935,
"inputCachedTokens": 3776,
"totalOutputTokens": 151,
"inputCacheMissTokens": 159
},
"createdAt": "2025-11-07T07:21:11.767Z",
"updatedAt": "2025-11-07T07:21:20.560Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_HhaoxginXhbfU9",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
},
"msg_eEAXz3HLKeAmEQ": {
"id": "msg_eEAXz3HLKeAmEQ",
"role": "assistant",
"content": "你好!😊 我是您的AI助手,很高兴再次与您交流!\n\n我可以为您提供多种帮助:\n\n🔍 **网络搜索** - 查找最新资讯、技术文档、学术资料等\n💻 **GitHub代码搜索** - 查找真实项目中的代码示例和实现模式\n📄 **网页内容抓取** - 获取特定网页的详细信息\n\n请告诉我您需要什么帮助?比如:\n- 想了解某个编程概念或库的使用方法\n- 需要查找最新的技术资讯或新闻\n- 想看看其他开发者如何实现特定功能\n- 需要获取某个网页的详细内容\n\n我会根据您的需求选择最合适的工具来为您服务!",
"metadata": {
"tps": 23.98775093569241,
"cost": 0.000179,
"ttft": 1522,
"latency": 7400,
"duration": 5878,
"totalTokens": 4076,
"inputTextTokens": 3935,
"outputTextTokens": 141,
"totalInputTokens": 3935,
"inputCachedTokens": 3904,
"totalOutputTokens": 141,
"inputCacheMissTokens": 31
},
"createdAt": "2025-11-07T07:21:41.587Z",
"updatedAt": "2025-11-07T07:21:48.866Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_HhaoxginXhbfU9",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
},
"msg_GjcBeBlQ69teA7": {
"id": "msg_GjcBeBlQ69teA7",
"role": "assistant",
"content": "你好!我是您的AI助手,很高兴为您服务!\n\n我可以为您提供以下帮助:\n\n🔍 **网络搜索** - 查找最新的新闻、技术文档、学术资料等\n💻 **GitHub代码搜索** - 查找真实项目中的代码示例和最佳实践\n📄 **网页内容获取** - 获取特定网页的详细信息\n\n请告诉我您需要什么帮助?比如:\n- 想了解某个技术概念或库的使用方法\n- 需要查找最新的新闻或资讯\n- 想看看其他开发者如何实现特定功能\n- 需要获取某个网页的详细内容\n\n我会根据您的需求选择最合适的工具来为您提供帮助!",
"metadata": {
"tps": 23.99573409171703,
"cost": 0.000176,
"ttft": 1194,
"latency": 6820,
"duration": 5626,
"totalTokens": 4070,
"inputTextTokens": 3935,
"outputTextTokens": 135,
"totalInputTokens": 3935,
"inputCachedTokens": 3904,
"totalOutputTokens": 135,
"inputCacheMissTokens": 31
},
"createdAt": "2025-11-07T07:22:25.223Z",
"updatedAt": "2025-11-07T07:22:32.162Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_HhaoxginXhbfU9",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
},
"msg_nSG0QuSDSmYlXj": {
"id": "msg_nSG0QuSDSmYlXj",
"role": "user",
"content": "你好",
"createdAt": "2025-11-07T07:23:33.255Z",
"updatedAt": "2025-11-07T07:23:33.255Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_eEAXz3HLKeAmEQ",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
"msg_VMXrVH9hXeCP1R": {
"id": "msg_VMXrVH9hXeCP1R",
"role": "user",
"content": "你是谁?",
"createdAt": "2025-11-07T07:23:49.990Z",
"updatedAt": "2025-11-07T07:23:49.990Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_GjcBeBlQ69teA7",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
"msg_wpUElxuYslyASJ": {
"id": "msg_wpUElxuYslyASJ",
"role": "assistant",
"content": "今天天气怎么样?",
"createdAt": "2025-11-07T07:24:15.934Z",
"updatedAt": "2025-11-07T07:24:15.934Z",
"topicId": "tpc_ruVgxvO16Ljh",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
"msg_LHG8QeuqpcFZml": {
"id": "msg_LHG8QeuqpcFZml",
"role": "user",
"content": "你好",
"createdAt": "2025-11-07T07:35:12.401Z",
"updatedAt": "2025-11-07T07:35:12.401Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_wpUElxuYslyASJ",
"chunksList": [],
"extra": {},
"fileList": [],
"imageList": [],
"meta": {},
"videoList": []
},
"msg_jEKKxHQbz5duC6": {
"id": "msg_jEKKxHQbz5duC6",
"role": "assistant",
"content": "...",
"createdAt": "2025-11-07T07:35:13.056Z",
"updatedAt": "2025-11-07T07:35:13.056Z",
"topicId": "tpc_ruVgxvO16Ljh",
"parentId": "msg_LHG8QeuqpcFZml",
"chunksList": [],
"extra": {
"model": "deepseek-chat",
"provider": "deepseek"
},
"fileList": [],
"imageList": [],
"meta": {},
"model": "deepseek-chat",
"provider": "deepseek",
"videoList": []
}
}
}
@@ -63,7 +63,11 @@
"parentId": null,
"createdAt": 1704067200000,
"updatedAt": 1704067200000,
"meta": {}
"meta": {},
"branch": {
"activeBranchIndex": 0,
"count": 3
}
},
{
"id": "msg-202",
@@ -1,14 +1,18 @@
import type { SerializedParseResult } from '../..';
import activeIndex1 from './active-index-1.json';
import assistantBranch from './assistant-branch.json';
import assistantGroupBranches from './assistant-group-branches.json';
import assistantUserBranch from './assistant-user-branch.json';
import conversation from './conversation.json';
import multiAssistantGroup from './multi-assistant-group.json';
import nested from './nested.json';
export const branch = {
activeIndex1: activeIndex1 as unknown as SerializedParseResult,
assistantBranch: assistantBranch as unknown as SerializedParseResult,
assistantGroupBranches: assistantGroupBranches as unknown as SerializedParseResult,
assistantUserBranch: assistantUserBranch as unknown as SerializedParseResult,
conversation: conversation as unknown as SerializedParseResult,
multiAssistantGroup: multiAssistantGroup as unknown as SerializedParseResult,
nested: nested as unknown as SerializedParseResult,
};
@@ -0,0 +1,407 @@
{
"contextTree": [
{
"id": "msg-user-1",
"type": "message"
},
{
"activeBranchIndex": 2,
"branches": [
[
{
"children": [
{
"id": "msg-assistant-1",
"tools": ["msg-tool-1"],
"type": "message"
},
{
"id": "msg-assistant-2",
"tools": ["msg-tool-2"],
"type": "message"
},
{
"id": "msg-assistant-3",
"type": "message"
}
],
"id": "msg-assistant-1",
"type": "assistantGroup"
}
],
[
{
"children": [
{
"id": "msg-assistant-4",
"tools": ["msg-tool-3", "msg-tool-4"],
"type": "message"
},
{
"id": "msg-assistant-5",
"type": "message"
}
],
"id": "msg-assistant-4",
"type": "assistantGroup"
}
],
[
{
"children": [
{
"id": "msg-assistant-6",
"tools": ["msg-tool-5"],
"type": "message"
},
{
"id": "msg-assistant-7",
"type": "message"
}
],
"id": "msg-assistant-6",
"type": "assistantGroup"
}
]
],
"id": "branch-msg-user-1-0",
"parentMessageId": "msg-user-1",
"type": "branch"
}
],
"flatList": [
{
"content": "杭州和北京天气如何",
"createdAt": 1704067200000,
"id": "msg-user-1",
"meta": {},
"metadata": {
"activeBranchIndex": 2
},
"parentId": null,
"role": "user",
"updatedAt": 1704067200000,
"branch": {
"activeBranchIndex": 2,
"count": 3
}
},
{
"id": "msg-assistant-6",
"role": "assistantGroup",
"content": "",
"parentId": "msg-user-1",
"createdAt": 1704067240000,
"updatedAt": 1704067240000,
"meta": {},
"children": [
{
"content": "",
"id": "msg-assistant-6",
"tools": [
{
"apiName": "search",
"arguments": "{\"query\":\"杭州 天气 现在 北京 天气 现在\"}",
"id": "call-5",
"identifier": "lobe-web-browsing",
"type": "builtin",
"result": {
"content": "<searchResults>\n <item title=\"Weather Portal\" url=\"https://weather3.example.com/\">Weather data...</item>\n</searchResults>",
"id": "msg-tool-5",
"state": {
"query": "杭州 天气 现在 北京 天气 现在",
"results": [
{
"title": "Weather Portal",
"url": "https://weather3.example.com/"
}
]
}
},
"result_msg_id": "msg-tool-5"
}
],
"usage": {
"totalInputTokens": 3441,
"totalOutputTokens": 186,
"totalTokens": 3627
}
},
{
"content": "好的——我可以立刻去查并把结果发给你。",
"id": "msg-assistant-7",
"usage": {
"totalInputTokens": 7604,
"totalOutputTokens": 584,
"totalTokens": 8188
}
}
],
"usage": {
"totalInputTokens": 11045,
"totalOutputTokens": 770,
"totalTokens": 11815
}
}
],
"messageMap": {
"msg-assistant-1": {
"content": "",
"createdAt": 1704067202000,
"id": "msg-assistant-1",
"meta": {},
"metadata": {
"totalInputTokens": 3441,
"totalOutputTokens": 310,
"totalTokens": 3751
},
"parentId": "msg-user-1",
"role": "assistant",
"tools": [
{
"apiName": "search",
"arguments": "{\"query\":\"杭州 北京 天气 现在\"}",
"id": "call-1",
"identifier": "lobe-web-browsing",
"type": "builtin"
}
],
"updatedAt": 1704067202000
},
"msg-assistant-2": {
"content": "",
"createdAt": 1704067208000,
"id": "msg-assistant-2",
"meta": {},
"metadata": {
"totalInputTokens": 4128,
"totalOutputTokens": 183,
"totalTokens": 4311
},
"parentId": "msg-tool-1",
"role": "assistant",
"tools": [
{
"apiName": "search",
"arguments": "{\"query\":\"杭州 天气 北京 天气\"}",
"id": "call-2",
"identifier": "lobe-web-browsing",
"type": "builtin"
}
],
"updatedAt": 1704067208000
},
"msg-assistant-3": {
"content": "你是想看现在的实时天气,还是未来几天的天气预报?",
"createdAt": 1704067214000,
"id": "msg-assistant-3",
"meta": {},
"metadata": {
"totalInputTokens": 6135,
"totalOutputTokens": 508,
"totalTokens": 6643
},
"parentId": "msg-tool-2",
"reasoning": {
"content": "用户询问天气,我需要确认他们想要的是实时天气还是预报。"
},
"role": "assistant",
"updatedAt": 1704067214000
},
"msg-assistant-4": {
"content": "",
"createdAt": 1704067220000,
"id": "msg-assistant-4",
"meta": {},
"metadata": {
"totalInputTokens": 3440,
"totalOutputTokens": 369,
"totalTokens": 3809
},
"parentId": "msg-user-1",
"role": "assistant",
"tools": [
{
"apiName": "search",
"arguments": "{\"query\":\"!google 杭州 天气\"}",
"id": "call-3",
"identifier": "lobe-web-browsing",
"type": "builtin"
},
{
"apiName": "search",
"arguments": "{\"query\":\"!google 北京 天气\"}",
"id": "call-4",
"identifier": "lobe-web-browsing",
"type": "builtin"
}
],
"updatedAt": 1704067220000
},
"msg-assistant-5": {
"content": "我可以帮你查天气 —— 想确认一下你的偏好再去查实时数据。",
"createdAt": 1704067229000,
"id": "msg-assistant-5",
"meta": {},
"metadata": {
"totalInputTokens": 6425,
"totalOutputTokens": 675,
"totalTokens": 7100
},
"parentId": "msg-tool-4",
"role": "assistant",
"updatedAt": 1704067229000
},
"msg-assistant-6": {
"content": "",
"createdAt": 1704067240000,
"id": "msg-assistant-6",
"meta": {},
"metadata": {
"totalInputTokens": 3441,
"totalOutputTokens": 186,
"totalTokens": 3627
},
"parentId": "msg-user-1",
"role": "assistant",
"tools": [
{
"apiName": "search",
"arguments": "{\"query\":\"杭州 天气 现在 北京 天气 现在\"}",
"id": "call-5",
"identifier": "lobe-web-browsing",
"type": "builtin"
}
],
"updatedAt": 1704067240000
},
"msg-assistant-7": {
"content": "好的——我可以立刻去查并把结果发给你。",
"createdAt": 1704067246000,
"id": "msg-assistant-7",
"meta": {},
"metadata": {
"totalInputTokens": 7604,
"totalOutputTokens": 584,
"totalTokens": 8188
},
"parentId": "msg-tool-5",
"role": "assistant",
"updatedAt": 1704067246000
},
"msg-tool-1": {
"content": "<searchResults>\n <item title=\"Weather Site\" url=\"https://weather.example.com/\">Weather information...</item>\n</searchResults>",
"createdAt": 1704067205000,
"id": "msg-tool-1",
"meta": {},
"parentId": "msg-assistant-1",
"plugin": {
"apiName": "search",
"identifier": "lobe-web-browsing",
"type": "builtin"
},
"pluginState": {
"query": "杭州 北京 天气 现在",
"results": [
{
"content": "Weather information...",
"title": "Weather Site",
"url": "https://weather.example.com/"
}
]
},
"role": "tool",
"tool_call_id": "call-1",
"updatedAt": 1704067205000
},
"msg-tool-2": {
"content": "<searchResults>\n <item title=\"Weather Info\" url=\"https://weather2.example.com/\">More weather...</item>\n</searchResults>",
"createdAt": 1704067211000,
"id": "msg-tool-2",
"meta": {},
"parentId": "msg-assistant-2",
"plugin": {
"apiName": "search",
"identifier": "lobe-web-browsing",
"type": "builtin"
},
"role": "tool",
"tool_call_id": "call-2",
"updatedAt": 1704067211000
},
"msg-tool-3": {
"content": "<searchResults />",
"createdAt": 1704067223000,
"id": "msg-tool-3",
"meta": {},
"parentId": "msg-assistant-4",
"plugin": {
"apiName": "search",
"identifier": "lobe-web-browsing",
"type": "builtin"
},
"pluginState": {
"query": "!google 杭州 天气",
"results": []
},
"role": "tool",
"tool_call_id": "call-3",
"updatedAt": 1704067223000
},
"msg-tool-4": {
"content": "<searchResults />",
"createdAt": 1704067226000,
"id": "msg-tool-4",
"meta": {},
"parentId": "msg-assistant-4",
"plugin": {
"apiName": "search",
"identifier": "lobe-web-browsing",
"type": "builtin"
},
"pluginState": {
"query": "!google 北京 天气",
"results": []
},
"role": "tool",
"tool_call_id": "call-4",
"updatedAt": 1704067226000
},
"msg-tool-5": {
"content": "<searchResults>\n <item title=\"Weather Portal\" url=\"https://weather3.example.com/\">Weather data...</item>\n</searchResults>",
"createdAt": 1704067243000,
"id": "msg-tool-5",
"meta": {},
"parentId": "msg-assistant-6",
"plugin": {
"apiName": "search",
"identifier": "lobe-web-browsing",
"type": "builtin"
},
"pluginState": {
"query": "杭州 天气 现在 北京 天气 现在",
"results": [
{
"title": "Weather Portal",
"url": "https://weather3.example.com/"
}
]
},
"role": "tool",
"tool_call_id": "call-5",
"updatedAt": 1704067243000
},
"msg-user-1": {
"content": "杭州和北京天气如何",
"createdAt": 1704067200000,
"id": "msg-user-1",
"meta": {},
"metadata": {
"activeBranchIndex": 2
},
"parentId": null,
"role": "user",
"updatedAt": 1704067200000
}
}
}
@@ -111,7 +111,11 @@
"parentId": null,
"createdAt": 1704067200000,
"updatedAt": 1704067200000,
"meta": {}
"meta": {},
"branch": {
"activeBranchIndex": 0,
"count": 3
}
},
{
"id": "msg-302",
@@ -138,6 +142,10 @@
"meta": {},
"metadata": {
"activeBranchIndex": 1
},
"branch": {
"activeBranchIndex": 1,
"count": 2
}
},
{
@@ -162,7 +170,11 @@
"parentId": "msg-307",
"createdAt": 1704067220000,
"updatedAt": 1704067220000,
"meta": {}
"meta": {},
"branch": {
"activeBranchIndex": 0,
"count": 2
}
},
{
"id": "msg-311",
@@ -189,6 +201,10 @@
"meta": {},
"metadata": {
"activeBranchIndex": 1
},
"branch": {
"activeBranchIndex": 1,
"count": 2
}
},
{
@@ -62,6 +62,9 @@
{
"content": "I'll help you debug the code. Let me first check the syntax and run some analysis.",
"id": "msg-502",
"performance": {
"tps": 45
},
"tools": [
{
"id": "tool-lint",
@@ -93,9 +96,24 @@
},
"result_msg_id": "msg-504"
}
]
],
"usage": {
"totalInputTokens": 32,
"totalOutputTokens": 48,
"totalTokens": 80,
"cost": 0.00024
}
}
]
],
"performance": {
"tps": 45
},
"usage": {
"totalInputTokens": 32,
"totalOutputTokens": 48,
"totalTokens": 80,
"cost": 0.00024
}
},
{
"id": "msg-505",
@@ -104,7 +122,11 @@
"parentId": "msg-504",
"createdAt": 1704067210000,
"updatedAt": 1704067210000,
"meta": {}
"meta": {},
"branch": {
"activeBranchIndex": 0,
"count": 2
}
},
{
"id": "msg-506",
@@ -71,6 +71,18 @@ describe('parse', () => {
expect(serializeParseResult(result)).toEqual(outputs.branch.nested);
});
it('should handle multiple assistant group branches', () => {
const result = parse(inputs.branch.multiAssistantGroup);
expect(serializeParseResult(result)).toEqual(outputs.branch.multiAssistantGroup);
});
it('should handle assistant group with branches', () => {
const result = parse(inputs.branch.assistantGroupBranches);
expect(serializeParseResult(result)).toEqual(outputs.branch.assistantGroupBranches);
});
});
describe('Compare Mode', () => {
+1 -1
View File
@@ -11,7 +11,7 @@ export type {
} from './types';
// Flat Message List Types - for virtual list rendering
export type { BranchMetadata, FlatMessage, FlatMessageExtra, FlatMessageRole } from './types';
export type { FlatMessage, FlatMessageExtra, FlatMessageRole } from './types';
// Shared Types
export type { HelperMaps, IdNode, Message, MessageGroupMetadata, ParseResult } from './types';
@@ -165,18 +165,74 @@ export class FlatListBuilder {
childMessages,
this.childrenMap,
);
const userWithBranches = this.createUserMessageWithBranches(message);
const activeBranchIndex = childMessages.indexOf(activeBranchId);
const userWithBranches = this.createUserMessageWithBranches(
message,
childMessages.length,
activeBranchIndex,
);
flatList.push(userWithBranches);
processedIds.add(message.id);
// Continue with active branch and process its message
// Continue with active branch - check if it's an assistantGroup
const activeBranchMsg = this.messageMap.get(activeBranchId);
if (activeBranchMsg) {
flatList.push(activeBranchMsg);
processedIds.add(activeBranchId);
// Check if active branch is assistant with tools (should be assistantGroup)
if (
activeBranchMsg.role === 'assistant' &&
activeBranchMsg.tools &&
activeBranchMsg.tools.length > 0
) {
// Collect the entire assistant group chain
const assistantChain: Message[] = [];
const allToolMessages: Message[] = [];
this.messageCollector.collectAssistantChain(
activeBranchMsg,
allMessages,
assistantChain,
allToolMessages,
processedIds,
);
// Continue with active branch's children
this.buildFlatListRecursive(activeBranchId, flatList, processedIds, allMessages);
// Create assistantGroup virtual message
const groupMessage = this.createAssistantGroupMessage(
assistantChain[0],
assistantChain,
allToolMessages,
);
flatList.push(groupMessage);
// Mark all as processed
assistantChain.forEach((m) => processedIds.add(m.id));
allToolMessages.forEach((m) => processedIds.add(m.id));
// Continue after the assistant chain
const lastAssistant = assistantChain.at(-1);
const toolIds = new Set(allToolMessages.map((t) => t.id));
const lastAssistantNonToolChildren = lastAssistant
? this.childrenMap.get(lastAssistant.id)?.filter((childId) => !toolIds.has(childId))
: undefined;
if (
lastAssistantNonToolChildren &&
lastAssistantNonToolChildren.length > 0 &&
lastAssistant
) {
this.buildFlatListRecursive(lastAssistant.id, flatList, processedIds, allMessages);
} else {
for (const toolMsg of allToolMessages) {
this.buildFlatListRecursive(toolMsg.id, flatList, processedIds, allMessages);
}
}
} else {
// Regular message (not assistantGroup)
flatList.push(activeBranchMsg);
processedIds.add(activeBranchId);
// Continue with active branch's children
this.buildFlatListRecursive(activeBranchId, flatList, processedIds, allMessages);
}
}
continue;
}
@@ -364,58 +420,80 @@ export class FlatListBuilder {
assistant.tools?.map((tool) => {
const toolMsg = toolMap.get(tool.id);
if (toolMsg) {
const result: any = {
content: toolMsg.content || '',
id: toolMsg.id,
};
if (toolMsg.error) result.error = toolMsg.error;
if (toolMsg.pluginState) result.state = toolMsg.pluginState;
return {
...tool,
result: {
content: toolMsg.content || '',
error: toolMsg.error,
id: toolMsg.id,
state: toolMsg.pluginState,
},
result,
result_msg_id: toolMsg.id,
};
}
return tool;
}) || [];
const { usage: msgUsage, performance: msgPerformance } =
// Prefer top-level usage/performance fields, fall back to metadata
const { usage: metaUsage, performance: metaPerformance } =
this.messageTransformer.splitMetadata(assistant.metadata);
const msgUsage = assistant.usage || metaUsage;
const msgPerformance = assistant.performance || metaPerformance;
children.push({
const childBlock: AssistantContentBlock = {
content: assistant.content || '',
error: assistant.error,
id: assistant.id,
imageList:
assistant.imageList && assistant.imageList.length > 0 ? assistant.imageList : undefined,
performance: msgPerformance,
reasoning: assistant.reasoning || undefined,
tools: toolsWithResults.length > 0 ? toolsWithResults : undefined,
usage: msgUsage,
});
} as AssistantContentBlock;
if (assistant.error) childBlock.error = assistant.error;
if (assistant.imageList && assistant.imageList.length > 0)
childBlock.imageList = assistant.imageList;
if (msgPerformance) childBlock.performance = msgPerformance;
if (assistant.reasoning) childBlock.reasoning = assistant.reasoning;
if (toolsWithResults.length > 0) childBlock.tools = toolsWithResults;
if (msgUsage) childBlock.usage = msgUsage;
children.push(childBlock);
}
const aggregated = this.messageTransformer.aggregateMetadata(children);
return {
const result: Message = {
...firstAssistant,
children,
content: '',
imageList: undefined,
metadata: undefined,
performance: aggregated.performance,
reasoning: undefined,
role: 'assistantGroup' as any,
tools: undefined,
usage: aggregated.usage,
};
// Remove fields that should not be in assistantGroup
delete result.imageList;
delete result.metadata;
delete result.reasoning;
delete result.tools;
// Add aggregated fields if they exist
if (aggregated.performance) result.performance = aggregated.performance;
if (aggregated.usage) result.usage = aggregated.usage;
return result;
}
/**
* Create user message with branch metadata
*/
private createUserMessageWithBranches(user: Message): Message {
// Just return the original user message with its metadata.activeBranchId
// No need to add extra.branches
return { ...user };
private createUserMessageWithBranches(
user: Message,
count: number,
activeBranchIndex: number,
): Message {
return {
...user,
branch: {
activeBranchIndex,
count,
},
} as Message;
}
}
@@ -41,22 +41,10 @@ export type FlatMessageRole =
*/
export type FlatMessage = UIChatMessage;
/**
* Branch metadata attached to user messages
*/
export interface BranchMetadata {
/** Active branch message ID */
activeId: string;
/** All branch message IDs */
branchIds: string[];
}
/**
* Virtual message extra fields for flat list
*/
export interface FlatMessageExtra {
/** Branch information for user messages with multiple children */
branches?: BranchMetadata;
/** Optional description for groups */
description?: string;
/** Group mode for messageGroup and compare virtual messages */
@@ -16,21 +16,10 @@ export type {
CompareNode,
ContextNode,
MessageNode,
} from './types/contextTree';
} from './contextTree';
// Flat Message List Types
export type {
BranchMetadata,
FlatMessage,
FlatMessageExtra,
FlatMessageRole,
} from './types/flatMessageList';
export type { FlatMessage, FlatMessageExtra, FlatMessageRole } from './flatMessageList';
// Shared Types
export type {
HelperMaps,
IdNode,
Message,
MessageGroupMetadata,
ParseResult,
} from './types/shared';
export type { HelperMaps, IdNode, Message, MessageGroupMetadata, ParseResult } from './shared';
@@ -1,812 +0,0 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { getTestDB } from '../../models/__tests__/_util';
import { files, messagePlugins, messages, messagesFiles, sessions, users } from '../../schemas';
import { LobeChatDatabase } from '../../type';
import { MessageModel } from '../message';
const serverDB: LobeChatDatabase = await getTestDB();
const userId = 'message-grouping-test';
const messageModel = new MessageModel(serverDB, userId);
beforeEach(async () => {
// Clear tables before each test
await serverDB.transaction(async (trx) => {
await trx.delete(users);
await trx.insert(users).values([{ id: userId }, { id: '456' }]);
await trx.insert(sessions).values([{ id: '1', userId }]);
await trx.insert(files).values({
id: 'f1',
userId: userId,
name: 'test.png',
fileType: 'image/png',
size: 100,
url: 'url1',
});
});
});
afterEach(async () => {
// Clean up after each test
await serverDB.delete(messages);
await serverDB.delete(messagePlugins);
await serverDB.delete(messagesFiles);
});
describe('MessageModel - Message Grouping', () => {
describe('Basic Grouping Scenarios', () => {
it('should group assistant message with single tool result', async () => {
// Create assistant message with tool
await serverDB.insert(messages).values({
id: 'msg-1',
userId,
role: 'assistant',
content: 'Checking weather',
tools: [
{
id: 'tool-1',
identifier: 'weather',
apiName: 'getWeather',
arguments: '{"city":"Beijing"}',
type: 'default',
},
],
});
// Create tool message
await serverDB.insert(messages).values({
id: 'msg-2',
userId,
role: 'tool',
content: 'Beijing: Sunny, 25°C',
});
await serverDB.insert(messagePlugins).values({
id: 'msg-2',
userId,
toolCallId: 'tool-1',
identifier: 'weather',
state: { cached: true },
});
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify grouping
expect(result).toHaveLength(1);
expect(result[0].role).toBe('group');
expect(result[0].content).toBe('');
expect(result[0].children).toHaveLength(1);
const block = result[0].children![0];
expect(block.content).toBe('Checking weather');
expect(block.tools).toHaveLength(1);
expect(block.tools![0]).toMatchObject({
id: 'tool-1',
identifier: 'weather',
apiName: 'getWeather',
});
expect(block.tools![0].result).toMatchObject({
content: 'Beijing: Sunny, 25°C',
state: { cached: true },
});
});
it('should group assistant message with multiple tool results', async () => {
// Create assistant message with multiple tools
await serverDB.insert(messages).values({
id: 'msg-1',
userId,
role: 'assistant',
content: 'Checking weather and news',
tools: [
{
id: 'tool-1',
identifier: 'weather',
apiName: 'getWeather',
arguments: '{}',
type: 'default',
},
{
id: 'tool-2',
identifier: 'news',
apiName: 'getNews',
arguments: '{}',
type: 'default',
},
],
});
// Create tool messages
await serverDB.insert(messages).values([
{
id: 'msg-2',
userId,
role: 'tool',
content: 'Beijing: Sunny, 25°C',
},
{
id: 'msg-3',
userId,
role: 'tool',
content: 'Latest tech news: AI breakthrough',
},
]);
await serverDB.insert(messagePlugins).values([
{
id: 'msg-2',
userId,
toolCallId: 'tool-1',
identifier: 'weather',
},
{
id: 'msg-3',
userId,
toolCallId: 'tool-2',
identifier: 'news',
},
]);
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify grouping
expect(result).toHaveLength(1);
expect(result[0].role).toBe('group');
expect(result[0].children).toHaveLength(1);
const block = result[0].children![0];
expect(block.tools).toHaveLength(2);
expect(block.tools![0].result?.content).toBe('Beijing: Sunny, 25°C');
expect(block.tools![1].result?.content).toBe('Latest tech news: AI breakthrough');
});
it('should not group assistant message without tools', async () => {
// Create assistant message without tools
await serverDB.insert(messages).values({
id: 'msg-1',
userId,
role: 'assistant',
content: 'Hello!',
});
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify no grouping
expect(result).toHaveLength(1);
expect(result[0].role).toBe('assistant');
expect(result[0].content).toBe('Hello!');
expect(result[0].children).toBeUndefined();
});
it('should handle assistant message with tool but no result yet', async () => {
// Create assistant message with tool
await serverDB.insert(messages).values({
id: 'msg-1',
userId,
role: 'assistant',
content: 'Checking weather',
tools: [
{
id: 'tool-1',
identifier: 'weather',
apiName: 'getWeather',
arguments: '{}',
type: 'default',
},
],
});
// No tool message created yet
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify grouping without result
expect(result).toHaveLength(1);
expect(result[0].role).toBe('group');
expect(result[0].children).toHaveLength(1);
const block = result[0].children![0];
expect(block.tools).toHaveLength(1);
expect(block.tools![0].result).toBeUndefined();
});
});
describe('Multi-turn Conversation Grouping', () => {
it('should group assistant with follow-up assistant (parentId→tool)', async () => {
// Scenario: assistant → tool → assistant (parentId → tool)
await serverDB.insert(messages).values([
{
id: 'msg-1',
userId,
role: 'assistant',
content: 'Let me check the weather',
createdAt: new Date('2023-01-01T10:00:00Z'),
tools: [
{
id: 'tool-1',
identifier: 'weather',
apiName: 'getWeather',
arguments: '{}',
type: 'default',
},
],
},
{
id: 'msg-2',
userId,
role: 'tool',
content: 'Sunny, 25°C',
createdAt: new Date('2023-01-01T10:00:01Z'),
},
{
id: 'msg-3',
userId,
role: 'assistant',
content: 'Based on the weather, let me check the news',
parentId: 'msg-2',
createdAt: new Date('2023-01-01T10:00:02Z'),
tools: [
{
id: 'tool-2',
identifier: 'news',
apiName: 'getNews',
arguments: '{}',
type: 'default',
},
],
},
{
id: 'msg-4',
userId,
role: 'tool',
content: 'Breaking: AI news',
createdAt: new Date('2023-01-01T10:00:03Z'),
},
]);
await serverDB.insert(messagePlugins).values([
{
id: 'msg-2',
userId,
toolCallId: 'tool-1',
identifier: 'weather',
},
{
id: 'msg-4',
userId,
toolCallId: 'tool-2',
identifier: 'news',
},
]);
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Should have 1 group with 2 children
expect(result).toHaveLength(1);
expect(result[0].role).toBe('group');
expect(result[0].children).toHaveLength(2);
// First child: original assistant with tool result
expect(result[0].children![0].id).toBe('msg-1');
expect(result[0].children![0].content).toBe('Let me check the weather');
expect(result[0].children![0].tools).toHaveLength(1);
expect(result[0].children![0].tools![0].result?.content).toBe('Sunny, 25°C');
// Second child: follow-up assistant with its own tool result
expect(result[0].children![1].id).toBe('msg-3');
expect(result[0].children![1].content).toBe('Based on the weather, let me check the news');
expect(result[0].children![1].tools).toHaveLength(1);
expect(result[0].children![1].tools![0].result?.content).toBe('Breaking: AI news');
});
it('should group multiple follow-up assistants in chain (3+ assistants)', async () => {
// Scenario: assistant → tool → assistant → tool → assistant (chain of parentId→tool)
await serverDB.insert(messages).values([
{
id: 'msg-1',
userId,
role: 'assistant',
content: 'Step 1: Check weather',
createdAt: new Date('2023-01-01T10:00:00Z'),
tools: [
{
id: 'tool-1',
identifier: 'weather',
apiName: 'getWeather',
arguments: '{}',
type: 'default',
},
],
},
{
id: 'msg-2',
userId,
role: 'tool',
content: 'Sunny, 25°C',
createdAt: new Date('2023-01-01T10:00:01Z'),
},
{
id: 'msg-3',
userId,
role: 'assistant',
content: 'Step 2: Based on weather, check news',
parentId: 'msg-2',
createdAt: new Date('2023-01-01T10:00:02Z'),
tools: [
{
id: 'tool-2',
identifier: 'news',
apiName: 'getNews',
arguments: '{}',
type: 'default',
},
],
},
{
id: 'msg-4',
userId,
role: 'tool',
content: 'Breaking: AI news',
createdAt: new Date('2023-01-01T10:00:03Z'),
},
{
id: 'msg-5',
userId,
role: 'assistant',
content: 'Step 3: Final summary based on weather and news',
parentId: 'msg-4',
createdAt: new Date('2023-01-01T10:00:04Z'),
},
]);
await serverDB.insert(messagePlugins).values([
{
id: 'msg-2',
userId,
toolCallId: 'tool-1',
identifier: 'weather',
},
{
id: 'msg-4',
userId,
toolCallId: 'tool-2',
identifier: 'news',
},
]);
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Should have 1 group with 3 children
expect(result).toHaveLength(1);
expect(result[0].role).toBe('group');
expect(result[0].children).toHaveLength(3);
// First child: original assistant with tool result
expect(result[0].children![0].id).toBe('msg-1');
expect(result[0].children![0].content).toBe('Step 1: Check weather');
expect(result[0].children![0].tools![0].result?.content).toBe('Sunny, 25°C');
// Second child: follow-up assistant with its own tool result
expect(result[0].children![1].id).toBe('msg-3');
expect(result[0].children![1].content).toBe('Step 2: Based on weather, check news');
expect(result[0].children![1].tools![0].result?.content).toBe('Breaking: AI news');
// Third child: final assistant (parentId pointed to second tool)
expect(result[0].children![2].id).toBe('msg-5');
expect(result[0].children![2].content).toBe(
'Step 3: Final summary based on weather and news',
);
});
it('should group messages in multi-turn conversation', async () => {
// Create multi-turn conversation
await serverDB.insert(messages).values([
{
id: 'msg-1',
userId,
role: 'user',
content: 'What is the weather?',
createdAt: new Date('2023-01-01T10:00:00Z'),
},
{
id: 'msg-2',
userId,
role: 'assistant',
content: 'Checking weather',
createdAt: new Date('2023-01-01T10:00:01Z'),
tools: [
{
id: 'tool-1',
identifier: 'weather',
apiName: 'getWeather',
arguments: '{}',
type: 'default',
},
],
},
{
id: 'msg-3',
userId,
role: 'tool',
content: 'Sunny, 25°C',
createdAt: new Date('2023-01-01T10:00:02Z'),
},
{
id: 'msg-4',
userId,
role: 'user',
content: 'What about news?',
createdAt: new Date('2023-01-01T10:00:03Z'),
},
{
id: 'msg-5',
userId,
role: 'assistant',
content: 'Checking news',
createdAt: new Date('2023-01-01T10:00:04Z'),
tools: [
{
id: 'tool-2',
identifier: 'news',
apiName: 'getNews',
arguments: '{}',
type: 'default',
},
],
},
{
id: 'msg-6',
userId,
role: 'tool',
content: 'AI breakthrough',
createdAt: new Date('2023-01-01T10:00:05Z'),
},
]);
await serverDB.insert(messagePlugins).values([
{
id: 'msg-3',
userId,
toolCallId: 'tool-1',
identifier: 'weather',
},
{
id: 'msg-6',
userId,
toolCallId: 'tool-2',
identifier: 'news',
},
]);
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify grouping
expect(result).toHaveLength(4); // 2 users + 2 grouped assistants
expect(result[0].role).toBe('user');
expect(result[1].role).toBe('group');
expect(result[2].role).toBe('user');
expect(result[3].role).toBe('group');
});
it('should handle mixed grouped and non-grouped messages', async () => {
// Create mixed messages
await serverDB.insert(messages).values([
{
id: 'msg-1',
userId,
role: 'assistant',
content: 'Hello!',
createdAt: new Date('2023-01-01T10:00:00Z'),
},
{
id: 'msg-2',
userId,
role: 'assistant',
content: 'Using tools',
createdAt: new Date('2023-01-01T10:00:01Z'),
tools: [
{
id: 'tool-1',
identifier: 'test',
apiName: 'test',
arguments: '{}',
type: 'default',
},
],
},
{
id: 'msg-3',
userId,
role: 'tool',
content: 'Result',
createdAt: new Date('2023-01-01T10:00:02Z'),
},
]);
await serverDB.insert(messagePlugins).values({
id: 'msg-3',
userId,
toolCallId: 'tool-1',
identifier: 'test',
});
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify grouping
expect(result).toHaveLength(2);
expect(result[0].role).toBe('assistant');
expect(result[0].children).toBeUndefined();
expect(result[1].role).toBe('group');
expect(result[1].children).toHaveLength(1);
});
});
describe('Edge Cases', () => {
it('should handle tool messages with errors', async () => {
// Create assistant with tool
await serverDB.insert(messages).values({
id: 'msg-1',
userId,
role: 'assistant',
content: 'Checking',
tools: [
{
id: 'tool-1',
identifier: 'test',
apiName: 'test',
arguments: '{}',
type: 'default',
},
],
});
// Create tool message with error
await serverDB.insert(messages).values({
id: 'msg-2',
userId,
role: 'tool',
content: '',
});
await serverDB.insert(messagePlugins).values({
id: 'msg-2',
userId,
toolCallId: 'tool-1',
identifier: 'test',
error: { message: 'Failed to execute' },
});
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify error is preserved
expect(result).toHaveLength(1);
expect(result[0].role).toBe('group');
expect(result[0].children![0].tools![0].result?.error).toEqual({
message: 'Failed to execute',
});
});
it('should preserve message order', async () => {
// Create messages in specific order
await serverDB.insert(messages).values([
{
id: 'msg-1',
userId,
role: 'user',
content: 'First',
createdAt: new Date('2023-01-01T10:00:00Z'),
},
{
id: 'msg-2',
userId,
role: 'assistant',
content: 'Second',
createdAt: new Date('2023-01-01T10:00:01Z'),
},
{
id: 'msg-3',
userId,
role: 'user',
content: 'Third',
createdAt: new Date('2023-01-01T10:00:02Z'),
},
]);
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify order
expect(result).toHaveLength(3);
expect(result[0].content).toBe('First');
expect(result[1].content).toBe('Second');
expect(result[2].content).toBe('Third');
});
it('should handle orphaned tool messages', async () => {
// Create orphaned tool message
await serverDB.insert(messages).values({
id: 'msg-1',
userId,
role: 'tool',
content: 'Orphaned result',
});
await serverDB.insert(messagePlugins).values({
id: 'msg-1',
userId,
toolCallId: 'unknown-tool',
identifier: 'test',
});
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify orphaned tool is not filtered
expect(result).toHaveLength(1);
expect(result[0].role).toBe('tool');
});
});
describe('Children Structure Validation', () => {
it('should use message ID as block ID', async () => {
// Create assistant with tool
await serverDB.insert(messages).values({
id: 'msg-1',
userId,
role: 'assistant',
content: 'Test',
tools: [
{
id: 'tool-1',
identifier: 'test',
apiName: 'test',
arguments: '{}',
type: 'default',
},
],
});
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify block ID uses message ID
expect(result[0].children![0].id).toBe('msg-1');
});
it('should convert empty imageList/fileList to undefined in children', async () => {
// Create assistant with tools but empty imageList/fileList
await serverDB.insert(messages).values({
id: 'msg-1',
userId,
role: 'assistant',
content: 'Test',
tools: [
{
id: 'tool-1',
identifier: 'test',
apiName: 'test',
arguments: '{}',
type: 'default',
},
],
});
// Query messages (no files attached, so imageList/fileList will be empty)
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify empty arrays become undefined
expect(result[0].children![0].imageList).toBeUndefined();
});
it('should move tools/imageList/fileList to children', async () => {
// Create files
await serverDB.insert(files).values([
{
id: 'img-1',
userId,
name: 'test.png',
fileType: 'image/png',
size: 1024,
url: 'http://example.com/img.png',
},
{
id: 'file-1',
userId,
name: 'test.pdf',
fileType: 'application/pdf',
size: 2048,
url: 'http://example.com/file.pdf',
},
]);
// Create assistant with tools and files
await serverDB.insert(messages).values({
id: 'msg-1',
userId,
role: 'assistant',
content: 'Test',
tools: [
{
id: 'tool-1',
identifier: 'test',
apiName: 'test',
arguments: '{}',
type: 'default',
},
],
});
await serverDB.insert(messagesFiles).values([
{ messageId: 'msg-1', fileId: 'img-1', userId },
{ messageId: 'msg-1', fileId: 'file-1', userId },
]);
// Query messages
const result = await messageModel.query(
{ sessionId: null },
{ groupAssistantMessages: true },
);
// Verify parent fields are cleared
expect(result[0].tools).toBeUndefined();
expect(result[0].imageList).toBeUndefined();
expect(result[0].fileList).toBeUndefined();
expect(result[0].content).toBe('');
// Verify children have the data
const block = result[0].children![0];
expect(block.content).toBe('Test');
expect(block.tools).toHaveLength(1);
expect(block.imageList).toHaveLength(1);
});
});
});
+18 -19
View File
@@ -40,7 +40,6 @@ import {
} from '../schemas';
import { LobeChatDatabase } from '../type';
import { genEndDateWhere, genRangeWhere, genStartDateWhere, genWhere } from '../utils/genWhere';
import { groupAssistantMessages } from '../utils/groupMessages';
import { idGenerator } from '../utils/idGenerator';
export class MessageModel {
@@ -227,8 +226,8 @@ export class MessageModel {
})),
extra: {
fromModel: model,
fromProvider: provider,
model: model,
provider: provider,
translate,
tts: ttsId
? {
@@ -269,9 +268,7 @@ export class MessageModel {
},
);
// Group assistant messages with their tool results
const { groupAssistantMessages: useGroup = false } = options;
return useGroup ? groupAssistantMessages(mappedMessages) : mappedMessages;
return mappedMessages;
};
findById = async (id: string) => {
@@ -464,8 +461,8 @@ export class MessageModel {
create = async (
{
fromModel,
fromProvider,
model: fromModel,
provider: fromProvider,
files,
plugin,
pluginState,
@@ -771,17 +768,19 @@ export class MessageModel {
sessionId?: string | null,
topicId?: string | null,
groupId?: string | null,
) =>
this.db
.delete(messages)
.where(
and(
eq(messages.userId, this.userId),
this.matchSession(sessionId),
this.matchTopic(topicId),
this.matchGroup(groupId),
),
);
) => {
const conditions = [eq(messages.userId, this.userId), this.matchSession(sessionId)];
// For deletion: only filter by topicId/groupId if explicitly provided
if (topicId !== undefined && topicId !== null) {
conditions.push(eq(messages.topicId, topicId));
}
if (groupId !== undefined && groupId !== null) {
conditions.push(eq(messages.groupId, groupId));
}
return this.db.delete(messages).where(and(...conditions));
};
deleteAllMessages = async () => {
return this.db.delete(messages).where(eq(messages.userId, this.userId));
File diff suppressed because it is too large Load Diff
@@ -1,361 +0,0 @@
import {
AssistantContentBlock,
ChatToolPayloadWithResult,
MessageMetadata,
ModelPerformance,
ModelUsage,
UIChatMessage,
} from '@lobechat/types';
/**
* Split MessageMetadata into usage and performance
*/
function splitMetadata(metadata?: MessageMetadata | null): {
performance?: ModelPerformance;
usage?: ModelUsage;
} {
if (!metadata) return {};
const usage: ModelUsage = {};
const performance: ModelPerformance = {};
// Extract usage fields (tokens and cost)
const usageFields = [
'inputCachedTokens',
'inputCacheMissTokens',
'inputWriteCacheTokens',
'inputTextTokens',
'inputImageTokens',
'inputAudioTokens',
'inputCitationTokens',
'outputTextTokens',
'outputImageTokens',
'outputAudioTokens',
'outputReasoningTokens',
'acceptedPredictionTokens',
'rejectedPredictionTokens',
'totalInputTokens',
'totalOutputTokens',
'totalTokens',
'cost',
] as const;
let hasUsage = false;
usageFields.forEach((field) => {
if (metadata[field] !== undefined) {
usage[field] = metadata[field] as any;
hasUsage = true;
}
});
// Extract performance fields
const performanceFields = ['tps', 'ttft', 'duration', 'latency'] as const;
let hasPerformance = false;
performanceFields.forEach((field) => {
if (metadata[field] !== undefined) {
performance[field] = metadata[field];
hasPerformance = true;
}
});
return {
performance: hasPerformance ? performance : undefined,
usage: hasUsage ? usage : undefined,
};
}
/**
* Aggregate metadata from all children blocks
* Creates structured usage and performance metrics
*/
function aggregateMetadata(
children: AssistantContentBlock[],
): { performance?: ModelPerformance; usage?: ModelUsage } | null {
const usage: ModelUsage = {};
const performance: ModelPerformance = {};
let hasUsageData = false;
let hasPerformanceData = false;
let tpsSum = 0;
let tpsCount = 0;
children.forEach((child) => {
// Aggregate usage metrics (tokens and cost)
if (child.usage) {
const tokenFields = [
'inputCachedTokens',
'inputCacheMissTokens',
'inputWriteCacheTokens',
'inputTextTokens',
'inputImageTokens',
'inputAudioTokens',
'inputCitationTokens',
'outputTextTokens',
'outputImageTokens',
'outputAudioTokens',
'outputReasoningTokens',
'acceptedPredictionTokens',
'rejectedPredictionTokens',
'totalInputTokens',
'totalOutputTokens',
'totalTokens',
] as const;
tokenFields.forEach((field) => {
if (typeof child.usage![field] === 'number') {
usage[field] = (usage[field] || 0) + child.usage![field]!;
hasUsageData = true;
}
});
if (typeof child.usage.cost === 'number') {
usage.cost = (usage.cost || 0) + child.usage.cost;
hasUsageData = true;
}
}
// Aggregate performance metrics
// - ttft: use the first child's value (time to first token)
// - tps: calculate average across all children
// - duration: sum all durations
// - latency: sum all latencies
if (child.performance) {
if (child.performance.ttft !== undefined && performance.ttft === undefined) {
performance.ttft = child.performance.ttft; // First child only
hasPerformanceData = true;
}
if (typeof child.performance.tps === 'number') {
tpsSum += child.performance.tps;
tpsCount += 1;
hasPerformanceData = true;
}
if (child.performance.duration !== undefined) {
performance.duration = (performance.duration || 0) + child.performance.duration;
hasPerformanceData = true;
}
if (child.performance.latency !== undefined) {
performance.latency = (performance.latency || 0) + child.performance.latency;
hasPerformanceData = true;
}
}
});
// Calculate average tps
if (tpsCount > 0) {
performance.tps = tpsSum / tpsCount;
}
// Return null if no data
if (!hasUsageData && !hasPerformanceData) return null;
// Return structured metrics
const result: { performance?: ModelPerformance; usage?: ModelUsage } = {};
if (hasUsageData) result.usage = usage;
if (hasPerformanceData) result.performance = performance;
return result;
}
/**
* Group assistant messages with their tool results
* Converts flat message list into grouped structure with children
*
* @param messages - Flat message list from database query
* @returns Grouped message list with assistant children populated
*/
export function groupAssistantMessages(messages: UIChatMessage[]): UIChatMessage[] {
const result: UIChatMessage[] = [];
const toolMessageIds = new Set<string>();
const processedAssistantIds = new Set<string>();
// 1. Create tool_call_id -> tool message mapping
const toolMessageMap = new Map<string, UIChatMessage>();
messages.forEach((msg) => {
if (msg.role === 'tool' && msg.tool_call_id) {
toolMessageMap.set(msg.tool_call_id, msg);
}
});
// 2. Build message ID -> message mapping for quick lookup
const messageMap = new Map<string, UIChatMessage>();
messages.forEach((msg) => {
messageMap.set(msg.id, msg);
});
// 3. Find follow-up assistants that have tool messages as parent
// Map: tool message id -> follow-up assistant messages
const toolToFollowUpAssistants = new Map<string, UIChatMessage[]>();
messages.forEach((msg) => {
if (msg.role === 'assistant' && msg.parentId) {
const parent = messageMap.get(msg.parentId);
if (parent && parent.role === 'tool') {
const existing = toolToFollowUpAssistants.get(msg.parentId) || [];
toolToFollowUpAssistants.set(msg.parentId, [...existing, msg]);
}
}
});
// 4. Process messages
messages.forEach((msg) => {
// Skip tool messages that have been merged
if (msg.role === 'tool') {
if (!toolMessageIds.has(msg.id)) {
result.push(msg);
}
return;
}
// Handle non-assistant messages
if (msg.role !== 'assistant') {
result.push(msg);
return;
}
// Skip assistant messages that have been processed as follow-ups
if (processedAssistantIds.has(msg.id)) {
return;
}
// 5. Process assistant messages
const assistantMsg = { ...msg };
// If no tools, add as-is (unless it's a follow-up, which should have been skipped above)
if (!msg.tools || msg.tools.length === 0) {
result.push(assistantMsg);
return;
}
// 6. Build children structure
const children: AssistantContentBlock[] = [];
// First child: original assistant with tool results
const toolsWithResults: ChatToolPayloadWithResult[] = msg.tools.map((tool) => {
const toolMsg = toolMessageMap.get(tool.id);
if (toolMsg) {
// Mark tool message as merged
toolMessageIds.add(toolMsg.id);
return {
...tool,
result: {
content: toolMsg.content,
error: toolMsg.pluginError,
id: toolMsg.id,
state: toolMsg.pluginState,
},
result_msg_id: toolMsg.id,
};
}
// Tool message not yet available (still executing)
return tool;
});
const { usage: msgUsage, performance: msgPerformance } = splitMetadata(msg.metadata);
children.push({
content: msg.content || '',
error: msg.error,
id: msg.id,
imageList: msg.imageList && msg.imageList.length > 0 ? msg.imageList : undefined,
performance: msgPerformance,
reasoning: msg.reasoning || undefined,
tools: toolsWithResults,
usage: msgUsage,
});
// 7. Recursively add follow-up assistants as additional children
// Keep track of tool result IDs that are part of this group
const groupToolResultIds = new Set<string>();
toolsWithResults.forEach((tool) => {
if (tool.result) {
groupToolResultIds.add(tool.result.id);
}
});
// Recursively collect all follow-up assistants
const collectFollowUpAssistants = (currentToolResultIds: Set<string>): void => {
const newToolResultIds = new Set<string>();
currentToolResultIds.forEach((toolResultId) => {
const followUps = toolToFollowUpAssistants.get(toolResultId) || [];
followUps.forEach((followUpMsg) => {
// Skip if already processed
if (processedAssistantIds.has(followUpMsg.id)) return;
// Process follow-up assistant's tools and fill in their results
const followUpToolsWithResults: ChatToolPayloadWithResult[] | undefined =
followUpMsg.tools?.map((followUpTool) => {
const followUpToolMsg = toolMessageMap.get(followUpTool.id);
if (followUpToolMsg) {
// Mark tool message as merged
toolMessageIds.add(followUpToolMsg.id);
// Track this tool result for next iteration
newToolResultIds.add(followUpToolMsg.id);
return {
...followUpTool,
result: {
content: followUpToolMsg.content,
error: followUpToolMsg.pluginError,
id: followUpToolMsg.id,
state: followUpToolMsg.pluginState,
},
result_msg_id: followUpToolMsg.id,
};
}
// Tool message not yet available
return followUpTool;
});
const { usage: followUpUsage, performance: followUpPerformance } = splitMetadata(
followUpMsg.metadata,
);
children.push({
content: followUpMsg.content || '',
error: followUpMsg.error,
id: followUpMsg.id,
imageList:
followUpMsg.imageList && followUpMsg.imageList.length > 0
? followUpMsg.imageList
: undefined,
performance: followUpPerformance,
reasoning: followUpMsg.reasoning || undefined,
tools: followUpToolsWithResults,
usage: followUpUsage,
});
processedAssistantIds.add(followUpMsg.id);
});
});
// Recursively process the next level
if (newToolResultIds.size > 0) {
collectFollowUpAssistants(newToolResultIds);
}
};
collectFollowUpAssistants(groupToolResultIds);
// 8. Aggregate usage and performance from all children
const aggregated = aggregateMetadata(children);
// 9. Set children and aggregated metrics
assistantMsg.role = 'group';
assistantMsg.children = children;
if (aggregated) {
assistantMsg.usage = aggregated.usage;
assistantMsg.performance = aggregated.performance;
}
delete assistantMsg.metadata; // Clear individual metadata
delete assistantMsg.reasoning; // Reasoning moved to children blocks
delete assistantMsg.tools;
delete assistantMsg.imageList;
delete assistantMsg.fileList;
assistantMsg.content = ''; // Content moved to children
result.push(assistantMsg);
});
return result;
}
+2
View File
@@ -9,6 +9,7 @@ export interface SendNewMessage {
content: string;
// if message has attached with files, then add files to message and the agent
files?: string[];
parentId?: string;
}
export interface SendMessageServerParams {
@@ -41,6 +42,7 @@ export const AiSendMessageServerSchema = z.object({
newUserMessage: z.object({
content: z.string(),
files: z.array(z.string()).optional(),
parentId: z.string().optional(),
}),
sessionId: z.string().optional(),
threadId: z.string().optional(),
+2 -2
View File
@@ -28,8 +28,8 @@ export interface ImportMessage {
// 扩展字段
extra?: {
fromModel?: string;
fromProvider?: string;
model?: string;
provider?: string;
// 翻译
translate?: ChatTranslate | false | null;
// TTS
+17 -1
View File
@@ -13,7 +13,13 @@ import { ChatMessageExtra } from './extra';
import { ChatFileChunk } from './rag';
import { ChatVideoItem } from './video';
export type UIMessageRoleType = 'user' | 'system' | 'assistant' | 'tool' | 'supervisor' | 'group';
export type UIMessageRoleType =
| 'user'
| 'system'
| 'assistant'
| 'tool'
| 'supervisor'
| 'assistantGroup';
export interface ChatFileItem {
content?: string;
@@ -34,10 +40,20 @@ export interface AssistantContentBlock {
tools?: ChatToolPayloadWithResult[];
usage?: ModelUsage;
}
interface UIMessageBranch {
/** Index of the active branch (0-based) */
activeBranchIndex: number;
/** Total number of branches */
count: number;
}
export interface UIChatMessage {
// Group chat fields (alphabetically before other fields)
agentId?: string | 'supervisor';
/**
* Branch information for user messages with multiple children
*/
branch?: UIMessageBranch;
/**
* children messages for grouped display
* Used to group tool messages under their parent assistant message
+2 -2
View File
@@ -7,8 +7,8 @@ export interface ChatTTS {
}
export interface ChatMessageExtra {
fromModel?: string;
fromProvider?: string;
model?: string;
provider?: string;
// 翻译
translate?: ChatTranslate | false | null;
// TTS
+2 -2
View File
@@ -16,8 +16,8 @@ export interface CreateMessageParams
error?: ChatMessageError | null;
fileChunks?: MessageSemanticSearchChunk[];
files?: string[];
fromModel?: string;
fromProvider?: string;
model?: string;
provider?: string;
groupId?: string;
role: CreateMessageRoleType;
sessionId: string;
-4
View File
@@ -35,10 +35,6 @@ export const UserGuideSchema = z.object({
export type UserGuide = z.infer<typeof UserGuideSchema>;
export const UserLabSchema = z.object({
/**
* enable assistant message grouping in chat display
*/
enableAssistantMessageGroup: z.boolean().optional(),
/**
* enable multi-agent group chat mode
*/
-35
View File
@@ -1,35 +0,0 @@
let worker: Worker | null = null;
const getWorker = () => {
if (!worker && typeof Worker !== 'undefined') {
worker = new Worker(new URL('tokenizer.worker.ts', import.meta.url));
}
return worker;
};
export const clientEncodeAsync = (str: string): Promise<number> =>
new Promise((resolve, reject) => {
const worker = getWorker();
if (!worker) {
// 如果 WebWorker 不可用,回退到字符串计算
resolve(str.length);
return;
}
const id = str;
const handleMessage = (event: MessageEvent) => {
if (event.data.id === id) {
worker.removeEventListener('message', handleMessage);
if (event.data.error) {
reject(new Error(event.data.error));
} else {
resolve(event.data.result);
}
}
};
worker.addEventListener('message', handleMessage);
worker.postMessage({ id, str });
});
@@ -1,4 +0,0 @@
import { approximateTokenSize } from 'tokenx';
export const estimatedEncodeAsync = async (str: string): Promise<number> =>
approximateTokenSize(str);
+3 -11
View File
@@ -1,15 +1,7 @@
import { estimateTokenCount } from 'tokenx';
export const encodeAsync = async (str: string): Promise<number> => {
if (str.length === 0) return 0;
// use gpt-tokenizer under 10000 str
// use approximation way if large then 10000
if (str.length <= 10_000) {
const { clientEncodeAsync } = await import('./client');
return await clientEncodeAsync(str);
} else {
const { estimatedEncodeAsync } = await import('./estimated');
return await estimatedEncodeAsync(str);
}
return estimateTokenCount(str);
};
-11
View File
@@ -1,11 +0,0 @@
export const serverEncodeAsync = async (str: string): Promise<number> => {
try {
const res = await fetch('/webapi/tokenizer', { body: str, method: 'POST' });
const data = await res.json();
return data.count;
} catch (e) {
console.error('serverEncodeAsync:', e);
return str.length;
}
};
@@ -1,12 +0,0 @@
addEventListener('message', async (event) => {
const { id, str } = event.data;
try {
const { encode } = await import('gpt-tokenizer');
const tokenCount = encode(str).length;
postMessage({ id, result: tokenCount });
} catch (error) {
postMessage({ error: (error as Error).message, id });
}
});
@@ -1,32 +0,0 @@
// @vitest-environment edge-runtime
import { describe, expect, it } from 'vitest';
import { POST } from './route';
describe('tokenizer Route', () => {
it('count hello world', async () => {
const txt = 'Hello, world!';
const request = new Request('https://test.com', {
method: 'POST',
body: txt,
});
const response = await POST(request);
const data = await response.json();
expect(data.count).toEqual(4);
});
it('count Chinese', async () => {
const txt = '今天天气真好';
const request = new Request('https://test.com', {
method: 'POST',
body: txt,
});
const response = await POST(request);
const data = await response.json();
expect(data.count).toEqual(5);
});
});
@@ -1,8 +0,0 @@
import { encode } from 'gpt-tokenizer/encoding/o200k_base';
import { NextResponse } from 'next/server';
export const POST = async (req: Request) => {
const str = await req.text();
return NextResponse.json({ count: encode(str).length });
};
@@ -8,7 +8,7 @@ import { useChatStore } from '@/store/chat';
import { useSend } from '../useSend';
const MessageFromUrl = () => {
const updateInputMessage = useChatStore((s) => s.updateInputMessage);
const updateMessageInput = useChatStore((s) => s.updateMessageInput);
const { send: sendMessage } = useSend();
const searchParams = useSearchParams();
@@ -21,10 +21,10 @@ const MessageFromUrl = () => {
const newUrl = `${window.location.pathname}?${params.toString()}`;
window.history.replaceState({}, '', newUrl);
updateInputMessage(message);
updateMessageInput(message);
sendMessage();
}
}, [searchParams, updateInputMessage, sendMessage]);
}, [searchParams, updateMessageInput, sendMessage]);
return null;
};
@@ -39,7 +39,7 @@ const MobileChatInput = memo(() => {
const [loading, value, onInput, onStop] = useChatStore((s) => [
messageStateSelectors.isAIGenerating(s),
s.inputMessage,
s.updateInputMessage,
s.updateMessageInput,
s.stopGenerateMessage,
]);
@@ -16,9 +16,9 @@ export type UseSendMessageParams = Pick<
>;
export const useSendMessage = () => {
const [sendMessage, updateInputMessage] = useChatStore((s) => [
const [sendMessage, updateMessageInput] = useChatStore((s) => [
s.sendMessage,
s.updateInputMessage,
s.updateMessageInput,
]);
const { analytics } = useAnalytics();
const checkGeminiChineseWarning = useGeminiChineseWarning();
@@ -66,7 +66,7 @@ export const useSendMessage = () => {
...params,
});
updateInputMessage('');
updateMessageInput('');
clearChatUploadFileList();
// 获取分析数据
@@ -161,14 +161,14 @@ export const useSendGroupMessage = () => {
const [
isContentEmpty,
sendGroupMessage,
updateInputMessage,
updateMessageInput,
stopGenerateMessage,
isSendButtonDisabledByMessage,
isCreatingMessage,
] = useChatStore((s) => [
!s.inputMessage,
s.sendGroupMessage,
s.updateInputMessage,
s.updateMessageInput,
s.stopGenerateMessage,
messageStateSelectors.isSendButtonDisabledByMessage(s),
messageStateSelectors.isCreatingMessage(s),
@@ -255,7 +255,7 @@ export const useSendGroupMessage = () => {
mainInputEditor.setExpand(false);
mainInputEditor.clearContent();
mainInputEditor.focus();
updateInputMessage('');
updateMessageInput('');
// clear mentioned users after sending
mentionState.clearMentionedUsers();
@@ -284,7 +284,7 @@ export const useSendGroupMessage = () => {
canNotSend,
fileList,
clearChatUploadFileList,
updateInputMessage,
updateMessageInput,
analytics,
checkGeminiChineseWarning,
],
@@ -314,8 +314,8 @@ export const useSendGroupMessage = () => {
generating: isSupervisorThinking || isCreatingMessage,
send: handleSend,
stop,
updateInputMessage,
updateMessageInput,
}),
[canNotSend, isSupervisorThinking, isCreatingMessage, handleSend, stop, updateInputMessage],
[canNotSend, isSupervisorThinking, isCreatingMessage, handleSend, stop, updateMessageInput],
);
};
@@ -6,7 +6,7 @@ import { SkeletonList, VirtualizedList } from '@/features/Conversation';
import WideScreenContainer from '@/features/Conversation/components/WideScreenContainer';
import { useFetchMessages } from '@/hooks/useFetchMessages';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { displayMessageSelectors } from '@/store/chat/selectors';
import MainChatItem from './ChatItem';
import Welcome from './WelcomeChatItem';
@@ -16,10 +16,12 @@ interface ListProps {
}
const Content = memo<ListProps>(({ mobile }) => {
const [isCurrentChatLoaded] = useChatStore((s) => [chatSelectors.isCurrentChatLoaded(s)]);
const [isCurrentChatLoaded] = useChatStore((s) => [
displayMessageSelectors.isCurrentDisplayChatLoaded(s),
]);
useFetchMessages();
const data = useChatStore(chatSelectors.mainDisplayChatIDs);
const data = useChatStore(displayMessageSelectors.mainDisplayChatIDs);
const itemContent = useCallback(
(index: number, id: string) => <MainChatItem id={id} index={index} />,
@@ -40,7 +40,7 @@ interface OpeningQuestionsProps {
const OpeningQuestions = memo<OpeningQuestionsProps>(({ mobile, questions }) => {
const { t } = useTranslation('welcome');
const [updateInputMessage] = useChatStore((s) => [s.updateInputMessage]);
const [updateMessageInput] = useChatStore((s) => [s.updateMessageInput]);
const { styles } = useStyles();
const { send: sendMessage } = useSend();
@@ -56,7 +56,7 @@ const OpeningQuestions = memo<OpeningQuestionsProps>(({ mobile, questions }) =>
clickable
key={question}
onClick={() => {
updateInputMessage(question);
updateMessageInput(question);
sendMessage({ isWelcomeQuestion: true });
}}
paddingBlock={8}
@@ -77,7 +77,7 @@ const getFallbackActivities = (t: any) => {
const GroupUsageSuggest = memo<{ mobile?: boolean }>(({ mobile }) => {
const { t } = useTranslation('welcome');
const { styles } = useStyles();
const { updateInputMessage, send } = useSendGroupMessage();
const { updateMessageInput, send } = useSendGroupMessage();
const templateMatch = useTemplateMatching();
const itemsPerPage = mobile ? 2 : 4;
@@ -139,7 +139,7 @@ const GroupUsageSuggest = memo<{ mobile?: boolean }>(({ mobile }) => {
horizontal
key={activityKey}
onClick={() => {
updateInputMessage(prompt);
updateMessageInput(prompt);
send();
}}
variant={'outlined'}
-9
View File
@@ -24,13 +24,11 @@ const LabsPage = memo(() => {
const [
isPreferenceInit,
enableInputMarkdown,
enableAssistantMessageGroup,
// enableGroupChat,
updateLab,
] = useUserStore((s) => [
preferenceSelectors.isPreferenceInit(s),
labPreferSelectors.enableInputMarkdown(s),
labPreferSelectors.enableAssistantMessageGroup(s),
// labPreferSelectors.enableGroupChat(s),
s.updateLab,
]);
@@ -43,13 +41,6 @@ const LabsPage = memo(() => {
key: 'enableInputMarkdown',
title: t('features.inputMarkdown.title'),
},
{
checked: enableAssistantMessageGroup,
cover: 'https://github.com/user-attachments/assets/ba517751-1f3b-4269-979e-f8471e3ebb89',
desc: t('features.assistantMessageGroup.desc'),
key: 'enableAssistantMessageGroup',
title: t('features.assistantMessageGroup.title'),
},
// {
// checked: enableGroupChat,
// cover: 'https://github.com/user-attachments/assets/72894d24-a96a-4d7c-a823-ff9e6a1a8b6d',
@@ -42,9 +42,9 @@ const BrowserSTT = memo<{ mobile?: boolean }>(({ mobile }) => {
const [error, setError] = useState<ChatMessageError>();
const { t } = useTranslation('chat');
const [loading, updateInputMessage] = useChatStore((s) => [
const [loading, updateMessageInput] = useChatStore((s) => [
messageStateSelectors.isAIGenerating(s),
s.updateInputMessage,
s.updateMessageInput,
]);
const setDefaultError = useCallback(
@@ -76,7 +76,7 @@ const BrowserSTT = memo<{ mobile?: boolean }>(({ mobile }) => {
},
onTextChange: (text) => {
if (loading) stop();
if (text) updateInputMessage(text);
if (text) updateMessageInput(text);
},
});
@@ -53,9 +53,9 @@ const OpenaiSTT = memo<{ mobile?: boolean }>(({ mobile }) => {
const [error, setError] = useState<ChatMessageError>();
const { t } = useTranslation('chat');
const [loading, updateInputMessage] = useChatStore((s) => [
const [loading, updateMessageInput] = useChatStore((s) => [
messageStateSelectors.isAIGenerating(s),
s.updateInputMessage,
s.updateMessageInput,
]);
const setDefaultError = useCallback(
@@ -87,7 +87,7 @@ const OpenaiSTT = memo<{ mobile?: boolean }>(({ mobile }) => {
},
onTextChange: (text) => {
if (loading) stop();
if (text) updateInputMessage(text);
if (text) updateMessageInput(text);
},
});
@@ -19,7 +19,7 @@ const AccessCodeForm = memo<AccessCodeFormProps>(({ id }) => {
keyVaultsConfigSelectors.password(s),
s.updateKeyVaults,
]);
const [resend, deleteMessage] = useChatStore((s) => [s.regenerateMessage, s.deleteMessage]);
const [resend, deleteMessage] = useChatStore((s) => [s.delAndRegenerateMessage, s.deleteMessage]);
return (
<>
@@ -13,7 +13,7 @@ interface ChatInvalidAPIKeyProps {
const ChatInvalidAPIKey = memo<ChatInvalidAPIKeyProps>(({ id, provider }) => {
const { t } = useTranslation('modelProvider');
const { t: modelProviderErrorT } = useTranslation(['modelProvider', 'error']);
const [resend, deleteMessage] = useChatStore((s) => [s.regenerateMessage, s.deleteMessage]);
const [resend, deleteMessage] = useChatStore((s) => [s.delAndRegenerateMessage, s.deleteMessage]);
const providerName = useProviderName(provider as GlobalLLMProviderKey);
return (
@@ -15,7 +15,7 @@ const ClerkLogin = memo<{ id: string }>(({ id }) => {
const [openSignIn, isSignedIn] = useUserStore((s) => [s.openLogin, s.isSignedIn]);
const greeting = useGreeting();
const nickName = useUserStore(userProfileSelectors.nickName);
const [resend, deleteMessage] = useChatStore((s) => [s.regenerateMessage, s.deleteMessage]);
const [resend, deleteMessage] = useChatStore((s) => [s.delAndRegenerateMessage, s.deleteMessage]);
return (
<ErrorActionContainer>
@@ -1,79 +0,0 @@
import { Icon, Segmented } from '@lobehub/ui';
import { SegmentedLabeledOption } from 'antd/es/segmented';
import { AsteriskSquare, KeySquare, ScanFace } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { useServerConfigStore } from '@/store/serverConfig';
import { featureFlagsSelectors, serverConfigSelectors } from '@/store/serverConfig/selectors';
import AccessCodeForm from './AccessCodeForm';
import ChatInvalidAPIKey from './ChatInvalidApiKey';
import OAuthForm from './OAuthForm';
import { ErrorActionContainer } from './style';
enum Tab {
Api = 'api',
Oauth = 'oauth',
Password = 'password',
}
interface InvalidAccessCodeProps {
id: string;
provider?: string;
}
const InvalidAccessCode = memo<InvalidAccessCodeProps>(({ id, provider }) => {
const { t } = useTranslation('error');
const isEnabledOAuth = useServerConfigStore(serverConfigSelectors.enabledOAuthSSO);
const defaultTab = isEnabledOAuth ? Tab.Oauth : Tab.Password;
const [mode, setMode] = useState<Tab>(defaultTab);
const { showOpenAIApiKey } = useServerConfigStore(featureFlagsSelectors);
const isEnabledTab = showOpenAIApiKey || isEnabledOAuth;
return (
<ErrorActionContainer>
{isEnabledTab && (
<Segmented
block
onChange={(value) => setMode(value as Tab)}
options={
[
isEnabledOAuth
? {
icon: <Icon icon={ScanFace} />,
label: t('oauth', { ns: 'common' }),
value: Tab.Oauth,
}
: undefined,
{
icon: <Icon icon={AsteriskSquare} />,
label: t('unlock.tabs.password'),
value: Tab.Password,
},
showOpenAIApiKey
? {
icon: <Icon icon={KeySquare} />,
label: t('unlock.tabs.apiKey'),
value: Tab.Api,
}
: undefined,
].filter(Boolean) as SegmentedLabeledOption[]
}
style={{ width: '100%' }}
value={mode}
variant={'filled'}
/>
)}
<Flexbox gap={24}>
{mode === Tab.Password && <AccessCodeForm id={id} />}
{showOpenAIApiKey && mode === Tab.Api && <ChatInvalidAPIKey id={id} provider={provider} />}
{isEnabledOAuth && mode === Tab.Oauth && <OAuthForm id={id} />}
</Flexbox>
</ErrorActionContainer>
);
});
export default InvalidAccessCode;
@@ -18,7 +18,7 @@ const OAuthForm = memo<{ id: string }>(({ id }) => {
const user = useUserStore(userProfileSelectors.userProfile);
const isOAuthLoggedIn = useUserStore(authSelectors.isLoginWithAuth);
const [resend, deleteMessage] = useChatStore((s) => [s.regenerateMessage, s.deleteMessage]);
const [resend, deleteMessage] = useChatStore((s) => [s.delAndRegenerateMessage, s.deleteMessage]);
const { message, modal } = App.useApp();
@@ -12,7 +12,6 @@ import { useProviderName } from '@/hooks/useProviderName';
import ChatInvalidAPIKey from './ChatInvalidApiKey';
import ClerkLogin from './ClerkLogin';
import ErrorJsonViewer from './ErrorJsonViewer';
import InvalidAccessCode from './InvalidAccessCode';
import { ErrorActionContainer } from './style';
interface ErrorMessageData {
@@ -118,10 +117,6 @@ const ErrorMessageExtra = memo<ErrorExtraProps>(({ data, block }) => {
return <ClerkLogin id={data.id} />;
}
case ChatErrorType.InvalidAccessCode: {
return <InvalidAccessCode id={data.id} provider={data.error?.body?.provider} />;
}
case AgentRuntimeErrorType.NoOpenAIAPIKey: {
{
return <ChatInvalidAPIKey id={data.id} provider={data.error?.body?.provider} />;
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import ShareMessageModal from '@/features/Conversation/components/ShareMessageModal';
import { VirtuosoContext } from '@/features/Conversation/components/VirtualizedList/VirtuosoContext';
import { useChatStore } from '@/store/chat';
import { threadSelectors } from '@/store/chat/selectors';
import { messageStateSelectors, threadSelectors } from '@/store/chat/selectors';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
@@ -23,9 +23,10 @@ interface AssistantActionsProps {
}
export const AssistantActionsBar = memo<AssistantActionsProps>(({ id, data, index }) => {
const { error, tools } = data;
const [isThreadMode, hasThread] = useChatStore((s) => [
const [isThreadMode, hasThread, isRegenerating] = useChatStore((s) => [
!!s.activeThreadId,
threadSelectors.hasThreadBySourceMsgId(id)(s),
messageStateSelectors.isMessageRegenerating(id)(s),
]);
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
const [showShareModal, setShareModal] = useState(false);
@@ -42,7 +43,7 @@ export const AssistantActionsBar = memo<AssistantActionsProps>(({ id, data, inde
share,
tts,
translate,
} = useChatListActionsBar({ hasThread });
} = useChatListActionsBar({ hasThread, isRegenerating });
const hasTools = !!tools;
@@ -52,17 +53,19 @@ export const AssistantActionsBar = memo<AssistantActionsProps>(({ id, data, inde
const items = useMemo(() => {
if (hasTools) return [delAndRegenerate, copy];
return [edit, copy, inThread || isGroupSession ? null : branching].filter(
Boolean,
) as ActionIconGroupItemType[];
}, [inThread, hasTools, isGroupSession]);
return [
edit,
copy,
// inThread || isGroupSession ? null : branching
].filter(Boolean) as ActionIconGroupItemType[];
}, [inThread, hasTools, isGroupSession, delAndRegenerate, copy, edit, branching]);
const { t } = useTranslation('common');
const searchParams = useSearchParams();
const topic = searchParams.get('topic');
const [
deleteMessage,
regenerateMessage,
regenerateAssistantMessage,
translateMessage,
ttsMessage,
delAndRegenerateMessage,
@@ -73,7 +76,7 @@ export const AssistantActionsBar = memo<AssistantActionsProps>(({ id, data, inde
toggleMessageEditing,
] = useChatStore((s) => [
s.deleteMessage,
s.regenerateMessage,
s.regenerateAssistantMessage,
s.translateMessage,
s.ttsMessage,
s.delAndRegenerateMessage,
@@ -120,7 +123,7 @@ export const AssistantActionsBar = memo<AssistantActionsProps>(({ id, data, inde
case 'regenerate': {
if (inPortalThread) {
resendThreadMessage(id);
} else regenerateMessage(id);
} else regenerateAssistantMessage(id);
// if this message is an error message, we need to delete it
if (data.error) deleteMessage(id);
@@ -46,20 +46,15 @@ describe('AssistantMessageExtra', () => {
expect(screen.queryByText('Translate Component')).toBeNull();
});
it('should not render content if extra is defined but does not contain fromModel, tts, or translate', async () => {
it('should not render content if extra is defined but does not contain model, tts, or translate', async () => {
render(<AssistantMessageExtra {...mockData} extra={{}} />);
expect(screen.queryByText('Usage Component')).toBeNull();
expect(screen.queryByText('TTS Component')).toBeNull();
expect(screen.queryByText('Translate Component')).toBeNull();
});
it('should render Usage component if extra.fromModel exists', async () => {
render(
<AssistantMessageExtra
{...mockData}
extra={{ fromModel: 'gpt-4', fromProvider: 'openai' }}
/>,
);
it('should render Usage component if extra.model exists', async () => {
render(<AssistantMessageExtra {...mockData} extra={{ model: 'gpt-4', provider: 'openai' }} />);
expect(screen.getByText('Usage Component')).toBeInTheDocument();
});
@@ -24,12 +24,8 @@ export const AssistantMessageExtra = memo<AssistantMessageExtraProps>(
return (
<Flexbox gap={8} style={{ marginTop: !!tools?.length ? 8 : 4 }}>
{content !== LOADING_FLAT && extra?.fromModel && (
<Usage
metadata={metadata || {}}
model={extra?.fromModel}
provider={extra.fromProvider!}
/>
{content !== LOADING_FLAT && extra?.model && (
<Usage metadata={metadata || {}} model={extra?.model} provider={extra.provider!} />
)}
<>
{!!extra?.tts && (
@@ -58,15 +58,13 @@ export const AssistantMessageContent = memo<
{isIntentUnderstanding ? (
<IntentUnderstanding />
) : (
content && (
<DefaultMessage
addIdOnDOM={false}
content={content}
id={id}
isToolCallGenerating={isToolCallGenerating}
{...props}
/>
)
<DefaultMessage
addIdOnDOM={false}
content={content}
id={id}
isToolCallGenerating={isToolCallGenerating}
{...props}
/>
)}
{showImageItems && <ImageFileListViewer items={imageList} />}
{tools && (
@@ -2,7 +2,7 @@ import { Highlighter } from '@lobehub/ui';
import { memo, useMemo } from 'react';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { dbMessageSelectors } from '@/store/chat/selectors';
export interface FunctionMessageProps {
toolCallId: string;
@@ -10,7 +10,7 @@ export interface FunctionMessageProps {
}
const PluginResult = memo<FunctionMessageProps>(({ toolCallId, variant }) => {
const toolMessage = useChatStore(chatSelectors.getMessageByToolCallId(toolCallId));
const toolMessage = useChatStore(dbMessageSelectors.getDbMessageByToolCallId(toolCallId));
const { data, language } = useMemo(() => {
try {
@@ -2,14 +2,14 @@ import { Highlighter } from '@lobehub/ui';
import { memo } from 'react';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { dbMessageSelectors } from '@/store/chat/selectors';
export interface FunctionMessageProps {
toolCallId: string;
}
const PluginState = memo<FunctionMessageProps>(({ toolCallId }) => {
const toolMessage = useChatStore(chatSelectors.getMessageByToolCallId(toolCallId));
const toolMessage = useChatStore(dbMessageSelectors.getDbMessageByToolCallId(toolCallId));
return (
toolMessage?.pluginState && (
@@ -23,7 +23,10 @@ const PluginSettings = memo<PluginSettingsProps>(({ id, plugin }) => {
const { styles } = useStyles();
const { t } = useTranslation('error');
const theme = useTheme();
const [resend, deleteMessage] = useChatStore((s) => [s.regenerateMessage, s.deleteMessage]);
const [resend, deleteMessage] = useChatStore((s) => [
s.regenerateAssistantMessage,
s.deleteMessage,
]);
const pluginIdentifier = plugin?.identifier as string;
const pluginMeta = useToolStore(pluginSelectors.getPluginMetaById(pluginIdentifier), isEqual);
const manifest = useToolStore(pluginSelectors.getToolManifestById(pluginIdentifier), isEqual);
@@ -1,9 +1,8 @@
import { LOADING_FLAT } from '@lobechat/const';
import isEqual from 'fast-deep-equal';
import { Suspense, memo } from 'react';
import { useChatStore } from '@/store/chat';
import { chatSelectors, messageStateSelectors } from '@/store/chat/selectors';
import { dbMessageSelectors, messageStateSelectors } from '@/store/chat/selectors';
import CustomRender from './CustomRender';
import ErrorResponse from './ErrorResponse';
@@ -32,7 +31,7 @@ const Render = memo<RenderProps>(
apiName,
}) => {
const loading = useChatStore(messageStateSelectors.isToolCallStreaming(messageId, toolIndex));
const toolMessage = useChatStore(chatSelectors.getMessageByToolCallId(toolCallId), isEqual);
const toolMessage = useChatStore(dbMessageSelectors.getDbMessageByToolCallId(toolCallId));
if (loading || !toolMessage) return null;
@@ -204,75 +204,72 @@ const AssistantMessage = memo<AssistantMessageProps>((props) => {
);
const errorMessage = <ErrorMessageExtra data={props} />;
return (
<Flexbox
className={styles.container}
direction={placement === 'left' ? 'horizontal' : 'horizontal-reverse'}
gap={mobile ? 6 : 12}
>
<Avatar
alt={avatar.title || 'avatar'}
avatar={avatar}
loading={loading}
onClick={onAvatarClick}
placement={placement}
size={mobile ? MOBILE_AVATAR_SIZE : undefined}
style={{ marginTop: 6 }}
/>
<Flexbox align={'flex-start'} className={styles.messageContainer}>
<Flexbox className={styles.container} gap={mobile ? 6 : 12}>
<Flexbox gap={4} horizontal>
<Avatar
alt={avatar.title || 'avatar'}
avatar={avatar}
loading={loading}
onClick={onAvatarClick}
placement={placement}
size={MOBILE_AVATAR_SIZE}
style={{ marginTop: 6 }}
/>
<Title
avatar={avatar}
placement={placement}
showTitle={showTitle}
showTitle
time={createdAt}
titleAddon={dmIndicator}
/>
<Flexbox
align={'flex-start'}
className={styles.messageContent}
data-layout={'vertical'} // 添加数据属性以方便样式选择
direction={'vertical'}
gap={8}
>
<Flexbox style={{ flex: 1, maxWidth: '100%' }}>
{error && (message === LOADING_FLAT || !message) ? (
<ErrorContent error={errorContent} message={errorMessage} placement={placement} />
) : (
<MessageContent
editing={editing}
id={id}
markdownProps={markdownProps}
message={reducted ? `*${t('hideForYou')}*` : message}
messageExtra={
<>
{errorContent && (
<ErrorContent
error={errorContent}
message={errorMessage}
placement={placement}
/>
)}
<AssistantMessageExtra
content={content}
extra={extra}
id={id}
metadata={metadata}
tools={tools}
</Flexbox>
<Flexbox
align={'flex-start'}
className={styles.messageContent}
data-layout={'vertical'} // 添加数据属性以方便样式选择
direction={'vertical'}
gap={8}
width={'fit-content'}
>
<Flexbox style={{ flex: 1, maxWidth: '100%' }}>
{error && (message === LOADING_FLAT || !message) ? (
<ErrorContent error={errorContent} message={errorMessage} placement={placement} />
) : (
<MessageContent
editing={editing}
id={id}
markdownProps={markdownProps}
message={reducted ? `*${t('hideForYou')}*` : message}
messageExtra={
<>
{errorContent && (
<ErrorContent
error={errorContent}
message={errorMessage}
placement={placement}
/>
</>
}
onDoubleClick={onDoubleClick}
placement={placement}
renderMessage={renderMessage}
variant={variant}
/>
)}
</Flexbox>
{!disableEditing && !editing && (
<Flexbox align={'flex-start'} className={styles.actions} role="menubar">
<AssistantActionsBar data={props} id={id} index={index} />
</Flexbox>
)}
<AssistantMessageExtra
content={content}
extra={extra}
id={id}
metadata={metadata}
tools={tools}
/>
</>
}
onDoubleClick={onDoubleClick}
placement={placement}
renderMessage={renderMessage}
variant={variant}
/>
)}
</Flexbox>
{!disableEditing && !editing && (
<Flexbox align={'flex-start'} className={styles.actions} role="menubar">
<AssistantActionsBar data={props} id={id} index={index} />
</Flexbox>
)}
</Flexbox>
{mobile && <BorderSpacing borderSpacing={MOBILE_AVATAR_SIZE} />}
</Flexbox>
@@ -17,6 +17,7 @@ export const DefaultMessage = memo<
if (isToolCallGenerating) return;
if (!content) return <BubblesLoading />;
if (content === LOADING_FLAT && !editing) return <BubblesLoading />;
return <div id={addIdOnDOM ? id : undefined}>{editableContent}</div>;
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import ShareMessageModal from '@/features/Conversation/components/ShareMessageModal';
import { VirtuosoContext } from '@/features/Conversation/components/VirtualizedList/VirtuosoContext';
import { useChatStore } from '@/store/chat';
import { threadSelectors } from '@/store/chat/selectors';
import { messageStateSelectors, threadSelectors } from '@/store/chat/selectors';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
@@ -24,16 +24,19 @@ interface GroupActionsProps {
const WithContentId = memo<GroupActionsProps>(({ id, data, index, contentBlock }) => {
const { tools } = data;
const [isThreadMode, hasThread] = useChatStore((s) => [
const [isThreadMode, hasThread, isRegenerating] = useChatStore((s) => [
!!s.activeThreadId,
threadSelectors.hasThreadBySourceMsgId(id)(s),
messageStateSelectors.isMessageRegenerating(id)(s),
]);
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
const [showShareModal, setShareModal] = useState(false);
const { edit, delAndRegenerate, copy, divider, del, branching, share } = useChatListActionsBar({
hasThread,
});
const { edit, delAndRegenerate, regenerate, copy, divider, del, branching, share } =
useChatListActionsBar({
hasThread,
isRegenerating,
});
const hasTools = !!tools;
@@ -43,28 +46,34 @@ const WithContentId = memo<GroupActionsProps>(({ id, data, index, contentBlock }
const items = useMemo(() => {
if (hasTools) return [delAndRegenerate, copy];
return [edit, copy, inThread || isGroupSession ? null : branching].filter(
Boolean,
) as ActionIconGroupItemType[];
}, [inThread, hasTools, isGroupSession]);
return [
edit,
copy,
// inThread || isGroupSession ? null : branching
].filter(Boolean) as ActionIconGroupItemType[];
}, [inThread, hasTools, isGroupSession, delAndRegenerate, copy, edit, branching]);
const { t } = useTranslation('common');
const searchParams = useSearchParams();
const topic = searchParams.get('topic');
const [
deleteMessage,
regenerateAssistantMessage,
translateMessage,
delAndRegenerateMessage,
copyMessage,
openThreadCreator,
resendThreadMessage,
delAndResendThreadMessage,
toggleMessageEditing,
] = useChatStore((s) => [
s.deleteMessage,
s.regenerateAssistantMessage,
s.translateMessage,
s.delAndRegenerateMessage,
s.copyMessage,
s.openThreadCreator,
s.resendThreadMessage,
s.delAndResendThreadMessage,
s.toggleMessageEditing,
]);
@@ -103,6 +112,16 @@ const WithContentId = memo<GroupActionsProps>(({ id, data, index, contentBlock }
break;
}
case 'regenerate': {
if (inPortalThread) {
resendThreadMessage(id);
} else regenerateAssistantMessage(id);
// if this message is an error message, we need to delete it
if (data.error) deleteMessage(id);
break;
}
case 'delAndRegenerate': {
if (inPortalThread) {
delAndResendThreadMessage(id);
@@ -134,7 +153,16 @@ const WithContentId = memo<GroupActionsProps>(({ id, data, index, contentBlock }
<ActionIconGroup
items={items}
menu={{
items: [edit, copy, divider, share, divider, delAndRegenerate, del],
items: [
edit,
copy,
divider,
share,
divider,
regenerate,
// delAndRegenerate,
del,
],
}}
onActionClick={onActionClick}
/>
@@ -16,6 +16,6 @@ export const GroupActionsBar = memo<GroupActionsProps>(
({ id, data, contentBlock, index, contentId }) => {
if (!contentId) return <WithoutContentId data={data} id={id} />;
return <WithContentId contentBlock={contentBlock} data={data} id={contentId} index={index} />;
return <WithContentId contentBlock={contentBlock} data={data} id={id} index={index} />;
},
);
@@ -77,9 +77,7 @@ export const ContentBlock = memo<ContentBlockProps>((props) => {
{showReasoning && <Reasoning {...reasoning} id={id} />}
{/* Content - markdown text */}
{content && (
<MessageContent content={content} hasTools={hasTools} markdownProps={markdownProps} />
)}
<MessageContent content={content} hasTools={hasTools} markdownProps={markdownProps} />
{/* Image files */}
{showImageItems && <ImageFileListViewer items={imageList} />}
@@ -1,6 +1,5 @@
import { AssistantContentBlock } from '@lobechat/types';
import { createStyles } from 'antd-style';
import { motion } from 'framer-motion';
import { memo, use } from 'react';
import { Flexbox } from 'react-layout-kit';
@@ -23,11 +22,12 @@ interface GroupChildrenProps {
blocks: AssistantContentBlock[];
contentId?: string;
disableEditing?: boolean;
id: string;
messageIndex: number;
}
const GroupChildren = memo<GroupChildrenProps>(
({ blocks, contentId, disableEditing, messageIndex }) => {
({ blocks, contentId, disableEditing, messageIndex, id }) => {
const { styles } = useStyles();
const [toggleMessageEditing] = useChatStore((s) => [s.toggleMessageEditing]);
@@ -53,16 +53,16 @@ const GroupChildren = memo<GroupChildrenProps>(
<ContentBlock index={index} {...item} />
</Flexbox>
) : (
<motion.div
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
initial={{ height: 0, opacity: 0 }}
key={index}
style={{ overflow: 'hidden' }}
transition={{ duration: 0.3, ease: 'easeInOut' }}
>
<ContentBlock index={index} {...item} />
</motion.div>
// <motion.div
// animate={{ height: 'auto', opacity: 1 }}
// exit={{ height: 0, opacity: 0 }}
// initial={{ height: 0, opacity: 0 }}
// key={`${id}_${index}`}
// style={{ overflow: 'hidden' }}
// transition={{ duration: 0.3, ease: 'easeInOut' }}
// >
<ContentBlock index={index} key={`${id}_${index}`} {...item} />
// </motion.div>
);
})}
</Flexbox>
@@ -25,7 +25,13 @@ const MessageContent = memo<ContentBlockProps>(({ content, hasTools, markdownPro
const { styles, cx } = useStyles();
if (content === LOADING_FLAT) return <BubblesLoading />;
if (!content && !hasTools) return <BubblesLoading />;
if (content === LOADING_FLAT) {
if (hasTools) return null;
return <BubblesLoading />;
}
return (
content && (
@@ -23,7 +23,7 @@ const PluginSettings = memo<PluginSettingsProps>(({ id, plugin }) => {
const { styles } = useStyles();
const { t } = useTranslation('error');
const theme = useTheme();
const [resend, deleteMessage] = useChatStore((s) => [s.regenerateMessage, s.deleteMessage]);
const [resend, deleteMessage] = useChatStore((s) => [s.delAndRegenerateMessage, s.deleteMessage]);
const pluginIdentifier = plugin?.identifier as string;
const pluginMeta = useToolStore(pluginSelectors.getPluginMetaById(pluginIdentifier), isEqual);
const manifest = useToolStore(pluginSelectors.getToolManifestById(pluginIdentifier), isEqual);
@@ -90,7 +90,7 @@ const GroupMessage = memo<GroupMessageProps>((props) => {
avatar={avatar}
onClick={onAvatarClick}
placement={placement}
size={mobile ? MOBILE_AVATAR_SIZE : undefined}
size={MOBILE_AVATAR_SIZE}
style={{ marginTop: 6 }}
/>
<Title avatar={avatar} placement={placement} showTitle time={createdAt} />
@@ -111,6 +111,7 @@ const GroupMessage = memo<GroupMessageProps>((props) => {
blocks={children}
contentId={contentId}
disableEditing={disableEditing}
id={id}
messageIndex={index}
/>
)}
@@ -128,8 +128,8 @@ const SupervisorMessage = memo<SupervisorMessageProps>((props) => {
// Render todo message with dedicated component
if (isTodoMessage && todoData) {
const model = props.extra?.fromModel;
const provider = props.extra?.fromProvider;
const model = props.extra?.model;
const provider = props.extra?.provider;
const hasModelInfo = model || provider;
return (
@@ -4,17 +4,15 @@ import { ActionIconGroupItemType } from '@lobehub/ui/es/ActionIconGroup';
import { ActionIconGroupEvent } from '@lobehub/ui/es/ActionIconGroup/type';
import { App } from 'antd';
import { useSearchParams } from 'next/navigation';
import { memo, use, useCallback, useMemo } from 'react';
import { memo, use, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useChatStore } from '@/store/chat';
import { threadSelectors } from '@/store/chat/selectors';
import { useSessionStore } from '@/store/session';
import { sessionSelectors } from '@/store/session/selectors';
import { messageStateSelectors, threadSelectors } from '@/store/chat/selectors';
import { VirtuosoContext } from '../../components/VirtualizedList/VirtuosoContext';
import { InPortalThreadContext } from '../../context/InPortalThreadContext';
import { useChatListActionsBar } from '../../hooks/useChatListActionsBar';
import { VirtuosoContext } from '../../../components/VirtualizedList/VirtuosoContext';
import { InPortalThreadContext } from '../../../context/InPortalThreadContext';
import { useChatListActionsBar } from '../../../hooks/useChatListActionsBar';
interface UserActionsProps {
data: UIChatMessage;
@@ -28,11 +26,12 @@ export const UserActionsBar = memo<UserActionsProps>(({ id, data, index }) => {
const topic = searchParams.get('topic');
const [
isThreadMode,
// isThreadMode,
hasThread,
isRegenerating,
toggleMessageEditing,
deleteMessage,
regenerateMessage,
regenerateUserMessage,
translateMessage,
ttsMessage,
delAndRegenerateMessage,
@@ -41,12 +40,13 @@ export const UserActionsBar = memo<UserActionsProps>(({ id, data, index }) => {
resendThreadMessage,
delAndResendThreadMessage,
] = useChatStore((s) => [
!!s.activeThreadId,
// !!s.activeThreadId,
threadSelectors.hasThreadBySourceMsgId(id)(s),
messageStateSelectors.isMessageRegenerating(id)(s),
s.toggleMessageEditing,
s.deleteMessage,
s.regenerateMessage,
s.regenerateUserMessage,
s.translateMessage,
s.ttsMessage,
s.delAndRegenerateMessage,
@@ -56,22 +56,21 @@ export const UserActionsBar = memo<UserActionsProps>(({ id, data, index }) => {
s.delAndResendThreadMessage,
]);
const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
// const isGroupSession = useSessionStore(sessionSelectors.isCurrentSessionGroupSession);
const { regenerate, edit, copy, divider, del, branching, tts, translate } = useChatListActionsBar(
{ hasThread },
);
const { regenerate, edit, copy, divider, del, tts, translate } = useChatListActionsBar({
hasThread,
isRegenerating,
});
const inPortalThread = use(InPortalThreadContext);
const inThread = isThreadMode || inPortalThread;
// const inThread = isThreadMode || inPortalThread;
const items = useMemo(
() =>
[regenerate, edit, inThread || isGroupSession ? null : branching].filter(
Boolean,
) as ActionIconGroupItemType[],
[inThread, isGroupSession],
);
const items = [
regenerate,
edit,
// inThread || isGroupSession ? null : branching
].filter(Boolean) as ActionIconGroupItemType[];
const { message } = App.useApp();
@@ -113,7 +112,7 @@ export const UserActionsBar = memo<UserActionsProps>(({ id, data, index }) => {
case 'regenerate': {
if (inPortalThread) {
resendThreadMessage(id);
} else regenerateMessage(id);
} else regenerateUserMessage(id);
// if this message is an error message, we need to delete it
if (data.error) deleteMessage(id);
@@ -153,6 +152,8 @@ export const UserActionsBar = memo<UserActionsProps>(({ id, data, index }) => {
items: [edit, copy, divider, tts, translate, divider, regenerate, del],
}}
onActionClick={onActionClick}
size={'small'}
variant={'borderless'}
/>
);
});
@@ -0,0 +1,107 @@
import { Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { memo } from 'react';
import { Center, Flexbox } from 'react-layout-kit';
import { useChatStore } from '@/store/chat';
const useStyles = createStyles(({ css, token, prefixCls }) => ({
button: css`
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 4px;
color: ${token.colorTextSecondary};
transition: all 0.2s ease;
&:hover:not(.${prefixCls}-disabled) {
color: ${token.colorText};
background: ${token.colorFillSecondary};
}
&.${prefixCls}-disabled {
cursor: not-allowed;
opacity: 0.4;
}
`,
container: css`
user-select: none;
height: 20px;
display: inline-flex;
gap: 4px;
align-items: center;
padding-inline: 4px;
border-radius: ${token.borderRadiusSM}px;
`,
text: css`
min-width: 24px;
height: 20px;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: ${token.colorTextSecondary};
text-align: center;
`,
}));
interface MessageBranchProps {
activeBranchIndex: number;
count: number;
messageId: string;
}
const MessageBranch = memo<MessageBranchProps>(({ activeBranchIndex, count, messageId }) => {
const { styles, cx, prefixCls } = useStyles();
const switchMessageBranch = useChatStore((s) => s.switchMessageBranch);
const handlePrevious = () => {
if (activeBranchIndex > 0) {
switchMessageBranch(messageId, activeBranchIndex - 1);
}
};
const handleNext = () => {
if (activeBranchIndex < count - 1) {
switchMessageBranch(messageId, activeBranchIndex + 1);
}
};
const canGoPrevious = activeBranchIndex > 0;
const canGoNext = activeBranchIndex < count - 1;
return (
<Flexbox className={styles.container} horizontal>
<div
className={cx(styles.button, !canGoPrevious && `${prefixCls}-disabled`)}
onClick={handlePrevious}
role="button"
tabIndex={canGoPrevious ? 0 : -1}
>
<Icon icon={ChevronLeft} size={16} />
</div>
<Center className={styles.text}>
{activeBranchIndex + 1}/{count}
</Center>
<div
className={cx(styles.button, !canGoNext && `${prefixCls}-disabled`)}
onClick={handleNext}
role="button"
tabIndex={canGoNext ? 0 : -1}
>
<Icon icon={ChevronRight} size={16} />
</div>
</Flexbox>
);
});
export default MessageBranch;
@@ -0,0 +1,42 @@
import { memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import { useChatStore } from '@/store/chat';
import { messageStateSelectors } from '@/store/chat/selectors';
import { UIChatMessage } from '@/types/index';
import { UserActionsBar } from './ActionsBar';
import MessageBranch from './MessageBranch';
interface ActionsProps {
data: UIChatMessage;
disableEditing?: boolean;
id: string;
index: number;
}
const Actions = memo<ActionsProps>(({ id, data, index, disableEditing }) => {
const { branch } = data;
const [editing] = useChatStore((s) => [messageStateSelectors.isMessageEditing(id)(s)]);
return (
!editing && (
<Flexbox align={'center'} horizontal>
{!disableEditing && (
<Flexbox align={'flex-start'} role="menubar">
<UserActionsBar data={data} id={id} index={index} />
</Flexbox>
)}
{branch && (
<MessageBranch
activeBranchIndex={branch.activeBranchIndex}
count={branch.count}
messageId={id}
/>
)}
</Flexbox>
)
);
});
export default Actions;
@@ -22,7 +22,7 @@ import { useUserStore } from '@/store/user';
import { userProfileSelectors } from '@/store/user/selectors';
import { useDoubleClickEdit } from '../../hooks/useDoubleClickEdit';
import { UserActionsBar } from './Actions';
import Actions from './Actions';
import { UserBelowMessage } from './BelowMessage';
import { UserMessageExtra } from './Extra';
import { MarkdownRender as UserMarkdownRender } from './MarkdownRender';
@@ -127,60 +127,59 @@ const UserMessage = memo<UserMessageProps>((props) => {
);
return (
<Flexbox
className={styles.container}
direction={placement === 'left' ? 'horizontal' : 'horizontal-reverse'}
gap={mobile ? 6 : 12}
>
<Avatar
alt={title}
avatar={{ avatar, title }}
loading={loading}
placement={placement}
size={mobile ? 32 : undefined}
style={{ marginTop: 6 }}
/>
<Flexbox className={styles.container} gap={8}>
<Flexbox
align={placement === 'left' ? 'flex-start' : 'flex-end'}
className={styles.messageContainer}
direction={placement === 'left' ? 'horizontal' : 'horizontal-reverse'}
gap={mobile ? 6 : 12}
>
<Title
<Avatar
alt={title}
avatar={{ avatar, title }}
loading={loading}
placement={placement}
showTitle={false}
time={createdAt}
titleAddon={dmIndicator}
size={32}
style={{ marginTop: 6 }}
/>
<Flexbox
align={placement === 'left' ? 'flex-start' : 'flex-end'}
className={styles.messageContent}
direction={placement === 'left' ? 'horizontal' : 'horizontal-reverse'}
gap={8}
className={styles.messageContainer}
>
<Flexbox flex={1} style={{ maxWidth: '100%', minWidth: 0 }}>
<MessageContent
editing={editing}
id={id}
markdownProps={markdownProps}
message={content}
messageExtra={<UserMessageExtra content={content} extra={extra} id={id} />}
onDoubleClick={onDoubleClick}
placement={placement}
primary
renderMessage={renderMessage}
variant={variant}
/>
</Flexbox>
{!disableEditing && !editing && (
<Flexbox align={'flex-start'} className={styles.actions} role="menubar">
<UserActionsBar data={props} id={id} index={index} />
<Title
avatar={{ avatar, title }}
placement={placement}
showTitle={false}
time={createdAt}
titleAddon={dmIndicator}
/>
<Flexbox
align={placement === 'left' ? 'flex-start' : 'flex-end'}
className={styles.messageContent}
direction={placement === 'left' ? 'horizontal' : 'horizontal-reverse'}
gap={8}
>
<Flexbox flex={1} style={{ maxWidth: '100%', minWidth: 0 }}>
<MessageContent
editing={editing}
id={id}
markdownProps={markdownProps}
message={content}
messageExtra={<UserMessageExtra content={content} extra={extra} id={id} />}
onDoubleClick={onDoubleClick}
placement={placement}
primary
renderMessage={renderMessage}
variant={variant}
/>
</Flexbox>
)}
</Flexbox>
<UserBelowMessage content={content} id={id} ragQuery={ragQuery} />
</Flexbox>
<UserBelowMessage content={content} id={id} ragQuery={ragQuery} />
{mobile && variant === 'bubble' && <BorderSpacing borderSpacing={32} />}
</Flexbox>
<Flexbox direction={'horizontal-reverse'}>
<Actions data={props} disableEditing={disableEditing} id={id} index={index} />
</Flexbox>
{mobile && variant === 'bubble' && <BorderSpacing borderSpacing={32} />}
</Flexbox>
);
});
+3 -3
View File
@@ -11,7 +11,7 @@ import {
upsertVirtuosoVisibleItem,
} from '@/features/Conversation/components/VirtualizedList/VirtuosoContext';
import { useChatStore } from '@/store/chat';
import { chatSelectors, messageStateSelectors } from '@/store/chat/selectors';
import { displayMessageSelectors, messageStateSelectors } from '@/store/chat/selectors';
import History from '../components/History';
import { InPortalThreadContext } from '../context/InPortalThreadContext';
@@ -56,7 +56,7 @@ const Item = memo<ChatListItemProps>(
const { styles, cx } = useStyles();
const containerRef = useRef<HTMLDivElement | null>(null);
const item = useChatStore(chatSelectors.getMessageById(id), isEqual);
const item = useChatStore(displayMessageSelectors.getDisplayMessageById(id), isEqual);
const [isMessageLoading] = useChatStore((s) => [messageStateSelectors.isMessageLoading(id)(s)]);
@@ -133,7 +133,7 @@ const Item = memo<ChatListItemProps>(
);
}
case 'group': {
case 'assistantGroup': {
return (
<GroupMessage
{...item}
@@ -1,7 +1,7 @@
import { memo, useEffect } from 'react';
import { useChatStore } from '@/store/chat';
import { chatSelectors, messageStateSelectors } from '@/store/chat/selectors';
import { displayMessageSelectors, messageStateSelectors } from '@/store/chat/selectors';
import BackBottom from './BackBottom';
@@ -12,8 +12,8 @@ interface AutoScrollProps {
}
const AutoScroll = memo<AutoScrollProps>(({ atBottom, isScrolling, onScrollToBottom }) => {
const trackVisibility = useChatStore(messageStateSelectors.isAIGenerating);
const str = useChatStore(chatSelectors.mainAIChatsMessageString);
const reasoningStr = useChatStore(chatSelectors.mainAILatestMessageReasoningContent);
const str = useChatStore(displayMessageSelectors.mainAIChatsMessageString);
const reasoningStr = useChatStore(displayMessageSelectors.mainAILatestMessageReasoningContent);
useEffect(() => {
if (atBottom && trackVisibility && !isScrolling) {
@@ -0,0 +1,55 @@
import { memo, useEffect, useRef, useState } from 'react';
interface AnimatedNumberProps {
duration?: number;
formatter?: (value: number) => string;
value: number;
}
const AnimatedNumber = memo<AnimatedNumberProps>(({ value, duration = 3000, formatter }) => {
const [displayValue, setDisplayValue] = useState(value);
const frameRef = useRef<number>(undefined);
const startTimeRef = useRef<number>(undefined);
const startValueRef = useRef(value);
useEffect(() => {
const startValue = startValueRef.current;
const diff = value - startValue;
if (diff === 0) return;
const animate = (currentTime: number) => {
if (!startTimeRef.current) {
startTimeRef.current = currentTime;
}
const elapsed = currentTime - startTimeRef.current;
const progress = Math.min(elapsed / duration, 1);
// 使用 easeOutCubic 缓动函数
const easeProgress = 1 - (1 - progress) ** 3;
const current = startValue + diff * easeProgress;
setDisplayValue(current);
if (progress < 1) {
frameRef.current = requestAnimationFrame(animate);
} else {
startValueRef.current = value;
startTimeRef.current = undefined;
}
};
frameRef.current = requestAnimationFrame(animate);
return () => {
if (frameRef.current) {
cancelAnimationFrame(frameRef.current);
}
};
}, [value, duration]);
return formatter ? formatter(displayValue) : displayValue.toString();
});
export default AnimatedNumber;
@@ -13,6 +13,7 @@ import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { formatNumber, formatShortenNumber } from '@/utils/format';
import AnimatedNumber from './AnimatedNumber';
import ModelCard from './ModelCard';
import TokenProgress, { TokenProgressItem } from './TokenProgress';
import { getDetailsToken } from './tokens';
@@ -116,7 +117,6 @@ const TokenDetail = memo<TokenDetailProps>(({ meta, model, provider }) => {
? detailTokens.totalTokens.credit
: detailTokens.totalTokens!.token;
const shortTotal = (formatShortenNumber(totalCount) as string).toLowerCase?.();
const detailTotal = formatNumber(totalCount);
const averagePricing = formatNumber(
@@ -215,7 +215,10 @@ const TokenDetail = memo<TokenDetailProps>(({ meta, model, provider }) => {
>
<Center gap={2} horizontal style={{ cursor: 'default' }}>
<Icon icon={isShowCredit ? BadgeCent : CoinsIcon} />
{shortTotal}
<AnimatedNumber
formatter={(value) => (formatShortenNumber(value) as string).toLowerCase?.()}
value={totalCount}
/>
</Center>
</Popover>
);
@@ -1,16 +1,29 @@
'use client';
import { ReactNode, forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react';
import {
ReactNode,
forwardRef,
memo,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Flexbox } from 'react-layout-kit';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import WideScreenContainer from '@/features/Conversation/components/WideScreenContainer';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { displayMessageSelectors } from '@/store/chat/selectors';
import AutoScroll from '../AutoScroll';
import SkeletonList from '../SkeletonList';
import { VirtuosoContext, resetVirtuosoVisibleItems, setVirtuosoGlobalRef } from './VirtuosoContext';
import {
VirtuosoContext,
resetVirtuosoVisibleItems,
setVirtuosoGlobalRef,
} from './VirtuosoContext';
interface VirtualizedListProps {
dataSource: string[];
@@ -32,10 +45,9 @@ const VirtualizedList = memo<VirtualizedListProps>(({ mobile, dataSource, itemCo
const [atBottom, setAtBottom] = useState(true);
const [isScrolling, setIsScrolling] = useState(false);
const [id, isFirstLoading, isCurrentChatLoaded] = useChatStore((s) => [
chatSelectors.currentChatKey(s),
chatSelectors.currentChatLoadingState(s),
chatSelectors.isCurrentChatLoaded(s),
const [isFirstLoading, isCurrentChatLoaded] = useChatStore((s) => [
displayMessageSelectors.currentChatLoadingState(s),
displayMessageSelectors.isCurrentDisplayChatLoaded(s),
]);
const getFollowOutput = useCallback(() => {
@@ -53,9 +65,8 @@ const VirtualizedList = memo<VirtualizedListProps>(({ mobile, dataSource, itemCo
[atBottom],
);
useEffect(() => {
scrollToBottom();
}, [id]);
const components = useMemo(() => ({ List }), []);
const computeItemKey = useCallback((index: number, item: string) => item, []);
useEffect(() => {
setVirtuosoGlobalRef(virtuosoRef);
@@ -71,8 +82,8 @@ const VirtualizedList = memo<VirtualizedListProps>(({ mobile, dataSource, itemCo
};
}, []);
// overscan should be 3 times the height of the window
const overscan = typeof window !== 'undefined' ? window.innerHeight * 3 : 0;
// overscan should be 2 times the height of the window
const overscan = typeof window !== 'undefined' ? window.innerHeight * 2 : 0;
// first time loading or not loaded
if (isFirstLoading || !isCurrentChatLoaded) return <SkeletonList mobile={mobile} />;
@@ -81,12 +92,9 @@ const VirtualizedList = memo<VirtualizedListProps>(({ mobile, dataSource, itemCo
<VirtuosoContext value={virtuosoRef}>
<Virtuoso
atBottomStateChange={setAtBottom}
atBottomThreshold={50 * (mobile ? 2 : 1)}
components={{
List,
}}
computeItemKey={(_, item) => item}
atBottomThreshold={200 * (mobile ? 2 : 1)}
components={components}
computeItemKey={computeItemKey}
data={dataSource}
followOutput={getFollowOutput}
increaseViewportBy={overscan}
@@ -108,13 +116,14 @@ const VirtualizedList = memo<VirtualizedListProps>(({ mobile, dataSource, itemCo
atBottom={atBottom}
isScrolling={isScrolling}
onScrollToBottom={(type) => {
const virtuoso = virtuosoRef.current;
switch (type) {
case 'auto': {
scrollToBottom();
virtuoso?.scrollToIndex({ align: 'end', behavior: 'auto', index: 'LAST' });
break;
}
case 'click': {
scrollToBottom('smooth');
virtuoso?.scrollToIndex({ align: 'end', behavior: 'smooth', index: 'LAST' });
break;
}
}
@@ -15,7 +15,6 @@ import {
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { isDeprecatedEdition } from '@/const/version';
import { localeOptions } from '@/locales/resources';
const translateStyle = css`
@@ -41,18 +40,16 @@ interface ChatListActionsBar {
export const useChatListActionsBar = ({
hasThread,
}: { hasThread?: boolean } = {}): ChatListActionsBar => {
isRegenerating,
}: { hasThread?: boolean; isRegenerating?: boolean } = {}): ChatListActionsBar => {
const { t } = useTranslation(['common', 'chat']);
return useMemo(
return useMemo<ChatListActionsBar>(
() => ({
branching: {
disable: isDeprecatedEdition || undefined,
icon: Split,
key: 'branching',
label: !isDeprecatedEdition
? t('branching', { defaultValue: 'Create Sub Topic' })
: t('branchingDisable'),
label: t('branching', { defaultValue: 'Create Sub Topic' }),
},
copy: {
icon: Copy,
@@ -61,13 +58,13 @@ export const useChatListActionsBar = ({
},
del: {
danger: true,
disable: hasThread || undefined,
disabled: hasThread,
icon: Trash,
key: 'del',
label: hasThread ? t('messageAction.deleteDisabledByThreads', { ns: 'chat' }) : t('delete'),
},
delAndRegenerate: {
disable: hasThread || undefined,
disabled: hasThread || isRegenerating,
icon: ListRestart,
key: 'delAndRegenerate',
label: t('messageAction.delAndRegenerate', {
@@ -89,6 +86,7 @@ export const useChatListActionsBar = ({
label: '导出为 PDF',
},
regenerate: {
disabled: isRegenerating,
icon: RotateCcw,
key: 'regenerate',
label: t('regenerate', { defaultValue: 'Regenerate' }),
@@ -114,6 +112,6 @@ export const useChatListActionsBar = ({
label: t('tts.action', { ns: 'chat' }),
},
}),
[hasThread],
[hasThread, isRegenerating],
);
};
@@ -17,7 +17,7 @@ export const useSendThreadMessage = () => {
const canNotSend = useChatStore(threadSelectors.isSendButtonDisabledByMessage);
const generating = useChatStore((s) => threadSelectors.isThreadAIGenerating(s));
const stop = useChatStore((s) => s.stopGenerateMessage);
const [sendMessage, updateInputMessage] = useChatStore((s) => [
const [sendMessage, updateMessageInput] = useChatStore((s) => [
s.sendThreadMessage,
s.updateThreadInputMessage,
]);
@@ -54,11 +54,11 @@ export const useSendThreadMessage = () => {
if (!shouldContinue) return;
updateInputMessage(inputMessage);
updateMessageInput(inputMessage);
sendMessage({ message: inputMessage, ...params });
updateInputMessage('');
updateMessageInput('');
threadInputEditor.clearContent();
threadInputEditor.focus();
};
+15 -7
View File
@@ -7,7 +7,7 @@ import { useClearCurrentMessages } from '@/features/ChatInput/ActionBar/Clear';
import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
import { useActionSWR } from '@/libs/swr';
import { useChatStore } from '@/store/chat';
import { chatSelectors } from '@/store/chat/selectors';
import { displayMessageSelectors } from '@/store/chat/selectors';
import { useGlobalStore } from '@/store/global';
import { systemStatusSelectors } from '@/store/global/selectors';
import { HotkeyEnum, HotkeyScopeEnum } from '@/types/hotkey';
@@ -32,14 +32,22 @@ export const useOpenChatSettingsHotkey = () => {
};
export const useRegenerateMessageHotkey = () => {
const regenerateMessage = useChatStore((s) => s.regenerateMessage);
const lastMessage = useChatStore(chatSelectors.latestMessage, isEqual);
const [regenerateUserMessage, regenerateAssistantMessage] = useChatStore((s) => [
s.regenerateUserMessage,
s.regenerateAssistantMessage,
]);
const lastMessage = useChatStore((s) => displayMessageSelectors.mainAIChats(s).at(-1), isEqual);
const disable = !lastMessage || lastMessage.id === 'default' || lastMessage.role === 'system';
const disable = !lastMessage;
return useHotkeyById(
HotkeyEnum.RegenerateMessage,
() => !disable && regenerateMessage(lastMessage.id),
() => {
if (!lastMessage) return;
if (lastMessage.role === 'user') return regenerateUserMessage(lastMessage.id);
return regenerateAssistantMessage(lastMessage.id);
},
{
enableOnContentEditable: true,
enabled: !disable,
@@ -49,7 +57,7 @@ export const useRegenerateMessageHotkey = () => {
export const useDeleteAndRegenerateMessageHotkey = () => {
const delAndRegenerateMessage = useChatStore((s) => s.delAndRegenerateMessage);
const lastMessage = useChatStore(chatSelectors.latestMessage, isEqual);
const lastMessage = useChatStore((s) => displayMessageSelectors.mainAIChats(s).at(-1), isEqual);
const disable = !lastMessage || lastMessage.id === 'default' || lastMessage.role === 'system';
@@ -65,7 +73,7 @@ export const useDeleteAndRegenerateMessageHotkey = () => {
export const useDeleteLastMessageHotkey = () => {
const deleteMessage = useChatStore((s) => s.deleteMessage);
const lastMessage = useChatStore(chatSelectors.latestMessage, isEqual);
const lastMessage = useChatStore((s) => displayMessageSelectors.mainAIChats(s).at(-1), isEqual);
const disable = !lastMessage || lastMessage.id === 'default' || lastMessage.role === 'system';
@@ -66,7 +66,7 @@ describe('aiChatRouter', () => {
2,
expect.objectContaining({
content: expect.any(String),
fromModel: 'gpt-4o',
model: 'gpt-4o',
parentId: 'm-user',
role: 'assistant',
sessionId: 's1',
@@ -358,32 +358,6 @@ describe('Message Router Integration Tests', () => {
expect(result).toHaveLength(1);
expect(result[0].id).toBe(msg1.id);
});
it('should support useGroup parameter', async () => {
const caller = messageRouter.createCaller(createTestContext(userId));
// 创建多个消息
await caller.createMessage({
content: 'Message 1',
role: 'assistant',
sessionId: testSessionId,
});
await caller.createMessage({
content: 'Message 2',
role: 'assistant',
sessionId: testSessionId,
});
// useGroup 参数应该影响消息分组展示
const result = await caller.getMessages({
sessionId: testSessionId,
useGroup: true,
});
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
});
});
describe('removeMessages', () => {
+3 -2
View File
@@ -96,6 +96,7 @@ export const aiChatRouter = router({
const userMessageItem = await ctx.messageModel.create({
content: input.newUserMessage.content,
files: input.newUserMessage.files,
parentId: input.newUserMessage.parentId,
role: 'user',
sessionId: input.sessionId!,
threadId: input.threadId,
@@ -113,9 +114,9 @@ export const aiChatRouter = router({
);
const assistantMessageItem = await ctx.messageModel.create({
content: LOADING_FLAT,
fromModel: input.newAssistantMessage.model,
fromProvider: input.newAssistantMessage.provider,
model: input.newAssistantMessage.model,
parentId: messageId,
provider: input.newAssistantMessage.provider,
role: 'assistant',
sessionId: input.sessionId!,
threadId: input.threadId,
+8 -16
View File
@@ -55,10 +55,9 @@ export const messageRouter = router({
}),
createMessage: messageProcedure
.input(CreateNewMessageParamsSchema.extend({ useGroup: z.boolean().optional() }))
.input(CreateNewMessageParamsSchema)
.mutation(async ({ input, ctx }) => {
const { useGroup, ...params } = input;
return ctx.messageService.createMessage(params as any, { useGroup });
return ctx.messageService.createMessage(input as any);
}),
getHeatmaps: messageProcedure.query(async ({ ctx }) => {
@@ -74,20 +73,17 @@ export const messageRouter = router({
pageSize: z.number().optional(),
sessionId: z.string().nullable().optional(),
topicId: z.string().nullable().optional(),
useGroup: z.boolean().optional(),
}),
)
.query(async ({ input, ctx }) => {
if (!ctx.userId) return [];
const serverDB = await getServerDB();
const { useGroup, ...queryParams } = input;
const messageModel = new MessageModel(serverDB, ctx.userId);
const fileService = new FileService(serverDB, ctx.userId);
return messageModel.query(queryParams, {
groupAssistantMessages: useGroup ?? false,
return messageModel.query(input, {
groupAssistantMessages: false,
postProcessUrl: (path) => fileService.getFullFileUrl(path),
});
}),
@@ -106,7 +102,6 @@ export const messageRouter = router({
id: z.string(),
sessionId: z.string().nullable().optional(),
topicId: z.string().nullable().optional(),
useGroup: z.boolean().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -126,7 +121,6 @@ export const messageRouter = router({
ids: z.array(z.string()),
sessionId: z.string().nullable().optional(),
topicId: z.string().nullable().optional(),
useGroup: z.boolean().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -173,7 +167,6 @@ export const messageRouter = router({
id: z.string(),
sessionId: z.string().nullable().optional(),
topicId: z.string().nullable().optional(),
useGroup: z.boolean().optional(),
value: UpdateMessageParamsSchema,
}),
)
@@ -198,7 +191,6 @@ export const messageRouter = router({
UpdateMessageRAGParamsSchema.extend({
sessionId: z.string().nullable().optional(),
topicId: z.string().nullable().optional(),
useGroup: z.boolean().optional(),
}),
)
.mutation(async ({ input, ctx }) => {
@@ -210,11 +202,14 @@ export const messageRouter = router({
.input(
z.object({
id: z.string(),
sessionId: z.string().nullable().optional(),
topicId: z.string().nullable().optional(),
value: z.object({}).passthrough(),
}),
)
.mutation(async ({ input, ctx }) => {
return ctx.messageModel.updateMetadata(input.id, input.value);
const { id, value, ...options } = input;
return ctx.messageService.updateMetadata(id, value, options);
}),
updatePluginError: messageProcedure
@@ -223,7 +218,6 @@ export const messageRouter = router({
id: z.string(),
sessionId: z.string().nullable().optional(),
topicId: z.string().nullable().optional(),
useGroup: z.boolean().optional(),
value: z.object({}).passthrough().nullable(),
}),
)
@@ -238,7 +232,6 @@ export const messageRouter = router({
id: z.string(),
sessionId: z.string().nullable().optional(),
topicId: z.string().nullable().optional(),
useGroup: z.boolean().optional(),
value: z.object({}).passthrough(),
}),
)
@@ -267,7 +260,6 @@ export const messageRouter = router({
return ctx.messageModel.updateTTS(input.id, input.value);
}),
updateTranslate: messageProcedure
.input(
z.object({
@@ -26,6 +26,7 @@ describe('MessageService', () => {
update: vi.fn(),
updateMessagePlugin: vi.fn(),
updateMessageRAG: vi.fn(),
updateMetadata: vi.fn(),
updatePluginState: vi.fn(),
} as any;
@@ -222,36 +223,51 @@ describe('MessageService', () => {
});
});
describe('useGroup option', () => {
it('should pass useGroup option to query', async () => {
const mockMessages = [{ id: 'msg-1', content: 'test' }];
vi.mocked(mockMessageModel.query).mockResolvedValue(mockMessages as any);
describe('updateMetadata', () => {
it('should update metadata and return { success: true } when no sessionId/topicId provided', async () => {
const messageId = 'msg-1';
const metadata = { someKey: 'someValue', count: 42 };
await messageService.removeMessage('msg-1', {
sessionId: 'session-1',
useGroup: true,
});
const result = await messageService.updateMetadata(messageId, metadata);
expect(mockMessageModel.query).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
groupAssistantMessages: true,
}),
);
expect(mockMessageModel.updateMetadata).toHaveBeenCalledWith(messageId, metadata);
expect(result).toEqual({ success: true });
expect(mockMessageModel.query).not.toHaveBeenCalled();
});
it('should default useGroup to false', async () => {
it('should update metadata and return message list when sessionId provided', async () => {
const messageId = 'msg-1';
const metadata = { someKey: 'someValue', count: 42 };
const mockMessages = [{ id: 'msg-1', content: 'test' }];
vi.mocked(mockMessageModel.query).mockResolvedValue(mockMessages as any);
await messageService.removeMessage('msg-1', { sessionId: 'session-1' });
const result = await messageService.updateMetadata(messageId, metadata, {
sessionId: 'session-1',
});
expect(mockMessageModel.updateMetadata).toHaveBeenCalledWith(messageId, metadata);
expect(mockMessageModel.query).toHaveBeenCalled();
expect(result).toEqual({ messages: mockMessages, success: true });
});
it('should update metadata and return message list when topicId provided', async () => {
const messageId = 'msg-1';
const metadata = { key: 'value' };
const mockMessages = [{ id: 'msg-1', content: 'test' }];
vi.mocked(mockMessageModel.query).mockResolvedValue(mockMessages as any);
const result = await messageService.updateMetadata(messageId, metadata, {
topicId: 'topic-1',
});
expect(mockMessageModel.updateMetadata).toHaveBeenCalledWith(messageId, metadata);
expect(mockMessageModel.query).toHaveBeenCalledWith(
expect.anything(),
{ groupId: undefined, sessionId: undefined, topicId: 'topic-1' },
expect.objectContaining({
groupAssistantMessages: false,
}),
);
expect(result).toEqual({ messages: mockMessages, success: true });
});
});
@@ -289,32 +305,6 @@ describe('MessageService', () => {
});
});
it('should create message with useGroup option', async () => {
const params = {
content: 'Hello',
role: 'assistant' as const,
sessionId: 'session-1',
};
const createdMessage = { id: 'msg-1', ...params };
const mockMessages = [createdMessage];
vi.mocked(mockMessageModel.create).mockResolvedValue(createdMessage as any);
vi.mocked(mockMessageModel.query).mockResolvedValue(mockMessages as any);
const result = await messageService.createMessage(params as any, { useGroup: true });
expect(mockMessageModel.query).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
groupAssistantMessages: true,
}),
);
expect(result).toEqual({
id: 'msg-1',
messages: mockMessages,
});
});
it('should create message with topicId and groupId', async () => {
const params = {
content: 'Hello',
+41 -36
View File
@@ -9,7 +9,6 @@ interface QueryOptions {
groupId?: string | null;
sessionId?: string | null;
topicId?: string | null;
useGroup?: boolean;
}
interface CreateMessageResult {
@@ -42,9 +41,9 @@ export class MessageService {
/**
* Unified query options
*/
private getQueryOptions(options: QueryOptions) {
private getQueryOptions() {
return {
groupAssistantMessages: options.useGroup ?? false,
groupAssistantMessages: false,
postProcessUrl: this.postProcessUrl,
};
}
@@ -61,12 +60,45 @@ export class MessageService {
const messages = await this.messageModel.query(
{ groupId, sessionId, topicId },
this.getQueryOptions(options),
this.getQueryOptions(),
);
return { messages, success: true };
}
/**
* Create a new message and return the complete message list
* Pattern: create + query
*
* This method combines message creation and querying into a single operation,
* reducing the need for separate refresh calls and improving performance.
*/
async createMessage(params: CreateMessageParams): Promise<CreateMessageResult> {
// 1. Create the message
const item = await this.messageModel.create(params);
// 2. Query all messages for this session/topic
const messages = await this.messageModel.query(
{
current: 0,
groupId: params.groupId,
pageSize: 9999,
sessionId: params.sessionId,
topicId: params.topicId,
},
{
groupAssistantMessages: false,
postProcessUrl: this.postProcessUrl,
},
);
// 3. Return the result
return {
id: item.id,
messages,
};
}
/**
* Remove messages with optional message list return
* Pattern: delete + conditional query
@@ -122,38 +154,11 @@ export class MessageService {
}
/**
* Create a new message and return the complete message list
* Pattern: create + query
*
* This method combines message creation and querying into a single operation,
* reducing the need for separate refresh calls and improving performance.
* Update message metadata with optional message list return
* Pattern: update + conditional query
*/
async createMessage(
params: CreateMessageParams,
options?: QueryOptions,
): Promise<CreateMessageResult> {
// 1. Create the message
const item = await this.messageModel.create(params);
// 2. Query all messages for this session/topic
const messages = await this.messageModel.query(
{
current: 0,
groupId: params.groupId,
pageSize: 9999,
sessionId: params.sessionId,
topicId: params.topicId,
},
{
groupAssistantMessages: options?.useGroup ?? false,
postProcessUrl: this.postProcessUrl,
},
);
// 3. Return the result
return {
id: item.id,
messages,
};
async updateMetadata(id: string, value: any, options?: QueryOptions) {
await this.messageModel.updateMetadata(id, value);
return this.queryWithSuccess(options);
}
}
@@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { lambdaClient } from '@/libs/trpc/client';
import { MessageService } from '../index';
vi.mock('@/libs/trpc/client', () => ({
lambdaClient: {
message: {
updateMetadata: {
mutate: vi.fn(),
},
},
},
}));
describe('MessageService - Race Condition Control', () => {
let messageService: MessageService;
beforeEach(() => {
vi.clearAllMocks();
messageService = new MessageService();
});
describe('updateMessageMetadata race condition', () => {
it('should cancel previous request when new update is triggered for same message', async () => {
const messageId = 'test-message-id';
let firstRequestAborted = false;
let secondRequestCompleted = false;
// Mock first request (slow)
vi.mocked(lambdaClient.message.updateMetadata.mutate).mockImplementationOnce(
(_params, options) =>
new Promise((resolve, reject) => {
const signal = options?.signal;
if (signal) {
signal.addEventListener('abort', () => {
firstRequestAborted = true;
reject(new Error('Aborted'));
});
}
// Simulate slow request
setTimeout(() => resolve({ success: true, messages: [] }), 200);
}),
);
// Mock second request (fast)
vi.mocked(lambdaClient.message.updateMetadata.mutate).mockImplementationOnce(
async (_params, _options) => {
secondRequestCompleted = true;
return { success: true, messages: [] };
},
);
// Start first update
const firstPromise = messageService.updateMessageMetadata(messageId, { compare: true });
// Wait a bit then start second update
await new Promise((resolve) => setTimeout(resolve, 10));
const secondPromise = messageService.updateMessageMetadata(messageId, { compare: false });
// First should be aborted
await expect(firstPromise).rejects.toThrow('Aborted');
expect(firstRequestAborted).toBe(true);
// Second should complete successfully
await expect(secondPromise).resolves.toEqual({ success: true, messages: [] });
expect(secondRequestCompleted).toBe(true);
});
it('should allow concurrent updates for different messages', async () => {
const message1Id = 'message-1';
const message2Id = 'message-2';
vi.mocked(lambdaClient.message.updateMetadata.mutate).mockResolvedValue({
success: true,
messages: [],
});
const [result1, result2] = await Promise.all([
messageService.updateMessageMetadata(message1Id, { cost: 0.001 }),
messageService.updateMessageMetadata(message2Id, { cost: 0.002 }),
]);
expect(result1).toEqual({ success: true, messages: [] });
expect(result2).toEqual({ success: true, messages: [] });
expect(lambdaClient.message.updateMetadata.mutate).toHaveBeenCalledTimes(2);
});
it('should handle rapid successive updates correctly', async () => {
const messageId = 'test-message-id';
let completedUpdates = 0;
const abortedUpdates: number[] = [];
// All but the last request should be aborted
let callIndex = 0;
vi.mocked(lambdaClient.message.updateMetadata.mutate).mockImplementation(
(_params, options) => {
const currentIndex = callIndex++;
return new Promise((resolve, reject) => {
const signal = options?.signal;
let isAborted = false;
if (signal) {
signal.addEventListener('abort', () => {
isAborted = true;
abortedUpdates.push(currentIndex);
reject(new Error('Aborted'));
});
}
setTimeout(() => {
if (!isAborted) {
completedUpdates++;
resolve({ success: true, messages: [] });
}
}, 50);
});
},
);
// Trigger 5 rapid updates sequentially with catch to prevent unhandled rejections
const promise1 = messageService
.updateMessageMetadata(messageId, { cost: 0.001 })
.catch((e) => e);
await new Promise((resolve) => setTimeout(resolve, 5));
const promise2 = messageService
.updateMessageMetadata(messageId, { cost: 0.002 })
.catch((e) => e);
await new Promise((resolve) => setTimeout(resolve, 5));
const promise3 = messageService.updateMessageMetadata(messageId, { tps: 10 }).catch((e) => e);
await new Promise((resolve) => setTimeout(resolve, 5));
const promise4 = messageService.updateMessageMetadata(messageId, { tps: 20 }).catch((e) => e);
await new Promise((resolve) => setTimeout(resolve, 5));
const promise5 = messageService
.updateMessageMetadata(messageId, { compare: true })
.catch((e) => e);
// Wait for all to settle
const results = await Promise.all([promise1, promise2, promise3, promise4, promise5]);
// First 4 should be errors (aborted), last should succeed
expect(results[0]).toBeInstanceOf(Error);
expect(results[1]).toBeInstanceOf(Error);
expect(results[2]).toBeInstanceOf(Error);
expect(results[3]).toBeInstanceOf(Error);
expect(results[4]).toEqual({ success: true, messages: [] });
// 4 requests should have been aborted
expect(abortedUpdates.length).toBe(4);
expect(abortedUpdates).toEqual([0, 1, 2, 3]);
// Only the last request should complete
expect(completedUpdates).toBe(1);
});
});
});
+21 -15
View File
@@ -5,6 +5,7 @@ import {
ChatTranslate,
CreateMessageParams,
CreateMessageResult,
MessageMetadata,
ModelRankItem,
UIChatMessage,
UpdateMessageParams,
@@ -15,14 +16,10 @@ import type { HeatmapsProps } from '@lobehub/charts';
import { INBOX_SESSION_ID } from '@/const/session';
import { lambdaClient } from '@/libs/trpc/client';
import { useUserStore } from '@/store/user';
import { labPreferSelectors } from '@/store/user/selectors';
import { abortableRequest } from '../utils/abortableRequest';
export class MessageService {
private get useGroup() {
return labPreferSelectors.enableAssistantMessageGroup(useUserStore.getState());
}
createMessage = async ({
sessionId,
...params
@@ -30,7 +27,6 @@ export class MessageService {
return lambdaClient.message.createMessage.mutate({
...params,
sessionId: sessionId ? this.toDbSessionId(sessionId) : undefined,
useGroup: this.useGroup,
});
};
@@ -43,7 +39,6 @@ export class MessageService {
groupId,
sessionId: this.toDbSessionId(sessionId),
topicId,
useGroup: this.useGroup,
});
return data as unknown as UIChatMessage[];
@@ -53,7 +48,6 @@ export class MessageService {
const data = await lambdaClient.message.getMessages.query({
groupId,
topicId,
useGroup: this.useGroup,
});
return data as unknown as UIChatMessage[];
};
@@ -100,7 +94,6 @@ export class MessageService {
id,
sessionId: options?.sessionId,
topicId: options?.topicId,
useGroup: this.useGroup,
value,
});
};
@@ -113,6 +106,24 @@ export class MessageService {
return lambdaClient.message.updateTTS.mutate({ id, value: tts });
};
updateMessageMetadata = async (
id: string,
value: Partial<MessageMetadata>,
options?: { sessionId?: string | null; topicId?: string | null },
): Promise<UpdateMessageResult> => {
return abortableRequest.execute(`message-metadata-${id}`, (signal) =>
lambdaClient.message.updateMetadata.mutate(
{
id,
sessionId: options?.sessionId,
topicId: options?.topicId,
value,
},
{ signal },
),
);
};
updateMessagePluginState = async (
id: string,
value: Record<string, any>,
@@ -122,7 +133,6 @@ export class MessageService {
id,
sessionId: options?.sessionId,
topicId: options?.topicId,
useGroup: this.useGroup,
value,
});
};
@@ -136,7 +146,6 @@ export class MessageService {
id,
sessionId: options?.sessionId,
topicId: options?.topicId,
useGroup: this.useGroup,
value: error as any,
});
};
@@ -150,7 +159,6 @@ export class MessageService {
id,
sessionId: options?.sessionId,
topicId: options?.topicId,
useGroup: this.useGroup,
value: data,
});
};
@@ -163,7 +171,6 @@ export class MessageService {
id,
sessionId: options?.sessionId,
topicId: options?.topicId,
useGroup: this.useGroup,
});
};
@@ -175,7 +182,6 @@ export class MessageService {
ids,
sessionId: options?.sessionId,
topicId: options?.topicId,
useGroup: this.useGroup,
});
};
+161
View File
@@ -0,0 +1,161 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { abortableRequest } from './abortableRequest';
describe('AbortableRequestManager', () => {
beforeEach(() => {
abortableRequest.cancelAll();
});
describe('execute', () => {
it('should execute request successfully', async () => {
const mockFetcher = vi.fn(async (signal: AbortSignal) => {
return 'result';
});
const result = await abortableRequest.execute('test-key', mockFetcher);
expect(result).toBe('result');
expect(mockFetcher).toHaveBeenCalledTimes(1);
expect(mockFetcher).toHaveBeenCalledWith(expect.any(AbortSignal));
});
it('should cancel previous request when new request with same key is triggered', async () => {
let firstRequestAborted = false;
let secondRequestAborted = false;
const firstFetcher = vi.fn(
async (signal: AbortSignal) =>
new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
firstRequestAborted = true;
reject(new Error('Aborted'));
});
setTimeout(() => resolve('first'), 100);
}),
);
const secondFetcher = vi.fn(
async (signal: AbortSignal) =>
new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
secondRequestAborted = true;
reject(new Error('Aborted'));
});
setTimeout(() => resolve('second'), 100);
}),
);
// Start first request
const firstPromise = abortableRequest.execute('same-key', firstFetcher);
// Start second request with same key (should cancel first)
await new Promise((resolve) => setTimeout(resolve, 10));
const secondPromise = abortableRequest.execute('same-key', secondFetcher);
// First should be aborted
await expect(firstPromise).rejects.toThrow('Aborted');
expect(firstRequestAborted).toBe(true);
// Second should succeed
const result = await secondPromise;
expect(result).toBe('second');
expect(secondRequestAborted).toBe(false);
});
it('should allow concurrent requests with different keys', async () => {
const fetcher1 = vi.fn(async () => 'result1');
const fetcher2 = vi.fn(async () => 'result2');
const [result1, result2] = await Promise.all([
abortableRequest.execute('key1', fetcher1),
abortableRequest.execute('key2', fetcher2),
]);
expect(result1).toBe('result1');
expect(result2).toBe('result2');
expect(fetcher1).toHaveBeenCalledTimes(1);
expect(fetcher2).toHaveBeenCalledTimes(1);
});
it('should clean up controller after request completes', async () => {
const fetcher = vi.fn(async () => 'result');
await abortableRequest.execute('cleanup-test', fetcher);
// Manually check that controller is cleaned up by starting a new request
// and verifying it doesn't abort anything (since map should be empty)
let aborted = false;
const fetcher2 = vi.fn(
async (signal: AbortSignal) =>
new Promise((resolve) => {
signal.addEventListener('abort', () => {
aborted = true;
});
setTimeout(() => resolve('result2'), 50);
}),
);
await abortableRequest.execute('cleanup-test', fetcher2);
expect(aborted).toBe(false);
});
});
describe('cancel', () => {
it('should cancel specific request by key', async () => {
let aborted = false;
const fetcher = vi.fn(
async (signal: AbortSignal) =>
new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
aborted = true;
reject(new Error('Cancelled'));
});
setTimeout(() => resolve('result'), 100);
}),
);
const promise = abortableRequest.execute('cancel-key', fetcher);
await new Promise((resolve) => setTimeout(resolve, 10));
abortableRequest.cancel('cancel-key');
await expect(promise).rejects.toThrow('Cancelled');
expect(aborted).toBe(true);
});
it('should do nothing when canceling non-existent key', () => {
expect(() => abortableRequest.cancel('non-existent')).not.toThrow();
});
});
describe('cancelAll', () => {
it('should cancel all pending requests', async () => {
const results = { req1: false, req2: false, req3: false };
const createFetcher = (key: keyof typeof results) =>
vi.fn(
async (signal: AbortSignal) =>
new Promise((resolve, reject) => {
signal.addEventListener('abort', () => {
results[key] = true;
reject(new Error('Cancelled'));
});
setTimeout(() => resolve(`result-${key}`), 100);
}),
);
const promise1 = abortableRequest.execute('key1', createFetcher('req1'));
const promise2 = abortableRequest.execute('key2', createFetcher('req2'));
const promise3 = abortableRequest.execute('key3', createFetcher('req3'));
await new Promise((resolve) => setTimeout(resolve, 10));
abortableRequest.cancelAll();
await expect(Promise.all([promise1, promise2, promise3])).rejects.toThrow();
expect(results.req1).toBe(true);
expect(results.req2).toBe(true);
expect(results.req3).toBe(true);
});
});
});
+67
View File
@@ -0,0 +1,67 @@
/**
* Abortable Request Manager
*
* Provides race condition control for async requests by canceling previous
* requests when a new one with the same key is triggered.
*
* @example
* ```ts
* const result = await abortableRequest.execute(
* 'update-user-profile',
* (signal) => api.updateProfile(data, { signal })
* );
* ```
*/
class AbortableRequestManager {
private controllers = new Map<string, AbortController>();
/**
* Execute a request with race condition control
* @param key - Unique key to identify the request group
* @param fetcher - Request function that accepts AbortSignal
* @returns Promise with the request result
*/
async execute<T>(key: string, fetcher: (signal: AbortSignal) => Promise<T>): Promise<T> {
// Cancel previous request with same key
const existingController = this.controllers.get(key);
if (existingController) {
existingController.abort('New request triggered');
}
const controller = new AbortController();
this.controllers.set(key, controller);
try {
return await fetcher(controller.signal);
} finally {
// Clean up controller if it's still the active one
if (this.controllers.get(key) === controller) {
this.controllers.delete(key);
}
}
}
/**
* Manually cancel a request by key
* @param key - Request key to cancel
*/
cancel(key: string): void {
const controller = this.controllers.get(key);
if (controller) {
controller.abort('Manually cancelled');
this.controllers.delete(key);
}
}
/**
* Cancel all pending requests
*/
cancelAll(): void {
for (const controller of this.controllers.values()) {
controller.abort('All requests cancelled');
}
this.controllers.clear();
}
}
export const abortableRequest = new AbortableRequestManager();
+137
View File
@@ -0,0 +1,137 @@
import {
Agent,
AgentInstruction,
AgentRuntimeContext,
AgentState,
GeneralAgentCallLLMInstructionPayload,
GeneralAgentCallLLMResultPayload,
GeneralAgentCallToolResultPayload,
GeneralAgentCallToolsBatchInstructionPayload,
GeneralAgentCallingToolInstructionPayload,
GeneralAgentConfig,
} from '@lobechat/agent-runtime';
/**
* ChatAgent - The "Brain" of the chat agent
*
* This agent implements a simple but powerful decision loop:
* 1. user_input call_llm (with optional RAG/Search preprocessing)
* 2. llm_result check for tool_calls
* - If has tool_calls call_tools_batch (parallel execution)
* - If no tool_calls finish
* 3. tools_batch_result call_llm (process tool results)
*
* Note: RAG and Search workflow preprocessing are handled externally
* before creating the agent runtime, keeping the agent logic simple.
*/
export class GeneralChatAgent implements Agent {
private config: GeneralAgentConfig;
constructor(config: GeneralAgentConfig) {
this.config = config;
}
async runner(
context: AgentRuntimeContext,
state: AgentState,
): Promise<AgentInstruction | AgentInstruction[]> {
switch (context.phase) {
case 'init':
case 'user_input': {
// User input received, call LLM to generate response
// At this point, messages may have been preprocessed with RAG/Search
return {
payload: {
...(context.payload as any),
messages: state.messages,
} as GeneralAgentCallLLMInstructionPayload,
type: 'call_llm',
};
}
case 'llm_result': {
// LLM response received, check if it contains tool calls
const { hasToolsCalling, toolsCalling, parentMessageId } =
context.payload as GeneralAgentCallLLMResultPayload;
if (hasToolsCalling && toolsCalling && toolsCalling.length > 0) {
// No intervention needed, proceed with tool execution
// Use batch execution for multiple tool calls to improve performance
if (toolsCalling.length > 1) {
return {
payload: {
parentMessageId,
toolsCalling,
} as GeneralAgentCallToolsBatchInstructionPayload,
type: 'call_tools_batch',
};
} else if (toolsCalling.length === 1) {
// Single tool executes directly
return {
payload: {
parentMessageId,
toolCalling: toolsCalling[0],
} as GeneralAgentCallingToolInstructionPayload,
type: 'call_tool',
};
}
}
// No tool calls, conversation is complete
return {
reason: 'completed',
reasonDetail: 'LLM response completed without tool calls',
type: 'finish',
};
}
case 'tool_result': {
const { parentMessageId } = context.payload as GeneralAgentCallToolResultPayload;
return {
payload: {
messages: state.messages,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload,
type: 'call_llm',
};
}
case 'tools_batch_result': {
const { parentMessageId } = context.payload as GeneralAgentCallToolResultPayload;
return {
payload: {
messages: state.messages,
model: this.config.modelRuntimeConfig?.model,
parentMessageId,
provider: this.config.modelRuntimeConfig?.provider,
tools: state.tools,
} as GeneralAgentCallLLMInstructionPayload,
type: 'call_llm',
};
}
case 'error': {
// Error occurred, finish execution
const { error } = context.payload as { error: any };
return {
reason: 'error_recovery',
reasonDetail: error?.message || 'Unknown error occurred',
type: 'finish',
};
}
default: {
// Unknown phase, finish execution
return {
reason: 'agent_decision',
reasonDetail: `Unknown phase: ${context.phase}`,
type: 'finish',
};
}
}
}
}
@@ -0,0 +1,395 @@
import {
AgentEvent,
AgentInstruction,
AgentInstructionCallLlm,
AgentInstructionCallTool,
AgentRuntimeContext,
GeneralAgentCallLLMInstructionPayload,
GeneralAgentCallLLMResultPayload,
GeneralAgentCallToolResultPayload,
GeneralAgentCallingToolInstructionPayload,
InstructionExecutor,
UsageCounter,
} from '@lobechat/agent-runtime';
import type { ChatToolPayload, CreateMessageParams } from '@lobechat/types';
import debug from 'debug';
import type { ChatStore } from '@/store/chat/store';
const log = debug('lobe-store:agent-executors');
// Tool pricing configuration (USD per call)
const TOOL_PRICING: Record<string, number> = {
'lobe-web-browsing/craw': 0.002,
'lobe-web-browsing/search': 0.001,
};
/**
* Creates custom executors for the Chat Agent Runtime
* These executors wrap existing chat store methods to integrate with agent-runtime
*/
export const createAgentExecutors = (context: {
get: () => ChatStore;
messageKey: string;
params: {
inPortalThread?: boolean;
inSearchWorkflow?: boolean;
ragQuery?: string;
threadId?: string;
traceId?: string;
};
parentId: string;
parentMessageType: 'user' | 'assistant';
}) => {
// 当通过 sendMessageInServer 的时候,已经有一条消息了,那么就不需要触发创建
let shouldSkipCreateMessage = context.parentMessageType === 'assistant';
const executors: Partial<Record<AgentInstruction['type'], InstructionExecutor>> = {
/**
* Custom call_llm executor
* Creates assistant message and calls internal_fetchAIChatMessage
*/
call_llm: async (instruction, state) => {
const sessionLogId = `${state.sessionId}:${state.stepCount}`;
const stagePrefix = `[${sessionLogId}][call_llm]`;
const llmPayload = (instruction as AgentInstructionCallLlm)
.payload as GeneralAgentCallLLMInstructionPayload;
const events: AgentEvent[] = [];
log(`${stagePrefix} Starting session`);
let assistantMessageId: string;
if (shouldSkipCreateMessage) {
// 跳过第一次创建,后续就不再跳过了
assistantMessageId = context.parentId;
shouldSkipCreateMessage = false;
} else {
// 如果是 userMessage 的第一次 regenerated 创建, llmPayload 不存在 parentMessageId
// 因此用这种方式做个赋值
// TODO: 也许未来这个应该用 init 方法实现
if (!llmPayload.parentMessageId) {
llmPayload.parentMessageId = context.parentId;
}
// Create assistant message (following server-side pattern)
const assistantMessageItem = await context.get().optimisticCreateMessage({
content: '',
model: llmPayload.model,
parentId: llmPayload.parentMessageId,
provider: llmPayload.provider,
role: 'assistant',
sessionId: state.metadata!.sessionId!,
threadId: state.metadata?.threadId,
topicId: state.metadata?.topicId,
});
if (!assistantMessageItem) {
throw new Error('Failed to create assistant message');
}
assistantMessageId = assistantMessageItem.id;
}
log(`${stagePrefix} Created assistant message, id: %s`, assistantMessageId);
log(
`${stagePrefix} calling model-runtime chat (model: %s, messages: %d, tools: %d)`,
llmPayload.model,
llmPayload.messages.length,
llmPayload.tools?.length ?? 0,
);
// Call existing internal_fetchAIChatMessage
// This method already handles:
// - Stream processing (text, tool_calls, reasoning, grounding, base64_image)
// - UI updates via dispatchMessage
// - Loading state management
// - Error handling
// Use messages from state (already contains full conversation history)
const {
isFunctionCall,
content,
tools,
usage: currentStepUsage,
tool_calls,
} = await context.get().internal_fetchAIChatMessage({
messageId: assistantMessageId,
messages: llmPayload.messages,
model: llmPayload.model,
params: context.params,
provider: llmPayload.provider,
});
log(`[${sessionLogId}] finish model-runtime calling`);
// Get latest messages from store (already updated by internal_fetchAIChatMessage)
const latestMessages = context.get().dbMessagesMap[context.messageKey] || [];
// Get updated assistant message to extract usage/cost information
const assistantMessage = latestMessages.find((m) => m.id === assistantMessageId);
const toolCalls = tools || [];
if (content) {
log(`[${sessionLogId}][content]`, content);
}
if (assistantMessage?.reasoning?.content) {
log(`[${sessionLogId}][reasoning]`, assistantMessage.reasoning.content);
}
if (toolCalls.length > 0) {
log(`[${sessionLogId}][toolsCalling] `, toolCalls);
}
// Log usage
if (currentStepUsage) {
log(`[${sessionLogId}][usage] %O`, currentStepUsage);
}
// Add llm_stream events (similar to backend)
if (content) {
events.push({
chunk: { text: content, type: 'text' },
type: 'llm_stream',
});
}
if (assistantMessage?.reasoning?.content) {
events.push({
chunk: { text: assistantMessage.reasoning.content, type: 'reasoning' },
type: 'llm_stream',
});
}
events.push({
result: {
content,
reasoning: assistantMessage?.reasoning?.content,
tool_calls: toolCalls,
usage: currentStepUsage,
},
type: 'llm_result',
});
log('[%s:%d] call_llm completed', state.sessionId, state.stepCount);
// Accumulate usage and cost to state
const newState = { ...state, messages: latestMessages };
if (currentStepUsage) {
// Use UsageCounter to accumulate LLM usage and cost
const { usage, cost } = UsageCounter.accumulateLLM({
cost: state.cost,
model: llmPayload.model,
modelUsage: currentStepUsage,
provider: llmPayload.provider,
usage: state.usage,
});
newState.usage = usage;
if (cost) newState.cost = cost;
}
return {
events,
newState,
nextContext: {
payload: {
hasToolsCalling: isFunctionCall,
parentMessageId: assistantMessageId,
result: { content, tool_calls },
toolsCalling: toolCalls,
} as GeneralAgentCallLLMResultPayload,
phase: 'llm_result',
session: {
eventCount: events.length,
messageCount: newState.messages.length,
sessionId: state.sessionId,
status: 'running',
stepCount: state.stepCount + 1,
},
stepUsage: currentStepUsage,
} as AgentRuntimeContext,
};
},
/**
* Custom call_tool executor
* Wraps internal_invokeDifferentTypePlugin
* Follows server-side pattern: always create tool message before execution
*/
call_tool: async (instruction, state) => {
const payload = (instruction as AgentInstructionCallTool)
.payload as GeneralAgentCallingToolInstructionPayload;
const events: AgentEvent[] = [];
const sessionLogId = `${state.sessionId}:${state.stepCount}`;
log('[%s][call_tool] Executor start, payload: %O', sessionLogId, payload);
// Convert CallingToolPayload to ChatToolPayload for ToolExecutionService
const chatToolPayload: ChatToolPayload = payload.toolCalling;
const toolName = `${chatToolPayload.identifier}/${chatToolPayload.apiName}`;
const startTime = performance.now();
try {
// Get assistant message to extract groupId
const latestMessages = context.get().dbMessagesMap[context.messageKey] || [];
// Find the last assistant message (should be created by call_llm)
const assistantMessage = latestMessages.findLast((m) => m.role === 'assistant');
// Always create new tool message (following server-side pattern)
// This ensures consistency and avoids duplicate execution
log(
'[%s][call_tool] Creating tool message for tool_call_id: %s',
sessionLogId,
chatToolPayload.id,
);
const toolMessageParams: CreateMessageParams = {
content: '',
groupId: assistantMessage?.groupId,
parentId: payload.parentMessageId,
plugin: chatToolPayload,
role: 'tool',
sessionId: context.get().activeId,
threadId: context.params.threadId,
tool_call_id: chatToolPayload.id,
topicId: context.get().activeTopicId,
};
const createResult = await context.get().optimisticCreateMessage(toolMessageParams);
if (!createResult) {
log(
'[%s][call_tool] ERROR: Failed to create tool message for tool_call_id: %s',
sessionLogId,
chatToolPayload.id,
);
throw new Error(`Failed to create tool message for tool_call_id: ${chatToolPayload.id}`);
}
const toolMessageId = createResult.id;
log('[%s][call_tool] Created tool message, id: %s', sessionLogId, toolMessageId);
// Execute tool
log('[%s][call_tool] Executing tool %s ...', sessionLogId, toolName);
// This method handles:
// - Tool execution (builtin, plugin, MCP)
// - Content updates via optimisticUpdateMessageContent
// - Error handling via internal_updateMessageError
const result = await context
.get()
.internal_invokeDifferentTypePlugin(toolMessageId, chatToolPayload);
const executionTime = Math.round(performance.now() - startTime);
const isSuccess = !result.error;
log(
'[%s][call_tool] Executing %s in %dms, result: %O',
sessionLogId,
toolName,
executionTime,
result,
);
events.push({ id: chatToolPayload.id, result, type: 'tool_result' });
// Get latest messages from store (already updated by internal_invokeDifferentTypePlugin)
const updatedMessages = context.get().dbMessagesMap[context.messageKey] || [];
const newState = { ...state, messages: updatedMessages };
// Get tool unit price
const toolCost = TOOL_PRICING[toolName] || 0;
// Use UsageCounter to accumulate tool usage
const { usage, cost } = UsageCounter.accumulateTool({
cost: state.cost,
executionTime,
success: isSuccess,
toolCost,
toolName,
usage: state.usage,
});
newState.usage = usage;
if (cost) newState.cost = cost;
// Find current tool statistics
const currentToolStats = usage.tools.byTool.find((t) => t.name === toolName);
// Log usage
log(
'[%s][tool usage] %s: calls=%d, time=%dms, success=%s, cost=$%s',
sessionLogId,
toolName,
currentToolStats?.calls || 0,
executionTime,
isSuccess,
toolCost.toFixed(4),
);
log('[%s][call_tool] Tool execution completed', sessionLogId);
return {
events,
newState,
nextContext: {
payload: {
data: result,
executionTime,
isSuccess,
parentMessageId: toolMessageId,
toolCall: chatToolPayload,
toolCallId: chatToolPayload.id,
} as GeneralAgentCallToolResultPayload,
phase: 'tool_result',
session: {
eventCount: events.length,
messageCount: newState.messages.length,
sessionId: state.sessionId,
status: 'running',
stepCount: state.stepCount + 1,
},
stepUsage: {
cost: toolCost,
toolName,
unitPrice: toolCost,
usageCount: 1,
},
} as AgentRuntimeContext,
};
} catch (error) {
log('[%s][call_tool] ERROR: Tool execution failed: %O', sessionLogId, error);
events.push({ error: error, type: 'error' });
// Return current state on error (no state change)
return { events, newState: state };
}
},
/**
* Finish executor
* Completes the runtime execution
*/
finish: async (instruction, state) => {
const { reason, reasonDetail } = instruction as Extract<AgentInstruction, { type: 'finish' }>;
const sessionLogId = `${state.sessionId}:${state.stepCount}`;
log(`[${sessionLogId}] Finishing execution: (%s)`, reason);
const newState = structuredClone(state);
newState.lastModified = new Date().toISOString();
newState.status = 'done';
const events: AgentEvent[] = [{ finalState: newState, reason, reasonDetail, type: 'done' }];
return { events, newState };
},
};
return executors;
};

Some files were not shown because too many files have changed in this diff Show More