@lobehub/chat 1.61.5 → 1.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/changelog/v1.json +21 -0
  3. package/docs/self-hosting/advanced/auth/next-auth/casdoor.mdx +2 -1
  4. package/docs/self-hosting/advanced/auth/next-auth/casdoor.zh-CN.mdx +2 -1
  5. package/locales/en-US/components.json +3 -3
  6. package/package.json +2 -2
  7. package/src/app/(backend)/api/webhooks/casdoor/route.ts +5 -7
  8. package/src/app/(backend)/api/webhooks/casdoor/validateRequest.ts +7 -4
  9. package/src/components/ModelSelect/index.tsx +24 -2
  10. package/src/components/Thinking/index.tsx +7 -2
  11. package/src/config/aiModels/jina.ts +7 -5
  12. package/src/config/aiModels/perplexity.ts +8 -0
  13. package/src/config/llm.ts +8 -0
  14. package/src/database/client/migrations.json +12 -8
  15. package/src/database/migrations/0015_add_message_search_metadata.sql +2 -0
  16. package/src/database/migrations/meta/0015_snapshot.json +3616 -0
  17. package/src/database/migrations/meta/_journal.json +7 -0
  18. package/src/database/schemas/message.ts +3 -1
  19. package/src/database/server/models/message.ts +2 -0
  20. package/src/features/Conversation/components/ChatItem/index.tsx +10 -1
  21. package/src/features/Conversation/components/MarkdownElements/Thinking/Render.tsx +5 -1
  22. package/src/features/Conversation/components/MarkdownElements/remarkPlugins/createRemarkCustomTagPlugin.ts +1 -0
  23. package/src/features/Conversation/components/MarkdownElements/remarkPlugins/getNodeContent.test.ts +107 -0
  24. package/src/features/Conversation/components/MarkdownElements/remarkPlugins/getNodeContent.ts +6 -0
  25. package/src/libs/agent-runtime/perplexity/index.test.ts +156 -12
  26. package/src/libs/agent-runtime/utils/streams/anthropic.ts +3 -3
  27. package/src/libs/agent-runtime/utils/streams/bedrock/claude.ts +6 -2
  28. package/src/libs/agent-runtime/utils/streams/bedrock/llama.ts +3 -3
  29. package/src/libs/agent-runtime/utils/streams/google-ai.ts +3 -3
  30. package/src/libs/agent-runtime/utils/streams/ollama.ts +3 -3
  31. package/src/libs/agent-runtime/utils/streams/openai.ts +26 -8
  32. package/src/libs/agent-runtime/utils/streams/protocol.ts +33 -8
  33. package/src/libs/agent-runtime/utils/streams/vertex-ai.ts +3 -3
  34. package/src/locales/default/components.ts +1 -0
  35. package/src/server/globalConfig/index.test.ts +81 -0
  36. package/src/server/routers/lambda/user.test.ts +305 -0
  37. package/src/server/services/nextAuthUser/index.ts +2 -2
  38. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +17 -6
  39. package/src/store/chat/slices/message/action.ts +12 -7
  40. package/src/types/aiModel.ts +5 -0
  41. package/src/types/message/base.ts +13 -0
  42. package/src/types/message/chat.ts +3 -2
  43. package/src/utils/errorResponse.test.ts +37 -1
  44. package/src/utils/fetch/fetchSSE.ts +17 -1
@@ -2,8 +2,16 @@ import { ChatStreamCallbacks } from '@/libs/agent-runtime';
2
2
 
3
3
  import { AgentRuntimeErrorType } from '../../error';
4
4
 
