diff --git a/.cursor/rules/testing-guide/zustand-store-action-test.mdc b/.cursor/rules/testing-guide/zustand-store-action-test.mdc new file mode 100644 index 00000000000..d5a3841abd9 --- /dev/null +++ b/.cursor/rules/testing-guide/zustand-store-action-test.mdc @@ -0,0 +1,579 @@ +--- +description: Best practices for testing Zustand store actions +globs: "src/store/**/*.test.ts" +alwaysApply: false +--- + +# Zustand Store Action Testing Guide + +This guide provides best practices for testing Zustand store actions, based on our proven testing patterns. + +## Basic Test Structure + +```typescript +import { act, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { messageService } from '@/services/message'; +import { useChatStore } from '../../store'; + +// Keep zustand mock as it's needed globally +vi.mock('zustand/traditional'); + +beforeEach(() => { + // Reset store state + vi.clearAllMocks(); + useChatStore.setState( + { + activeId: 'test-session-id', + messagesMap: {}, + loadingIds: [], + }, + false, + ); + + // ✅ Setup only spies that MOST tests need + vi.spyOn(messageService, 'createMessage').mockResolvedValue('new-message-id'); + // ❌ Don't setup spies that only few tests need - spy only when needed + + // Setup common mock methods + act(() => { + useChatStore.setState({ + refreshMessages: vi.fn(), + internal_coreProcessMessage: vi.fn(), + }); + }); +}); + +afterEach(() => { + vi.restoreAllMocks(); +}); + +describe('action name', () => { + describe('validation', () => { + // Validation tests + }); + + describe('normal flow', () => { + // Happy path tests + }); + + describe('error handling', () => { + // Error case tests + }); +}); +``` + +## Testing Best Practices + +### 1. Test Layering - Spy Direct Dependencies Only + +✅ **Good**: Spy on the direct dependency + +```typescript +// When testing internal_coreProcessMessage, spy its direct dependency +const fetchAIChatSpy = vi + .spyOn(result.current, 'internal_fetchAIChatMessage') + .mockResolvedValue({ isFunctionCall: false, content: 'AI response' }); +``` + +❌ **Bad**: Spy on lower-level implementation details + +```typescript +// Don't spy on services that internal_fetchAIChatMessage uses +const streamSpy = vi + .spyOn(chatService, 'createAssistantMessageStream') + .mockImplementation(...); +``` + +**Why**: Each test should only mock its direct dependencies, not the entire call chain. This makes tests more maintainable and less brittle. + +### 2. Mock Management - Minimize Global Spies + +✅ **Good**: Spy only when needed + +```typescript +beforeEach(() => { + // ✅ Only spy services that most tests need + vi.spyOn(messageService, 'createMessage').mockResolvedValue('new-message-id'); + // ✅ Don't spy chatService globally +}); + +it('should process message', async () => { + // ✅ Spy chatService only in tests that need it + const streamSpy = vi.spyOn(chatService, 'createAssistantMessageStream') + .mockImplementation(...); + + // test logic + + streamSpy.mockRestore(); +}); +``` + +❌ **Bad**: Setup all spies globally + +```typescript +beforeEach(() => { + vi.spyOn(messageService, 'createMessage').mockResolvedValue('id'); + vi.spyOn(chatService, 'createAssistantMessageStream').mockResolvedValue({}); // ❌ Not all tests need this + vi.spyOn(fileService, 'uploadFile').mockResolvedValue({}); // ❌ Creates implicit coupling +}); +``` + +### 3. Service Mocking - Mock the Correct Layer + +✅ **Good**: Mock the service method + +```typescript +it('should fetch AI chat response', async () => { + const streamSpy = vi + .spyOn(chatService, 'createAssistantMessageStream') + .mockImplementation(async ({ onMessageHandle, onFinish }) => { + await onMessageHandle?.({ type: 'text', text: 'Hello' } as any); + await onFinish?.('Hello', {}); + }); + + // test logic +}); +``` + +❌ **Bad**: Mock global fetch + +```typescript +it('should fetch AI chat response', async () => { + global.fetch = vi.fn().mockResolvedValue(...); // ❌ Too low level +}); +``` + +### 4. Test Organization - Use Descriptive Nesting + +✅ **Good**: Clear nested structure + +```typescript +describe('sendMessage', () => { + describe('validation', () => { + it('should not send when session is inactive', async () => {}); + it('should not send when message is empty', async () => {}); + }); + + describe('message creation', () => { + it('should create user message and trigger AI processing', async () => {}); + it('should send message with files attached', async () => {}); + }); + + describe('error handling', () => { + it('should handle message creation errors gracefully', async () => {}); + }); +}); +``` + +❌ **Bad**: Flat structure + +```typescript +describe('sendMessage', () => { + it('test 1', async () => {}); + it('test 2', async () => {}); + it('test 3', async () => {}); +}); +``` + +### 5. Testing Async Actions + +Always wrap async operations in `act()`: + +```typescript +it('should send message', async () => { + const { result } = renderHook(() => useChatStore()); + + await act(async () => { + await result.current.sendMessage({ message: 'Hello' }); + }); + + expect(messageService.createMessage).toHaveBeenCalled(); +}); +``` + +### 6. State Setup - Use act() for setState + +```typescript +it('should handle disabled state', async () => { + act(() => { + useChatStore.setState({ activeId: undefined }); + }); + + const { result } = renderHook(() => useChatStore()); + // test logic +}); +``` + +### 7. Testing Complex Flows + +For complex flows with multiple steps, use clear spy setup: + +```typescript +it('should handle topic creation flow', async () => { + // Setup store state + act(() => { + useChatStore.setState({ + activeTopicId: undefined, + messagesMap: { + 'test-session-id': [ + { id: 'msg-1', role: 'user', content: 'Message 1' }, + { id: 'msg-2', role: 'assistant', content: 'Response 1' }, + { id: 'msg-3', role: 'user', content: 'Message 2' }, + ], + }, + }); + }); + + const { result } = renderHook(() => useChatStore()); + + // Spy on action dependencies + const createTopicSpy = vi.spyOn(result.current, 'createTopic') + .mockResolvedValue('new-topic-id'); + const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleMessageLoading'); + + // Execute + await act(async () => { + await result.current.sendMessage({ message: 'Test message' }); + }); + + // Assert + expect(createTopicSpy).toHaveBeenCalled(); + expect(toggleLoadingSpy).toHaveBeenCalledWith(true, expect.any(String)); +}); +``` + +### 8. Streaming Response Mocking + +When testing streaming responses, simulate the flow properly: + +```typescript +it('should handle streaming chunks', async () => { + const { result } = renderHook(() => useChatStore()); + const messages = [ + { id: 'msg-1', role: 'user', content: 'Hello', sessionId: 'test-session' }, + ]; + + const streamSpy = vi + .spyOn(chatService, 'createAssistantMessageStream') + .mockImplementation(async ({ onMessageHandle, onFinish }) => { + // Simulate streaming chunks + await onMessageHandle?.({ type: 'text', text: 'Hello' } as any); + await onMessageHandle?.({ type: 'text', text: ' World' } as any); + await onFinish?.('Hello World', {}); + }); + + await act(async () => { + await result.current.internal_fetchAIChatMessage({ + messages, + messageId: 'test-message-id', + model: 'gpt-4o-mini', + provider: 'openai', + }); + }); + + expect(result.current.internal_dispatchMessage).toHaveBeenCalled(); + + streamSpy.mockRestore(); +}); +``` + +### 9. Error Handling Tests + +Always test error scenarios: + +```typescript +it('should handle errors gracefully', async () => { + const { result } = renderHook(() => useChatStore()); + + vi.spyOn(messageService, 'createMessage').mockRejectedValue( + new Error('create message error'), + ); + + await act(async () => { + try { + await result.current.sendMessage({ message: 'Test message' }); + } catch { + // Expected to throw + } + }); + + expect(result.current.internal_coreProcessMessage).not.toHaveBeenCalled(); +}); +``` + +### 10. Cleanup After Tests + +Always restore mocks after each test: + +```typescript +afterEach(() => { + vi.restoreAllMocks(); +}); + +// For individual test cleanup: +it('should test something', async () => { + const spy = vi.spyOn(service, 'method').mockImplementation(...); + + // test logic + + spy.mockRestore(); // Optional: cleanup immediately after test +}); +``` + +## Common Patterns + +### Testing Store Methods That Call Other Store Methods + +```typescript +it('should call internal methods', async () => { + const { result } = renderHook(() => useChatStore()); + + const internalMethodSpy = vi.spyOn(result.current, 'internal_method') + .mockResolvedValue(); + + await act(async () => { + await result.current.publicMethod(); + }); + + expect(internalMethodSpy).toHaveBeenCalledWith( + expect.any(String), + expect.objectContaining({ key: 'value' }), + ); +}); +``` + +### Testing Conditional Logic + +```typescript +describe('conditional behavior', () => { + it('should execute when condition is true', async () => { + const { result } = renderHook(() => useChatStore()); + vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(true); + + await act(async () => { + await result.current.sendMessage({ message: 'test' }); + }); + + expect(result.current.internal_retrieveChunks).toHaveBeenCalled(); + }); + + it('should not execute when condition is false', async () => { + const { result } = renderHook(() => useChatStore()); + vi.spyOn(result.current, 'internal_shouldUseRAG').mockReturnValue(false); + + await act(async () => { + await result.current.sendMessage({ message: 'test' }); + }); + + expect(result.current.internal_retrieveChunks).not.toHaveBeenCalled(); + }); +}); +``` + +### Testing AbortController + +```typescript +it('should abort generation and clear loading state', () => { + const abortController = new AbortController(); + + act(() => { + useChatStore.setState({ chatLoadingIdsAbortController: abortController }); + }); + + const { result } = renderHook(() => useChatStore()); + const toggleLoadingSpy = vi.spyOn(result.current, 'internal_toggleChatLoading'); + + act(() => { + result.current.stopGenerateMessage(); + }); + + expect(abortController.signal.aborted).toBe(true); + expect(toggleLoadingSpy).toHaveBeenCalledWith(false, undefined, expect.any(String)); +}); +``` + +## Anti-Patterns to Avoid + +❌ **Don't**: Mock the entire store + +```typescript +vi.mock('../../store', () => ({ + useChatStore: vi.fn(() => ({ + sendMessage: vi.fn(), + })), +})); +``` + +❌ **Don't**: Test implementation details + +```typescript +// Bad: testing internal state structure +expect(result.current.messagesMap).toHaveProperty('test-session'); + +// Good: testing behavior +expect(result.current.refreshMessages).toHaveBeenCalled(); +``` + +❌ **Don't**: Create tight coupling between tests + +```typescript +// Bad: Tests depend on order +let messageId: string; + +it('test 1', () => { + messageId = 'some-id'; // Side effect +}); + +it('test 2', () => { + expect(messageId).toBeDefined(); // Depends on test 1 +}); +``` + +❌ **Don't**: Over-mock services + +```typescript +// Bad: Mocking everything +beforeEach(() => { + vi.mock('@/services/chat'); + vi.mock('@/services/message'); + vi.mock('@/services/file'); + vi.mock('@/services/agent'); + // ... too many global mocks +}); +``` + +## Testing SWR Hooks in Zustand Stores + +Some Zustand store slices use SWR hooks for data fetching. These require a different testing approach. + +### Basic SWR Hook Test Structure + +```typescript +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { discoverService } from '@/services/discover'; +import { globalHelpers } from '@/store/global/helpers'; +import { useDiscoverStore as useStore } from '../../store'; + +vi.mock('zustand/traditional'); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('SWR Hook Actions', () => { + it('should fetch data and return correct response', async () => { + const mockData = [{ id: '1', name: 'Item 1' }]; + + // Mock the service call (the fetcher) + vi.spyOn(discoverService, 'getPluginCategories').mockResolvedValue(mockData as any); + vi.spyOn(globalHelpers, 'getCurrentLanguage').mockReturnValue('en-US'); + + const params = {} as any; + const { result } = renderHook(() => useStore.getState().usePluginCategories(params)); + + // Use waitFor to wait for async data loading + await waitFor(() => { + expect(result.current.data).toEqual(mockData); + }); + + expect(discoverService.getPluginCategories).toHaveBeenCalledWith(params); + }); +}); +``` + +**Key points**: +- **DO NOT mock useSWR** - let it use the real implementation +- Only mock the **service methods** (fetchers) +- Use `waitFor` from `@testing-library/react` to wait for async operations +- Check `result.current.data` directly after waitFor completes + +### Testing SWR Key Generation + +```typescript +it('should generate correct SWR key with locale and params', () => { + vi.spyOn(globalHelpers, 'getCurrentLanguage').mockReturnValue('zh-CN'); + + const useSWRMock = vi.mocked(useSWR); + let capturedKey: string | null = null; + useSWRMock.mockImplementation(((key: string) => { + capturedKey = key; + return { data: undefined, error: undefined, isValidating: false, mutate: vi.fn() }; + }) as any); + + const params = { page: 2, category: 'tools' } as any; + renderHook(() => useStore.getState().usePluginList(params)); + + expect(capturedKey).toBe('plugin-list-zh-CN-2-tools'); +}); +``` + +### Testing SWR Configuration + +```typescript +it('should have correct SWR configuration', () => { + const useSWRMock = vi.mocked(useSWR); + let capturedOptions: any = null; + useSWRMock.mockImplementation(((key: string, fetcher: any, options: any) => { + capturedOptions = options; + return { data: undefined, error: undefined, isValidating: false, mutate: vi.fn() }; + }) as any); + + renderHook(() => useStore.getState().usePluginIdentifiers()); + + expect(capturedOptions).toMatchObject({ revalidateOnFocus: false }); +}); +``` + +### Testing Conditional Fetching + +```typescript +it('should not fetch when required parameter is missing', () => { + const useSWRMock = vi.mocked(useSWR); + let capturedKey: string | null = null; + useSWRMock.mockImplementation(((key: string | null) => { + capturedKey = key; + return { data: undefined, error: undefined, isValidating: false, mutate: vi.fn() }; + }) as any); + + // When identifier is undefined, SWR key should be null + renderHook(() => useStore.getState().usePluginDetail({ identifier: undefined })); + + expect(capturedKey).toBeNull(); +}); +``` + +### Key Differences from Regular Action Tests + +1. **Mock useSWR globally**: Use `vi.mock('swr')` at the top level +2. **Mock the fetcher, not the result**: + - ✅ **Correct**: `const data = fetcher?.()` - call fetcher and return its Promise + - ❌ **Wrong**: `return { data: mockData }` - hardcode the result +3. **Await Promise results**: The `data` field is a Promise, use `await result.current.data` +4. **No act() wrapper needed**: SWR hooks don't trigger React state updates in these tests +5. **Test SWR key generation**: Verify keys include locale and parameters +6. **Test configuration**: Verify revalidation and other SWR options +7. **Type assertions**: Use `as any` for test mock data where type definitions are strict + +**Why this matters**: +- The fetcher (service method) is what we're testing - it must be called +- Hardcoding the return value bypasses the actual fetcher logic +- SWR returns Promises in real usage, tests should mirror this behavior + +## Benefits of This Approach + +✅ **Clear test layers** - Each test only spies on direct dependencies +✅ **Correct mocks** - Mocks match actual implementation +✅ **Better maintainability** - Changes to implementation require fewer test updates +✅ **Improved coverage** - Structured approach ensures all branches are tested +✅ **Reduced coupling** - Tests are independent and can run in any order + +## Reference + +See example implementation in: +- `src/store/chat/slices/aiChat/actions/__tests__/generateAIChat.test.ts` (Regular actions) +- `src/store/discover/slices/plugin/action.test.ts` (SWR hooks) +- `src/store/discover/slices/mcp/action.test.ts` (SWR hooks) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ec0613abd0f..e0e5ddac3d3 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -22,7 +22,7 @@ jobs: - name: Setup Bun uses: oven-sh/setup-bun@v2 with: - bun-version: latest + bun-version: 1.2.23 - name: Install dependencies (bun) run: bun install diff --git a/.npmrc b/.npmrc index e51c1c20ecd..446637e5fa3 100644 --- a/.npmrc +++ b/.npmrc @@ -4,8 +4,6 @@ resolution-mode=highest ignore-workspace-root-check=true enable-pre-post-scripts=true -# Load dotenv files for all the npm scripts -node-options="--require dotenv-expand/config" public-hoist-pattern[]=*@umijs/lint* public-hoist-pattern[]=*changelog* diff --git a/CHANGELOG.md b/CHANGELOG.md index da18b9cd0fb..0ce76e1f07c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,64 @@ # Changelog +### [Version 1.137.8](https://github.com/lobehub/lobe-chat/compare/v1.137.7...v1.137.8) + +Released on **2025-10-15** + +#### 🐛 Bug Fixes + +- **misc**: Fix duplicate tools id issue and fix link dialog issue. + +#### 💄 Styles + +- **misc**: Add region support for Vertex AI provider. + +
+ +
+Improvements and Fixes + +#### What's fixed + +- **misc**: Fix duplicate tools id issue and fix link dialog issue, closes [#9731](https://github.com/lobehub/lobe-chat/issues/9731) ([0a8c80d](https://github.com/lobehub/lobe-chat/commit/0a8c80d)) + +#### Styles + +- **misc**: Add region support for Vertex AI provider, closes [#9720](https://github.com/lobehub/lobe-chat/issues/9720) ([d17b50c](https://github.com/lobehub/lobe-chat/commit/d17b50c)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ +### [Version 1.137.7](https://github.com/lobehub/lobe-chat/compare/v1.137.6...v1.137.7) + +Released on **2025-10-15** + +#### 💄 Styles + +- **misc**: Use different favicon.ico in dev mode. + +
+ +
+Improvements and Fixes + +#### Styles + +- **misc**: Use different favicon.ico in dev mode, closes [#9723](https://github.com/lobehub/lobe-chat/issues/9723) ([2f7317b](https://github.com/lobehub/lobe-chat/commit/2f7317b)) + +
+ +
+ +[![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top) + +
+ ### [Version 1.137.6](https://github.com/lobehub/lobe-chat/compare/v1.137.5...v1.137.6) Released on **2025-10-14** diff --git a/changelog/v1.json b/changelog/v1.json index 4efdf6a2046..b19d8b21a0c 100644 --- a/changelog/v1.json +++ b/changelog/v1.json @@ -1,4 +1,19 @@ [ + { + "children": { + "fixes": ["Fix duplicate tools id issue and fix link dialog issue."], + "improvements": ["Add region support for Vertex AI provider."] + }, + "date": "2025-10-15", + "version": "1.137.8" + }, + { + "children": { + "improvements": ["Use different favicon.ico in dev mode."] + }, + "date": "2025-10-15", + "version": "1.137.7" + }, { "children": { "fixes": ["Update Claude workflows to use oauth token, vertext ai create image."] diff --git a/locales/en-US/modelProvider.json b/locales/en-US/modelProvider.json index 8429f4e33e1..a74e3afae27 100644 --- a/locales/en-US/modelProvider.json +++ b/locales/en-US/modelProvider.json @@ -399,6 +399,11 @@ "desc": "Enter your Vertex AI Keys", "placeholder": "{ \"type\": \"service_account\", \"project_id\": \"xxx\", \"private_key_id\": ... }", "title": "Vertex AI Keys" + }, + "region": { + "desc": "Select the region for Vertex AI service. Some models like Gemini 2.5 are only available in specific regions (e.g., global)", + "placeholder": "Select region", + "title": "Vertex AI Region" } }, "zeroone": { diff --git a/locales/zh-CN/modelProvider.json b/locales/zh-CN/modelProvider.json index 4f863b6ed6b..df2f710350c 100644 --- a/locales/zh-CN/modelProvider.json +++ b/locales/zh-CN/modelProvider.json @@ -399,6 +399,11 @@ "desc": "填入你的 Vertex Ai Keys", "placeholder": "{ \"type\": \"service_account\", \"project_id\": \"xxx\", \"private_key_id\": ... }", "title": "Vertex AI Keys" + }, + "region": { + "desc": "选择 Vertex AI 服务的区域。某些模型如 Gemini 2.5 仅在特定区域可用(如 global)", + "placeholder": "选择区域", + "title": "Vertex AI 区域" } }, "zeroone": { diff --git a/package.json b/package.json index f0037707418..e12a5e12dca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@lobehub/chat", - "version": "1.137.6", + "version": "1.137.8", "description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.", "keywords": [ "framework", diff --git a/packages/context-engine/src/tools/ToolsEngine.ts b/packages/context-engine/src/tools/ToolsEngine.ts index 50ab964b78b..402ffcd9ca5 100644 --- a/packages/context-engine/src/tools/ToolsEngine.ts +++ b/packages/context-engine/src/tools/ToolsEngine.ts @@ -56,7 +56,7 @@ export class ToolsEngine { const { toolIds = [], model, provider, context } = params; // Merge user-provided tool IDs with default tool IDs - const allToolIds = [...toolIds, ...this.defaultToolIds]; + const allToolIds = [...new Set([...toolIds, ...this.defaultToolIds])]; log( 'Generating tools for model=%s, provider=%s, pluginIds=%o (includes %d default tools)', @@ -96,8 +96,8 @@ export class ToolsEngine { generateToolsDetailed(params: GenerateToolsParams): ToolsGenerationResult { const { toolIds = [], model, provider, context } = params; - // Merge user-provided tool IDs with default tool IDs - const allToolIds = [...toolIds, ...this.defaultToolIds]; + // Merge user-provided tool IDs with default tool IDs and deduplicate + const allToolIds = [...new Set([...toolIds, ...this.defaultToolIds])]; log( 'Generating detailed tools for model=%s, provider=%s, pluginIds=%o (includes %d default tools)', diff --git a/packages/context-engine/src/tools/__tests__/ToolNameResolver.test.ts b/packages/context-engine/src/tools/__tests__/ToolNameResolver.test.ts index 83b1c9edd2b..6c2132e0374 100644 --- a/packages/context-engine/src/tools/__tests__/ToolNameResolver.test.ts +++ b/packages/context-engine/src/tools/__tests__/ToolNameResolver.test.ts @@ -150,6 +150,9 @@ describe('ToolNameResolver', () => { it('should handle web browsing tools correctly', () => { const result = resolver.generate('lobe-web-browsing', 'search', 'builtin'); expect(result).toBe('lobe-web-browsing____search____builtin'); + + const result2 = resolver.generate('lobe-web-browsing', 'crawlSinglePage', 'builtin'); + expect(result2).toBe('lobe-web-browsing____crawlSinglePage____builtin'); }); it('should handle plugin tools correctly', () => { diff --git a/packages/context-engine/src/tools/__tests__/ToolsEngine.test.ts b/packages/context-engine/src/tools/__tests__/ToolsEngine.test.ts index 81d591e959b..a9b07407c9e 100644 --- a/packages/context-engine/src/tools/__tests__/ToolsEngine.test.ts +++ b/packages/context-engine/src/tools/__tests__/ToolsEngine.test.ts @@ -900,4 +900,83 @@ describe('ToolsEngine', () => { }); }); }); + + describe('deduplication', () => { + it('should deduplicate tool IDs in toolIds array', () => { + const engine = new ToolsEngine({ + manifestSchemas: [mockWebBrowsingManifest, mockDalleManifest], + enableChecker: () => true, + functionCallChecker: () => true, + }); + + const result = engine.generateTools({ + toolIds: ['lobe-web-browsing', 'lobe-web-browsing', 'dalle'], + model: 'gpt-4', + provider: 'openai', + }); + + // Should only generate 2 tools, not 3 + expect(result).toHaveLength(2); + expect(result![0].function.name).toBe('lobe-web-browsing____search____builtin'); + expect(result![1].function.name).toBe('dalle____generateImage____builtin'); + }); + + it('should deduplicate between toolIds and defaultToolIds', () => { + const engine = new ToolsEngine({ + manifestSchemas: [mockWebBrowsingManifest, mockDalleManifest], + defaultToolIds: ['lobe-web-browsing'], + enableChecker: () => true, + functionCallChecker: () => true, + }); + + const result = engine.generateTools({ + toolIds: ['lobe-web-browsing', 'dalle'], + model: 'gpt-4', + provider: 'openai', + }); + + // Should only generate 2 tools (lobe-web-browsing should appear once) + expect(result).toHaveLength(2); + expect(result![0].function.name).toBe('lobe-web-browsing____search____builtin'); + expect(result![1].function.name).toBe('dalle____generateImage____builtin'); + }); + + it('should deduplicate in generateToolsDetailed', () => { + const engine = new ToolsEngine({ + manifestSchemas: [mockWebBrowsingManifest, mockDalleManifest], + defaultToolIds: ['dalle'], + enableChecker: () => true, + functionCallChecker: () => true, + }); + + const result = engine.generateToolsDetailed({ + toolIds: ['lobe-web-browsing', 'dalle', 'dalle'], + model: 'gpt-4', + provider: 'openai', + }); + + // Should only generate 2 unique tools + expect(result.tools).toHaveLength(2); + expect(result.enabledToolIds).toEqual(['lobe-web-browsing', 'dalle']); + expect(result.filteredTools).toEqual([]); + }); + + it('should handle complex deduplication scenarios', () => { + const engine = new ToolsEngine({ + manifestSchemas: [mockWebBrowsingManifest, mockDalleManifest], + defaultToolIds: ['lobe-web-browsing', 'dalle'], + enableChecker: () => true, + functionCallChecker: () => true, + }); + + const result = engine.generateTools({ + toolIds: ['dalle', 'lobe-web-browsing', 'dalle', 'lobe-web-browsing'], + model: 'gpt-4', + provider: 'openai', + }); + + // Should only generate 2 unique tools despite multiple duplicates + expect(result).toHaveLength(2); + }); + }); }); diff --git a/packages/types/src/auth.ts b/packages/types/src/auth.ts index 2ed5dfc0750..8c13ffd69ef 100644 --- a/packages/types/src/auth.ts +++ b/packages/types/src/auth.ts @@ -27,6 +27,8 @@ export interface ClientSecretPayload { cloudflareBaseURLOrAccountID?: string; + vertexAIRegion?: string; + /** * user id * in client db mode it's a uuid diff --git a/packages/types/src/user/settings/keyVaults.ts b/packages/types/src/user/settings/keyVaults.ts index 673350f160a..a758e2db77b 100644 --- a/packages/types/src/user/settings/keyVaults.ts +++ b/packages/types/src/user/settings/keyVaults.ts @@ -24,6 +24,11 @@ export interface AWSBedrockKeyVault { sessionToken?: string; } +export interface VertexAIKeyVault { + apiKey?: string; + region?: string; +} + export interface CloudflareKeyVault { apiKey?: string; baseURLOrAccountID?: string; @@ -96,7 +101,7 @@ export interface UserKeyVaults extends SearchEngineKeyVaults { upstage?: OpenAICompatibleKeyVault; v0?: OpenAICompatibleKeyVault; vercelaigateway?: OpenAICompatibleKeyVault; - vertexai?: OpenAICompatibleKeyVault; + vertexai?: VertexAIKeyVault; vllm?: OpenAICompatibleKeyVault; volcengine?: OpenAICompatibleKeyVault; wenxin?: OpenAICompatibleKeyVault; diff --git a/public/favicon-32x32-dev.ico b/public/favicon-32x32-dev.ico new file mode 100644 index 00000000000..a44bbc23ea5 Binary files /dev/null and b/public/favicon-32x32-dev.ico differ diff --git a/public/favicon-dev.ico b/public/favicon-dev.ico new file mode 100644 index 00000000000..8b183cfb703 Binary files /dev/null and b/public/favicon-dev.ico differ diff --git a/src/app/[variants]/(main)/settings/provider/detail/vertexai/index.tsx b/src/app/[variants]/(main)/settings/provider/detail/vertexai/index.tsx index 98cf37fb3f0..7079201dc57 100644 --- a/src/app/[variants]/(main)/settings/provider/detail/vertexai/index.tsx +++ b/src/app/[variants]/(main)/settings/provider/detail/vertexai/index.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Markdown } from '@lobehub/ui'; +import { Markdown, Select } from '@lobehub/ui'; import { createStyles } from 'antd-style'; import { useTranslation } from 'react-i18next'; @@ -28,6 +28,48 @@ const useStyles = createStyles(({ css, token }) => ({ const providerKey: GlobalLLMProviderKey = 'vertexai'; +const VERTEX_AI_REGIONS: string[] = [ + 'global', + 'us-central1', + 'us-east1', + 'us-east4', + 'us-west1', + 'us-west2', + 'us-west3', + 'us-west4', + 'us-south1', + 'northamerica-northeast1', + 'northamerica-northeast2', + 'southamerica-east1', + 'southamerica-west1', + 'europe-central2', + 'europe-north1', + 'europe-southwest1', + 'europe-west1', + 'europe-west2', + 'europe-west3', + 'europe-west4', + 'europe-west6', + 'europe-west8', + 'europe-west9', + 'europe-west10', + 'europe-west12', + 'me-central1', + 'me-central2', + 'me-west1', + 'africa-south1', + 'asia-east1', + 'asia-east2', + 'asia-northeast1', + 'asia-northeast2', + 'asia-northeast3', + 'asia-south1', + 'asia-southeast1', + 'asia-southeast2', + 'australia-southeast1', + 'australia-southeast2', +]; + // Same as OpenAIProvider, but replace API Key with HuggingFace Access Token const useProviderCard = (): ProviderItem => { const { t } = useTranslation('modelProvider'); @@ -54,6 +96,27 @@ const useProviderCard = (): ProviderItem => { label: t('vertexai.apiKey.title'), name: [KeyVaultsConfigKey, LLMProviderApiTokenKey], }, + { + children: isLoading ? ( + + ) : ( +