@lobehub/lobehub 2.0.0-next.266 → 2.0.0-next.268
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/.cursor/rules/microcopy-cn.mdc +75 -63
- package/.cursor/rules/microcopy-en.mdc +4 -8
- package/CHANGELOG.md +50 -0
- package/README.md +8 -8
- package/README.zh-CN.md +8 -8
- package/apps/desktop/src/main/locales/default/common.ts +2 -2
- package/changelog/v1.json +10 -0
- package/docs/development/database-schema.dbml +4 -0
- package/e2e/CLAUDE.md +43 -81
- package/e2e/cucumber.config.js +1 -0
- package/e2e/docs/local-setup.md +67 -219
- package/e2e/scripts/setup.ts +529 -0
- package/e2e/src/features/home/sidebarAgent.feature +62 -0
- package/e2e/src/features/home/sidebarGroup.feature +62 -0
- package/e2e/src/features/page/README.md +118 -0
- package/e2e/src/features/page/crud.feature +62 -0
- package/e2e/src/features/page/editor-content.feature +93 -0
- package/e2e/src/features/page/editor-meta.feature +60 -0
- package/e2e/src/steps/agent/conversation.steps.ts +4 -4
- package/e2e/src/steps/home/sidebarAgent.steps.ts +370 -0
- package/e2e/src/steps/home/sidebarGroup.steps.ts +168 -0
- package/e2e/src/steps/hooks.ts +4 -0
- package/e2e/src/steps/page/editor-content.steps.ts +344 -0
- package/e2e/src/steps/page/editor-meta.steps.ts +410 -0
- package/e2e/src/steps/page/page-crud.steps.ts +363 -0
- package/e2e/src/support/world.ts +12 -0
- package/locales/ar/file.json +2 -0
- package/locales/bg-BG/file.json +2 -0
- package/locales/de-DE/file.json +2 -0
- package/locales/en-US/auth.json +1 -1
- package/locales/en-US/file.json +2 -0
- package/locales/en-US/metadata.json +2 -2
- package/locales/es-ES/file.json +2 -0
- package/locales/fa-IR/file.json +2 -0
- package/locales/fr-FR/file.json +2 -0
- package/locales/it-IT/file.json +2 -0
- package/locales/ja-JP/file.json +2 -0
- package/locales/ko-KR/file.json +2 -0
- package/locales/nl-NL/file.json +2 -0
- package/locales/pl-PL/file.json +2 -0
- package/locales/pt-BR/file.json +2 -0
- package/locales/ru-RU/file.json +2 -0
- package/locales/tr-TR/file.json +2 -0
- package/locales/vi-VN/file.json +2 -0
- package/locales/zh-CN/file.json +2 -0
- package/locales/zh-TW/file.json +2 -0
- package/package.json +3 -3
- package/packages/builtin-agents/src/agents/agent-builder/index.ts +1 -1
- package/packages/builtin-agents/src/agents/group-agent-builder/index.ts +1 -1
- package/packages/builtin-agents/src/agents/page-agent/index.ts +1 -1
- package/packages/const/src/settings/group.ts +0 -10
- package/packages/database/migrations/0068_update_group_data.sql +4 -0
- package/packages/database/migrations/meta/0068_snapshot.json +9588 -0
- package/packages/database/migrations/meta/_journal.json +7 -0
- package/packages/database/src/models/__tests__/chatGroup.test.ts +5 -7
- package/packages/database/src/models/__tests__/knowledgeBase.test.ts +185 -0
- package/packages/database/src/models/knowledgeBase.ts +67 -3
- package/packages/database/src/repositories/agentGroup/index.test.ts +23 -29
- package/packages/database/src/repositories/agentGroup/index.ts +4 -9
- package/packages/database/src/repositories/knowledge/index.ts +3 -3
- package/packages/database/src/schemas/chatGroup.ts +4 -3
- package/packages/database/src/types/chatGroup.ts +0 -7
- package/packages/types/src/agentGroup/index.ts +30 -9
- package/packages/utils/src/multimodalContent.test.ts +302 -0
- package/packages/utils/src/server/__tests__/sse.test.ts +353 -0
- package/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/Editing.tsx +4 -11
- package/src/app/[variants]/(main)/home/_layout/Body/Agent/List/AgentGroupItem/index.tsx +3 -3
- package/src/app/[variants]/(main)/home/_layout/Body/Agent/ModalProvider.tsx +9 -32
- package/src/app/[variants]/(main)/home/_layout/hooks/useCreateMenuItems.tsx +3 -37
- package/src/app/[variants]/(main)/home/_layout/hooks/useSessionGroupMenuItems.tsx +7 -53
- package/src/app/[variants]/(main)/home/features/RecentPage/List.tsx +2 -1
- package/src/app/[variants]/(main)/resource/features/DndContextWrapper.tsx +1 -1
- package/src/app/[variants]/(main)/resource/library/_layout/Sidebar.tsx +2 -2
- package/src/app/[variants]/(main)/resource/library/features/LibraryMenu.tsx +2 -2
- package/src/app/[variants]/(mobile)/chat/settings/features/SettingButton.tsx +2 -12
- package/src/components/ChatGroupWizard/ChatGroupWizard.tsx +5 -27
- package/src/components/DragUpload/index.tsx +24 -27
- package/src/components/MemberSelectionModal/MemberSelectionModal.tsx +2 -11
- package/src/features/ChatInput/ActionBar/Params/Controls.tsx +42 -7
- package/src/features/CommandMenu/useCommandMenu.ts +4 -14
- package/src/features/ResourceManager/components/Editor/index.tsx +2 -3
- package/src/features/ResourceManager/components/Explorer/Header/index.tsx +13 -17
- package/src/features/ResourceManager/components/Explorer/ItemDropdown/useFileItemDropdown.tsx +1 -1
- package/src/features/ResourceManager/components/Explorer/ListView/ListItem/TruncatedFileName.tsx +130 -0
- package/src/features/ResourceManager/components/Explorer/ListView/ListItem/index.tsx +36 -4
- package/src/features/ResourceManager/components/Explorer/ListView/Skeleton.tsx +4 -3
- package/src/features/ResourceManager/components/Explorer/ListView/index.tsx +58 -2
- package/src/features/ResourceManager/components/Explorer/MasonryView/index.tsx +58 -6
- package/src/features/ResourceManager/components/Explorer/MoveToFolderModal.tsx +2 -5
- package/src/features/ResourceManager/components/Explorer/ToolBar/BatchActionsDropdown.tsx +9 -5
- package/src/features/ResourceManager/components/Explorer/index.tsx +11 -56
- package/src/features/ResourceManager/components/Header/AddButton.tsx +5 -6
- package/src/features/ResourceManager/components/LibraryHierarchy/HierarchyNode.tsx +382 -0
- package/src/features/ResourceManager/components/LibraryHierarchy/index.tsx +396 -0
- package/src/features/ResourceManager/components/LibraryHierarchy/styles.ts +19 -0
- package/src/features/ResourceManager/components/LibraryHierarchy/treeState.ts +178 -0
- package/src/features/ResourceManager/components/LibraryHierarchy/types.ts +10 -0
- package/src/features/ResourceManager/index.tsx +3 -0
- package/src/layout/GlobalProvider/GroupWizardProvider.tsx +6 -29
- package/src/locales/default/auth.ts +1 -1
- package/src/locales/default/file.ts +2 -0
- package/src/locales/default/metadata.ts +2 -2
- package/src/server/modules/AgentRuntime/AgentRuntimeCoordinator.ts +30 -30
- package/src/server/modules/AgentRuntime/AgentStateManager.ts +23 -23
- package/src/server/modules/AgentRuntime/InMemoryAgentStateManager.ts +16 -16
- package/src/server/modules/AgentRuntime/InMemoryStreamEventManager.ts +13 -13
- package/src/server/modules/AgentRuntime/RuntimeExecutors.ts +2 -2
- package/src/server/modules/AgentRuntime/StreamEventManager.ts +18 -18
- package/src/server/modules/AgentRuntime/types.ts +21 -21
- package/src/server/routers/lambda/__tests__/agentGroup.test.ts +8 -8
- package/src/server/routers/lambda/agentGroup.ts +10 -12
- package/src/server/services/document/index.ts +1 -0
- package/src/store/agentGroup/slices/curd.test.ts +4 -4
- package/src/store/file/slices/fileManager/action.ts +12 -4
- package/src/store/home/slices/homeInput/action.ts +0 -3
- package/src/store/home/slices/sidebarUI/action.ts +9 -0
- package/src/store/session/slices/session/action.ts +5 -9
- package/src/app/[variants]/(mobile)/chat/settings/features/AgentTeamSettings/index.tsx +0 -95
- package/src/features/GroupChatSettings/AgentCard.tsx +0 -154
- package/src/features/GroupChatSettings/AgentTeamChatSettings.tsx +0 -179
- package/src/features/GroupChatSettings/AgentTeamMembersSettings.tsx +0 -244
- package/src/features/GroupChatSettings/AgentTeamMetaSettings.tsx +0 -94
- package/src/features/GroupChatSettings/AgentTeamSettings.tsx +0 -54
- package/src/features/GroupChatSettings/GroupCategory/index.tsx +0 -30
- package/src/features/GroupChatSettings/GroupCategory/useGroupCategory.tsx +0 -42
- package/src/features/GroupChatSettings/GroupChatSettingsProvider.tsx +0 -19
- package/src/features/GroupChatSettings/HostMemberCard.tsx +0 -113
- package/src/features/GroupChatSettings/StoreUpdater.tsx +0 -34
- package/src/features/GroupChatSettings/hooks/useGroupChatSettings.ts +0 -25
- package/src/features/GroupChatSettings/index.ts +0 -16
- package/src/features/GroupChatSettings/store/action.ts +0 -105
- package/src/features/GroupChatSettings/store/index.ts +0 -18
- package/src/features/GroupChatSettings/store/initialState.ts +0 -23
- package/src/features/GroupChatSettings/store/selectors.ts +0 -13
- package/src/features/ResourceManager/components/Tree/index.tsx +0 -883
- /package/src/features/ResourceManager/components/{Tree → LibraryHierarchy}/TreeSkeleton.tsx +0 -0
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
import { MessageContentPart } from '@lobechat/types';
|
|
2
|
+
import { describe, expect, it } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { deserializeParts, serializePartsForStorage } from './multimodalContent';
|
|
5
|
+
|
|
6
|
+
describe('multimodalContent', () => {
|
|
7
|
+
describe('serializePartsForStorage', () => {
|
|
8
|
+
it('should serialize text content part to JSON string', () => {
|
|
9
|
+
const parts: MessageContentPart[] = [
|
|
10
|
+
{
|
|
11
|
+
type: 'text',
|
|
12
|
+
text: 'Hello, world!',
|
|
13
|
+
},
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
const result = serializePartsForStorage(parts);
|
|
17
|
+
|
|
18
|
+
expect(result).toBe('[{"type":"text","text":"Hello, world!"}]');
|
|
19
|
+
expect(typeof result).toBe('string');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('should serialize image content part to JSON string', () => {
|
|
23
|
+
const parts: MessageContentPart[] = [
|
|
24
|
+
{
|
|
25
|
+
type: 'image',
|
|
26
|
+
image: 'data:image/png;base64,iVBORw0KGgoAAAANS',
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const result = serializePartsForStorage(parts);
|
|
31
|
+
|
|
32
|
+
expect(result).toBe('[{"type":"image","image":"data:image/png;base64,iVBORw0KGgoAAAANS"}]');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should serialize multiple content parts to JSON string', () => {
|
|
36
|
+
const parts: MessageContentPart[] = [
|
|
37
|
+
{
|
|
38
|
+
type: 'text',
|
|
39
|
+
text: 'Check this image:',
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
type: 'image',
|
|
43
|
+
image: 'data:image/png;base64,abc123',
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
type: 'text',
|
|
47
|
+
text: 'What do you think?',
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const result = serializePartsForStorage(parts);
|
|
52
|
+
const parsed = JSON.parse(result);
|
|
53
|
+
|
|
54
|
+
expect(Array.isArray(parsed)).toBe(true);
|
|
55
|
+
expect(parsed).toHaveLength(3);
|
|
56
|
+
expect(parsed[0].type).toBe('text');
|
|
57
|
+
expect(parsed[1].type).toBe('image');
|
|
58
|
+
expect(parsed[2].type).toBe('text');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should serialize content parts with thoughtSignature', () => {
|
|
62
|
+
const parts: MessageContentPart[] = [
|
|
63
|
+
{
|
|
64
|
+
type: 'text',
|
|
65
|
+
text: 'Reasoning content',
|
|
66
|
+
thoughtSignature: 'sig-123',
|
|
67
|
+
},
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
const result = serializePartsForStorage(parts);
|
|
71
|
+
const parsed = JSON.parse(result);
|
|
72
|
+
|
|
73
|
+
expect(parsed[0].thoughtSignature).toBe('sig-123');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('should handle empty array', () => {
|
|
77
|
+
const parts: MessageContentPart[] = [];
|
|
78
|
+
|
|
79
|
+
const result = serializePartsForStorage(parts);
|
|
80
|
+
|
|
81
|
+
expect(result).toBe('[]');
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should handle special characters in text', () => {
|
|
85
|
+
const parts: MessageContentPart[] = [
|
|
86
|
+
{
|
|
87
|
+
type: 'text',
|
|
88
|
+
text: 'Hello "world"!\nNew line\tTab\r\nWindows line',
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const result = serializePartsForStorage(parts);
|
|
93
|
+
const parsed = JSON.parse(result);
|
|
94
|
+
|
|
95
|
+
expect(parsed[0].text).toBe('Hello "world"!\nNew line\tTab\r\nWindows line');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should handle unicode characters', () => {
|
|
99
|
+
const parts: MessageContentPart[] = [
|
|
100
|
+
{
|
|
101
|
+
type: 'text',
|
|
102
|
+
text: '你好世界 🌍 مرحبا بالعالم',
|
|
103
|
+
},
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
const result = serializePartsForStorage(parts);
|
|
107
|
+
const parsed = JSON.parse(result);
|
|
108
|
+
|
|
109
|
+
expect(parsed[0].text).toBe('你好世界 🌍 مرحبا بالعالم');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('deserializeParts', () => {
|
|
114
|
+
it('should deserialize valid JSON string to content parts', () => {
|
|
115
|
+
const jsonString = '[{"type":"text","text":"Hello, world!"}]';
|
|
116
|
+
|
|
117
|
+
const result = deserializeParts(jsonString);
|
|
118
|
+
|
|
119
|
+
expect(result).not.toBeNull();
|
|
120
|
+
expect(Array.isArray(result)).toBe(true);
|
|
121
|
+
expect(result).toHaveLength(1);
|
|
122
|
+
expect(result![0].type).toBe('text');
|
|
123
|
+
if (result![0].type === 'text') {
|
|
124
|
+
expect(result![0].text).toBe('Hello, world!');
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should deserialize multiple content parts', () => {
|
|
129
|
+
const jsonString =
|
|
130
|
+
'[{"type":"text","text":"Check this:"},{"type":"image","image":"data:image/png"}]';
|
|
131
|
+
|
|
132
|
+
const result = deserializeParts(jsonString);
|
|
133
|
+
|
|
134
|
+
expect(result).not.toBeNull();
|
|
135
|
+
expect(result).toHaveLength(2);
|
|
136
|
+
expect(result![0].type).toBe('text');
|
|
137
|
+
expect(result![1].type).toBe('image');
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('should deserialize content parts with thoughtSignature', () => {
|
|
141
|
+
const jsonString = '[{"type":"text","text":"Reasoning","thoughtSignature":"sig-123"}]';
|
|
142
|
+
|
|
143
|
+
const result = deserializeParts(jsonString);
|
|
144
|
+
|
|
145
|
+
expect(result).not.toBeNull();
|
|
146
|
+
if (result![0].type === 'text') {
|
|
147
|
+
expect(result![0].thoughtSignature).toBe('sig-123');
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('should return null for invalid JSON', () => {
|
|
152
|
+
const invalidJSON = '{not valid json}';
|
|
153
|
+
|
|
154
|
+
const result = deserializeParts(invalidJSON);
|
|
155
|
+
|
|
156
|
+
expect(result).toBeNull();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it('should return null for empty string', () => {
|
|
160
|
+
const result = deserializeParts('');
|
|
161
|
+
|
|
162
|
+
expect(result).toBeNull();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should return null for plain text string', () => {
|
|
166
|
+
const plainText = 'Just a plain text message';
|
|
167
|
+
|
|
168
|
+
const result = deserializeParts(plainText);
|
|
169
|
+
|
|
170
|
+
expect(result).toBeNull();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should return null for empty array', () => {
|
|
174
|
+
const emptyArray = '[]';
|
|
175
|
+
|
|
176
|
+
const result = deserializeParts(emptyArray);
|
|
177
|
+
|
|
178
|
+
expect(result).toBeNull();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('should return null for array without type property', () => {
|
|
182
|
+
const invalidParts = '[{"text":"Hello"}]';
|
|
183
|
+
|
|
184
|
+
const result = deserializeParts(invalidParts);
|
|
185
|
+
|
|
186
|
+
expect(result).toBeNull();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('should return null for non-array JSON', () => {
|
|
190
|
+
const objectJSON = '{"type":"text","text":"Hello"}';
|
|
191
|
+
|
|
192
|
+
const result = deserializeParts(objectJSON);
|
|
193
|
+
|
|
194
|
+
expect(result).toBeNull();
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
it('should return null for array of primitives', () => {
|
|
198
|
+
const primitiveArray = '[1, 2, 3, "text"]';
|
|
199
|
+
|
|
200
|
+
const result = deserializeParts(primitiveArray);
|
|
201
|
+
|
|
202
|
+
expect(result).toBeNull();
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should handle malformed JSON gracefully', () => {
|
|
206
|
+
const malformedJSON = '[{"type":"text","text":}]';
|
|
207
|
+
|
|
208
|
+
const result = deserializeParts(malformedJSON);
|
|
209
|
+
|
|
210
|
+
expect(result).toBeNull();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should deserialize content with special characters', () => {
|
|
214
|
+
const jsonString = '[{"type":"text","text":"Hello \\"world\\"!\\nNew line"}]';
|
|
215
|
+
|
|
216
|
+
const result = deserializeParts(jsonString);
|
|
217
|
+
|
|
218
|
+
expect(result).not.toBeNull();
|
|
219
|
+
if (result![0].type === 'text') {
|
|
220
|
+
expect(result![0].text).toBe('Hello "world"!\nNew line');
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should deserialize content with unicode characters', () => {
|
|
225
|
+
const jsonString = '[{"type":"text","text":"你好世界 🌍"}]';
|
|
226
|
+
|
|
227
|
+
const result = deserializeParts(jsonString);
|
|
228
|
+
|
|
229
|
+
expect(result).not.toBeNull();
|
|
230
|
+
if (result![0].type === 'text') {
|
|
231
|
+
expect(result![0].text).toBe('你好世界 🌍');
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe('round-trip serialization', () => {
|
|
237
|
+
it('should maintain data integrity for text parts', () => {
|
|
238
|
+
const originalParts: MessageContentPart[] = [
|
|
239
|
+
{
|
|
240
|
+
type: 'text',
|
|
241
|
+
text: 'Original text content',
|
|
242
|
+
},
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
const serialized = serializePartsForStorage(originalParts);
|
|
246
|
+
const deserialized = deserializeParts(serialized);
|
|
247
|
+
|
|
248
|
+
expect(deserialized).toEqual(originalParts);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it('should maintain data integrity for image parts', () => {
|
|
252
|
+
const originalParts: MessageContentPart[] = [
|
|
253
|
+
{
|
|
254
|
+
type: 'image',
|
|
255
|
+
image: 'data:image/png;base64,abc123',
|
|
256
|
+
},
|
|
257
|
+
];
|
|
258
|
+
|
|
259
|
+
const serialized = serializePartsForStorage(originalParts);
|
|
260
|
+
const deserialized = deserializeParts(serialized);
|
|
261
|
+
|
|
262
|
+
expect(deserialized).toEqual(originalParts);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it('should maintain data integrity for mixed content', () => {
|
|
266
|
+
const originalParts: MessageContentPart[] = [
|
|
267
|
+
{
|
|
268
|
+
type: 'text',
|
|
269
|
+
text: 'Text before image',
|
|
270
|
+
thoughtSignature: 'sig-1',
|
|
271
|
+
},
|
|
272
|
+
{
|
|
273
|
+
type: 'image',
|
|
274
|
+
image: 'data:image/png;base64,xyz789',
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
type: 'text',
|
|
278
|
+
text: 'Text after image',
|
|
279
|
+
},
|
|
280
|
+
];
|
|
281
|
+
|
|
282
|
+
const serialized = serializePartsForStorage(originalParts);
|
|
283
|
+
const deserialized = deserializeParts(serialized);
|
|
284
|
+
|
|
285
|
+
expect(deserialized).toEqual(originalParts);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('should maintain data integrity for complex text with special characters', () => {
|
|
289
|
+
const originalParts: MessageContentPart[] = [
|
|
290
|
+
{
|
|
291
|
+
type: 'text',
|
|
292
|
+
text: 'Complex text:\n- Line 1\n- "Quoted"\n- Tab:\t<-\n- Unicode: 你好 🌍',
|
|
293
|
+
},
|
|
294
|
+
];
|
|
295
|
+
|
|
296
|
+
const serialized = serializePartsForStorage(originalParts);
|
|
297
|
+
const deserialized = deserializeParts(serialized);
|
|
298
|
+
|
|
299
|
+
expect(deserialized).toEqual(originalParts);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
});
|
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { createSSEHeaders, createSSEWriter, formatSSEEvent } from '../sse';
|
|
4
|
+
|
|
5
|
+
describe('formatSSEEvent', () => {
|
|
6
|
+
it('should format SSE event with data only', () => {
|
|
7
|
+
const result = formatSSEEvent({ data: 'test message' });
|
|
8
|
+
|
|
9
|
+
expect(result).toBe('data: test message\n\n');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('should format SSE event with all fields', () => {
|
|
13
|
+
const result = formatSSEEvent({
|
|
14
|
+
data: { message: 'hello' },
|
|
15
|
+
event: 'custom-event',
|
|
16
|
+
id: 'event-123',
|
|
17
|
+
retry: 3000,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
expect(result).toBe(
|
|
21
|
+
'id: event-123\nevent: custom-event\nretry: 3000\ndata: {"message":"hello"}\n\n',
|
|
22
|
+
);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('should serialize object data to JSON', () => {
|
|
26
|
+
const result = formatSSEEvent({
|
|
27
|
+
data: { foo: 'bar', count: 42 },
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
expect(result).toBe('data: {"foo":"bar","count":42}\n\n');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('should handle multi-line string data', () => {
|
|
34
|
+
const result = formatSSEEvent({
|
|
35
|
+
data: 'line1\nline2\nline3',
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
expect(result).toBe('data: line1\ndata: line2\ndata: line3\n\n');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('should handle empty string data', () => {
|
|
42
|
+
const result = formatSSEEvent({ data: '' });
|
|
43
|
+
|
|
44
|
+
expect(result).toBe('data: \n\n');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('should format event with id only', () => {
|
|
48
|
+
const result = formatSSEEvent({
|
|
49
|
+
data: 'test',
|
|
50
|
+
id: 'msg-001',
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(result).toBe('id: msg-001\ndata: test\n\n');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should format event with event type only', () => {
|
|
57
|
+
const result = formatSSEEvent({
|
|
58
|
+
data: 'test',
|
|
59
|
+
event: 'notification',
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
expect(result).toBe('event: notification\ndata: test\n\n');
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('should format event with retry only', () => {
|
|
66
|
+
const result = formatSSEEvent({
|
|
67
|
+
data: 'test',
|
|
68
|
+
retry: 5000,
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
expect(result).toBe('retry: 5000\ndata: test\n\n');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should handle numeric data', () => {
|
|
75
|
+
const result = formatSSEEvent({
|
|
76
|
+
data: 42,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
expect(result).toBe('data: 42\n\n');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should handle boolean data', () => {
|
|
83
|
+
const result = formatSSEEvent({
|
|
84
|
+
data: true,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
expect(result).toBe('data: true\n\n');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('should handle null data', () => {
|
|
91
|
+
const result = formatSSEEvent({
|
|
92
|
+
data: null,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(result).toBe('data: null\n\n');
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should handle array data', () => {
|
|
99
|
+
const result = formatSSEEvent({
|
|
100
|
+
data: [1, 2, 3],
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(result).toBe('data: [1,2,3]\n\n');
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('should handle nested object data', () => {
|
|
107
|
+
const result = formatSSEEvent({
|
|
108
|
+
data: {
|
|
109
|
+
user: {
|
|
110
|
+
name: 'Alice',
|
|
111
|
+
profile: {
|
|
112
|
+
age: 30,
|
|
113
|
+
},
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
expect(result).toBe('data: {"user":{"name":"Alice","profile":{"age":30}}}\n\n');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe('createSSEWriter', () => {
|
|
123
|
+
describe('writeConnection', () => {
|
|
124
|
+
it('should write connection event with required fields', () => {
|
|
125
|
+
const mockController = { enqueue: vi.fn() };
|
|
126
|
+
const writer = createSSEWriter(mockController as any);
|
|
127
|
+
const timestamp = 1234567890;
|
|
128
|
+
|
|
129
|
+
writer.writeConnection('op-123', 'last-event-456', timestamp);
|
|
130
|
+
|
|
131
|
+
expect(mockController.enqueue).toHaveBeenCalledWith(
|
|
132
|
+
'id: conn_1234567890\nevent: connected\ndata: {"lastEventId":"last-event-456","operationId":"op-123","timestamp":1234567890,"type":"connected"}\n\n',
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should use Date.now() when timestamp is not provided', () => {
|
|
137
|
+
const mockController = { enqueue: vi.fn() };
|
|
138
|
+
const writer = createSSEWriter(mockController as any);
|
|
139
|
+
const now = Date.now();
|
|
140
|
+
|
|
141
|
+
writer.writeConnection('op-456', 'last-789');
|
|
142
|
+
|
|
143
|
+
const call = mockController.enqueue.mock.calls[0]![0];
|
|
144
|
+
expect(call).toContain('event: connected');
|
|
145
|
+
expect(call).toContain('"operationId":"op-456"');
|
|
146
|
+
expect(call).toContain('"lastEventId":"last-789"');
|
|
147
|
+
expect(call).toMatch(/"timestamp":\d+/);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
describe('writeError', () => {
|
|
152
|
+
it('should write error event with Error object', () => {
|
|
153
|
+
const mockController = { enqueue: vi.fn() };
|
|
154
|
+
const writer = createSSEWriter(mockController as any);
|
|
155
|
+
const error = new Error('Something went wrong');
|
|
156
|
+
error.stack = 'Error: Something went wrong\n at test.ts:10';
|
|
157
|
+
const timestamp = 1234567890;
|
|
158
|
+
|
|
159
|
+
writer.writeError(error, 'op-999', 'processing', timestamp);
|
|
160
|
+
|
|
161
|
+
expect(mockController.enqueue).toHaveBeenCalledWith(expect.stringContaining('event: error'));
|
|
162
|
+
expect(mockController.enqueue).toHaveBeenCalledWith(
|
|
163
|
+
expect.stringContaining('"error":"Something went wrong"'),
|
|
164
|
+
);
|
|
165
|
+
expect(mockController.enqueue).toHaveBeenCalledWith(
|
|
166
|
+
expect.stringContaining('"phase":"processing"'),
|
|
167
|
+
);
|
|
168
|
+
expect(mockController.enqueue).toHaveBeenCalledWith(
|
|
169
|
+
expect.stringContaining('"stack":"Error: Something went wrong'),
|
|
170
|
+
);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('should write error event without stack trace when not available', () => {
|
|
174
|
+
const mockController = { enqueue: vi.fn() };
|
|
175
|
+
const writer = createSSEWriter(mockController as any);
|
|
176
|
+
const error = new Error('No stack');
|
|
177
|
+
delete error.stack;
|
|
178
|
+
const timestamp = 1234567890;
|
|
179
|
+
|
|
180
|
+
writer.writeError(error, 'op-111', 'init', timestamp);
|
|
181
|
+
|
|
182
|
+
const call = mockController.enqueue.mock.calls[0]![0];
|
|
183
|
+
expect(call).toContain('event: error');
|
|
184
|
+
expect(call).toContain('"error":"No stack"');
|
|
185
|
+
expect(call).not.toContain('"stack"');
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it('should use "unknown" phase when phase is not provided', () => {
|
|
189
|
+
const mockController = { enqueue: vi.fn() };
|
|
190
|
+
const writer = createSSEWriter(mockController as any);
|
|
191
|
+
const error = new Error('Test error');
|
|
192
|
+
const timestamp = 1234567890;
|
|
193
|
+
|
|
194
|
+
writer.writeError(error, 'op-222', undefined, timestamp);
|
|
195
|
+
|
|
196
|
+
expect(mockController.enqueue).toHaveBeenCalledWith(
|
|
197
|
+
expect.stringContaining('"phase":"unknown"'),
|
|
198
|
+
);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it('should handle string error', () => {
|
|
202
|
+
const mockController = { enqueue: vi.fn() };
|
|
203
|
+
const writer = createSSEWriter(mockController as any);
|
|
204
|
+
const timestamp = 1234567890;
|
|
205
|
+
|
|
206
|
+
writer.writeError('Simple error string', 'op-333', 'validation', timestamp);
|
|
207
|
+
|
|
208
|
+
expect(mockController.enqueue).toHaveBeenCalledWith(
|
|
209
|
+
expect.stringContaining('"error":"Simple error string"'),
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should use Date.now() when timestamp is not provided', () => {
|
|
214
|
+
const mockController = { enqueue: vi.fn() };
|
|
215
|
+
const writer = createSSEWriter(mockController as any);
|
|
216
|
+
const error = new Error('Test');
|
|
217
|
+
|
|
218
|
+
writer.writeError(error, 'op-444');
|
|
219
|
+
|
|
220
|
+
const call = mockController.enqueue.mock.calls[0]![0];
|
|
221
|
+
expect(call).toContain('event: error');
|
|
222
|
+
expect(call).toMatch(/"timestamp":\d+/);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('writeEvent', () => {
|
|
227
|
+
it('should write custom event', () => {
|
|
228
|
+
const mockController = { enqueue: vi.fn() };
|
|
229
|
+
const writer = createSSEWriter(mockController as any);
|
|
230
|
+
|
|
231
|
+
writer.writeEvent({
|
|
232
|
+
data: { custom: 'data' },
|
|
233
|
+
event: 'custom-event',
|
|
234
|
+
id: 'custom-123',
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
expect(mockController.enqueue).toHaveBeenCalledWith(
|
|
238
|
+
'id: custom-123\nevent: custom-event\ndata: {"custom":"data"}\n\n',
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('should write event with retry', () => {
|
|
243
|
+
const mockController = { enqueue: vi.fn() };
|
|
244
|
+
const writer = createSSEWriter(mockController as any);
|
|
245
|
+
|
|
246
|
+
writer.writeEvent({
|
|
247
|
+
data: 'test',
|
|
248
|
+
retry: 10000,
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
expect(mockController.enqueue).toHaveBeenCalledWith('retry: 10000\ndata: test\n\n');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('writeHeartbeat', () => {
|
|
256
|
+
it('should write heartbeat event with timestamp', () => {
|
|
257
|
+
const mockController = { enqueue: vi.fn() };
|
|
258
|
+
const writer = createSSEWriter(mockController as any);
|
|
259
|
+
const timestamp = 1234567890;
|
|
260
|
+
|
|
261
|
+
writer.writeHeartbeat(timestamp);
|
|
262
|
+
|
|
263
|
+
expect(mockController.enqueue).toHaveBeenCalledWith(
|
|
264
|
+
'id: heartbeat_1234567890\nevent: heartbeat\ndata: {"timestamp":1234567890,"type":"heartbeat"}\n\n',
|
|
265
|
+
);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it('should use Date.now() when timestamp is not provided', () => {
|
|
269
|
+
const mockController = { enqueue: vi.fn() };
|
|
270
|
+
const writer = createSSEWriter(mockController as any);
|
|
271
|
+
|
|
272
|
+
writer.writeHeartbeat();
|
|
273
|
+
|
|
274
|
+
const call = mockController.enqueue.mock.calls[0]![0];
|
|
275
|
+
expect(call).toContain('event: heartbeat');
|
|
276
|
+
expect(call).toMatch(/"timestamp":\d+/);
|
|
277
|
+
expect(call).toMatch(/id: heartbeat_\d+/);
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
describe('writeStreamEvent', () => {
|
|
282
|
+
it('should write stream event with custom data', () => {
|
|
283
|
+
const mockController = { enqueue: vi.fn() };
|
|
284
|
+
const writer = createSSEWriter(mockController as any);
|
|
285
|
+
|
|
286
|
+
writer.writeStreamEvent({ type: 'stream_chunk', content: 'hello' }, 'event-555');
|
|
287
|
+
|
|
288
|
+
expect(mockController.enqueue).toHaveBeenCalledWith(
|
|
289
|
+
'id: event-555\nevent: stream_chunk\ndata: {"type":"stream_chunk","content":"hello"}\n\n',
|
|
290
|
+
);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it('should use "stream" as default event type when type is not in data', () => {
|
|
294
|
+
const mockController = { enqueue: vi.fn() };
|
|
295
|
+
const writer = createSSEWriter(mockController as any);
|
|
296
|
+
|
|
297
|
+
writer.writeStreamEvent({ content: 'message' }, 'event-666');
|
|
298
|
+
|
|
299
|
+
expect(mockController.enqueue).toHaveBeenCalledWith(
|
|
300
|
+
'id: event-666\nevent: stream\ndata: {"content":"message"}\n\n',
|
|
301
|
+
);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('should generate event ID when not provided', () => {
|
|
305
|
+
const mockController = { enqueue: vi.fn() };
|
|
306
|
+
const writer = createSSEWriter(mockController as any);
|
|
307
|
+
|
|
308
|
+
writer.writeStreamEvent({ type: 'stream_end' });
|
|
309
|
+
|
|
310
|
+
const call = mockController.enqueue.mock.calls[0]![0];
|
|
311
|
+
expect(call).toContain('event: stream_end');
|
|
312
|
+
expect(call).toMatch(/id: event_\d+/);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('createSSEHeaders', () => {
|
|
318
|
+
it('should create headers with correct SSE configuration', () => {
|
|
319
|
+
const headers = createSSEHeaders();
|
|
320
|
+
|
|
321
|
+
expect(headers).toEqual({
|
|
322
|
+
'Access-Control-Allow-Headers': 'Cache-Control, Last-Event-ID',
|
|
323
|
+
'Access-Control-Allow-Methods': 'GET',
|
|
324
|
+
'Access-Control-Allow-Origin': '*',
|
|
325
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
326
|
+
'Connection': 'keep-alive',
|
|
327
|
+
'Content-Type': 'text/event-stream',
|
|
328
|
+
'X-Accel-Buffering': 'no',
|
|
329
|
+
});
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
it('should include CORS headers', () => {
|
|
333
|
+
const headers = createSSEHeaders() as Record<string, string>;
|
|
334
|
+
|
|
335
|
+
expect(headers['Access-Control-Allow-Origin']).toBe('*');
|
|
336
|
+
expect(headers['Access-Control-Allow-Methods']).toBe('GET');
|
|
337
|
+
expect(headers['Access-Control-Allow-Headers']).toBe('Cache-Control, Last-Event-ID');
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should include cache control headers', () => {
|
|
341
|
+
const headers = createSSEHeaders() as Record<string, string>;
|
|
342
|
+
|
|
343
|
+
expect(headers['Cache-Control']).toBe('no-cache, no-transform');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('should include SSE-specific headers', () => {
|
|
347
|
+
const headers = createSSEHeaders() as Record<string, string>;
|
|
348
|
+
|
|
349
|
+
expect(headers['Content-Type']).toBe('text/event-stream');
|
|
350
|
+
expect(headers['Connection']).toBe('keep-alive');
|
|
351
|
+
expect(headers['X-Accel-Buffering']).toBe('no');
|
|
352
|
+
});
|
|
353
|
+
});
|
|
@@ -10,7 +10,7 @@ interface EditingProps {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
const Editing = memo<EditingProps>(({ id, title, toggleEditing }) => {
|
|
13
|
-
const editing = useHomeStore((s) => s.
|
|
13
|
+
const editing = useHomeStore((s) => s.groupRenamingId === id);
|
|
14
14
|
|
|
15
15
|
const [newTitle, setNewTitle] = useState(title);
|
|
16
16
|
|
|
@@ -19,17 +19,10 @@ const Editing = memo<EditingProps>(({ id, title, toggleEditing }) => {
|
|
|
19
19
|
|
|
20
20
|
if (hasChanges) {
|
|
21
21
|
try {
|
|
22
|
-
|
|
23
|
-
useHomeStore.getState().
|
|
24
|
-
|
|
25
|
-
// TODO: Add group title update logic here
|
|
26
|
-
// await updateGroupTitle(id, newTitle);
|
|
27
|
-
|
|
28
|
-
// Refresh agent list to update sidebar display
|
|
29
|
-
await useHomeStore.getState().refreshAgentList();
|
|
22
|
+
useHomeStore.getState().setGroupUpdatingId(id);
|
|
23
|
+
await useHomeStore.getState().renameAgentGroup(id, newTitle);
|
|
30
24
|
} finally {
|
|
31
|
-
|
|
32
|
-
useHomeStore.getState().setAgentUpdatingId(null);
|
|
25
|
+
useHomeStore.getState().setGroupUpdatingId(null);
|
|
33
26
|
}
|
|
34
27
|
}
|
|
35
28
|
toggleEditing(false);
|
|
@@ -30,8 +30,8 @@ const GroupItem = memo<GroupItemProps>(({ item, style, className }) => {
|
|
|
30
30
|
|
|
31
31
|
// Get UI state from homeStore (editing, updating)
|
|
32
32
|
const [editing, isUpdating] = useHomeStore((s) => [
|
|
33
|
-
s.
|
|
34
|
-
s.
|
|
33
|
+
s.groupRenamingId === id,
|
|
34
|
+
s.groupUpdatingId === id,
|
|
35
35
|
]);
|
|
36
36
|
|
|
37
37
|
// Get display title with fallback
|
|
@@ -63,7 +63,7 @@ const GroupItem = memo<GroupItemProps>(({ item, style, className }) => {
|
|
|
63
63
|
|
|
64
64
|
const toggleEditing = useCallback(
|
|
65
65
|
(visible?: boolean) => {
|
|
66
|
-
useHomeStore.getState().
|
|
66
|
+
useHomeStore.getState().setGroupRenamingId(visible ? id : null);
|
|
67
67
|
},
|
|
68
68
|
[id],
|
|
69
69
|
);
|