@lobehub/chat 1.70.11 → 1.71.1

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 (74) hide show
  1. package/.github/workflows/sync-database-schema.yml +25 -0
  2. package/CHANGELOG.md +58 -0
  3. package/changelog/v1.json +21 -0
  4. package/docs/developer/database-schema.dbml +569 -0
  5. package/locales/ar/components.json +1 -0
  6. package/locales/ar/models.json +6 -0
  7. package/locales/bg-BG/components.json +1 -0
  8. package/locales/bg-BG/models.json +6 -0
  9. package/locales/de-DE/components.json +1 -0
  10. package/locales/de-DE/models.json +6 -0
  11. package/locales/en-US/components.json +1 -0
  12. package/locales/en-US/models.json +6 -0
  13. package/locales/es-ES/components.json +1 -0
  14. package/locales/es-ES/models.json +6 -0
  15. package/locales/fa-IR/components.json +1 -0
  16. package/locales/fa-IR/models.json +6 -0
  17. package/locales/fr-FR/components.json +1 -0
  18. package/locales/fr-FR/models.json +6 -0
  19. package/locales/it-IT/components.json +1 -0
  20. package/locales/it-IT/models.json +6 -0
  21. package/locales/ja-JP/components.json +1 -0
  22. package/locales/ja-JP/models.json +6 -0
  23. package/locales/ko-KR/components.json +1 -0
  24. package/locales/ko-KR/models.json +6 -0
  25. package/locales/nl-NL/components.json +1 -0
  26. package/locales/nl-NL/models.json +6 -0
  27. package/locales/pl-PL/components.json +1 -0
  28. package/locales/pl-PL/models.json +6 -0
  29. package/locales/pt-BR/components.json +1 -0
  30. package/locales/pt-BR/models.json +6 -0
  31. package/locales/ru-RU/components.json +1 -0
  32. package/locales/ru-RU/models.json +6 -0
  33. package/locales/tr-TR/components.json +1 -0
  34. package/locales/tr-TR/models.json +6 -0
  35. package/locales/vi-VN/components.json +1 -0
  36. package/locales/vi-VN/models.json +6 -0
  37. package/locales/zh-CN/components.json +1 -0
  38. package/locales/zh-CN/models.json +6 -0
  39. package/locales/zh-TW/components.json +1 -0
  40. package/locales/zh-TW/models.json +6 -0
  41. package/package.json +6 -2
  42. package/scripts/dbmlWorkflow/index.ts +11 -0
  43. package/src/components/ModelSelect/index.tsx +16 -0
  44. package/src/config/aiModels/google.ts +36 -0
  45. package/src/config/aiModels/vertexai.ts +24 -6
  46. package/src/config/modelProviders/vertexai.ts +1 -1
  47. package/src/database/client/migrations.json +4 -4
  48. package/src/database/server/models/message.ts +20 -9
  49. package/src/database/server/models/user.test.ts +58 -0
  50. package/src/features/AlertBanner/CloudBanner.tsx +1 -1
  51. package/src/features/Conversation/Messages/Assistant/index.tsx +4 -1
  52. package/src/features/Conversation/Messages/User/index.tsx +4 -4
  53. package/src/libs/agent-runtime/google/index.ts +23 -5
  54. package/src/libs/agent-runtime/utils/streams/google-ai.test.ts +189 -0
  55. package/src/libs/agent-runtime/utils/streams/google-ai.ts +70 -23
  56. package/src/libs/agent-runtime/utils/streams/index.ts +1 -0
  57. package/src/libs/agent-runtime/utils/streams/protocol.ts +2 -0
  58. package/src/locales/default/components.ts +1 -0
  59. package/src/services/chat.ts +33 -15
  60. package/src/services/file/client.ts +3 -1
  61. package/src/services/message/server.ts +2 -2
  62. package/src/services/message/type.ts +2 -2
  63. package/src/services/upload.ts +82 -1
  64. package/src/store/chat/slices/aiChat/actions/generateAIChat.ts +44 -4
  65. package/src/store/chat/slices/message/action.ts +3 -0
  66. package/src/store/file/slices/upload/action.ts +36 -13
  67. package/src/store/file/store.ts +2 -0
  68. package/src/types/aiModel.ts +4 -1
  69. package/src/types/files/upload.ts +7 -0
  70. package/src/types/message/base.ts +22 -1
  71. package/src/types/message/chat.ts +1 -6
  72. package/src/types/message/image.ts +11 -0
  73. package/src/types/message/index.ts +1 -0
  74. package/src/utils/fetch/fetchSSE.ts +24 -1
