@lobehub/lobehub 2.0.0-next.242 → 2.0.0-next.244
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/desktop-feature-implementation.mdc +2 -2
- package/.cursor/rules/desktop-local-tools-implement.mdc +2 -2
- package/.github/workflows/test.yml +29 -14
- package/CHANGELOG.md +52 -0
- package/apps/desktop/Development.md +1 -6
- package/apps/desktop/README.md +2 -17
- package/apps/desktop/README.zh-CN.md +1 -15
- package/apps/desktop/src/main/controllers/index.ts +1 -1
- package/apps/desktop/src/main/controllers/registry.ts +0 -9
- package/apps/desktop/src/main/core/App.ts +1 -11
- package/apps/desktop/src/main/core/browser/Browser.ts +278 -457
- package/apps/desktop/src/main/core/browser/WindowStateManager.ts +180 -0
- package/apps/desktop/src/main/core/browser/WindowThemeManager.ts +167 -0
- package/apps/desktop/src/main/core/browser/__tests__/WindowStateManager.test.ts +237 -0
- package/apps/desktop/src/main/core/browser/__tests__/WindowThemeManager.test.ts +240 -0
- package/apps/desktop/src/main/exports.d.ts +1 -1
- package/apps/desktop/src/main/exports.ts +1 -1
- package/apps/desktop/src/main/utils/__tests__/http-headers.test.ts +131 -0
- package/apps/desktop/src/main/utils/http-headers.ts +61 -0
- package/apps/desktop/src/main/utils/ipc/__tests__/base.test.ts +1 -22
- package/apps/desktop/src/main/utils/ipc/base.ts +0 -20
- package/apps/desktop/src/main/utils/ipc/index.ts +1 -9
- package/changelog/v1.json +10 -0
- package/locales/en-US/subscription.json +1 -1
- package/locales/zh-CN/subscription.json +1 -1
- package/package.json +1 -1
- package/src/features/ChatInput/InputEditor/Placeholder.tsx +4 -1
- package/src/locales/default/subscription.ts +1 -1
- package/src/services/__tests__/models.test.ts +2 -1
- package/src/services/chat/index.ts +1 -1
- package/src/services/models.ts +2 -2
- package/vitest.config.mts +0 -1
- package/apps/desktop/src/main/controllers/UploadFileServerCtr.ts +0 -33
- package/apps/desktop/src/main/controllers/__tests__/UploadFileServerCtr.test.ts +0 -55
- package/src/server/modules/ElectronIPCClient/index.ts +0 -92
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { WindowThemeManager } from '../WindowThemeManager';
|
|
4
|
+
|
|
5
|
+
// Use vi.hoisted to define mocks before hoisting
|
|
6
|
+
const { mockNativeTheme, mockBrowserWindow } = vi.hoisted(() => ({
|
|
7
|
+
mockBrowserWindow: {
|
|
8
|
+
isDestroyed: vi.fn().mockReturnValue(false),
|
|
9
|
+
setBackgroundColor: vi.fn(),
|
|
10
|
+
setTitleBarOverlay: vi.fn(),
|
|
11
|
+
},
|
|
12
|
+
mockNativeTheme: {
|
|
13
|
+
off: vi.fn(),
|
|
14
|
+
on: vi.fn(),
|
|
15
|
+
shouldUseDarkColors: false,
|
|
16
|
+
},
|
|
17
|
+
}));
|
|
18
|
+
|
|
19
|
+
vi.mock('electron', () => ({
|
|
20
|
+
nativeTheme: mockNativeTheme,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
vi.mock('@/utils/logger', () => ({
|
|
24
|
+
createLogger: () => ({
|
|
25
|
+
debug: vi.fn(),
|
|
26
|
+
error: vi.fn(),
|
|
27
|
+
info: vi.fn(),
|
|
28
|
+
warn: vi.fn(),
|
|
29
|
+
}),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
vi.mock('@/const/dir', () => ({
|
|
33
|
+
buildDir: '/mock/build',
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
vi.mock('@/const/env', () => ({
|
|
37
|
+
isDev: false,
|
|
38
|
+
isWindows: true,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
vi.mock('@/const/theme', () => ({
|
|
42
|
+
BACKGROUND_DARK: '#1a1a1a',
|
|
43
|
+
BACKGROUND_LIGHT: '#ffffff',
|
|
44
|
+
SYMBOL_COLOR_DARK: '#ffffff',
|
|
45
|
+
SYMBOL_COLOR_LIGHT: '#000000',
|
|
46
|
+
THEME_CHANGE_DELAY: 0,
|
|
47
|
+
TITLE_BAR_HEIGHT: 32,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
describe('WindowThemeManager', () => {
|
|
51
|
+
let manager: WindowThemeManager;
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
vi.clearAllMocks();
|
|
55
|
+
vi.useFakeTimers();
|
|
56
|
+
|
|
57
|
+
mockNativeTheme.shouldUseDarkColors = false;
|
|
58
|
+
mockBrowserWindow.isDestroyed.mockReturnValue(false);
|
|
59
|
+
|
|
60
|
+
manager = new WindowThemeManager('test-window');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
afterEach(() => {
|
|
64
|
+
vi.useRealTimers();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('isDarkMode', () => {
|
|
68
|
+
it('should return true when shouldUseDarkColors is true', () => {
|
|
69
|
+
mockNativeTheme.shouldUseDarkColors = true;
|
|
70
|
+
|
|
71
|
+
expect(manager.isDarkMode).toBe(true);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('should return false when shouldUseDarkColors is false', () => {
|
|
75
|
+
mockNativeTheme.shouldUseDarkColors = false;
|
|
76
|
+
|
|
77
|
+
expect(manager.isDarkMode).toBe(false);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('getPlatformConfig', () => {
|
|
82
|
+
it('should return Windows dark theme config when in dark mode', () => {
|
|
83
|
+
mockNativeTheme.shouldUseDarkColors = true;
|
|
84
|
+
|
|
85
|
+
const config = manager.getPlatformConfig();
|
|
86
|
+
|
|
87
|
+
expect(config).toEqual({
|
|
88
|
+
backgroundColor: '#1a1a1a',
|
|
89
|
+
icon: undefined,
|
|
90
|
+
titleBarOverlay: {
|
|
91
|
+
color: '#1a1a1a',
|
|
92
|
+
height: 32,
|
|
93
|
+
symbolColor: '#ffffff',
|
|
94
|
+
},
|
|
95
|
+
titleBarStyle: 'hidden',
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('should return Windows light theme config when in light mode', () => {
|
|
100
|
+
mockNativeTheme.shouldUseDarkColors = false;
|
|
101
|
+
|
|
102
|
+
const config = manager.getPlatformConfig();
|
|
103
|
+
|
|
104
|
+
expect(config).toEqual({
|
|
105
|
+
backgroundColor: '#ffffff',
|
|
106
|
+
icon: undefined,
|
|
107
|
+
titleBarOverlay: {
|
|
108
|
+
color: '#ffffff',
|
|
109
|
+
height: 32,
|
|
110
|
+
symbolColor: '#000000',
|
|
111
|
+
},
|
|
112
|
+
titleBarStyle: 'hidden',
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe('attach', () => {
|
|
118
|
+
it('should setup theme listener', () => {
|
|
119
|
+
manager.attach(mockBrowserWindow as any);
|
|
120
|
+
|
|
121
|
+
expect(mockNativeTheme.on).toHaveBeenCalledWith('updated', expect.any(Function));
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it('should apply initial visual effects', () => {
|
|
125
|
+
manager.attach(mockBrowserWindow as any);
|
|
126
|
+
|
|
127
|
+
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalled();
|
|
128
|
+
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalled();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('should not setup duplicate listeners', () => {
|
|
132
|
+
manager.attach(mockBrowserWindow as any);
|
|
133
|
+
manager.attach(mockBrowserWindow as any);
|
|
134
|
+
|
|
135
|
+
expect(mockNativeTheme.on).toHaveBeenCalledTimes(1);
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
describe('cleanup', () => {
|
|
140
|
+
it('should remove theme listener', () => {
|
|
141
|
+
manager.attach(mockBrowserWindow as any);
|
|
142
|
+
manager.cleanup();
|
|
143
|
+
|
|
144
|
+
expect(mockNativeTheme.off).toHaveBeenCalledWith('updated', expect.any(Function));
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('should not throw if cleanup called without attach', () => {
|
|
148
|
+
expect(() => manager.cleanup()).not.toThrow();
|
|
149
|
+
expect(mockNativeTheme.off).not.toHaveBeenCalled();
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
describe('handleAppThemeChange', () => {
|
|
154
|
+
it('should reapply visual effects after delay', () => {
|
|
155
|
+
manager.attach(mockBrowserWindow as any);
|
|
156
|
+
mockBrowserWindow.setBackgroundColor.mockClear();
|
|
157
|
+
mockBrowserWindow.setTitleBarOverlay.mockClear();
|
|
158
|
+
|
|
159
|
+
manager.handleAppThemeChange();
|
|
160
|
+
vi.advanceTimersByTime(0);
|
|
161
|
+
|
|
162
|
+
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalled();
|
|
163
|
+
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe('reapplyVisualEffects', () => {
|
|
168
|
+
it('should apply visual effects', () => {
|
|
169
|
+
manager.attach(mockBrowserWindow as any);
|
|
170
|
+
mockBrowserWindow.setBackgroundColor.mockClear();
|
|
171
|
+
|
|
172
|
+
manager.reapplyVisualEffects();
|
|
173
|
+
|
|
174
|
+
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalled();
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
describe('applyVisualEffects', () => {
|
|
179
|
+
it('should apply dark theme when in dark mode', () => {
|
|
180
|
+
mockNativeTheme.shouldUseDarkColors = true;
|
|
181
|
+
manager.attach(mockBrowserWindow as any);
|
|
182
|
+
|
|
183
|
+
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalledWith('#1a1a1a');
|
|
184
|
+
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalledWith({
|
|
185
|
+
color: '#1a1a1a',
|
|
186
|
+
height: 32,
|
|
187
|
+
symbolColor: '#ffffff',
|
|
188
|
+
});
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
it('should apply light theme when in light mode', () => {
|
|
192
|
+
mockNativeTheme.shouldUseDarkColors = false;
|
|
193
|
+
manager.attach(mockBrowserWindow as any);
|
|
194
|
+
|
|
195
|
+
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalledWith('#ffffff');
|
|
196
|
+
expect(mockBrowserWindow.setTitleBarOverlay).toHaveBeenCalledWith({
|
|
197
|
+
color: '#ffffff',
|
|
198
|
+
height: 32,
|
|
199
|
+
symbolColor: '#000000',
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it('should not apply effects when window is destroyed', () => {
|
|
204
|
+
manager.attach(mockBrowserWindow as any);
|
|
205
|
+
mockBrowserWindow.setBackgroundColor.mockClear();
|
|
206
|
+
mockBrowserWindow.isDestroyed.mockReturnValue(true);
|
|
207
|
+
|
|
208
|
+
manager.reapplyVisualEffects();
|
|
209
|
+
|
|
210
|
+
expect(mockBrowserWindow.setBackgroundColor).not.toHaveBeenCalled();
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it('should not apply effects when no window attached', () => {
|
|
214
|
+
// Manager without attached window
|
|
215
|
+
const freshManager = new WindowThemeManager('fresh-window');
|
|
216
|
+
|
|
217
|
+
// Should not throw
|
|
218
|
+
expect(() => freshManager.reapplyVisualEffects()).not.toThrow();
|
|
219
|
+
});
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
describe('theme change listener', () => {
|
|
223
|
+
it('should reapply visual effects on system theme change', () => {
|
|
224
|
+
manager.attach(mockBrowserWindow as any);
|
|
225
|
+
|
|
226
|
+
// Get the theme change handler
|
|
227
|
+
const themeHandler = mockNativeTheme.on.mock.calls.find((call) => call[0] === 'updated')?.[1];
|
|
228
|
+
|
|
229
|
+
expect(themeHandler).toBeDefined();
|
|
230
|
+
|
|
231
|
+
mockBrowserWindow.setBackgroundColor.mockClear();
|
|
232
|
+
|
|
233
|
+
// Simulate theme change
|
|
234
|
+
themeHandler();
|
|
235
|
+
vi.advanceTimersByTime(0);
|
|
236
|
+
|
|
237
|
+
expect(mockBrowserWindow.setBackgroundColor).toHaveBeenCalled();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -5,4 +5,4 @@ declare module '@lobechat/electron-client-ipc' {
|
|
|
5
5
|
interface DesktopIpcServicesMap extends DesktopIpcServices {}
|
|
6
6
|
}
|
|
7
7
|
|
|
8
|
-
export { type DesktopIpcServices
|
|
8
|
+
export { type DesktopIpcServices } from './controllers/registry';
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Export types for renderer/server to use
|
|
2
|
-
export type { DesktopIpcServices
|
|
2
|
+
export type { DesktopIpcServices } from './controllers/registry';
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
deleteResponseHeader,
|
|
5
|
+
getResponseHeader,
|
|
6
|
+
hasResponseHeader,
|
|
7
|
+
setResponseHeader,
|
|
8
|
+
} from '../http-headers';
|
|
9
|
+
|
|
10
|
+
describe('http-headers utilities', () => {
|
|
11
|
+
describe('setResponseHeader', () => {
|
|
12
|
+
it('should set a new header', () => {
|
|
13
|
+
const headers: Record<string, string[]> = {};
|
|
14
|
+
|
|
15
|
+
setResponseHeader(headers, 'Content-Type', 'application/json');
|
|
16
|
+
|
|
17
|
+
expect(headers['Content-Type']).toEqual(['application/json']);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('should replace existing header with same case', () => {
|
|
21
|
+
const headers: Record<string, string[]> = {
|
|
22
|
+
'Content-Type': ['text/html'],
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
setResponseHeader(headers, 'Content-Type', 'application/json');
|
|
26
|
+
|
|
27
|
+
expect(headers['Content-Type']).toEqual(['application/json']);
|
|
28
|
+
expect(Object.keys(headers)).toHaveLength(1);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should replace existing header with different case', () => {
|
|
32
|
+
const headers: Record<string, string[]> = {
|
|
33
|
+
'content-type': ['text/html'],
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
setResponseHeader(headers, 'Content-Type', 'application/json');
|
|
37
|
+
|
|
38
|
+
expect(headers['Content-Type']).toEqual(['application/json']);
|
|
39
|
+
expect(headers['content-type']).toBeUndefined();
|
|
40
|
+
expect(Object.keys(headers)).toHaveLength(1);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should handle array values', () => {
|
|
44
|
+
const headers: Record<string, string[]> = {};
|
|
45
|
+
|
|
46
|
+
setResponseHeader(headers, 'Set-Cookie', ['a=1', 'b=2']);
|
|
47
|
+
|
|
48
|
+
expect(headers['Set-Cookie']).toEqual(['a=1', 'b=2']);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('should replace multiple headers with different cases', () => {
|
|
52
|
+
const headers: Record<string, string[]> = {
|
|
53
|
+
'ACCESS-CONTROL-ALLOW-ORIGIN': ['*'],
|
|
54
|
+
'access-control-allow-origin': ['http://localhost'],
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
setResponseHeader(headers, 'Access-Control-Allow-Origin', 'http://example.com');
|
|
58
|
+
|
|
59
|
+
expect(headers['Access-Control-Allow-Origin']).toEqual(['http://example.com']);
|
|
60
|
+
expect(Object.keys(headers)).toHaveLength(1);
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe('hasResponseHeader', () => {
|
|
65
|
+
it('should return true for existing header', () => {
|
|
66
|
+
const headers = { 'Content-Type': ['application/json'] };
|
|
67
|
+
|
|
68
|
+
expect(hasResponseHeader(headers, 'Content-Type')).toBe(true);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it('should return true for existing header with different case', () => {
|
|
72
|
+
const headers = { 'content-type': ['application/json'] };
|
|
73
|
+
|
|
74
|
+
expect(hasResponseHeader(headers, 'Content-Type')).toBe(true);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('should return false for non-existing header', () => {
|
|
78
|
+
const headers = { 'Content-Type': ['application/json'] };
|
|
79
|
+
|
|
80
|
+
expect(hasResponseHeader(headers, 'Authorization')).toBe(false);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('getResponseHeader', () => {
|
|
85
|
+
it('should get header value', () => {
|
|
86
|
+
const headers = { 'Content-Type': ['application/json'] };
|
|
87
|
+
|
|
88
|
+
expect(getResponseHeader(headers, 'Content-Type')).toEqual(['application/json']);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('should get header value with different case', () => {
|
|
92
|
+
const headers = { 'content-type': ['application/json'] };
|
|
93
|
+
|
|
94
|
+
expect(getResponseHeader(headers, 'Content-Type')).toEqual(['application/json']);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('should return undefined for non-existing header', () => {
|
|
98
|
+
const headers = { 'Content-Type': ['application/json'] };
|
|
99
|
+
|
|
100
|
+
expect(getResponseHeader(headers, 'Authorization')).toBeUndefined();
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('deleteResponseHeader', () => {
|
|
105
|
+
it('should delete existing header', () => {
|
|
106
|
+
const headers: Record<string, string[]> = { 'Content-Type': ['application/json'] };
|
|
107
|
+
|
|
108
|
+
const result = deleteResponseHeader(headers, 'Content-Type');
|
|
109
|
+
|
|
110
|
+
expect(result).toBe(true);
|
|
111
|
+
expect(headers['Content-Type']).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('should delete header with different case', () => {
|
|
115
|
+
const headers: Record<string, string[]> = { 'content-type': ['application/json'] };
|
|
116
|
+
|
|
117
|
+
const result = deleteResponseHeader(headers, 'Content-Type');
|
|
118
|
+
|
|
119
|
+
expect(result).toBe(true);
|
|
120
|
+
expect(headers['content-type']).toBeUndefined();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('should return false for non-existing header', () => {
|
|
124
|
+
const headers: Record<string, string[]> = { 'Content-Type': ['application/json'] };
|
|
125
|
+
|
|
126
|
+
const result = deleteResponseHeader(headers, 'Authorization');
|
|
127
|
+
|
|
128
|
+
expect(result).toBe(false);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP headers utilities for Electron webRequest
|
|
3
|
+
*
|
|
4
|
+
* Electron's webRequest responseHeaders is a plain JS object where keys are case-sensitive,
|
|
5
|
+
* but HTTP headers are case-insensitive per spec. These utilities handle this mismatch.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
type ElectronResponseHeaders = Record<string, string[]>;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Set a header value, replacing any existing header with the same name (case-insensitive)
|
|
12
|
+
*/
|
|
13
|
+
export function setResponseHeader(
|
|
14
|
+
headers: ElectronResponseHeaders,
|
|
15
|
+
name: string,
|
|
16
|
+
value: string | string[],
|
|
17
|
+
): void {
|
|
18
|
+
// Delete any existing header with same name (case-insensitive)
|
|
19
|
+
for (const key of Object.keys(headers)) {
|
|
20
|
+
if (key.toLowerCase() === name.toLowerCase()) {
|
|
21
|
+
delete headers[key];
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
headers[name] = Array.isArray(value) ? value : [value];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if a header exists (case-insensitive)
|
|
29
|
+
*/
|
|
30
|
+
export function hasResponseHeader(headers: ElectronResponseHeaders, name: string): boolean {
|
|
31
|
+
return Object.keys(headers).some((key) => key.toLowerCase() === name.toLowerCase());
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Get a header value (case-insensitive)
|
|
36
|
+
*/
|
|
37
|
+
export function getResponseHeader(
|
|
38
|
+
headers: ElectronResponseHeaders,
|
|
39
|
+
name: string,
|
|
40
|
+
): string[] | undefined {
|
|
41
|
+
for (const key of Object.keys(headers)) {
|
|
42
|
+
if (key.toLowerCase() === name.toLowerCase()) {
|
|
43
|
+
return headers[key];
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Delete a header (case-insensitive)
|
|
51
|
+
*/
|
|
52
|
+
export function deleteResponseHeader(headers: ElectronResponseHeaders, name: string): boolean {
|
|
53
|
+
let deleted = false;
|
|
54
|
+
for (const key of Object.keys(headers)) {
|
|
55
|
+
if (key.toLowerCase() === name.toLowerCase()) {
|
|
56
|
+
delete headers[key];
|
|
57
|
+
deleted = true;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return deleted;
|
|
61
|
+
}
|
|
@@ -1,13 +1,7 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
2
|
|
|
3
3
|
import type { IpcContext } from '../base';
|
|
4
|
-
import {
|
|
5
|
-
IpcMethod,
|
|
6
|
-
IpcServerMethod,
|
|
7
|
-
IpcService,
|
|
8
|
-
getIpcContext,
|
|
9
|
-
getServerMethodMetadata,
|
|
10
|
-
} from '../base';
|
|
4
|
+
import { IpcMethod, IpcService, getIpcContext } from '../base';
|
|
11
5
|
|
|
12
6
|
const { ipcMainHandleMock } = vi.hoisted(() => ({
|
|
13
7
|
ipcMainHandleMock: vi.fn(),
|
|
@@ -73,19 +67,4 @@ describe('ipc service base', () => {
|
|
|
73
67
|
expect(service.invokedWith).toBe('test');
|
|
74
68
|
expect(ipcMainHandleMock).toHaveBeenCalledWith('direct.run', expect.any(Function));
|
|
75
69
|
});
|
|
76
|
-
|
|
77
|
-
it('collects server method metadata for decorators', () => {
|
|
78
|
-
class ServerService extends IpcService {
|
|
79
|
-
static readonly groupName = 'server';
|
|
80
|
-
|
|
81
|
-
@IpcServerMethod()
|
|
82
|
-
fetch(_: string) {
|
|
83
|
-
return 'ok';
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const metadata = getServerMethodMetadata(ServerService);
|
|
88
|
-
expect(metadata).toBeDefined();
|
|
89
|
-
expect(metadata?.get('fetch')).toBe('fetch');
|
|
90
|
-
});
|
|
91
70
|
});
|
|
@@ -10,7 +10,6 @@ export interface IpcContext {
|
|
|
10
10
|
|
|
11
11
|
// Metadata storage for decorated methods
|
|
12
12
|
const methodMetadata = new WeakMap<any, Map<string, string>>();
|
|
13
|
-
const serverMethodMetadata = new WeakMap<any, Map<string, string>>();
|
|
14
13
|
const ipcContextStorage = new AsyncLocalStorage<IpcContext>();
|
|
15
14
|
|
|
16
15
|
// Decorator for IPC methods
|
|
@@ -29,21 +28,6 @@ export function IpcMethod() {
|
|
|
29
28
|
};
|
|
30
29
|
}
|
|
31
30
|
|
|
32
|
-
export function IpcServerMethod(channelName?: string) {
|
|
33
|
-
return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
|
34
|
-
const { constructor } = target;
|
|
35
|
-
|
|
36
|
-
if (!serverMethodMetadata.has(constructor)) {
|
|
37
|
-
serverMethodMetadata.set(constructor, new Map());
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
const methods = serverMethodMetadata.get(constructor)!;
|
|
41
|
-
methods.set(propertyKey, channelName || propertyKey);
|
|
42
|
-
|
|
43
|
-
return descriptor;
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
|
|
47
31
|
// Handler registry for IPC methods
|
|
48
32
|
export class IpcHandler {
|
|
49
33
|
private static instance: IpcHandler;
|
|
@@ -157,10 +141,6 @@ export type CreateServicesResult<T extends readonly IpcServiceConstructor[]> = {
|
|
|
157
141
|
[K in T[number] as K['groupName']]: InstanceType<K>;
|
|
158
142
|
};
|
|
159
143
|
|
|
160
|
-
export function getServerMethodMetadata(target: IpcServiceConstructor) {
|
|
161
|
-
return serverMethodMetadata.get(target);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
144
|
export function getIpcContext() {
|
|
165
145
|
return ipcContextStorage.getStore();
|
|
166
146
|
}
|
|
@@ -1,11 +1,3 @@
|
|
|
1
1
|
export type { CreateServicesResult, IpcContext, IpcServiceConstructor } from './base';
|
|
2
|
-
export {
|
|
3
|
-
createServices,
|
|
4
|
-
getIpcContext,
|
|
5
|
-
getServerMethodMetadata,
|
|
6
|
-
IpcMethod,
|
|
7
|
-
IpcServerMethod,
|
|
8
|
-
IpcService,
|
|
9
|
-
runWithIpcContext,
|
|
10
|
-
} from './base';
|
|
2
|
+
export { createServices, getIpcContext, IpcMethod, IpcService, runWithIpcContext } from './base';
|
|
11
3
|
export type { ExtractServiceMethods, MergeIpcService } from './utility';
|
package/changelog/v1.json
CHANGED
|
@@ -245,7 +245,7 @@
|
|
|
245
245
|
"qa.list.embeddings.q": "How is vector storage calculated?",
|
|
246
246
|
"qa.list.free.a": "{{name}} has always adhered to open source principles. For professional developers, you can use all open source capabilities through self-deployment of the community version. In {{cloud}}, we provide all registered users with {{credit}} free computing credits per month, ready to use without complex configuration. If you need more usage, you can subscribe to {{starter}}, {{premium}} or {{ultimate}}.",
|
|
247
247
|
"qa.list.free.q": "Can {{name}} be used for free?",
|
|
248
|
-
"qa.list.limit.a": "{{cloud}} subscription plans are divided into {{starter}}, {{premium}} and {{ultimate}}, each providing different computing credits. If your current plan credits are insufficient, we recommend upgrading. Alternatively, you can set up a custom model API key to use API credits purchased from other sources.",
|
|
248
|
+
"qa.list.limit.a": "{{cloud}} subscription plans are divided into {{starter}}, {{premium}} and {{ultimate}}, each providing different computing credits. If your current plan credits are insufficient, we recommend upgrading. You can also purchase credit packages on the <fundsLink>\"{{funds}}\"</fundsLink> page for pay-as-you-go usage. Alternatively, you can set up a custom model API key to use API credits purchased from other sources.",
|
|
249
249
|
"qa.list.limit.q": "What if I run out of computing credits?",
|
|
250
250
|
"qa.list.management.a": "On the {{subscribe}} page, you can \"Upgrade / Downgrade\" your current subscription plan, or switch between yearly and monthly billing. Through \"{{usage}}-{{management}}\" you can go to Stripe for subscription management, and you can cancel your subscription at any time. After cancellation, you will be automatically downgraded to the free version when your current plan expires.",
|
|
251
251
|
"qa.list.management.q": "How do I change or cancel my subscription?",
|
|
@@ -245,7 +245,7 @@
|
|
|
245
245
|
"qa.list.embeddings.q": "向量存储是如何计算的?",
|
|
246
246
|
"qa.list.free.a": "{{name}} 始终坚持开源原则。专业开发者可通过部署社区版使用全部开源功能。在 {{cloud}} 中,我们为所有注册用户每月提供 {{credit}} 点免费算力,无需复杂配置即可使用。如需更多使用量,可订阅 {{starter}}、{{premium}} 或 {{ultimate}}。",
|
|
247
247
|
"qa.list.free.q": "{{name}} 可以免费使用吗?",
|
|
248
|
-
"qa.list.limit.a": "{{cloud}} 的订阅计划分为 {{starter}}、{{premium}} 和 {{ultimate}}
|
|
248
|
+
"qa.list.limit.a": "{{cloud}} 的订阅计划分为 {{starter}}、{{premium}} 和 {{ultimate}},每个计划提供不同的算力点数。如当前计划点数不足,建议升级计划。您也可以在<fundsLink>「{{funds}}」</fundsLink>页面购买积分包进行按量付费充值。此外,您也可以配置自定义模型 API 密钥,使用其他来源购买的 API 点数。",
|
|
249
249
|
"qa.list.limit.q": "算力点数用完怎么办?",
|
|
250
250
|
"qa.list.management.a": "在 {{subscribe}} 页面,您可以“升级 / 降级”当前订阅计划,或切换年付与月付。在“{{usage}}-{{management}}”中可跳转至 Stripe 进行订阅管理,您可随时取消订阅。取消后,当前计划到期时将自动降级为免费版。",
|
|
251
251
|
"qa.list.management.q": "如何更改或取消订阅?",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/lobehub",
|
|
3
|
-
"version": "2.0.0-next.
|
|
3
|
+
"version": "2.0.0-next.244",
|
|
4
4
|
"description": "LobeHub - an open-source,comprehensive AI Agent framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { KeyEnum } from '@lobechat/types';
|
|
2
2
|
import { Flexbox, Hotkey, combineKeys } from '@lobehub/ui';
|
|
3
3
|
import { memo } from 'react';
|
|
4
|
-
import { Trans } from 'react-i18next';
|
|
4
|
+
import { Trans, useTranslation } from 'react-i18next';
|
|
5
5
|
|
|
6
6
|
import { useUserStore } from '@/store/user';
|
|
7
7
|
import { preferenceSelectors } from '@/store/user/selectors';
|
|
@@ -12,6 +12,9 @@ const Placeholder = memo(() => {
|
|
|
12
12
|
? KeyEnum.Enter
|
|
13
13
|
: combineKeys([KeyEnum.Mod, KeyEnum.Enter]);
|
|
14
14
|
|
|
15
|
+
// Don't remove this line for i18n reactivity
|
|
16
|
+
void useTranslation('chat');
|
|
17
|
+
|
|
15
18
|
return (
|
|
16
19
|
<Flexbox align={'center'} as={'span'} gap={4} horizontal wrap={'wrap'}>
|
|
17
20
|
<Trans
|
|
@@ -278,7 +278,7 @@ export default {
|
|
|
278
278
|
'{{name}} has always adhered to open source principles. For professional developers, you can use all open source capabilities through self-deployment of the community version. In {{cloud}}, we provide all registered users with {{credit}} free computing credits per month, ready to use without complex configuration. If you need more usage, you can subscribe to {{starter}}, {{premium}} or {{ultimate}}.',
|
|
279
279
|
'qa.list.free.q': 'Can {{name}} be used for free?',
|
|
280
280
|
'qa.list.limit.a':
|
|
281
|
-
'{{cloud}} subscription plans are divided into {{starter}}, {{premium}} and {{ultimate}}, each providing different computing credits. If your current plan credits are insufficient, we recommend upgrading. Alternatively, you can set up a custom model API key to use API credits purchased from other sources.',
|
|
281
|
+
'{{cloud}} subscription plans are divided into {{starter}}, {{premium}} and {{ultimate}}, each providing different computing credits. If your current plan credits are insufficient, we recommend upgrading. You can also purchase credit packages on the <fundsLink>"{{funds}}"</fundsLink> page for pay-as-you-go usage. Alternatively, you can set up a custom model API key to use API credits purchased from other sources.',
|
|
282
282
|
'qa.list.limit.q': 'What if I run out of computing credits?',
|
|
283
283
|
'qa.list.management.a':
|
|
284
284
|
'On the {{subscribe}} page, you can "Upgrade / Downgrade" your current subscription plan, or switch between yearly and monthly billing. Through "{{usage}}-{{management}}" you can go to Stripe for subscription management, and you can cancel your subscription at any time. After cancellation, you will be automatically downgraded to the free version when your current plan expires.',
|
|
@@ -82,7 +82,8 @@ describe('ModelsService', () => {
|
|
|
82
82
|
await modelsService.getModels('custom-provider');
|
|
83
83
|
|
|
84
84
|
expect(mockedResolveRuntimeProvider).toHaveBeenCalledWith('custom-provider');
|
|
85
|
-
|
|
85
|
+
// API endpoint uses original provider, allowing server to query correct config
|
|
86
|
+
expect(fetch).toHaveBeenCalledWith('/webapi/models/custom-provider', { headers: {} });
|
|
86
87
|
expect(mockedInitializeWithClientStore).not.toHaveBeenCalled();
|
|
87
88
|
});
|
|
88
89
|
|
|
@@ -402,7 +402,7 @@ class ChatService {
|
|
|
402
402
|
responseAnimation,
|
|
403
403
|
].reduce((acc, cur) => merge(acc, standardizeAnimationStyle(cur)), {});
|
|
404
404
|
|
|
405
|
-
return fetchSSE(API_ENDPOINTS.chat(
|
|
405
|
+
return fetchSSE(API_ENDPOINTS.chat(provider), {
|
|
406
406
|
body: JSON.stringify(payload),
|
|
407
407
|
fetcher: fetcher,
|
|
408
408
|
headers,
|
package/src/services/models.ts
CHANGED
|
@@ -49,7 +49,7 @@ export class ModelsService {
|
|
|
49
49
|
return agentRuntime.models();
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
const res = await fetch(API_ENDPOINTS.models(
|
|
52
|
+
const res = await fetch(API_ENDPOINTS.models(provider), { headers });
|
|
53
53
|
if (!res.ok) return;
|
|
54
54
|
|
|
55
55
|
return res.json();
|
|
@@ -87,7 +87,7 @@ export class ModelsService {
|
|
|
87
87
|
});
|
|
88
88
|
res = (await agentRuntime.pullModel({ model }, { signal }))!;
|
|
89
89
|
} else {
|
|
90
|
-
res = await fetch(API_ENDPOINTS.modelPull(
|
|
90
|
+
res = await fetch(API_ENDPOINTS.modelPull(provider), {
|
|
91
91
|
body: JSON.stringify({ model }),
|
|
92
92
|
headers,
|
|
93
93
|
method: 'POST',
|