5
- export interface StreamStack {
5
+ /**
6
+ * context in the stream to save temporarily data
7
+ */
8
+ export interface StreamContext {
6
9
  id: string;
10
+ /**
11
+ * As pplx citations is in every chunk, but we only need to return it once
12
+ * this flag is used to check if the pplx citation is returned,and then not return it again
13
+ */
14
+ returnedPplxCitation?: boolean;
7
15
  tool?: {
8
16
  id: string;
9
17
  index: number;
@@ -15,7 +23,20 @@ export interface StreamStack {
15
23
  export interface StreamProtocolChunk {
16
24
  data: any;
17
25
  id?: string;
18
- type: 'text' | 'tool_calls' | 'data' | 'stop' | 'error' | 'reasoning';
26
+ type: // pure text
27
+ | 'text'
28
+ // Tools use
29
+ | 'tool_calls'
30
+ // Model Thinking
31
+ | 'reasoning'
32
+ // Search or Grounding
33
+ | 'citations'
34
+ // stop signal
35
+ | 'stop'
36
+ // Error
37
+ | 'error'
38
+ // unknown data result
39
+ | 'data';
19
40
  }
20
41
 
21
42
  export interface StreamToolCallChunkData {
@@ -85,16 +106,20 @@ export const convertIterableToStream = <T>(stream: AsyncIterable<T>) => {
85
106
  * Create a transformer to convert the response into an SSE format
86
107
  */
87
108
  export const createSSEProtocolTransformer = (
88
- transformer: (chunk: any, stack: StreamStack) => StreamProtocolChunk,
89
- streamStack?: StreamStack,
109
+ transformer: (chunk: any, stack: StreamContext) => StreamProtocolChunk | StreamProtocolChunk[],
110
+ streamStack?: StreamContext,
90
111
  ) =>
91
112
  new TransformStream({
92
113
  transform: (chunk, controller) => {
93
- const { type, id, data } = transformer(chunk, streamStack || { id: '' });
114
+ const result = transformer(chunk, streamStack || { id: '' });
115
+
116
+ const buffers = Array.isArray(result) ? result : [result];
94
117
 
95
- controller.enqueue(`id: ${id}\n`);
96
- controller.enqueue(`event: ${type}\n`);
97
- controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
118
+ buffers.forEach(({ type, id, data }) => {
119
+ controller.enqueue(`id: ${id}\n`);
120
+ controller.enqueue(`event: ${type}\n`);
121
+ controller.enqueue(`data: ${JSON.stringify(data)}\n\n`);
122
+ });
98
123
  },
99
124
  });
100
125
 
@@ -4,8 +4,8 @@ import { nanoid } from '@/utils/uuid';
4
4
 
5
5
  import { ChatStreamCallbacks } from '../../types';
6
6
  import {
7
+ StreamContext,
7
8
  StreamProtocolChunk,
8
- StreamStack,
9
9
  createCallbacksTransformer,
10
10
  createSSEProtocolTransformer,
11
11
  generateToolCallId,
@@ -13,7 +13,7 @@ import {
13
13
 
14
14
  const transformVertexAIStream = (
15
15
  chunk: GenerateContentResponse,
16
- stack: StreamStack,
16
+ stack: StreamContext,
17
17
  ): StreamProtocolChunk => {
18
18
  // maybe need another structure to add support for multiple choices
19
19
  const candidates = chunk.candidates;
@@ -67,7 +67,7 @@ export const VertexAIStream = (
67
67
  rawStream: ReadableStream<EnhancedGenerateContentResponse>,
68
68
  callbacks?: ChatStreamCallbacks,
69
69
  ) => {
70
- const streamStack: StreamStack = { id: 'chat_' + nanoid() };
70
+ const streamStack: StreamContext = { id: 'chat_' + nanoid() };
71
71
 
72
72
  return rawStream
73
73
  .pipeThrough(createSSEProtocolTransformer(transformVertexAIStream, streamStack))
@@ -79,6 +79,7 @@ export default {
79
79
  file: '该模型支持上传文件读取与识别',
80
80
  functionCall: '该模型支持函数调用(Function Call)',
81
81
  reasoning: '该模型支持深度思考',
82
+ search: '该模型支持联网搜索',
82
83
  tokens: '该模型单个会话最多支持 {{tokens}} Tokens',
83
84
  vision: '该模型支持视觉识别',
84
85
  },
@@ -0,0 +1,81 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { getAppConfig } from '@/config/app';
4
+ import { knowledgeEnv } from '@/config/knowledge';
5
+ import { SystemEmbeddingConfig } from '@/types/knowledgeBase';
6
+ import { FilesConfigItem } from '@/types/user/settings/filesConfig';
7
+
8
+ import { getServerDefaultAgentConfig, getServerDefaultFilesConfig } from './index';
9
+ import { parseAgentConfig } from './parseDefaultAgent';
10
+ import { parseFilesConfig } from './parseFilesConfig';
11
+
12
+ vi.mock('@/config/app', () => ({
13
+ getAppConfig: vi.fn(),
14
+ }));
15
+
16
+ vi.mock('@/config/knowledge', () => ({
17
+ knowledgeEnv: {
18
+ DEFAULT_FILES_CONFIG: 'test_config',
19
+ },
20
+ }));
21
+
22
+ vi.mock('./parseDefaultAgent', () => ({
23
+ parseAgentConfig: vi.fn(),
24
+ }));
25
+
26
+ vi.mock('./parseFilesConfig', () => ({
27
+ parseFilesConfig: vi.fn(),
28
+ }));
29
+
30
+ describe('getServerDefaultAgentConfig', () => {
31
+ it('should return parsed agent config', () => {
32
+ const mockConfig = { key: 'value' };
33
+ vi.mocked(getAppConfig).mockReturnValue({
34
+ DEFAULT_AGENT_CONFIG: 'test_agent_config',
35
+ } as any);
36
+ vi.mocked(parseAgentConfig).mockReturnValue(mockConfig);
37
+
38
+ const result = getServerDefaultAgentConfig();
39
+
40
+ expect(parseAgentConfig).toHaveBeenCalledWith('test_agent_config');
41
+ expect(result).toEqual(mockConfig);
42
+ });
43
+
44
+ it('should return empty object if parseAgentConfig returns undefined', () => {
45
+ vi.mocked(getAppConfig).mockReturnValue({
46
+ DEFAULT_AGENT_CONFIG: 'test_agent_config',
47
+ } as any);
48
+ vi.mocked(parseAgentConfig).mockReturnValue(undefined);
49
+
50
+ const result = getServerDefaultAgentConfig();
51
+
52
+ expect(result).toEqual({});
53
+ });
54
+ });
55
+
56
+ describe('getServerDefaultFilesConfig', () => {
57
+ it('should return parsed files config', () => {
58
+ const mockEmbeddingModel: FilesConfigItem = {
59
+ model: 'test-model',
60
+ provider: 'test-provider',
61
+ };
62
+
63
+ const mockRerankerModel: FilesConfigItem = {
64
+ model: 'test-reranker',
65
+ provider: 'test-provider',
66
+ };
67
+
68
+ const mockConfig: SystemEmbeddingConfig = {
69
+ embeddingModel: mockEmbeddingModel,
70
+ queryMode: 'hybrid',
71
+ rerankerModel: mockRerankerModel,
72
+ };
73
+
74
+ vi.mocked(parseFilesConfig).mockReturnValue(mockConfig);
75
+
76
+ const result = getServerDefaultFilesConfig();
77
+
78
+ expect(parseFilesConfig).toHaveBeenCalledWith('test_config');
79
+ expect(result).toEqual(mockConfig);
80
+ });
81
+ });
@@ -0,0 +1,305 @@
1
+ // @vitest-environment node
2
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
3
+
4
+ import { enableClerk } from '@/const/auth';
5
+ import { serverDB } from '@/database/server';
6
+ import { MessageModel } from '@/database/server/models/message';
7
+ import { SessionModel } from '@/database/server/models/session';
8
+ import { UserModel, UserNotFoundError } from '@/database/server/models/user';
9
+ import { LobeNextAuthDbAdapter } from '@/libs/next-auth/adapter';
10
+ import { KeyVaultsGateKeeper } from '@/server/modules/KeyVaultsEncrypt';
11
+ import { UserService } from '@/server/services/user';
12
+
13
+ import { userRouter } from './user';
14
+
15
+ // Mock modules
16
+ vi.mock('@clerk/nextjs/server', () => ({
17
+ currentUser: vi.fn(),
18
+ }));
19
+
20
+ vi.mock('@/database/server', () => ({
21
+ serverDB: {},
22
+ }));
23
+
24
+ vi.mock('@/database/server/models/message');
25
+ vi.mock('@/database/server/models/session');
26
+ vi.mock('@/database/server/models/user');
27
+ vi.mock('@/libs/next-auth/adapter');
28
+ vi.mock('@/server/modules/KeyVaultsEncrypt');
29
+ vi.mock('@/server/services/user');
30
+ vi.mock('@/const/auth', () => ({
31
+ enableClerk: true,
32
+ }));
33
+
34
+ describe('userRouter', () => {
35
+ const mockUserId = 'test-user-id';
36
+ const mockCtx = {
37
+ userId: mockUserId,
38
+ };
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks();
42
+ });
43
+
44
+ describe('getUserRegistrationDuration', () => {
45
+ it('should return registration duration', async () => {
46
+ const mockDuration = { duration: 100, createdAt: '2023-01-01', updatedAt: '2023-01-02' };
47
+ vi.mocked(UserModel).mockImplementation(
48
+ () =>
49
+ ({
50
+ getUserRegistrationDuration: vi.fn().mockResolvedValue(mockDuration),
51
+ }) as any,
52
+ );
53
+
54
+ const result = await userRouter.createCaller({ ...mockCtx }).getUserRegistrationDuration();
55
+
56
+ expect(result).toEqual(mockDuration);
57
+ expect(UserModel).toHaveBeenCalledWith(serverDB, mockUserId);
58
+ });
59
+ });
60
+
61
+ describe('getUserSSOProviders', () => {
62
+ it('should return SSO providers', async () => {
63
+ const mockProviders = [
64
+ {
65
+ provider: 'google',
66
+ providerAccountId: '123',
67
+ userId: 'user-1',
68
+ type: 'oauth',
69
+ },
70
+ ];
71
+ vi.mocked(UserModel).mockImplementation(
72
+ () =>
73
+ ({
74
+ getUserSSOProviders: vi.fn().mockResolvedValue(mockProviders),
75
+ }) as any,
76
+ );
77
+
78
+ const result = await userRouter.createCaller({ ...mockCtx }).getUserSSOProviders();
79
+
80
+ expect(result).toEqual(mockProviders);
81
+ expect(UserModel).toHaveBeenCalledWith(serverDB, mockUserId);
82
+ });
83
+ });
84
+
85
+ describe('getUserState', () => {
86
+ it('should return user state', async () => {
87
+ const mockState = {
88
+ isOnboarded: true,
89
+ preference: { telemetry: true },
90
+ settings: {},
91
+ userId: mockUserId,
92
+ };
93
+
94
+ vi.mocked(UserModel).mockImplementation(
95
+ () =>
96
+ ({
97
+ getUserState: vi.fn().mockResolvedValue(mockState),
98
+ }) as any,
99
+ );
100
+
101
+ vi.mocked(MessageModel).mockImplementation(
102
+ () =>
103
+ ({
104
+ hasMoreThanN: vi.fn().mockResolvedValue(true),
105
+ }) as any,
106
+ );
107
+
108
+ vi.mocked(SessionModel).mockImplementation(
109
+ () =>
110
+ ({
111
+ hasMoreThanN: vi.fn().mockResolvedValue(true),
112
+ }) as any,
113
+ );
114
+
115
+ const result = await userRouter.createCaller({ ...mockCtx }).getUserState();
116
+
117
+ expect(result).toEqual({
118
+ isOnboard: true,
119
+ preference: { telemetry: true },
120
+ settings: {},
121
+ hasConversation: true,
122
+ canEnablePWAGuide: true,
123
+ canEnableTrace: true,
124
+ userId: mockUserId,
125
+ });
126
+ });
127
+
128
+ it('should create new user when user not found (clerk enabled)', async () => {
129
+ const mockClerkUser = {
130
+ id: mockUserId,
131
+ createdAt: new Date(),
132
+ emailAddresses: [{ id: 'email-1', emailAddress: 'test@example.com' }],
133
+ firstName: 'Test',
134
+ lastName: 'User',
135
+ imageUrl: 'avatar.jpg',
136
+ phoneNumbers: [],
137
+ primaryEmailAddressId: 'email-1',
138
+ primaryPhoneNumberId: null,
139
+ username: 'testuser',
140
+ };
141
+
142
+ const { currentUser } = await import('@clerk/nextjs/server');
143
+ vi.mocked(currentUser).mockResolvedValue(mockClerkUser as any);
144
+
145
+ vi.mocked(UserService).mockImplementation(
146
+ () =>
147
+ ({
148
+ createUser: vi.fn().mockResolvedValue({ success: true }),
149
+ }) as any,
150
+ );
151
+
152
+ vi.mocked(UserModel).mockImplementation(
153
+ () =>
154
+ ({
155
+ getUserState: vi
156
+ .fn()
157
+ .mockRejectedValueOnce(new UserNotFoundError())
158
+ .mockResolvedValueOnce({
159
+ isOnboarded: false,
160
+ preference: { telemetry: null },
161
+ settings: {},
162
+ }),
163
+ }) as any,
164
+ );
165
+
166
+ vi.mocked(MessageModel).mockImplementation(
167
+ () =>
168
+ ({
169
+ hasMoreThanN: vi.fn().mockResolvedValue(false),
170
+ }) as any,
171
+ );
172
+
173
+ vi.mocked(SessionModel).mockImplementation(
174
+ () =>
175
+ ({
176
+ hasMoreThanN: vi.fn().mockResolvedValue(false),
177
+ }) as any,
178
+ );
179
+
180
+ const result = await userRouter.createCaller({ ...mockCtx } as any).getUserState();
181
+
182
+ expect(result).toEqual({
183
+ isOnboard: true,
184
+ preference: { telemetry: null },
185
+ settings: {},
186
+ hasConversation: false,
187
+ canEnablePWAGuide: false,
188
+ canEnableTrace: false,
189
+ userId: mockUserId,
190
+ });
191
+ });
192
+ });
193
+
194
+ describe('makeUserOnboarded', () => {
195
+ it('should update user onboarded status', async () => {
196
+ vi.mocked(UserModel).mockImplementation(
197
+ () =>
198
+ ({
199
+ updateUser: vi.fn().mockResolvedValue({ rowCount: 1 }),
200
+ }) as any,
201
+ );
202
+
203
+ await userRouter.createCaller({ ...mockCtx }).makeUserOnboarded();
204
+
205
+ expect(UserModel).toHaveBeenCalledWith(serverDB, mockUserId);
206
+ });
207
+ });
208
+
209
+ describe('unlinkSSOProvider', () => {
210
+ it('should unlink SSO provider successfully', async () => {
211
+ const mockInput = {
212
+ provider: 'google',
213
+ providerAccountId: '123',
214
+ };
215
+
216
+ const mockAccount = {
217
+ userId: mockUserId,
218
+ provider: 'google',
219
+ providerAccountId: '123',
220
+ type: 'oauth',
221
+ };
222
+
223
+ vi.mocked(LobeNextAuthDbAdapter).mockReturnValue({
224
+ getAccount: vi.fn().mockResolvedValue(mockAccount),
225
+ unlinkAccount: vi.fn().mockResolvedValue(undefined),
226
+ } as any);
227
+
228
+ await expect(
229
+ userRouter.createCaller({ ...mockCtx }).unlinkSSOProvider(mockInput),
230
+ ).resolves.not.toThrow();
231
+ });
232
+
233
+ it('should throw error if account does not exist', async () => {
234
+ const mockInput = {
235
+ provider: 'google',
236
+ providerAccountId: '123',
237
+ };
238
+
239
+ vi.mocked(LobeNextAuthDbAdapter).mockReturnValue({
240
+ getAccount: vi.fn().mockResolvedValue(null),
241
+ unlinkAccount: vi.fn(),
242
+ } as any);
243
+
244
+ await expect(
245
+ userRouter.createCaller({ ...mockCtx }).unlinkSSOProvider(mockInput),
246
+ ).rejects.toThrow('The account does not exist');
247
+ });
248
+
249
+ it('should throw error if adapter methods are not implemented', async () => {
250
+ const mockInput = {
251
+ provider: 'google',
252
+ providerAccountId: '123',
253
+ };
254
+
255
+ vi.mocked(LobeNextAuthDbAdapter).mockReturnValue({} as any);
256
+
257
+ await expect(
258
+ userRouter.createCaller({ ...mockCtx }).unlinkSSOProvider(mockInput),
259
+ ).rejects.toThrow('The method in LobeNextAuthDbAdapter `unlinkAccount` is not implemented');
260
+ });
261
+ });
262
+
263
+ describe('updateSettings', () => {
264
+ it('should update settings with encrypted key vaults', async () => {
265
+ const mockSettings = {
266
+ keyVaults: { openai: { key: 'test-key' } },
267
+ general: { language: 'en-US' },
268
+ };
269
+
270
+ const mockEncryptedVaults = 'encrypted-data';
271
+ const mockGateKeeper = {
272
+ encrypt: vi.fn().mockResolvedValue(mockEncryptedVaults),
273
+ };
274
+
275
+ vi.mocked(KeyVaultsGateKeeper.initWithEnvKey).mockResolvedValue(mockGateKeeper as any);
276
+ vi.mocked(UserModel).mockImplementation(
277
+ () =>
278
+ ({
279
+ updateSetting: vi.fn().mockResolvedValue({ rowCount: 1 }),
280
+ }) as any,
281
+ );
282
+
283
+ await userRouter.createCaller({ ...mockCtx }).updateSettings(mockSettings);
284
+
285
+ expect(mockGateKeeper.encrypt).toHaveBeenCalledWith(JSON.stringify(mockSettings.keyVaults));
286
+ });
287
+
288
+ it('should update settings without key vaults', async () => {
289
+ const mockSettings = {
290
+ general: { language: 'en-US' },
291
+ };
292
+
293
+ vi.mocked(UserModel).mockImplementation(
294
+ () =>
295
+ ({
296
+ updateSetting: vi.fn().mockResolvedValue({ rowCount: 1 }),
297
+ }) as any,
298
+ );
299
+
300
+ await userRouter.createCaller({ ...mockCtx }).updateSettings(mockSettings);
301
+
302
+ expect(UserModel).toHaveBeenCalledWith(serverDB, mockUserId);
303
+ });
304
+ });
305
+ });
@@ -17,7 +17,7 @@ export class NextAuthUserService {
17
17
  { providerAccountId, provider }: { provider: string; providerAccountId: string },
18
18
  data: Partial<UserItem>,
19
19
  ) => {
20
- pino.info('updating user due to webhook');
20
+ pino.info(`updating user "${JSON.stringify({ provider, providerAccountId })}" due to webhook`);
21
21
  // 1. Find User by account
22
22
  // @ts-expect-error: Already impl in `LobeNextauthDbAdapter`
23
23
  const user = await this.adapter.getUserByAccount({
@@ -37,7 +37,7 @@ export class NextAuthUserService {
37
37
  });
38
38
  } else {
39
39
  pino.warn(
40
- `[${provider}]: Webhooks handler user update for "${JSON.stringify(data)}", but no user was found by the providerAccountId.`,
40
+ `[${provider}]: Webhooks handler user "${JSON.stringify({ provider, providerAccountId })}" update for "${JSON.stringify(data)}", but no user was found by the providerAccountId.`,
41
41
  );
42
42
  }
43
43
  return NextResponse.json({ message: 'user updated', success: true }, { status: 200 });
@@ -455,7 +455,7 @@ export const generateAIChat: StateCreator<
455
455
  await messageService.updateMessageError(messageId, error);
456
456
  await refreshMessages();
457
457
  },
458
- onFinish: async (content, { traceId, observationId, toolCalls, reasoning }) => {
458
+ onFinish: async (content, { traceId, observationId, toolCalls, reasoning, citations }) => {
459
459
  // if there is traceId, update it
460
460
  if (traceId) {
461
461
  msgTraceId = traceId;
@@ -470,15 +470,26 @@ export const generateAIChat: StateCreator<
470
470
  }
471
471
 
472
472
  // update the content after fetch result
473
- await internal_updateMessageContent(
474
- messageId,
475
- content,
473
+ await internal_updateMessageContent(messageId, content, {
476
474
  toolCalls,
477
- !!reasoning ? { content: reasoning, duration } : undefined,
478
- );
475
+ reasoning: !!reasoning ? { content: reasoning, duration } : undefined,
476
+ search: !!citations ? { citations } : undefined,
477
+ });
479
478
  },
480
479
  onMessageHandle: async (chunk) => {
481
480
  switch (chunk.type) {
481
+ case 'citations': {
482
+ // if there is no citations, then stop
483
+ if (!chunk.citations || chunk.citations.length <= 0) return;
484
+
485
+ internal_dispatchMessage({
486
+ id: messageId,
487
+ type: 'updateMessage',
488
+ value: { search: { citations: chunk.citations } },
489
+ });
490
+ break;
491
+ }
492
+
482
493
  case 'text': {
483
494
  output += chunk.text;
484
495
 
@@ -16,6 +16,7 @@ import {
16
16
  ChatMessage,
17
17
  ChatMessageError,
18
18
  CreateMessageParams,
19
+ GroundingSearch,
19
20
  MessageToolCall,
20
21
  ModelReasoning,
21
22
  } from '@/types/message';
@@ -73,8 +74,11 @@ export interface ChatMessageAction {
73
74
  internal_updateMessageContent: (
74
75
  id: string,
75
76
  content: string,
76
- toolCalls?: MessageToolCall[],
77
- reasoning?: ModelReasoning,
77
+ extra?: {
78
+ toolCalls?: MessageToolCall[];
79
+ reasoning?: ModelReasoning;
80
+ search?: GroundingSearch;
81
+ },
78
82
  ) => Promise<void>;
79
83
  /**
80
84
  * update the message error with optimistic update
@@ -272,17 +276,17 @@ export const chatMessage: StateCreator<
272
276
  await messageService.updateMessage(id, { error });
273
277
  await get().refreshMessages();
274
278
  },
275
- internal_updateMessageContent: async (id, content, toolCalls, reasoning) => {
279
+ internal_updateMessageContent: async (id, content, extra) => {
276
280
  const { internal_dispatchMessage, refreshMessages, internal_transformToolCalls } = get();
277
281
 
278
282
  // Due to the async update method and refresh need about 100ms
279
283
  // we need to update the message content at the frontend to avoid the update flick
280
284
  // refs: https://medium.com/@kyledeguzmanx/what-are-optimistic-updates-483662c3e171
281
- if (toolCalls) {
285
+ if (extra?.toolCalls) {
282
286
  internal_dispatchMessage({
283
287
  id,
284
288
  type: 'updateMessage',
285
- value: { tools: internal_transformToolCalls(toolCalls) },
289
+ value: { tools: internal_transformToolCalls(extra?.toolCalls) },
286
290
  });
287
291
  } else {
288
292
  internal_dispatchMessage({ id, type: 'updateMessage', value: { content } });
@@ -290,8 +294,9 @@ export const chatMessage: StateCreator<
290
294
 
291
295
  await messageService.updateMessage(id, {
292
296
  content,
293
- tools: toolCalls ? internal_transformToolCalls(toolCalls) : undefined,
294
- reasoning,
297
+ tools: extra?.toolCalls ? internal_transformToolCalls(extra?.toolCalls) : undefined,
298
+ reasoning: extra?.reasoning,
299
+ search: extra?.search,
295
300
  });
296
301
  await refreshMessages();
297
302
  },
@@ -34,6 +34,11 @@ export interface ModelAbilities {
34
34
  * whether model supports reasoning
35
35
  */
36
36
  reasoning?: boolean;
37
+ /**
38
+ * whether model supports search web
39
+ */
40
+ search?: boolean;
41
+
37
42
  /**
38
43
  * whether model supports vision
39
44
  */
@@ -1,3 +1,15 @@
1
+ export interface CitationItem {
2
+ id?: string;
3
+ onlyUrl?: boolean;
4
+ title?: string;
5
+ url: string;
6
+ }
7
+
8
+ export interface GroundingSearch {
9
+ citations?: CitationItem[];
10
+ searchQueries?: string[];
11
+ }
12
+
1
13
  export interface ModelReasoning {
2
14
  content?: string;
3
15
  duration?: number;
@@ -20,6 +32,7 @@ export interface MessageItem {
20
32
  quotaId: string | null;
21
33
  reasoning: ModelReasoning | null;
22
34
  role: string;
35
+ search: GroundingSearch | null;
23
36
  sessionId: string | null;
24
37
  threadId: string | null;
25
38
  // jsonb type
@@ -2,7 +2,7 @@ import { IPluginErrorType } from '@lobehub/chat-plugin-sdk';
2
2
 
3
3
  import { ILobeAgentRuntimeErrorType } from '@/libs/agent-runtime';
4
4
  import { ErrorType } from '@/types/fetch';
5
- import { MessageRoleType, ModelReasoning } from '@/types/message/base';
5
+ import { GroundingSearch, MessageRoleType, ModelReasoning } from '@/types/message/base';
6
6
  import { ChatPluginPayload, ChatToolPayload } from '@/types/message/tools';
7
7
  import { Translate } from '@/types/message/translate';
8
8
  import { MetaData } from '@/types/meta';
@@ -100,11 +100,12 @@ export interface ChatMessage {
100
100
  ragRawQuery?: string | null;
101
101
 
102
102
  reasoning?: ModelReasoning | null;
103
-
104
103
  /**
105
104
  * message role type
106
105
  */
107
106
  role: MessageRoleType;
107
+
108
+ search?: GroundingSearch | null;
108
109
  sessionId?: string;
109
110
  threadId?: string | null;
110
111
  tool_call_id?: string;