mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
✨ 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:
+5
-6
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
+249
@@ -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[],
|
||||
};
|
||||
|
||||
+260
@@ -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
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
+481
@@ -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,
|
||||
};
|
||||
|
||||
+407
@@ -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', () => {
|
||||
|
||||
@@ -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 */
|
||||
|
||||
+3
-14
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -28,8 +28,8 @@ export interface ImportMessage {
|
||||
|
||||
// 扩展字段
|
||||
extra?: {
|
||||
fromModel?: string;
|
||||
fromProvider?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
// 翻译
|
||||
translate?: ChatTranslate | false | null;
|
||||
// TTS
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,8 +7,8 @@ export interface ChatTTS {
|
||||
}
|
||||
|
||||
export interface ChatMessageExtra {
|
||||
fromModel?: string;
|
||||
fromProvider?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
// 翻译
|
||||
translate?: ChatTranslate | false | null;
|
||||
// TTS
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
+3
-3
@@ -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;
|
||||
};
|
||||
|
||||
+1
-1
@@ -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,
|
||||
]);
|
||||
|
||||
|
||||
+3
-3
@@ -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();
|
||||
|
||||
// 获取分析数据
|
||||
|
||||
+6
-6
@@ -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],
|
||||
);
|
||||
};
|
||||
|
||||
+5
-3
@@ -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} />,
|
||||
|
||||
+2
-2
@@ -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}
|
||||
|
||||
+2
-2
@@ -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'}
|
||||
|
||||
@@ -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 (
|
||||
|
||||
+25
-24
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user