@@ -21,6 +21,7 @@ import {
21
21
  CreateMessageParams,
22
22
  MessageItem,
23
23
  ModelRankItem,
24
+ UpdateMessageParams,
24
25
  } from '@/types/message';
25
26
  import { merge } from '@/utils/merge';
26
27
  import { today } from '@/utils/time';
@@ -497,15 +498,25 @@ export class MessageModel {
497
498
  };
498
499
  // **************** Update *************** //
499
500
 
500
- update = async (id: string, message: Partial<MessageItem>) => {
501
- return this.db
502
- .update(messages)
503
- .set({
504
- ...message,
505
- // TODO: need a better way to handle this
506
- role: message.role as any,
507
- })
508
- .where(and(eq(messages.id, id), eq(messages.userId, this.userId)));
501
+ update = async (id: string, { imageList, ...message }: Partial<UpdateMessageParams>) => {
502
+ return this.db.transaction(async (trx) => {
503
+ // 1. insert message files
504
+ if (imageList && imageList.length > 0) {
505
+ await trx
506
+ .insert(messagesFiles)
507
+ .values(imageList.map((file) => ({ fileId: file.id, messageId: id })));
508
+ }
509
+
510
+ return trx
511
+ .update(messages)
512
+ .set({
513
+ ...message,
514
+ // TODO: need a better way to handle this
515
+ // TODO: but I forget why 🤡
516
+ role: message.role as any,
517
+ })
518
+ .where(and(eq(messages.id, id), eq(messages.userId, this.userId)));
519
+ });
509
520
  };
510
521
 
511
522
  updatePluginState = async (id: string, state: Record<string, any>) => {
@@ -0,0 +1,58 @@
1
+ // @vitest-environment node
2
+ import { TRPCError } from '@trpc/server';
3
+ import { describe, expect, it, vi } from 'vitest';
4
+
5
+ import { UserModel, UserNotFoundError } from '@/database/server/models/user';
6
+
7
+ describe('UserNotFoundError', () => {
8
+ it('should extend TRPCError with correct code and message', () => {
9
+ const error = new UserNotFoundError();
10
+
11
+ expect(error).toBeInstanceOf(TRPCError);
12
+ expect(error.code).toBe('UNAUTHORIZED');
13
+ expect(error.message).toBe('user not found');
14
+ });
15
+ });
16
+
17
+ describe('UserModel', () => {
18
+ const mockDb = {
19
+ query: {
20
+ users: {
21
+ findFirst: vi.fn(),
22
+ },
23
+ },
24
+ };
25
+
26
+ const mockUserId = 'test-user-id';
27
+ const userModel = new UserModel(mockDb as any, mockUserId);
28
+
29
+ describe('getUserRegistrationDuration', () => {
30
+ it('should return default values when user not found', async () => {
31
+ mockDb.query.users.findFirst.mockResolvedValue(null);
32
+
33
+ const result = await userModel.getUserRegistrationDuration();
34
+
35
+ expect(result).toEqual({
36
+ createdAt: expect.any(String),
37
+ duration: 1,
38
+ updatedAt: expect.any(String),
39
+ });
40
+ });
41
+
42
+ it('should calculate duration correctly for existing user', async () => {
43
+ const createdAt = new Date('2024-01-01');
44
+ mockDb.query.users.findFirst.mockResolvedValue({
45
+ createdAt,
46
+ });
47
+
48
+ const result = await userModel.getUserRegistrationDuration();
49
+
50
+ expect(result).toEqual({
51
+ createdAt: '2024-01-01',
52
+ duration: expect.any(Number),
53
+ updatedAt: expect.any(String),
54
+ });
55
+ expect(result.duration).toBeGreaterThan(0);
56
+ });
57
+ });
58
+ });
@@ -61,7 +61,7 @@ const CloudBanner = memo<{ mobile?: boolean }>(({ mobile }) => {
61
61
  <b>{t('alert.cloud.title', { name: LOBE_CHAT_CLOUD })}:</b>
62
62
  <span>
63
63
  {t(mobile ? 'alert.cloud.descOnMobile' : 'alert.cloud.desc', {
64
- credit: new Intl.NumberFormat('en-US').format(450_000),
64
+ credit: new Intl.NumberFormat('en-US').format(500_000),
65
65
  name: LOBE_CHAT_CLOUD,
66
66
  })}
67
67
  </span>
@@ -2,6 +2,7 @@ import { ReactNode, memo } from 'react';
2
2
  import { Flexbox } from 'react-layout-kit';
3
3
 
4
4
  import { LOADING_FLAT } from '@/const/message';
5
+ import ImageFileListViewer from '@/features/Conversation/Messages/User/ImageFileListViewer';
5
6
  import { useChatStore } from '@/store/chat';
6
7
  import { aiChatSelectors, chatSelectors } from '@/store/chat/selectors';
7
8
  import { ChatMessage } from '@/types/message';
@@ -17,7 +18,7 @@ export const AssistantMessage = memo<
17
18
  ChatMessage & {
18
19
  editableContent: ReactNode;
19
20
  }
20
- >(({ id, tools, content, chunksList, search, ...props }) => {
21
+ >(({ id, tools, content, chunksList, search, imageList, ...props }) => {
21
22
  const editing = useChatStore(chatSelectors.isMessageEditing(id));
22
23
  const generating = useChatStore(chatSelectors.isMessageGenerating(id));
23
24
 
@@ -28,6 +29,7 @@ export const AssistantMessage = memo<
28
29
  const isIntentUnderstanding = useChatStore(aiChatSelectors.isIntentUnderstanding(id));
29
30
 
30
31
  const showSearch = !!search && !!search.citations?.length;
32
+ const showImageItems = !!imageList && imageList.length > 0;
31
33
 
32
34
  // remove \n to avoid empty content
33
35
  // refs: https://github.com/lobehub/lobe-chat/pull/6153
@@ -64,6 +66,7 @@ export const AssistantMessage = memo<
64
66
  />
65
67
  )
66
68
  )}
69
+ {showImageItems && <ImageFileListViewer items={imageList} />}
67
70
  {tools && (
68
71
  <Flexbox gap={8}>
69
72
  {tools.map((toolCall, index) => (
@@ -12,16 +12,16 @@ export const UserMessage = memo<
12
12
  ChatMessage & {
13
13
  editableContent: ReactNode;
14
14
  }
15
- >(({ id, editableContent, content, ...res }) => {
15
+ >(({ id, editableContent, content, imageList, fileList }) => {
16
16
  if (content === LOADING_FLAT) return <BubblesLoading />;
17
17
 
18
18
  return (
19
19
  <Flexbox gap={8} id={id}>
20
20
  {editableContent}
21
- {res.imageList && res.imageList?.length > 0 && <ImageFileListViewer items={res.imageList} />}
22
- {res.fileList && res.fileList?.length > 0 && (
21
+ {imageList && imageList?.length > 0 && <ImageFileListViewer items={imageList} />}
22
+ {fileList && fileList?.length > 0 && (
23
23
  <div style={{ marginTop: 8 }}>
24
- <FileListViewer items={res.fileList} />
24
+ <FileListViewer items={fileList} />
25
25
  </div>
26
26
  )}
27
27
  </Flexbox>
@@ -10,7 +10,6 @@ import {
10
10
  SchemaType,
11
11
  } from '@google/generative-ai';
12
12
 
13
- import { VertexAIStream } from '@/libs/agent-runtime/utils/streams/vertex-ai';
14
13
  import type { ChatModelCard } from '@/types/llm';
15
14
  import { imageUrlToBase64 } from '@/utils/imageToBase64';
16
15
  import { safeParseJSON } from '@/utils/safeParseJSON';
@@ -28,9 +27,25 @@ import { ModelProvider } from '../types/type';
28
27
  import { AgentRuntimeError } from '../utils/createError';
29
28
  import { debugStream } from '../utils/debugStream';
30
29
  import { StreamingResponse } from '../utils/response';
31
- import { GoogleGenerativeAIStream, convertIterableToStream } from '../utils/streams';
30
+ import {
31
+ GoogleGenerativeAIStream,
32
+ VertexAIStream,
33
+ convertIterableToStream,
34
+ } from '../utils/streams';
32
35
  import { parseDataUri } from '../utils/uriParser';
33
36
 
37
+ const modelsOffSafetySettings = new Set(['gemini-2.0-flash-exp']);
38
+
39
+ const modelsWithModalities = new Set([
40
+ 'gemini-2.0-flash-exp',
41
+ 'gemini-2.0-flash-exp-image-generation',
42
+ ]);
43
+
44
+ const modelsDisableInstuction = new Set([
45
+ 'gemini-2.0-flash-exp',
46
+ 'gemini-2.0-flash-exp-image-generation',
47
+ ]);
48
+
34
49
  export interface GoogleModelCard {
35
50
  displayName: string;
36
51
  inputTokenLimit: number;
@@ -50,8 +65,7 @@ enum HarmBlockThreshold {
50
65
  }
51
66
 
52
67
  function getThreshold(model: string): HarmBlockThreshold {
53
- const useOFF = ['gemini-2.0-flash-exp'];
54
- if (useOFF.includes(model)) {
68
+ if (modelsOffSafetySettings.has(model)) {
55
69
  return 'OFF' as HarmBlockThreshold; // https://discuss.ai.google.dev/t/59352
56
70
  }
57
71
  return HarmBlockThreshold.BLOCK_NONE;
@@ -94,6 +108,8 @@ export class LobeGoogleAI implements LobeRuntimeAI {
94
108
  {
95
109
  generationConfig: {
96
110
  maxOutputTokens: payload.max_tokens,
111
+ // @ts-expect-error - Google SDK 0.24.0 doesn't have this property for now with
112
+ response_modalities: modelsWithModalities.has(model) ? ['Text', 'Image'] : undefined,
97
113
  temperature: payload.temperature,
98
114
  topP: payload.top_p,
99
115
  },
@@ -123,7 +139,9 @@ export class LobeGoogleAI implements LobeRuntimeAI {
123
139
  )
124
140
  .generateContentStream({
125
141
  contents,
126
- systemInstruction: payload.system as string,
142
+ systemInstruction: modelsDisableInstuction.has(model)
143
+ ? undefined
144
+ : (payload.system as string),
127
145
  tools: this.buildGoogleTools(payload.tools, payload),
128
146
  });
129
147
 
@@ -94,4 +94,193 @@ describe('GoogleGenerativeAIStream', () => {
94
94
 
95
95
  expect(chunks).toEqual([]);
96
96
  });
97
+
98
+ it('should handle image', async () => {
99
+ vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1');
100
+
101
+ const data = {
102
+ candidates: [
103
+ {
104
+ content: {
105
+ parts: [{ inlineData: { mimeType: 'image/png', data: 'iVBORw0KGgoAA' } }],
106
+ role: 'model',
107
+ },
108
+ index: 0,
109
+ },
110
+ ],
111
+ usageMetadata: {
112
+ promptTokenCount: 6,
113
+ totalTokenCount: 6,
114
+ promptTokensDetails: [{ modality: 'TEXT', tokenCount: 6 }],
115
+ },
116
+ modelVersion: 'gemini-2.0-flash-exp',
117
+ };
118
+ const mockGenerateContentResponse = (text: string, functionCalls?: any[]) =>
119
+ ({
120
+ text: () => text,
121
+ functionCall: () => functionCalls?.[0],
122
+ functionCalls: () => functionCalls,
123
+ }) as EnhancedGenerateContentResponse;
124
+
125
+ const mockGoogleStream = new ReadableStream({
126
+ start(controller) {
127
+ controller.enqueue(data);
128
+
129
+ controller.close();
130
+ },
131
+ });
132
+
133
+ const protocolStream = GoogleGenerativeAIStream(mockGoogleStream);
134
+
135
+ const decoder = new TextDecoder();
136
+ const chunks = [];
137
+
138
+ // @ts-ignore
139
+ for await (const chunk of protocolStream) {
140
+ chunks.push(decoder.decode(chunk, { stream: true }));
141
+ }
142
+
143
+ expect(chunks).toEqual([
144
+ // image
145
+ 'id: chat_1\n',
146
+ 'event: base64_image\n',
147
+ `data: "data:image/png;base64,iVBORw0KGgoAA"\n\n`,
148
+ ]);
149
+ });
150
+
151
+ it('should handle token count', async () => {
152
+ vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1');
153
+
154
+ const data = {
155
+ candidates: [{ content: { role: 'model' }, finishReason: 'STOP', index: 0 }],
156
+ usageMetadata: {
157
+ promptTokenCount: 266,
158
+ totalTokenCount: 266,
159
+ promptTokensDetails: [
160
+ { modality: 'TEXT', tokenCount: 8 },
161
+ { modality: 'IMAGE', tokenCount: 258 },
162
+ ],
163
+ },
164
+ modelVersion: 'gemini-2.0-flash-exp',
165
+ };
166
+
167
+ const mockGoogleStream = new ReadableStream({
168
+ start(controller) {
169
+ controller.enqueue(data);
170
+
171
+ controller.close();
172
+ },
173
+ });
174
+
175
+ const protocolStream = GoogleGenerativeAIStream(mockGoogleStream);
176
+
177
+ const decoder = new TextDecoder();
178
+ const chunks = [];
179
+
180
+ // @ts-ignore
181
+ for await (const chunk of protocolStream) {
182
+ chunks.push(decoder.decode(chunk, { stream: true }));
183
+ }
184
+
185
+ expect(chunks).toEqual([
186
+ // stop
187
+ 'id: chat_1\n',
188
+ 'event: stop\n',
189
+ `data: "STOP"\n\n`,
190
+ // usage
191
+ 'id: chat_1\n',
192
+ 'event: usage\n',
193
+ `data: {"inputImageTokens":258,"inputTextTokens":8,"totalInputTokens":266,"totalTokens":266}\n\n`,
194
+ ]);
195
+ });
196
+
197
+ it('should handle stop with content', async () => {
198
+ vi.spyOn(uuidModule, 'nanoid').mockReturnValueOnce('1');
199
+
200
+ const data = [
201
+ {
202
+ candidates: [
203
+ {
204
+ content: { parts: [{ text: '234' }], role: 'model' },
205
+ safetyRatings: [
206
+ { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' },
207
+ { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' },
208
+ { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' },
209
+ { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' },
210
+ ],
211
+ },
212
+ ],
213
+ text: () => '234',
214
+ usageMetadata: {
215
+ promptTokenCount: 20,
216
+ totalTokenCount: 20,
217
+ promptTokensDetails: [{ modality: 'TEXT', tokenCount: 20 }],
218
+ },
219
+ modelVersion: 'gemini-2.0-flash-exp-image-generation',
220
+ },
221
+ {
222
+ text: () => '567890\n',
223
+ candidates: [
224
+ {
225
+ content: { parts: [{ text: '567890\n' }], role: 'model' },
226
+ finishReason: 'STOP',
227
+ safetyRatings: [
228
+ { category: 'HARM_CATEGORY_HATE_SPEECH', probability: 'NEGLIGIBLE' },
229
+ { category: 'HARM_CATEGORY_DANGEROUS_CONTENT', probability: 'NEGLIGIBLE' },
230
+ { category: 'HARM_CATEGORY_HARASSMENT', probability: 'NEGLIGIBLE' },
231
+ { category: 'HARM_CATEGORY_SEXUALLY_EXPLICIT', probability: 'NEGLIGIBLE' },
232
+ ],
233
+ },
234
+ ],
235
+ usageMetadata: {
236
+ promptTokenCount: 19,
237
+ candidatesTokenCount: 11,
238
+ totalTokenCount: 30,
239
+ promptTokensDetails: [{ modality: 'TEXT', tokenCount: 19 }],
240
+ candidatesTokensDetails: [{ modality: 'TEXT', tokenCount: 11 }],
241
+ },
242
+ modelVersion: 'gemini-2.0-flash-exp-image-generation',
243
+ },
244
+ ];
245
+
246
+ const mockGoogleStream = new ReadableStream({
247
+ start(controller) {
248
+ data.forEach((item) => {
249
+ controller.enqueue(item);
250
+ });
251
+
252
+ controller.close();
253
+ },
254
+ });
255
+
256
+ const protocolStream = GoogleGenerativeAIStream(mockGoogleStream);
257
+
258
+ const decoder = new TextDecoder();
259
+ const chunks = [];
260
+
261
+ // @ts-ignore
262
+ for await (const chunk of protocolStream) {
263
+ chunks.push(decoder.decode(chunk, { stream: true }));
264
+ }
265
+
266
+ expect(chunks).toEqual(
267
+ [
268
+ 'id: chat_1',
269
+ 'event: text',
270
+ 'data: "234"\n',
271
+
272
+ 'id: chat_1',
273
+ 'event: text',
274
+ `data: "567890\\n"\n`,
275
+ // stop
276
+ 'id: chat_1',
277
+ 'event: stop',
278
+ `data: "STOP"\n`,
279
+ // usage
280
+ 'id: chat_1',
281
+ 'event: usage',
282
+ `data: {"inputTextTokens":19,"totalInputTokens":19,"totalOutputTokens":11,"totalTokens":30}\n`,
283
+ ].map((i) => i + '\n'),
284
+ );
285
+ });
97
286
  });
@@ -1,5 +1,6 @@
1
1
  import { EnhancedGenerateContentResponse } from '@google/generative-ai';
2
2
 
3
+ import { ModelTokensUsage } from '@/types/message';
3
4
  import { GroundingSearch } from '@/types/search';
4
5
  import { nanoid } from '@/utils/uuid';
5
6
 
@@ -18,7 +19,7 @@ const transformGoogleGenerativeAIStream = (
18
19
  context: StreamContext,
19
20
  ): StreamProtocolChunk | StreamProtocolChunk[] => {
20
21
  // maybe need another structure to add support for multiple choices
21
- const functionCalls = chunk.functionCalls();
22
+ const functionCalls = chunk.functionCalls?.();
22
23
 
23
24
  if (functionCalls) {
24
25
  return {
@@ -37,30 +38,76 @@ const transformGoogleGenerativeAIStream = (
37
38
  type: 'tool_calls',
38
39
  };
39
40
  }
40
- const text = chunk.text();
41
41
 
42
- if (chunk.candidates && chunk.candidates[0].groundingMetadata) {
43
- const { webSearchQueries, groundingSupports, groundingChunks } =
44
- chunk.candidates[0].groundingMetadata;
45
- console.log({ groundingChunks, groundingSupports, webSearchQueries });
42
+ const text = chunk.text?.();
46
43
 
47
- return [
48
- { data: text, id: context.id, type: 'text' },
49
- {
50
- data: {
51
- citations: groundingChunks?.map((chunk) => ({
52
- // google 返回的 uri 是经过 google 自己处理过的 url,因此无法展现真实的 favicon
53
- // 需要使用 title 作为替换
54
- favicon: chunk.web?.title,
55
- title: chunk.web?.title,
56
- url: chunk.web?.uri,
57
- })),
58
- searchQueries: webSearchQueries,
59
- } as GroundingSearch,
60
- id: context.id,
61
- type: 'grounding',
62
- },
63
- ];
44
+ if (chunk.candidates) {
45
+ const candidate = chunk.candidates[0];
46
+
47
+ // return the grounding
48
+ if (candidate.groundingMetadata) {
49
+ const { webSearchQueries, groundingChunks } = candidate.groundingMetadata;
50
+
51
+ return [
52
+ { data: text, id: context.id, type: 'text' },
53
+ {
54
+ data: {
55
+ citations: groundingChunks?.map((chunk) => ({
56
+ // google 返回的 uri 是经过 google 自己处理过的 url,因此无法展现真实的 favicon
57
+ // 需要使用 title 作为替换
58
+ favicon: chunk.web?.title,
59
+ title: chunk.web?.title,
60
+ url: chunk.web?.uri,
61
+ })),
62
+ searchQueries: webSearchQueries,
63
+ } as GroundingSearch,
64
+ id: context.id,
65
+ type: 'grounding',
66
+ },
67
+ ];
68
+ }
69
+
70
+ if (candidate.finishReason) {
71
+ if (chunk.usageMetadata) {
72
+ const usage = chunk.usageMetadata;
73
+ return [
74
+ !!text ? { data: text, id: context?.id, type: 'text' } : undefined,
75
+ { data: candidate.finishReason, id: context?.id, type: 'stop' },
76
+ {
77
+ data: {
78
+ // TODO: Google SDK 0.24.0 don't have promptTokensDetails types
79
+ inputImageTokens: (usage as any).promptTokensDetails?.find(
80
+ (i: any) => i.modality === 'IMAGE',
81
+ )?.tokenCount,
82
+ inputTextTokens: (usage as any).promptTokensDetails?.find(
83
+ (i: any) => i.modality === 'TEXT',
84
+ )?.tokenCount,
85
+ totalInputTokens: usage.promptTokenCount,
86
+ totalOutputTokens: usage.candidatesTokenCount,
87
+ totalTokens: usage.totalTokenCount,
88
+ } as ModelTokensUsage,
89
+ id: context?.id,
90
+ type: 'usage',
91
+ },
92
+ ].filter(Boolean) as StreamProtocolChunk[];
93
+ }
94
+ return { data: candidate.finishReason, id: context?.id, type: 'stop' };
95
+ }
96
+
97
+ if (!!text?.trim()) return { data: text, id: context?.id, type: 'text' };
98
+
99
+ // streaming the image
100
+ if (Array.isArray(candidate.content.parts) && candidate.content.parts.length > 0) {
101
+ const part = candidate.content.parts[0];
102
+
103
+ if (part && part.inlineData && part.inlineData.data && part.inlineData.mimeType) {
104
+ return {
105
+ data: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`,
106
+ id: context.id,
107
+ type: 'base64_image',
108
+ };
109
+ }
110
+ }
64
111
  }
65
112
 
66
113
  return {
@@ -6,3 +6,4 @@ export * from './openai';
6
6
  export * from './protocol';
7
7
  export * from './qwen';
8
8
  export * from './spark';
9
+ export * from './vertex-ai';
@@ -32,6 +32,8 @@ export interface StreamProtocolChunk {
32
32
  id?: string;
33
33
  type: // pure text
34
34
  | 'text'
35
+ // base64 format image
36
+ | 'base64_image'
35
37
  // Tools use
36
38
  | 'tool_calls'
37
39
  // Model Thinking
@@ -78,6 +78,7 @@ export default {
78
78
  custom: '自定义模型,默认设定同时支持函数调用与视觉识别,请根据实际情况验证上述能力的可用性',
79
79
  file: '该模型支持上传文件读取与识别',
80
80
  functionCall: '该模型支持函数调用(Function Call)',
81
+ imageOutput: '该模型支持生成图片',
81
82
  reasoning: '该模型支持深度思考',
82
83
  search: '该模型支持联网搜索',
83
84
  tokens: '该模型单个会话最多支持 {{tokens}} Tokens',
@@ -438,6 +438,8 @@ class ChatService {
438
438
  provider: params.provider!,
439
439
  });
440
440
 
441
+ // remove plugins
442
+ delete params.plugins;
441
443
  await this.getChatCompletion(
442
444
  { ...params, messages: oaiMessages, tools },
443
445
  {
@@ -474,7 +476,7 @@ class ChatService {
474
476
  // handle content type for vision model
475
477
  // for the models with visual ability, add image url to content
476
478
  // refs: https://platform.openai.com/docs/guides/vision/quick-start
477
- const getContent = (m: ChatMessage) => {
479
+ const getUserContent = (m: ChatMessage) => {
478
480
  // only if message doesn't have images and files, then return the plain content
479
481
  if ((!m.imageList || m.imageList.length === 0) && (!m.fileList || m.fileList.length === 0))
480
482
  return m.content;
@@ -490,27 +492,43 @@ class ChatService {
490
492
  ] as UserMessageContentPart[];
491
493
  };
492
494
 
495
+ const getAssistantContent = (m: ChatMessage) => {
496
+ // signature is a signal of anthropic thinking mode
497
+ const shouldIncludeThinking = m.reasoning && !!m.reasoning?.signature;
498
+
499
+ if (shouldIncludeThinking) {
500
+ return [
501
+ {
502
+ signature: m.reasoning!.signature,
503
+ thinking: m.reasoning!.content,
504
+ type: 'thinking',
505
+ },
506
+ { text: m.content, type: 'text' },
507
+ ] as UserMessageContentPart[];
508
+ }
509
+ // only if message doesn't have images and files, then return the plain content
510
+
511
+ if (m.imageList && m.imageList.length > 0) {
512
+ return [
513
+ !!m.content ? { text: m.content, type: 'text' } : undefined,
514
+ ...m.imageList.map(
515
+ (i) => ({ image_url: { detail: 'auto', url: i.url }, type: 'image_url' }) as const,
516
+ ),
517
+ ].filter(Boolean) as UserMessageContentPart[];
518
+ }
519
+
520
+ return m.content;
521
+ };
522
+
493
523
  let postMessages = messages.map((m): OpenAIChatMessage => {
494
524
  const supportTools = isCanUseFC(model, provider);
495
525
  switch (m.role) {
496
526
  case 'user': {
497
- return { content: getContent(m), role: m.role };
527
+ return { content: getUserContent(m), role: m.role };
498
528
  }
499
529
 
500
530
  case 'assistant': {
501
- // signature is a signal of anthropic thinking mode
502
- const shouldIncludeThinking = m.reasoning && !!m.reasoning?.signature;
503
-
504
- const content = shouldIncludeThinking
505
- ? [
506
- {
507
- signature: m.reasoning!.signature,
508
- thinking: m.reasoning!.content,
509
- type: 'thinking',
510
- } as any,
511
- { text: m.content, type: 'text' },
512
- ]
513
- : m.content;
531
+ const content = getAssistantContent(m);
514
532
 
515
533
  if (!supportTools) {
516
534
  return { content, role: m.role };
@@ -11,6 +11,8 @@ export class ClientService extends BaseClientService implements IFileService {
11
11
  }
12
12
 
13
13
  createFile: IFileService['createFile'] = async (file) => {
14
+ const { isExist } = await this.fileModel.checkHash(file.hash!);
15
+
14
16
  // save to local storage
15
17
  // we may want to save to a remote server later
16
18
  const res = await this.fileModel.create(
@@ -23,7 +25,7 @@ export class ClientService extends BaseClientService implements IFileService {
23
25
  size: file.size,
24
26
  url: file.url!,
25
27
  },
26
- true,
28
+ !isExist,
27
29
  );
28
30
 
29
31
  // get file to base64 url
@@ -64,8 +64,8 @@ export class ServerService implements IMessageService {
64
64
  return lambdaClient.message.updateMessagePlugin.mutate({ id, value: { arguments: args } });
65
65
  };
66
66
 
67
- updateMessage: IMessageService['updateMessage'] = async (id, message) => {
68
- return lambdaClient.message.update.mutate({ id, value: message });
67
+ updateMessage: IMessageService['updateMessage'] = async (id, value) => {
68
+ return lambdaClient.message.update.mutate({ id, value });
69
69
  };
70
70
 
71
71
  updateMessageTranslate: IMessageService['updateMessageTranslate'] = async (id, translate) => {