diff --git a/.github/workflows/auto-i18n.yml b/.github/workflows/auto-i18n.yml index 1a85b16757..1584ab48db 100644 --- a/.github/workflows/auto-i18n.yml +++ b/.github/workflows/auto-i18n.yml @@ -1,4 +1,4 @@ -name: Auto I18N +name: Auto I18N Weekly env: TRANSLATION_API_KEY: ${{ secrets.TRANSLATE_API_KEY }} @@ -7,14 +7,15 @@ env: TRANSLATION_BASE_LOCALE: ${{ vars.AUTO_I18N_BASE_LOCALE || 'en-us'}} on: - pull_request: - types: [opened, synchronize, reopened] + schedule: + # Runs at 00:00 UTC every Sunday. + # This corresponds to 08:00 AM UTC+8 (Beijing time) every Sunday. + - cron: "0 0 * * 0" workflow_dispatch: jobs: auto-i18n: runs-on: ubuntu-latest - if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.full_name == 'CherryHQ/cherry-studio' name: Auto I18N permissions: contents: write @@ -24,45 +25,69 @@ jobs: - name: 🐈‍⬛ Checkout uses: actions/checkout@v5 with: - ref: ${{ github.event.pull_request.head.ref }} + fetch-depth: 0 - name: 📦 Setting Node.js uses: actions/setup-node@v6 with: node-version: 22 - package-manager-cache: false - - name: 📦 Install dependencies in isolated directory + - name: 📦 Install corepack + run: corepack enable && corepack prepare yarn@4.9.1 --activate + + - name: 📂 Get yarn cache directory path + id: yarn-cache-dir-path + run: echo "dir=$(yarn config get cacheFolder)" >> $GITHUB_OUTPUT + + - name: 💾 Cache yarn dependencies + uses: actions/cache@v4 + with: + path: | + ${{ steps.yarn-cache-dir-path.outputs.dir }} + node_modules + key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }} + restore-keys: | + ${{ runner.os }}-yarn- + + - name: 📦 Install dependencies run: | - # 在临时目录安装依赖 - mkdir -p /tmp/translation-deps - cd /tmp/translation-deps - echo '{"dependencies": {"@cherrystudio/openai": "^6.5.0", "cli-progress": "^3.12.0", "tsx": "^4.20.3", "@biomejs/biome": "2.2.4"}}' > package.json - npm install --no-package-lock - - # 设置 NODE_PATH 让项目能找到这些依赖 - echo "NODE_PATH=/tmp/translation-deps/node_modules" >> $GITHUB_ENV + yarn install - name: 🏃‍♀️ Translate - run: npx tsx scripts/sync-i18n.ts && npx tsx scripts/auto-translate-i18n.ts + run: yarn sync:i18n && yarn auto:i18n - name: 🔍 Format - run: cd /tmp/translation-deps && npx biome format --config-path /home/runner/work/cherry-studio/cherry-studio/biome.jsonc --write /home/runner/work/cherry-studio/cherry-studio/src/renderer/src/i18n/ + run: yarn format - - name: 🔄 Commit changes + - name: 🔍 Check for changes + id: git_status run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - git add . + # Check if there are any uncommitted changes git reset -- package.json yarn.lock # 不提交 package.json 和 yarn.lock 的更改 - if git diff --cached --quiet; then - echo "No changes to commit" - else - git commit -m "fix(i18n): Auto update translations for PR #${{ github.event.pull_request.number }}" - fi + git diff --exit-code --quiet || echo "::set-output name=has_changes::true" + git status --porcelain - - name: 🚀 Push changes - uses: ad-m/github-push-action@master + - name: 📅 Set current date for PR title + id: set_date + run: echo "CURRENT_DATE=$(date +'%b %d, %Y')" >> $GITHUB_ENV # e.g., "Jun 06, 2024" + + - name: 🚀 Create Pull Request if changes exist + if: steps.git_status.outputs.has_changes == 'true' + uses: peter-evans/create-pull-request@v6 with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: ${{ github.event.pull_request.head.ref }} + token: ${{ secrets.GITHUB_TOKEN }} # Use the built-in GITHUB_TOKEN for bot actions + commit-message: "feat(bot): Weekly automated script run" + title: "🤖 Weekly Automated Update: ${{ env.CURRENT_DATE }}" + body: | + This PR includes changes generated by the weekly auto i18n. + Review the changes before merging. + + --- + _Generated by the automated weekly workflow_ + branch: "auto-i18n-weekly-${{ github.run_id }}" # Unique branch name + base: "main" # Or 'develop', set your base branch + delete-branch: true # Delete the branch after merging or closing the PR + + - name: 📢 Notify if no changes + if: steps.git_status.outputs.has_changes != 'true' + run: echo "Bot script ran, but no changes were detected. No PR created." diff --git a/eslint.config.mjs b/eslint.config.mjs index 1c4779fb99..544f79ba3b 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -151,12 +151,7 @@ export default defineConfig([ importNames: ['Flex', 'Switch', 'message', 'Button', 'Tooltip'], message: '❌ Do not import this component from antd. Use our custom components instead: import { ... } from "@cherrystudio/ui"' - }, - // { - // name: '@heroui/react', - // message: - // '❌ Do not import components from heroui directly. Use our wrapped components instead: import { ... } from "@cherrystudio/ui"' - // } + } ] } ] diff --git a/package.json b/package.json index 2be66ace10..0e900424ad 100644 --- a/package.json +++ b/package.json @@ -151,7 +151,6 @@ "@eslint/js": "^9.22.0", "@google/genai": "patch:@google/genai@npm%3A1.0.1#~/.yarn/patches/@google-genai-npm-1.0.1-e26f0f9af7.patch", "@hello-pangea/dnd": "^18.0.1", - "@heroui/react": "^2.8.3", "@langchain/community": "^1.0.0", "@langchain/core": "patch:@langchain/core@npm%3A1.0.2#~/.yarn/patches/@langchain-core-npm-1.0.2-183ef83fe4.patch", "@langchain/openai": "patch:@langchain/openai@npm%3A1.0.0#~/.yarn/patches/@langchain-openai-npm-1.0.0-474d0ad9d4.patch", @@ -352,6 +351,7 @@ "striptags": "^3.2.0", "styled-components": "^6.1.11", "swr": "^2.3.6", + "tailwind-merge": "^3.3.1", "tailwindcss": "^4.1.13", "tar": "^7.4.3", "tiny-pinyin": "^1.3.2", diff --git a/packages/ui/README.md b/packages/ui/README.md index be3dd2d809..4769676c75 100644 --- a/packages/ui/README.md +++ b/packages/ui/README.md @@ -20,7 +20,7 @@ Cherry Studio UI 组件库 - 为 Cherry Studio 设计的 React 组件集合 ```bash npm install @cherrystudio/ui # peer dependencies -npm install @heroui/react framer-motion react react-dom tailwindcss +npm install framer-motion react react-dom tailwindcss ``` ### 两种使用方式 diff --git a/packages/ui/src/components/index.ts b/packages/ui/src/components/index.ts index 3293709164..56cb212c81 100644 --- a/packages/ui/src/components/index.ts +++ b/packages/ui/src/components/index.ts @@ -9,7 +9,6 @@ export { ErrorBoundary } from './primitives/ErrorBoundary' export { default as IndicatorLight } from './primitives/indicatorLight' export { default as Spinner } from './primitives/spinner' export { DescriptionSwitch, Switch } from './primitives/switch' -export { getToastUtilities, type ToastUtilities } from './primitives/toast' export { Tooltip, type TooltipProps } from './primitives/tooltip' // Composite Components diff --git a/packages/ui/src/components/primitives/toast.ts b/packages/ui/src/components/primitives/toast.ts deleted file mode 100644 index e8f8b7d347..0000000000 --- a/packages/ui/src/components/primitives/toast.ts +++ /dev/null @@ -1,85 +0,0 @@ -import type { RequireSome } from '@cherrystudio/ui/types' -import { addToast, closeAll, closeToast, getToastQueue, isToastClosing } from '@heroui/toast' - -type AddToastProps = Parameters[0] -type ToastPropsColored = Omit - -const createToast = (color: 'danger' | 'success' | 'warning' | 'default') => { - return (arg: ToastPropsColored | string): string | null => { - if (typeof arg === 'string') { - return addToast({ color, title: arg }) - } else { - return addToast({ color, ...arg }) - } - } -} - -// syntatic sugar, oh yeah - -/** - * Display an error toast notification with red color - * @param arg - Toast content (string) or toast options object - * @returns Toast ID or null - */ -const error = createToast('danger') - -/** - * Display a success toast notification with green color - * @param arg - Toast content (string) or toast options object - * @returns Toast ID or null - */ -const success = createToast('success') - -/** - * Display a warning toast notification with yellow color - * @param arg - Toast content (string) or toast options object - * @returns Toast ID or null - */ -const warning = createToast('warning') - -/** - * Display an info toast notification with default color - * @param arg - Toast content (string) or toast options object - * @returns Toast ID or null - */ -const info = createToast('default') - -/** - * Display a loading toast notification that resolves with a promise - * @param args - Toast options object containing a promise to resolve - * @returns Toast ID or null - */ -const loading = (args: RequireSome) => { - // Disappear immediately by default - if (args.timeout === undefined) { - args.timeout = 1 - } - return addToast(args) -} - -export type ToastUtilities = { - getToastQueue: typeof getToastQueue - addToast: typeof addToast - closeToast: typeof closeToast - closeAll: typeof closeAll - isToastClosing: typeof isToastClosing - error: typeof error - success: typeof success - warning: typeof warning - info: typeof info - loading: typeof loading -} - -export const getToastUtilities = (): ToastUtilities => - ({ - getToastQueue, - addToast, - closeToast, - closeAll, - isToastClosing, - error, - success, - warning, - info, - loading - }) as const diff --git a/src/main/apiServer/middleware/openapi.ts b/src/main/apiServer/middleware/openapi.ts index c136fecdde..ff01005bd9 100644 --- a/src/main/apiServer/middleware/openapi.ts +++ b/src/main/apiServer/middleware/openapi.ts @@ -171,7 +171,7 @@ const swaggerOptions: swaggerJSDoc.Options = { } ] }, - apis: ['./src/main/apiServer/routes/*.ts', './src/main/apiServer/app.ts'] + apis: ['./src/main/apiServer/routes/**/*.ts', './src/main/apiServer/app.ts'] } export function setupOpenAPIDocumentation(app: Express) { diff --git a/src/main/services/AppMenuService.ts b/src/main/services/AppMenuService.ts index 8e870d432e..a9b7345c01 100644 --- a/src/main/services/AppMenuService.ts +++ b/src/main/services/AppMenuService.ts @@ -4,17 +4,36 @@ import { getAppLanguage, locales } from '@main/utils/language' import { IpcChannel } from '@shared/IpcChannel' import type { MenuItemConstructorOptions } from 'electron' import { app, Menu, shell } from 'electron' + +import { configManager } from './ConfigManager' export class AppMenuService { + private languageChangeCallback?: (newLanguage: string) => void + + constructor() { + // Subscribe to language change events + this.languageChangeCallback = () => { + this.setupApplicationMenu() + } + configManager.subscribe('language', this.languageChangeCallback) + } + + public destroy(): void { + // Clean up subscription to prevent memory leaks + if (this.languageChangeCallback) { + configManager.unsubscribe('language', this.languageChangeCallback) + } + } + public setupApplicationMenu(): void { const locale = locales[getAppLanguage()] - const { common } = locale.translation + const { appMenu } = locale.translation const template: MenuItemConstructorOptions[] = [ { label: app.name, submenu: [ { - label: common.about + ' ' + app.name, + label: appMenu.about + ' ' + app.name, click: () => { // Emit event to navigate to About page const mainWindow = windowService.getMainWindow() @@ -25,50 +44,78 @@ export class AppMenuService { } }, { type: 'separator' }, - { role: 'services' }, + { role: 'services', label: appMenu.services }, { type: 'separator' }, - { role: 'hide' }, - { role: 'hideOthers' }, - { role: 'unhide' }, + { role: 'hide', label: `${appMenu.hide} ${app.name}` }, + { role: 'hideOthers', label: appMenu.hideOthers }, + { role: 'unhide', label: appMenu.unhide }, { type: 'separator' }, - { role: 'quit' } + { role: 'quit', label: `${appMenu.quit} ${app.name}` } ] }, { - role: 'fileMenu' + label: appMenu.file, + submenu: [{ role: 'close', label: appMenu.close }] }, { - role: 'editMenu' + label: appMenu.edit, + submenu: [ + { role: 'undo', label: appMenu.undo }, + { role: 'redo', label: appMenu.redo }, + { type: 'separator' }, + { role: 'cut', label: appMenu.cut }, + { role: 'copy', label: appMenu.copy }, + { role: 'paste', label: appMenu.paste }, + { role: 'delete', label: appMenu.delete }, + { role: 'selectAll', label: appMenu.selectAll } + ] }, { - role: 'viewMenu' + label: appMenu.view, + submenu: [ + { role: 'reload', label: appMenu.reload }, + { role: 'forceReload', label: appMenu.forceReload }, + { role: 'toggleDevTools', label: appMenu.toggleDevTools }, + { type: 'separator' }, + { role: 'resetZoom', label: appMenu.resetZoom }, + { role: 'zoomIn', label: appMenu.zoomIn }, + { role: 'zoomOut', label: appMenu.zoomOut }, + { type: 'separator' }, + { role: 'togglefullscreen', label: appMenu.toggleFullscreen } + ] }, { - role: 'windowMenu' + label: appMenu.window, + submenu: [ + { role: 'minimize', label: appMenu.minimize }, + { role: 'zoom', label: appMenu.zoom }, + { type: 'separator' }, + { role: 'front', label: appMenu.front } + ] }, { - role: 'help', + label: appMenu.help, submenu: [ { - label: 'Website', + label: appMenu.website, click: () => { shell.openExternal('https://cherry-ai.com') } }, { - label: 'Documentation', + label: appMenu.documentation, click: () => { shell.openExternal('https://cherry-ai.com/docs') } }, { - label: 'Feedback', + label: appMenu.feedback, click: () => { shell.openExternal('https://github.com/CherryHQ/cherry-studio/issues/new/choose') } }, { - label: 'Releases', + label: appMenu.releases, click: () => { shell.openExternal('https://github.com/CherryHQ/cherry-studio/releases') } diff --git a/src/main/services/agents/services/claudecode/index.ts b/src/main/services/agents/services/claudecode/index.ts index c9a8c677cd..4e20520017 100644 --- a/src/main/services/agents/services/claudecode/index.ts +++ b/src/main/services/agents/services/claudecode/index.ts @@ -365,6 +365,16 @@ class ClaudeCodeService implements AgentServiceInterface { type: 'chunk', chunk }) + + // Close prompt stream when SDK signals completion or error + if (chunk.type === 'finish' || chunk.type === 'error') { + logger.info('Closing prompt stream as SDK signaled completion', { + chunkType: chunk.type, + reason: chunk.type === 'finish' ? 'finished' : 'error_occurred' + }) + closePromptStream() + logger.info('Prompt stream closed successfully') + } } } diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 55b1c5cc1a..fb34a13a26 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -7,11 +7,9 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { Provider } from 'react-redux' import { PersistGate } from 'redux-persist/integration/react' -import { ToastPortal } from './components/ToastPortal' import TopViewContainer from './components/TopView' import AntdProvider from './context/AntdProvider' import { CodeStyleProvider } from './context/CodeStyleProvider' -import { HeroUIProvider } from './context/HeroUIProvider' import { NotificationProvider } from './context/NotificationProvider' import StyleSheetManager from './context/StyleSheetManager' import { ThemeProvider } from './context/ThemeProvider' @@ -37,24 +35,21 @@ function App(): React.ReactElement { return ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + ) diff --git a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts index cc5f20c63e..39786231e6 100644 --- a/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts +++ b/src/renderer/src/aiCore/provider/__tests__/providerConfig.test.ts @@ -39,6 +39,7 @@ vi.mock('@renderer/config/providers', async (importOriginal) => { return { ...actual, isCherryAIProvider: vi.fn(), + isPerplexityProvider: vi.fn(), isAnthropicProvider: vi.fn(() => false), isAzureOpenAIProvider: vi.fn(() => false), isGeminiProvider: vi.fn(() => false), @@ -52,7 +53,7 @@ vi.mock('@renderer/hooks/useVertexAI', () => ({ createVertexProvider: vi.fn() })) -import { isCherryAIProvider } from '@renderer/config/providers' +import { isCherryAIProvider, isPerplexityProvider } from '@renderer/config/providers' import { getProviderByModel } from '@renderer/services/AssistantService' import type { Model, Provider } from '@renderer/types' import { formatApiHost } from '@renderer/utils/api' @@ -97,6 +98,16 @@ const createCherryAIProvider = (): Provider => ({ isSystem: false }) +const createPerplexityProvider = (): Provider => ({ + id: 'perplexity', + type: 'openai', + name: 'Perplexity', + apiKey: 'test-key', + apiHost: 'https://api.perplexity.ai', + models: [], + isSystem: false +}) + describe('Copilot responses routing', () => { beforeEach(() => { ;(globalThis as any).window = { @@ -195,3 +206,70 @@ describe('CherryAI provider configuration', () => { expect(actualProvider.apiHost).toBe('') }) }) + +describe('Perplexity provider configuration', () => { + beforeEach(() => { + ;(globalThis as any).window = { + ...(globalThis as any).window, + keyv: createWindowKeyv() + } + vi.clearAllMocks() + }) + + it('formats Perplexity provider apiHost with false parameter', () => { + const provider = createPerplexityProvider() + const model = createModel('sonar', 'Sonar', 'perplexity') + + // Mock the functions to simulate Perplexity provider detection + vi.mocked(isCherryAIProvider).mockReturnValue(false) + vi.mocked(isPerplexityProvider).mockReturnValue(true) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + // Call getActualProvider which should trigger formatProviderApiHost + const actualProvider = getActualProvider(model) + + // Verify that formatApiHost was called with false as the second parameter + expect(formatApiHost).toHaveBeenCalledWith('https://api.perplexity.ai', false) + expect(actualProvider.apiHost).toBe('https://api.perplexity.ai') + }) + + it('does not format non-Perplexity provider with false parameter', () => { + const provider = { + id: 'openai', + type: 'openai', + name: 'OpenAI', + apiKey: 'test-key', + apiHost: 'https://api.openai.com', + models: [], + isSystem: false + } as Provider + const model = createModel('gpt-4', 'GPT-4', 'openai') + + // Mock the functions to simulate non-Perplexity provider + vi.mocked(isCherryAIProvider).mockReturnValue(false) + vi.mocked(isPerplexityProvider).mockReturnValue(false) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + // Call getActualProvider + const actualProvider = getActualProvider(model) + + // Verify that formatApiHost was called with default parameters (true) + expect(formatApiHost).toHaveBeenCalledWith('https://api.openai.com') + expect(actualProvider.apiHost).toBe('https://api.openai.com/v1') + }) + + it('handles Perplexity provider with empty apiHost', () => { + const provider = createPerplexityProvider() + provider.apiHost = '' + const model = createModel('sonar', 'Sonar', 'perplexity') + + vi.mocked(isCherryAIProvider).mockReturnValue(false) + vi.mocked(isPerplexityProvider).mockReturnValue(true) + vi.mocked(getProviderByModel).mockReturnValue(provider) + + const actualProvider = getActualProvider(model) + + expect(formatApiHost).toHaveBeenCalledWith('', false) + expect(actualProvider.apiHost).toBe('') + }) +}) diff --git a/src/renderer/src/aiCore/provider/providerConfig.ts b/src/renderer/src/aiCore/provider/providerConfig.ts index 4dd146e08b..3d8be1221e 100644 --- a/src/renderer/src/aiCore/provider/providerConfig.ts +++ b/src/renderer/src/aiCore/provider/providerConfig.ts @@ -12,7 +12,8 @@ import { isAzureOpenAIProvider, isCherryAIProvider, isGeminiProvider, - isNewApiProvider + isNewApiProvider, + isPerplexityProvider } from '@renderer/config/providers' import { getAwsBedrockAccessKeyId, @@ -104,6 +105,8 @@ function formatProviderApiHost(provider: Provider): Provider { formatted.apiHost = formatVertexApiHost(formatted) } else if (isCherryAIProvider(formatted)) { formatted.apiHost = formatApiHost(formatted.apiHost, false) + } else if (isPerplexityProvider(formatted)) { + formatted.apiHost = formatApiHost(formatted.apiHost, false) } else { formatted.apiHost = formatApiHost(formatted.apiHost) } diff --git a/src/renderer/src/assets/styles/index.css b/src/renderer/src/assets/styles/index.css index ee3d267db6..8a361020fb 100644 --- a/src/renderer/src/assets/styles/index.css +++ b/src/renderer/src/assets/styles/index.css @@ -41,11 +41,11 @@ body, margin: 0; } -/* #root { +#root { display: flex; flex-direction: row; flex: 1; -} */ +} body { display: flex; diff --git a/src/renderer/src/assets/styles/tailwind.css b/src/renderer/src/assets/styles/tailwind.css index 59dd7b3b29..80d1649953 100644 --- a/src/renderer/src/assets/styles/tailwind.css +++ b/src/renderer/src/assets/styles/tailwind.css @@ -3,11 +3,6 @@ @import '../../../../../packages/ui/src/styles/theme.css'; -/* TODO heroui 迁移完成后即可删除 */ -/* heroui */ -/* @plugin '../../hero.ts'; */ -@source '../../../../../packages/ui/src/components/**/*.{js,ts,jsx,tsx}'; - @custom-variant dark (&:is(.dark *)); /* 如需自定义: @@ -82,4 +77,4 @@ :root { background-color: unset; -} +} \ No newline at end of file diff --git a/src/renderer/src/components/ApiModelLabel.tsx b/src/renderer/src/components/ApiModelLabel.tsx deleted file mode 100644 index 3e36083a69..0000000000 --- a/src/renderer/src/components/ApiModelLabel.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { Avatar, cn } from '@heroui/react' -import { getModelLogoById } from '@renderer/config/models' -import type { ApiModel } from '@renderer/types' -import React from 'react' - -import Ellipsis from './Ellipsis' - -export interface ModelLabelProps extends Omit, 'children'> { - model?: ApiModel - classNames?: { - container?: string - avatar?: string - modelName?: string - divider?: string - providerName?: string - } -} - -export const ApiModelLabel: React.FC = ({ model, className, classNames, ...props }) => { - return ( -
- - {model?.name} - | - {model?.provider_name} -
- ) -} diff --git a/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx b/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx index a1b8b6ce4a..649f619cba 100644 --- a/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx +++ b/src/renderer/src/components/Avatar/EmojiAvatarWithPicker.tsx @@ -1,5 +1,4 @@ -import { Button } from '@cherrystudio/ui' -import { Popover, PopoverContent, PopoverTrigger } from '@heroui/react' +import { Button, Popover } from 'antd' import React from 'react' import EmojiPicker from '../EmojiPicker' @@ -11,15 +10,10 @@ type Props = { export const EmojiAvatarWithPicker: React.FC = ({ emoji, onPick }) => { return ( - - - - - - - + } trigger="click"> + ) } diff --git a/src/renderer/src/components/HorizontalScrollContainer/index.tsx b/src/renderer/src/components/HorizontalScrollContainer/index.tsx index fdc890d2e2..ec9f7a1043 100644 --- a/src/renderer/src/components/HorizontalScrollContainer/index.tsx +++ b/src/renderer/src/components/HorizontalScrollContainer/index.tsx @@ -1,5 +1,5 @@ -import { cn } from '@heroui/react' import Scrollbar from '@renderer/components/Scrollbar' +import { cn } from '@renderer/utils' import { ChevronRight } from 'lucide-react' import { useEffect, useRef, useState } from 'react' import styled from 'styled-components' diff --git a/src/renderer/src/components/Popups/AddAssistantOrAgentPopup.tsx b/src/renderer/src/components/Popups/AddAssistantOrAgentPopup.tsx index 4094b3f3d1..4b3f969a26 100644 --- a/src/renderer/src/components/Popups/AddAssistantOrAgentPopup.tsx +++ b/src/renderer/src/components/Popups/AddAssistantOrAgentPopup.tsx @@ -1,5 +1,5 @@ -import { cn } from '@heroui/react' import { TopView } from '@renderer/components/TopView' +import { cn } from '@renderer/utils' import { Modal } from 'antd' import { Bot, MessageSquare } from 'lucide-react' import { useState } from 'react' @@ -51,7 +51,7 @@ const PopupContainer: React.FC = ({ onSelect, resolve }) => { - - - + +
+ + +
+
- - {selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')} - + + {selectedFolderPath || t('settings.data.export_to_phone.lan.noZipSelected')} + - - - - - - {showCloseConfirm && ( - -
-
- ⚠️ - - {t('settings.data.export_to_phone.lan.confirm_close_title')} - -
- - {t('settings.data.export_to_phone.lan.confirm_close_message')} - -
- - -
-
-
- )} - - )} - + + + ) } diff --git a/src/renderer/src/components/Popups/UpdateDialogPopup.tsx b/src/renderer/src/components/Popups/UpdateDialogPopup.tsx new file mode 100644 index 0000000000..29afcc0d24 --- /dev/null +++ b/src/renderer/src/components/Popups/UpdateDialogPopup.tsx @@ -0,0 +1,205 @@ +import { loggerService } from '@logger' +import { TopView } from '@renderer/components/TopView' +import { handleSaveData } from '@renderer/store' +import { Button, Modal } from 'antd' +import type { ReleaseNoteInfo, UpdateInfo } from 'builder-util-runtime' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import Markdown from 'react-markdown' +import styled from 'styled-components' + +const logger = loggerService.withContext('UpdateDialog') + +interface ShowParams { + releaseInfo: UpdateInfo | null +} + +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ releaseInfo, resolve }) => { + const { t } = useTranslation() + const [open, setOpen] = useState(true) + const [isInstalling, setIsInstalling] = useState(false) + + useEffect(() => { + if (releaseInfo) { + logger.info('Update dialog opened', { version: releaseInfo.version }) + } + }, [releaseInfo]) + + const handleInstall = async () => { + setIsInstalling(true) + try { + await handleSaveData() + await window.api.quitAndInstall() + setOpen(false) + } catch (error) { + logger.error('Failed to save data before update', error as Error) + setIsInstalling(false) + window.toast.error(t('update.saveDataError')) + } + } + + const onCancel = () => { + setOpen(false) + } + + const onClose = () => { + resolve({}) + } + + UpdateDialogPopup.hide = onCancel + + const releaseNotes = releaseInfo?.releaseNotes + + return ( + +

{t('update.title')}

+

{t('update.message').replace('{{version}}', releaseInfo?.version || '')}

+ + } + open={open} + onCancel={onCancel} + afterClose={onClose} + transitionName="animation-move-down" + centered + width={720} + footer={[ + , + + ]}> + + + + {typeof releaseNotes === 'string' + ? releaseNotes + : Array.isArray(releaseNotes) + ? releaseNotes + .map((note: ReleaseNoteInfo) => note.note) + .filter(Boolean) + .join('\n\n') + : t('update.noReleaseNotes')} + + + +
+ ) +} + +const TopViewKey = 'UpdateDialogPopup' + +export default class UpdateDialogPopup { + static topviewId = 0 + static hide() { + TopView.hide(TopViewKey) + } + static show(props: ShowParams) { + return new Promise((resolve) => { + TopView.show( + { + resolve(v) + TopView.hide(TopViewKey) + }} + />, + TopViewKey + ) + }) + } +} + +const ModalHeaderWrapper = styled.div` + display: flex; + flex-direction: column; + gap: 4px; + + h3 { + margin: 0; + font-size: 16px; + font-weight: 600; + color: var(--color-text-1); + } + + p { + margin: 0; + font-size: 14px; + color: var(--color-text-2); + } +` + +const ModalBodyWrapper = styled.div` + max-height: 450px; + overflow-y: auto; + padding: 12px 0; +` + +const ReleaseNotesWrapper = styled.div` + background-color: var(--color-bg-2); + border-radius: 8px; + + p { + margin: 0 0 12px 0; + color: var(--color-text-2); + font-size: 14px; + line-height: 1.6; + + &:last-child { + margin-bottom: 0; + } + } + + h1, + h2, + h3, + h4, + h5, + h6 { + margin: 16px 0 8px 0; + color: var(--color-text-1); + font-weight: 600; + + &:first-child { + margin-top: 0; + } + } + + ul, + ol { + margin: 8px 0; + padding-left: 24px; + color: var(--color-text-2); + } + + li { + margin: 4px 0; + } + + code { + padding: 2px 6px; + background-color: var(--color-bg-3); + border-radius: 4px; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 13px; + } + + pre { + padding: 12px; + background-color: var(--color-bg-3); + border-radius: 6px; + overflow-x: auto; + + code { + padding: 0; + background-color: transparent; + } + } +` diff --git a/src/renderer/src/components/Popups/agent/AgentModal.tsx b/src/renderer/src/components/Popups/agent/AgentModal.tsx index 547ea885bc..d504699399 100644 --- a/src/renderer/src/components/Popups/agent/AgentModal.tsx +++ b/src/renderer/src/components/Popups/agent/AgentModal.tsx @@ -1,44 +1,32 @@ -import type { SelectedItemProps } from '@heroui/react' -import { - Button, - Form, - Input, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - Select, - SelectItem, - Textarea, - useDisclosure -} from '@heroui/react' import { loggerService } from '@logger' -import type { Selection } from '@react-types/shared' import ClaudeIcon from '@renderer/assets/images/models/claude.png' +import { ErrorBoundary } from '@renderer/components/ErrorBoundary' +import { TopView } from '@renderer/components/TopView' import { permissionModeCards } from '@renderer/config/agent' -import { agentModelFilter, getModelLogoById } from '@renderer/config/models' import { useAgents } from '@renderer/hooks/agents/useAgents' -import { useApiModels } from '@renderer/hooks/agents/useModels' import { useUpdateAgent } from '@renderer/hooks/agents/useUpdateAgent' +import SelectAgentBaseModelButton from '@renderer/pages/home/components/SelectAgentBaseModelButton' import type { AddAgentForm, AgentEntity, AgentType, + ApiModel, BaseAgentForm, PermissionMode, Tool, UpdateAgentForm } from '@renderer/types' import { AgentConfigurationSchema, isAgentType } from '@renderer/types' +import { Avatar, Button, Input, Modal, Select } from 'antd' import { AlertTriangleIcon } from 'lucide-react' import type { ChangeEvent, FormEvent } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' +import styled from 'styled-components' -import { ErrorBoundary } from '../../ErrorBoundary' -import type { BaseOption, ModelOption } from './shared' -import { Option, renderOption } from './shared' +import type { BaseOption } from './shared' + +const { TextArea } = Input const logger = loggerService.withContext('AddAgentPopup') @@ -48,8 +36,6 @@ interface AgentTypeOption extends BaseOption { name: AgentEntity['name'] } -type Option = AgentTypeOption | ModelOption - type AgentWithTools = AgentEntity & { tools?: Tool[] } const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({ @@ -64,58 +50,37 @@ const buildAgentForm = (existing?: AgentWithTools): BaseAgentForm => ({ configuration: AgentConfigurationSchema.parse(existing?.configuration ?? {}) }) -type Props = { +interface ShowParams { agent?: AgentWithTools - isOpen: boolean - onClose: () => void afterSubmit?: (a: AgentEntity) => void } -/** - * Modal component for creating or editing an agent. - * - * Either trigger or isOpen and onClose is given. - * @param agent - Optional agent entity for editing mode. - * @param isOpen - Optional controlled modal open state. From useDisclosure. - * @param onClose - Optional callback when modal closes. From useDisclosure. - * @returns Modal component for agent creation/editing - */ -export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _onClose, afterSubmit }) => { - const { isOpen, onClose } = useDisclosure({ isOpen: _isOpen, onClose: _onClose }) +interface Props extends ShowParams { + resolve: (data: any) => void +} + +const PopupContainer: React.FC = ({ agent, afterSubmit, resolve }) => { const { t } = useTranslation() + const [open, setOpen] = useState(true) const loadingRef = useRef(false) - // const { setTimeoutTimer } = useTimer() const { addAgent } = useAgents() const { updateAgent } = useUpdateAgent() - // hard-coded. We only support anthropic for now. - const { models } = useApiModels({ providerType: 'anthropic' }) const isEditing = (agent?: AgentWithTools) => agent !== undefined const [form, setForm] = useState(() => buildAgentForm(agent)) useEffect(() => { - if (isOpen) { + if (open) { setForm(buildAgentForm(agent)) } - }, [agent, isOpen]) + }, [agent, open]) const selectedPermissionMode = form.configuration?.permission_mode ?? 'default' - const onPermissionModeChange = useCallback((keys: Selection) => { - if (keys === 'all') { - return - } - - const [first] = Array.from(keys) - if (!first) { - return - } - + const onPermissionModeChange = useCallback((value: PermissionMode) => { setForm((prev) => { const parsedConfiguration = AgentConfigurationSchema.parse(prev.configuration ?? {}) - const nextMode = first as PermissionMode - - if (parsedConfiguration.permission_mode === nextMode) { + if (parsedConfiguration.permission_mode === value) { if (!prev.configuration) { return { ...prev, @@ -129,7 +94,7 @@ export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _ ...prev, configuration: { ...parsedConfiguration, - permission_mode: nextMode + permission_mode: value } } }) @@ -150,55 +115,57 @@ export const AgentModal: React.FC = ({ agent, isOpen: _isOpen, onClose: _ [] ) - const agentOptions: AgentTypeOption[] = useMemo( + const agentOptions = useMemo( () => - agentConfig.map( - (option) => - ({ - ...option, - rendered: