@lobehub/lobehub 2.1.23 → 2.1.25
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.
- package/.github/workflows/claude-auto-e2e-testing.yml +131 -0
- package/CHANGELOG.md +42 -0
- package/changelog/v2.json +14 -0
- package/docs/development/database-schema.dbml +43 -14
- package/package.json +1 -1
- package/packages/database/migrations/0078_added_id_nanoid_for_replacing_id.sql +7 -0
- package/packages/database/migrations/0079_update_id_nanoid_from_casted_id.sql +7 -0
- package/packages/database/migrations/0080_add_constraint_unique_not_null_to_id_nanoid.sql +27 -0
- package/packages/database/migrations/0081_switch_forgien_key_to_id_nanoid.sql +37 -0
- package/packages/database/migrations/0082_set_id_nanoid_as_primary.sql +20 -0
- package/packages/database/migrations/0083_remove_id_seq_identity_column.sql +7 -0
- package/packages/database/migrations/0084_rename_id_nanoid_to_id.sql +53 -0
- package/packages/database/migrations/0085_remove_id_unique_constraint.sql +7 -0
- package/packages/database/migrations/meta/0078_snapshot.json +11515 -0
- package/packages/database/migrations/meta/0079_snapshot.json +11515 -0
- package/packages/database/migrations/meta/0080_snapshot.json +11554 -0
- package/packages/database/migrations/meta/0081_snapshot.json +11554 -0
- package/packages/database/migrations/meta/0082_snapshot.json +11554 -0
- package/packages/database/migrations/meta/0083_snapshot.json +11435 -0
- package/packages/database/migrations/meta/0084_snapshot.json +11435 -0
- package/packages/database/migrations/meta/0085_snapshot.json +11396 -0
- package/packages/database/migrations/meta/_journal.json +56 -0
- package/packages/database/src/models/__tests__/apiKey.test.ts +18 -6
- package/packages/database/src/models/apiKey.ts +5 -5
- package/packages/database/src/schemas/apiKey.ts +6 -2
- package/packages/database/src/schemas/ragEvals.ts +27 -20
- package/packages/database/src/schemas/rbac.ts +15 -15
- package/packages/database/src/server/models/ragEval/dataset.ts +3 -3
- package/packages/database/src/server/models/ragEval/datasetRecord.ts +5 -5
- package/packages/database/src/server/models/ragEval/evaluation.ts +3 -3
- package/packages/database/src/server/models/ragEval/evaluationRecord.ts +6 -6
- package/packages/memory-user-memory/src/prompts/layers/activity.ts +19 -18
- package/packages/memory-user-memory/src/prompts/layers/context.ts +39 -38
- package/packages/memory-user-memory/src/prompts/layers/experience.ts +40 -39
- package/packages/memory-user-memory/src/prompts/layers/identity.ts +55 -48
- package/packages/memory-user-memory/src/prompts/layers/preference.ts +42 -41
- package/packages/types/src/apiKey.ts +1 -1
- package/packages/types/src/eval/dataset.ts +2 -2
- package/packages/types/src/eval/evaluation.ts +3 -3
- package/src/app/[variants]/(main)/settings/apikey/features/ApiKey.tsx +2 -2
- package/src/server/routers/async/ragEval.ts +1 -1
- package/src/server/routers/lambda/apiKey.ts +3 -3
- package/src/server/routers/lambda/ragEval.ts +10 -10
- package/src/server/routers/tools/_helpers/scheduleToolCallReport.test.ts +589 -0
- package/src/services/ragEval.ts +10 -10
- package/src/store/chat/slices/aiChat/actions/StreamingHandler.ts +6 -5
- package/src/store/chat/slices/aiChat/actions/__tests__/StreamingHandler.test.ts +302 -0
- package/src/store/chat/slices/aiChat/actions/__tests__/streamingExecutor.test.ts +168 -0
- package/src/store/chat/slices/aiChat/actions/streamingExecutor.ts +11 -2
- package/src/store/chat/slices/aiChat/actions/types/streaming.ts +12 -8
- package/src/store/chat/slices/message/actions/optimisticUpdate.ts +1 -1
- package/src/store/library/slices/ragEval/actions/dataset.ts +3 -3
- package/src/store/library/slices/ragEval/actions/evaluation.ts +3 -3
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { StreamingHandler } from '../StreamingHandler';
|
|
4
|
+
import type { StreamChunk, StreamingCallbacks, StreamingContext } from '../types/streaming';
|
|
5
|
+
|
|
6
|
+
// Helper to create a mock streaming context
|
|
7
|
+
const createContext = (overrides: Partial<StreamingContext> = {}): StreamingContext => ({
|
|
8
|
+
agentId: 'test-agent',
|
|
9
|
+
messageId: 'test-message',
|
|
10
|
+
operationId: 'test-op',
|
|
11
|
+
...overrides,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
// Helper to create mock callbacks
|
|
15
|
+
const createCallbacks = (overrides: Partial<StreamingCallbacks> = {}): StreamingCallbacks => ({
|
|
16
|
+
onContentUpdate: vi.fn(),
|
|
17
|
+
onGroundingUpdate: vi.fn(),
|
|
18
|
+
onImagesUpdate: vi.fn(),
|
|
19
|
+
onReasoningComplete: vi.fn(),
|
|
20
|
+
onReasoningStart: vi.fn().mockReturnValue('reasoning-op-id'),
|
|
21
|
+
onReasoningUpdate: vi.fn(),
|
|
22
|
+
onToolCallsUpdate: vi.fn(),
|
|
23
|
+
toggleToolCallingStreaming: vi.fn(),
|
|
24
|
+
transformToolCalls: vi.fn().mockReturnValue([]),
|
|
25
|
+
uploadBase64Image: vi
|
|
26
|
+
.fn()
|
|
27
|
+
.mockResolvedValue({ id: 'img-id', url: 'https://uploaded.url/img.png' }),
|
|
28
|
+
...overrides,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('StreamingHandler', () => {
|
|
32
|
+
describe('content_part image handling', () => {
|
|
33
|
+
it('should pass contentMetadata with isMultimodal when content_part image chunks are received', () => {
|
|
34
|
+
const callbacks = createCallbacks();
|
|
35
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
36
|
+
|
|
37
|
+
// Send a text content_part
|
|
38
|
+
handler.handleChunk({
|
|
39
|
+
type: 'content_part',
|
|
40
|
+
partType: 'text',
|
|
41
|
+
content: 'Here is an image: ',
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
// Send an image content_part
|
|
45
|
+
handler.handleChunk({
|
|
46
|
+
type: 'content_part',
|
|
47
|
+
partType: 'image',
|
|
48
|
+
content: 'base64imagedata',
|
|
49
|
+
mimeType: 'image/png',
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The last onContentUpdate call should include contentMetadata
|
|
53
|
+
const lastCall = (callbacks.onContentUpdate as ReturnType<typeof vi.fn>).mock.calls.at(-1);
|
|
54
|
+
expect(lastCall).toBeDefined();
|
|
55
|
+
|
|
56
|
+
const [content, reasoning, contentMetadata] = lastCall!;
|
|
57
|
+
expect(content).toBe('Here is an image: ');
|
|
58
|
+
expect(contentMetadata).toBeDefined();
|
|
59
|
+
expect(contentMetadata.isMultimodal).toBe(true);
|
|
60
|
+
expect(contentMetadata.tempDisplayContent).toBeDefined();
|
|
61
|
+
// tempDisplayContent should be a serialized JSON string containing the parts
|
|
62
|
+
const parsed = JSON.parse(contentMetadata.tempDisplayContent);
|
|
63
|
+
expect(parsed).toHaveLength(2);
|
|
64
|
+
expect(parsed[0]).toEqual({ type: 'text', text: 'Here is an image: ' });
|
|
65
|
+
expect(parsed[1]).toEqual(
|
|
66
|
+
expect.objectContaining({
|
|
67
|
+
type: 'image',
|
|
68
|
+
image: expect.stringContaining('data:image/png;base64,'),
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('should NOT pass contentMetadata when only text content_part chunks are received', () => {
|
|
74
|
+
const callbacks = createCallbacks();
|
|
75
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
76
|
+
|
|
77
|
+
handler.handleChunk({
|
|
78
|
+
type: 'content_part',
|
|
79
|
+
partType: 'text',
|
|
80
|
+
content: 'Hello ',
|
|
81
|
+
});
|
|
82
|
+
handler.handleChunk({
|
|
83
|
+
type: 'content_part',
|
|
84
|
+
partType: 'text',
|
|
85
|
+
content: 'world',
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
const lastCall = (callbacks.onContentUpdate as ReturnType<typeof vi.fn>).mock.calls.at(-1);
|
|
89
|
+
expect(lastCall).toBeDefined();
|
|
90
|
+
|
|
91
|
+
const [content, _reasoning, contentMetadata] = lastCall!;
|
|
92
|
+
expect(content).toBe('Hello world');
|
|
93
|
+
// No contentMetadata when there are no images
|
|
94
|
+
expect(contentMetadata).toBeUndefined();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should include isMultimodal in final result metadata when content has images', async () => {
|
|
98
|
+
const callbacks = createCallbacks();
|
|
99
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
100
|
+
|
|
101
|
+
// Send mixed content
|
|
102
|
+
handler.handleChunk({
|
|
103
|
+
type: 'content_part',
|
|
104
|
+
partType: 'text',
|
|
105
|
+
content: 'A cat: ',
|
|
106
|
+
});
|
|
107
|
+
handler.handleChunk({
|
|
108
|
+
type: 'content_part',
|
|
109
|
+
partType: 'image',
|
|
110
|
+
content: 'base64catimage',
|
|
111
|
+
mimeType: 'image/jpeg',
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
const result = await handler.handleFinish({});
|
|
115
|
+
|
|
116
|
+
expect(result.metadata.isMultimodal).toBe(true);
|
|
117
|
+
// Content should be serialized JSON containing text + image parts
|
|
118
|
+
const parsed = JSON.parse(result.content);
|
|
119
|
+
expect(parsed).toHaveLength(2);
|
|
120
|
+
expect(parsed[0].type).toBe('text');
|
|
121
|
+
expect(parsed[1].type).toBe('image');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should NOT include isMultimodal in final result when only text content', async () => {
|
|
125
|
+
const callbacks = createCallbacks();
|
|
126
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
127
|
+
|
|
128
|
+
handler.handleChunk({ type: 'text', text: 'Hello world' });
|
|
129
|
+
|
|
130
|
+
const result = await handler.handleFinish({});
|
|
131
|
+
|
|
132
|
+
expect(result.metadata.isMultimodal).toBeUndefined();
|
|
133
|
+
expect(result.content).toBe('Hello world');
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe('text chunk handling', () => {
|
|
138
|
+
it('should accumulate text chunks and notify via onContentUpdate', () => {
|
|
139
|
+
const callbacks = createCallbacks();
|
|
140
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
141
|
+
|
|
142
|
+
handler.handleChunk({ type: 'text', text: 'Hello' });
|
|
143
|
+
handler.handleChunk({ type: 'text', text: ' World' });
|
|
144
|
+
|
|
145
|
+
expect(callbacks.onContentUpdate).toHaveBeenCalledTimes(2);
|
|
146
|
+
expect(handler.getOutput()).toBe('Hello World');
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe('reasoning chunk handling', () => {
|
|
151
|
+
it('should track reasoning content and start/end timing', () => {
|
|
152
|
+
const callbacks = createCallbacks();
|
|
153
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
154
|
+
|
|
155
|
+
// Reasoning starts
|
|
156
|
+
handler.handleChunk({ type: 'reasoning', text: 'Let me think...' });
|
|
157
|
+
expect(callbacks.onReasoningStart).toHaveBeenCalledTimes(1);
|
|
158
|
+
expect(callbacks.onReasoningUpdate).toHaveBeenCalledWith({ content: 'Let me think...' });
|
|
159
|
+
|
|
160
|
+
// Text ends reasoning
|
|
161
|
+
handler.handleChunk({ type: 'text', text: 'Answer' });
|
|
162
|
+
expect(handler.getThinkingDuration()).toBeDefined();
|
|
163
|
+
expect(handler.getThinkingDuration()).toBeGreaterThanOrEqual(0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should include reasoning in final result with signature', async () => {
|
|
167
|
+
const callbacks = createCallbacks();
|
|
168
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
169
|
+
|
|
170
|
+
handler.handleChunk({ type: 'reasoning', text: 'Thinking' });
|
|
171
|
+
handler.handleChunk({ type: 'text', text: 'Answer' });
|
|
172
|
+
|
|
173
|
+
const result = await handler.handleFinish({
|
|
174
|
+
reasoning: { content: 'Thinking', signature: 'test-sig' },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
expect(result.metadata.reasoning).toBeDefined();
|
|
178
|
+
expect(result.metadata.reasoning?.content).toBe('Thinking');
|
|
179
|
+
expect(result.metadata.reasoning?.signature).toBe('test-sig');
|
|
180
|
+
// Duration may be 0 in fast tests (which becomes undefined due to `0 && ...` check)
|
|
181
|
+
// So we just verify it's a number or undefined
|
|
182
|
+
expect(
|
|
183
|
+
result.metadata.reasoning?.duration === undefined ||
|
|
184
|
+
typeof result.metadata.reasoning?.duration === 'number',
|
|
185
|
+
).toBe(true);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
describe('reasoning_part with images', () => {
|
|
190
|
+
it('should handle reasoning_part image chunks and report isMultimodal', () => {
|
|
191
|
+
const callbacks = createCallbacks();
|
|
192
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
193
|
+
|
|
194
|
+
handler.handleChunk({
|
|
195
|
+
type: 'reasoning_part',
|
|
196
|
+
partType: 'text',
|
|
197
|
+
content: 'Thinking about image: ',
|
|
198
|
+
});
|
|
199
|
+
handler.handleChunk({
|
|
200
|
+
type: 'reasoning_part',
|
|
201
|
+
partType: 'image',
|
|
202
|
+
content: 'base64data',
|
|
203
|
+
mimeType: 'image/png',
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const lastCall = (callbacks.onReasoningUpdate as ReturnType<typeof vi.fn>).mock.calls.at(-1);
|
|
207
|
+
expect(lastCall).toBeDefined();
|
|
208
|
+
expect(lastCall![0].isMultimodal).toBe(true);
|
|
209
|
+
expect(lastCall![0].tempDisplayContent).toBeDefined();
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe('tool_calls handling', () => {
|
|
214
|
+
it('should mark as function call when tool_calls chunk is received', () => {
|
|
215
|
+
const callbacks = createCallbacks();
|
|
216
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
217
|
+
|
|
218
|
+
handler.handleChunk({
|
|
219
|
+
type: 'tool_calls',
|
|
220
|
+
tool_calls: [
|
|
221
|
+
{ id: 'tool-1', type: 'function', function: { name: 'test', arguments: '{}' } },
|
|
222
|
+
],
|
|
223
|
+
isAnimationActives: [true],
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
expect(handler.getIsFunctionCall()).toBe(true);
|
|
227
|
+
expect(callbacks.toggleToolCallingStreaming).toHaveBeenCalledWith('test-message', [true]);
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
describe('handleFinish with tool calls', () => {
|
|
232
|
+
it('should process final tool calls and set isFunctionCall', async () => {
|
|
233
|
+
const callbacks = createCallbacks({
|
|
234
|
+
transformToolCalls: vi.fn().mockReturnValue([{ identifier: 'test', arguments: '{}' }]),
|
|
235
|
+
});
|
|
236
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
237
|
+
|
|
238
|
+
const result = await handler.handleFinish({
|
|
239
|
+
toolCalls: [{ id: 'tool-1', type: 'function', function: { name: 'test', arguments: '' } }],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(result.isFunctionCall).toBe(true);
|
|
243
|
+
expect(result.tools).toBeDefined();
|
|
244
|
+
expect(result.tools).toHaveLength(1);
|
|
245
|
+
});
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
describe('base64_image handling', () => {
|
|
249
|
+
it('should dispatch images immediately and upload async', async () => {
|
|
250
|
+
const callbacks = createCallbacks();
|
|
251
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
252
|
+
|
|
253
|
+
handler.handleChunk({
|
|
254
|
+
type: 'base64_image',
|
|
255
|
+
image: { id: 'img-1', data: 'base64data' },
|
|
256
|
+
images: [{ id: 'img-1', data: 'base64data' }],
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
expect(callbacks.onImagesUpdate).toHaveBeenCalledWith([
|
|
260
|
+
{ alt: 'img-1', id: 'img-1', url: 'base64data' },
|
|
261
|
+
]);
|
|
262
|
+
|
|
263
|
+
// After finish, uploaded images should be in the result
|
|
264
|
+
const result = await handler.handleFinish({});
|
|
265
|
+
expect(result.metadata.imageList).toBeDefined();
|
|
266
|
+
expect(result.metadata.imageList).toHaveLength(1);
|
|
267
|
+
expect(result.metadata.imageList![0].url).toBe('https://uploaded.url/img.png');
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe('grounding handling', () => {
|
|
272
|
+
it('should forward citations to onGroundingUpdate', () => {
|
|
273
|
+
const callbacks = createCallbacks();
|
|
274
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
275
|
+
|
|
276
|
+
handler.handleChunk({
|
|
277
|
+
type: 'grounding',
|
|
278
|
+
grounding: {
|
|
279
|
+
citations: [{ url: 'https://example.com', title: 'Example' }],
|
|
280
|
+
searchQueries: ['query'],
|
|
281
|
+
},
|
|
282
|
+
} as any);
|
|
283
|
+
|
|
284
|
+
expect(callbacks.onGroundingUpdate).toHaveBeenCalledWith({
|
|
285
|
+
citations: [{ url: 'https://example.com', title: 'Example' }],
|
|
286
|
+
searchQueries: ['query'],
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
it('should skip grounding when no citations', () => {
|
|
291
|
+
const callbacks = createCallbacks();
|
|
292
|
+
const handler = new StreamingHandler(createContext(), callbacks);
|
|
293
|
+
|
|
294
|
+
handler.handleChunk({
|
|
295
|
+
type: 'grounding',
|
|
296
|
+
grounding: { citations: [], searchQueries: [] },
|
|
297
|
+
} as any);
|
|
298
|
+
|
|
299
|
+
expect(callbacks.onGroundingUpdate).not.toHaveBeenCalled();
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -1791,6 +1791,174 @@ describe('StreamingExecutor actions', () => {
|
|
|
1791
1791
|
});
|
|
1792
1792
|
});
|
|
1793
1793
|
|
|
1794
|
+
describe('content_part multimodal streaming', () => {
|
|
1795
|
+
it('should dispatch isMultimodal metadata when content_part image chunks are received', async () => {
|
|
1796
|
+
// Mock file store to prevent upload from hanging
|
|
1797
|
+
const fileStoreMod = await import('@/store/file/store');
|
|
1798
|
+
vi.spyOn(fileStoreMod, 'getFileStoreState').mockReturnValue({
|
|
1799
|
+
uploadBase64FileWithProgress: vi
|
|
1800
|
+
.fn()
|
|
1801
|
+
.mockResolvedValue({ id: 'img-id', url: 'https://cdn.example.com/img.png' }),
|
|
1802
|
+
} as any);
|
|
1803
|
+
|
|
1804
|
+
const { result } = renderHook(() => useChatStore());
|
|
1805
|
+
const messages = [createMockMessage({ role: 'user' })];
|
|
1806
|
+
const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
|
|
1807
|
+
|
|
1808
|
+
// Create operation for this test
|
|
1809
|
+
const { operationId } = result.current.startOperation({
|
|
1810
|
+
type: 'execAgentRuntime',
|
|
1811
|
+
context: {
|
|
1812
|
+
agentId: TEST_IDS.SESSION_ID,
|
|
1813
|
+
topicId: null,
|
|
1814
|
+
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
|
|
1815
|
+
},
|
|
1816
|
+
label: 'Test AI Generation',
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
const streamSpy = vi
|
|
1820
|
+
.spyOn(chatService, 'createAssistantMessageStream')
|
|
1821
|
+
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
|
|
1822
|
+
// Send text content_part
|
|
1823
|
+
await onMessageHandle?.({
|
|
1824
|
+
type: 'content_part',
|
|
1825
|
+
partType: 'text',
|
|
1826
|
+
content: 'Here is a cat: ',
|
|
1827
|
+
} as any);
|
|
1828
|
+
// Send image content_part
|
|
1829
|
+
await onMessageHandle?.({
|
|
1830
|
+
type: 'content_part',
|
|
1831
|
+
partType: 'image',
|
|
1832
|
+
content: 'base64catimage',
|
|
1833
|
+
mimeType: 'image/jpeg',
|
|
1834
|
+
} as any);
|
|
1835
|
+
await onFinish?.('Here is a cat: ', {} as any);
|
|
1836
|
+
});
|
|
1837
|
+
|
|
1838
|
+
await act(async () => {
|
|
1839
|
+
await result.current.internal_fetchAIChatMessage({
|
|
1840
|
+
messages,
|
|
1841
|
+
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
|
|
1842
|
+
model: 'gemini-3-pro-image-preview',
|
|
1843
|
+
provider: 'google',
|
|
1844
|
+
operationId,
|
|
1845
|
+
agentConfig: createMockResolvedAgentConfig(),
|
|
1846
|
+
});
|
|
1847
|
+
});
|
|
1848
|
+
|
|
1849
|
+
// Find dispatch calls with metadata.isMultimodal
|
|
1850
|
+
const multimodalDispatches = dispatchSpy.mock.calls.filter((call) => {
|
|
1851
|
+
const dispatch = call[0];
|
|
1852
|
+
return (
|
|
1853
|
+
dispatch?.type === 'updateMessage' &&
|
|
1854
|
+
'value' in dispatch &&
|
|
1855
|
+
dispatch.value?.metadata?.isMultimodal === true
|
|
1856
|
+
);
|
|
1857
|
+
});
|
|
1858
|
+
|
|
1859
|
+
// Should have dispatched at least once with isMultimodal metadata during streaming
|
|
1860
|
+
expect(multimodalDispatches.length).toBeGreaterThanOrEqual(1);
|
|
1861
|
+
|
|
1862
|
+
// Verify the dispatch includes tempDisplayContent
|
|
1863
|
+
const firstMultimodalDispatch = multimodalDispatches[0][0] as any;
|
|
1864
|
+
expect(firstMultimodalDispatch.value.metadata.tempDisplayContent).toBeDefined();
|
|
1865
|
+
|
|
1866
|
+
streamSpy.mockRestore();
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
it('should call optimisticUpdateMessageContent with isMultimodal metadata for multimodal content', async () => {
|
|
1870
|
+
// Mock file store to prevent upload from hanging
|
|
1871
|
+
const fileStoreMod = await import('@/store/file/store');
|
|
1872
|
+
vi.spyOn(fileStoreMod, 'getFileStoreState').mockReturnValue({
|
|
1873
|
+
uploadBase64FileWithProgress: vi
|
|
1874
|
+
.fn()
|
|
1875
|
+
.mockResolvedValue({ id: 'img-id', url: 'https://cdn.example.com/img.png' }),
|
|
1876
|
+
} as any);
|
|
1877
|
+
|
|
1878
|
+
const { result } = renderHook(() => useChatStore());
|
|
1879
|
+
const messages = [createMockMessage({ role: 'user' })];
|
|
1880
|
+
const updateContentSpy = vi.spyOn(result.current, 'optimisticUpdateMessageContent');
|
|
1881
|
+
|
|
1882
|
+
const streamSpy = vi
|
|
1883
|
+
.spyOn(chatService, 'createAssistantMessageStream')
|
|
1884
|
+
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
|
|
1885
|
+
await onMessageHandle?.({
|
|
1886
|
+
type: 'content_part',
|
|
1887
|
+
partType: 'text',
|
|
1888
|
+
content: 'Generated image: ',
|
|
1889
|
+
} as any);
|
|
1890
|
+
await onMessageHandle?.({
|
|
1891
|
+
type: 'content_part',
|
|
1892
|
+
partType: 'image',
|
|
1893
|
+
content: 'base64imagedata',
|
|
1894
|
+
mimeType: 'image/png',
|
|
1895
|
+
} as any);
|
|
1896
|
+
await onFinish?.('Generated image: ', {} as any);
|
|
1897
|
+
});
|
|
1898
|
+
|
|
1899
|
+
await act(async () => {
|
|
1900
|
+
await result.current.internal_fetchAIChatMessage({
|
|
1901
|
+
messages,
|
|
1902
|
+
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
|
|
1903
|
+
model: 'gemini-3-pro-image-preview',
|
|
1904
|
+
provider: 'google',
|
|
1905
|
+
agentConfig: createMockResolvedAgentConfig(),
|
|
1906
|
+
});
|
|
1907
|
+
});
|
|
1908
|
+
|
|
1909
|
+
// optimisticUpdateMessageContent should be called with metadata containing isMultimodal
|
|
1910
|
+
expect(updateContentSpy).toHaveBeenCalledWith(
|
|
1911
|
+
TEST_IDS.ASSISTANT_MESSAGE_ID,
|
|
1912
|
+
expect.any(String), // serialized JSON content
|
|
1913
|
+
expect.objectContaining({
|
|
1914
|
+
metadata: expect.objectContaining({
|
|
1915
|
+
isMultimodal: true,
|
|
1916
|
+
}),
|
|
1917
|
+
}),
|
|
1918
|
+
expect.any(Object),
|
|
1919
|
+
);
|
|
1920
|
+
|
|
1921
|
+
streamSpy.mockRestore();
|
|
1922
|
+
});
|
|
1923
|
+
|
|
1924
|
+
it('should NOT dispatch isMultimodal metadata for plain text streaming', async () => {
|
|
1925
|
+
const { result } = renderHook(() => useChatStore());
|
|
1926
|
+
const messages = [createMockMessage({ role: 'user' })];
|
|
1927
|
+
const dispatchSpy = vi.spyOn(result.current, 'internal_dispatchMessage');
|
|
1928
|
+
|
|
1929
|
+
const streamSpy = vi
|
|
1930
|
+
.spyOn(chatService, 'createAssistantMessageStream')
|
|
1931
|
+
.mockImplementation(async ({ onMessageHandle, onFinish }) => {
|
|
1932
|
+
await onMessageHandle?.({ type: 'text', text: 'Hello World' } as any);
|
|
1933
|
+
await onFinish?.('Hello World', {} as any);
|
|
1934
|
+
});
|
|
1935
|
+
|
|
1936
|
+
await act(async () => {
|
|
1937
|
+
await result.current.internal_fetchAIChatMessage({
|
|
1938
|
+
messages,
|
|
1939
|
+
messageId: TEST_IDS.ASSISTANT_MESSAGE_ID,
|
|
1940
|
+
model: 'gpt-4o-mini',
|
|
1941
|
+
provider: 'openai',
|
|
1942
|
+
agentConfig: createMockResolvedAgentConfig(),
|
|
1943
|
+
});
|
|
1944
|
+
});
|
|
1945
|
+
|
|
1946
|
+
// No dispatch should contain isMultimodal metadata
|
|
1947
|
+
const multimodalDispatches = dispatchSpy.mock.calls.filter((call) => {
|
|
1948
|
+
const dispatch = call[0];
|
|
1949
|
+
return (
|
|
1950
|
+
dispatch?.type === 'updateMessage' &&
|
|
1951
|
+
'value' in dispatch &&
|
|
1952
|
+
dispatch.value?.metadata?.isMultimodal === true
|
|
1953
|
+
);
|
|
1954
|
+
});
|
|
1955
|
+
|
|
1956
|
+
expect(multimodalDispatches).toHaveLength(0);
|
|
1957
|
+
|
|
1958
|
+
streamSpy.mockRestore();
|
|
1959
|
+
});
|
|
1960
|
+
});
|
|
1961
|
+
|
|
1794
1962
|
describe('isSubTask filtering', () => {
|
|
1795
1963
|
it('should filter out lobe-gtd tools when isSubTask is true', async () => {
|
|
1796
1964
|
const { result } = renderHook(() => useChatStore());
|
|
@@ -420,12 +420,21 @@ export const streamingExecutor: StateCreator<
|
|
|
420
420
|
const handler = new StreamingHandler(
|
|
421
421
|
{ messageId, operationId, agentId, groupId, topicId },
|
|
422
422
|
{
|
|
423
|
-
onContentUpdate: (content, reasoning) => {
|
|
423
|
+
onContentUpdate: (content, reasoning, contentMetadata) => {
|
|
424
424
|
internal_dispatchMessage(
|
|
425
425
|
{
|
|
426
426
|
id: messageId,
|
|
427
427
|
type: 'updateMessage',
|
|
428
|
-
value: {
|
|
428
|
+
value: {
|
|
429
|
+
content,
|
|
430
|
+
reasoning,
|
|
431
|
+
...(contentMetadata && {
|
|
432
|
+
metadata: {
|
|
433
|
+
isMultimodal: contentMetadata.isMultimodal,
|
|
434
|
+
tempDisplayContent: contentMetadata.tempDisplayContent,
|
|
435
|
+
},
|
|
436
|
+
}),
|
|
437
|
+
},
|
|
429
438
|
},
|
|
430
439
|
{ operationId },
|
|
431
440
|
);
|
|
@@ -40,7 +40,11 @@ export type GroundingData = GroundingSearch;
|
|
|
40
40
|
*/
|
|
41
41
|
export interface StreamingCallbacks {
|
|
42
42
|
/** Content update */
|
|
43
|
-
onContentUpdate: (
|
|
43
|
+
onContentUpdate: (
|
|
44
|
+
content: string,
|
|
45
|
+
reasoning?: ReasoningState,
|
|
46
|
+
contentMetadata?: { isMultimodal: boolean; tempDisplayContent: string },
|
|
47
|
+
) => void;
|
|
44
48
|
/** Search grounding update */
|
|
45
49
|
onGroundingUpdate: (grounding: GroundingData) => void;
|
|
46
50
|
/** Image list update */
|
|
@@ -101,19 +105,19 @@ export interface StreamingResult {
|
|
|
101
105
|
* Stream chunk types
|
|
102
106
|
*/
|
|
103
107
|
export type StreamChunk =
|
|
104
|
-
| { text: string
|
|
105
|
-
| { text: string
|
|
106
|
-
| { content: string; mimeType?: string
|
|
107
|
-
| { content: string; mimeType?: string
|
|
108
|
+
| { text: string; type: 'text' }
|
|
109
|
+
| { text: string; type: 'reasoning' }
|
|
110
|
+
| { content: string; mimeType?: string; partType: 'text' | 'image'; type: 'reasoning_part' }
|
|
111
|
+
| { content: string; mimeType?: string; partType: 'text' | 'image'; type: 'content_part' }
|
|
108
112
|
| {
|
|
109
113
|
isAnimationActives?: boolean[];
|
|
110
114
|
tool_calls: MessageToolCall[];
|
|
111
115
|
type: 'tool_calls';
|
|
112
116
|
}
|
|
113
|
-
| { grounding?: GroundingData
|
|
117
|
+
| { grounding?: GroundingData; type: 'grounding' }
|
|
114
118
|
| {
|
|
115
|
-
image: { data: string
|
|
116
|
-
images: { data: string
|
|
119
|
+
image: { data: string; id: string };
|
|
120
|
+
images: { data: string; id: string }[];
|
|
117
121
|
type: 'base64_image';
|
|
118
122
|
}
|
|
119
123
|
| { type: 'stop' };
|
|
@@ -19,10 +19,10 @@ const FETCH_DATASET_RECORD_KEY = 'FETCH_DATASET_RECORD_KEY';
|
|
|
19
19
|
export interface RAGEvalDatasetAction {
|
|
20
20
|
createNewDataset: (params: CreateNewEvalDatasets) => Promise<void>;
|
|
21
21
|
|
|
22
|
-
importDataset: (file: File, datasetId:
|
|
22
|
+
importDataset: (file: File, datasetId: string) => Promise<void>;
|
|
23
23
|
refreshDatasetList: () => Promise<void>;
|
|
24
|
-
removeDataset: (id:
|
|
25
|
-
useFetchDatasetRecords: (datasetId:
|
|
24
|
+
removeDataset: (id: string) => Promise<void>;
|
|
25
|
+
useFetchDatasetRecords: (datasetId: string | null) => SWRResponse<EvalDatasetRecord[]>;
|
|
26
26
|
useFetchDatasets: (knowledgeBaseId: string) => SWRResponse<RAGEvalDataSetItem[]>;
|
|
27
27
|
}
|
|
28
28
|
|
|
@@ -9,13 +9,13 @@ import { type KnowledgeBaseStore } from '@/store/library/store';
|
|
|
9
9
|
const FETCH_EVALUATION_LIST_KEY = 'FETCH_EVALUATION_LIST_KEY';
|
|
10
10
|
|
|
11
11
|
export interface RAGEvalEvaluationAction {
|
|
12
|
-
checkEvaluationStatus: (id:
|
|
12
|
+
checkEvaluationStatus: (id: string) => Promise<void>;
|
|
13
13
|
|
|
14
14
|
createNewEvaluation: (params: CreateNewEvalEvaluation) => Promise<void>;
|
|
15
15
|
refreshEvaluationList: () => Promise<void>;
|
|
16
16
|
|
|
17
|
-
removeEvaluation: (id:
|
|
18
|
-
runEvaluation: (id:
|
|
17
|
+
removeEvaluation: (id: string) => Promise<void>;
|
|
18
|
+
runEvaluation: (id: string) => Promise<void>;
|
|
19
19
|
|
|
20
20
|
useFetchEvaluationList: (knowledgeBaseId: string) => SWRResponse<RAGEvalDataSetItem[]>;
|
|
21
21
|
}
|