From 43dc1e06e494493614d271c8e2e5610d2596bf40 Mon Sep 17 00:00:00 2001 From: one Date: Fri, 1 Aug 2025 20:13:37 +0800 Subject: [PATCH 01/18] perf: shiki code block (#8763) * perf: inlining completeLineTokens and use memo for minor improvements * chore: bump shiki to 3.9.1 * refactor: improve token line * refactor: add plainTokenStyle --- package.json | 4 +- .../components/CodeBlockView/CodePreview.tsx | 77 ++++++++-------- yarn.lock | 92 +++++++++---------- 3 files changed, 87 insertions(+), 86 deletions(-) diff --git a/package.json b/package.json index 92bdeced82..875515bbf9 100644 --- a/package.json +++ b/package.json @@ -129,7 +129,7 @@ "@opentelemetry/sdk-trace-web": "^2.0.0", "@playwright/test": "^1.52.0", "@reduxjs/toolkit": "^2.2.5", - "@shikijs/markdown-it": "^3.7.0", + "@shikijs/markdown-it": "^3.9.1", "@swc/plugin-styled-components": "^7.1.5", "@tanstack/react-query": "^5.27.0", "@tanstack/react-virtual": "^3.13.12", @@ -249,7 +249,7 @@ "remove-markdown": "^0.6.2", "rollup-plugin-visualizer": "^5.12.0", "sass": "^1.88.0", - "shiki": "^3.7.0", + "shiki": "^3.9.1", "strict-url-sanitise": "^0.0.1", "string-width": "^7.2.0", "styled-components": "^6.1.11", diff --git a/src/renderer/src/components/CodeBlockView/CodePreview.tsx b/src/renderer/src/components/CodeBlockView/CodePreview.tsx index c78b4af99c..9e08dab5ae 100644 --- a/src/renderer/src/components/CodeBlockView/CodePreview.tsx +++ b/src/renderer/src/components/CodeBlockView/CodePreview.tsx @@ -189,44 +189,12 @@ const CodePreview = ({ children, language, setTools }: CodePreviewProps) => { CodePreview.displayName = 'CodePreview' -/** - * 补全代码行 tokens,把原始内容拼接到高亮内容之后,确保渲染出整行来。 - */ -function completeLineTokens(themedTokens: ThemedToken[], rawLine: string): ThemedToken[] { - // 如果出现空行,补一个空格保证行高 - if (rawLine.length === 0) { - return [ - { - content: ' ', - offset: 0, - color: 'inherit', - bgColor: 'inherit', - htmlStyle: { - opacity: '0.35' - } - } - ] +const plainTokenStyle = { + color: 'inherit', + bgColor: 'inherit', + htmlStyle: { + opacity: '0.35' } - - const themedContent = themedTokens.map((token) => token.content).join('') - const extraContent = rawLine.slice(themedContent.length) - - // 已有内容已经全部高亮,直接返回 - if (!extraContent) return themedTokens - - // 补全剩余内容 - return [ - ...themedTokens, - { - content: extraContent, - offset: themedContent.length, - color: 'inherit', - bgColor: 'inherit', - htmlStyle: { - opacity: '0.35' - } - } - ] } interface VirtualizedRowData { @@ -240,11 +208,43 @@ interface VirtualizedRowData { */ const VirtualizedRow = memo( ({ rawLine, tokenLine, showLineNumbers, index }: VirtualizedRowData & { index: number }) => { + // 补全代码行 tokens,把原始内容拼接到高亮内容之后,确保渲染出整行来。 + const completeTokenLine = useMemo(() => { + // 如果出现空行,补一个空元素保证行高 + if (rawLine.length === 0) { + return [ + { + content: '', + offset: 0, + ...plainTokenStyle + } + ] + } + + const currentTokens = tokenLine ?? [] + const themedContentLength = currentTokens.reduce((acc, token) => acc + token.content.length, 0) + + // 已有内容已经全部高亮,直接返回 + if (themedContentLength >= rawLine.length) { + return currentTokens + } + + // 补全剩余内容 + return [ + ...currentTokens, + { + content: rawLine.slice(themedContentLength), + offset: themedContentLength, + ...plainTokenStyle + } + ] + }, [rawLine, tokenLine]) + return (
{showLineNumbers && {index + 1}} - {completeLineTokens(tokenLine ?? [], rawLine).map((token, tokenIndex) => ( + {completeTokenLine.map((token, tokenIndex) => ( {token.content} @@ -272,6 +272,7 @@ const ScrollContainer = styled.div<{ align-items: flex-start; width: 100%; line-height: ${(props) => props.$lineHeight}px; + contain: content; .line-number { width: var(--gutter-width, 1.2ch); diff --git a/yarn.lock b/yarn.lock index b7eb2b98f8..a1643ab873 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4923,79 +4923,79 @@ __metadata: languageName: node linkType: hard -"@shikijs/core@npm:3.7.0": - version: 3.7.0 - resolution: "@shikijs/core@npm:3.7.0" +"@shikijs/core@npm:3.9.1": + version: 3.9.1 + resolution: "@shikijs/core@npm:3.9.1" dependencies: - "@shikijs/types": "npm:3.7.0" + "@shikijs/types": "npm:3.9.1" "@shikijs/vscode-textmate": "npm:^10.0.2" "@types/hast": "npm:^3.0.4" hast-util-to-html: "npm:^9.0.5" - checksum: 10c0/885c9d00712d350ab0aac0d239b26d8ba045f075abbaee5eda33c4a39fe841a1a2b0ca34391a3704cde0edcf1468ca583bea25fcbd37b7b8d6189b7afcf2a55d + checksum: 10c0/2267cb9b056f29d93d60b5591340161db614719f1cee8e0050af8ca048eb8ee32bac51fcfe536de65dcaeadae8697fba1157c178803daae33771a2baf6bf9672 languageName: node linkType: hard -"@shikijs/engine-javascript@npm:3.7.0": - version: 3.7.0 - resolution: "@shikijs/engine-javascript@npm:3.7.0" +"@shikijs/engine-javascript@npm:3.9.1": + version: 3.9.1 + resolution: "@shikijs/engine-javascript@npm:3.9.1" dependencies: - "@shikijs/types": "npm:3.7.0" + "@shikijs/types": "npm:3.9.1" "@shikijs/vscode-textmate": "npm:^10.0.2" oniguruma-to-es: "npm:^4.3.3" - checksum: 10c0/ac792fe99a2007ab076856d32d4934e191d3b03f8bd416e96f461821580c223da73de8abc199e5eceb8b56fd47e992824b2571270e7c3d55efa6b9a6d87fec80 + checksum: 10c0/9d5e5e0fde46c9fc3813363f61b75cee9b06df10a676609b2006df344123993af94444f7564e44adb877c8299a33fa144c0bf35688370d0a70077249c2a5836b languageName: node linkType: hard -"@shikijs/engine-oniguruma@npm:3.7.0": - version: 3.7.0 - resolution: "@shikijs/engine-oniguruma@npm:3.7.0" +"@shikijs/engine-oniguruma@npm:3.9.1": + version: 3.9.1 + resolution: "@shikijs/engine-oniguruma@npm:3.9.1" dependencies: - "@shikijs/types": "npm:3.7.0" + "@shikijs/types": "npm:3.9.1" "@shikijs/vscode-textmate": "npm:^10.0.2" - checksum: 10c0/e1ec52ec2255e3330812084d62bde8853d20162b1cd285dbb63440d63d0b16c03b6ce6983982e41ac2fc2eceb3e2f6b2bc1c627d093482c4c3836c4fbb9567b0 + checksum: 10c0/70eb64cccb043d01f82804a0c630ce1861ab9cb0f79eca31ea550c1f9c6e7de2f37094c4c28f0fca81b26d78b77287d11c110809e7f76a59829c443abd88ef2c languageName: node linkType: hard -"@shikijs/langs@npm:3.7.0": - version: 3.7.0 - resolution: "@shikijs/langs@npm:3.7.0" +"@shikijs/langs@npm:3.9.1": + version: 3.9.1 + resolution: "@shikijs/langs@npm:3.9.1" dependencies: - "@shikijs/types": "npm:3.7.0" - checksum: 10c0/326e8b014e74d25ce84a63bf7fdd47d5582f85c8404d4c48d6bdacf2f32ab92ddb39b41710ee7eff3daaecbbea7ee96a6c49d427344ee8375551597c74010a81 + "@shikijs/types": "npm:3.9.1" + checksum: 10c0/94351ef82e0a7a26351eaf70e33a5c0a48727ef052b907cb3c09ebbd3bb8fb1ef7825ae27c0ff2829888d5fb9da24eeca86c914178c354754eefd7fab70a613f languageName: node linkType: hard -"@shikijs/markdown-it@npm:^3.7.0": - version: 3.7.0 - resolution: "@shikijs/markdown-it@npm:3.7.0" +"@shikijs/markdown-it@npm:^3.9.1": + version: 3.9.1 + resolution: "@shikijs/markdown-it@npm:3.9.1" dependencies: markdown-it: "npm:^14.1.0" - shiki: "npm:3.7.0" + shiki: "npm:3.9.1" peerDependencies: markdown-it-async: ^2.2.0 peerDependenciesMeta: markdown-it-async: optional: true - checksum: 10c0/28d7ccacf241ef9b60080f232ac352694e9f29556e27c4c874226cf222b2deac3edb6f27813f46cb6316fbcaaf1b7880a76fdd6acd6f40a36cde3b3392107449 + checksum: 10c0/54b7acbf1e12b8686a71fe22b988e1a1475d70bdca5434824f2cb75efc5fc929d9be793c7118e3d9a112589d39197e954b8d47dddbfc1e6981b05b5b1a28d98a languageName: node linkType: hard -"@shikijs/themes@npm:3.7.0": - version: 3.7.0 - resolution: "@shikijs/themes@npm:3.7.0" +"@shikijs/themes@npm:3.9.1": + version: 3.9.1 + resolution: "@shikijs/themes@npm:3.9.1" dependencies: - "@shikijs/types": "npm:3.7.0" - checksum: 10c0/6887eb99b55439988edab21a1af00302eaed6ba0dd7e2bea6c844ff4dfb8879a0c6c2178ba3fcfe2dbf3fd9f3ab6105572c57ae871e147aaceaf53bcc345d0cd + "@shikijs/types": "npm:3.9.1" + checksum: 10c0/a061eec4d9dd147d83cda9c41b296263fab92d6113146279a244751b9f016f8af543f91c37dcefe33f47cff9f1a1d7898f78a80169947ac119617b32d16766d4 languageName: node linkType: hard -"@shikijs/types@npm:3.7.0": - version: 3.7.0 - resolution: "@shikijs/types@npm:3.7.0" +"@shikijs/types@npm:3.9.1": + version: 3.9.1 + resolution: "@shikijs/types@npm:3.9.1" dependencies: "@shikijs/vscode-textmate": "npm:^10.0.2" "@types/hast": "npm:^3.0.4" - checksum: 10c0/d7c4fcca358c0585602090e2b4ed0a3f6742b55bea340030c115cb7aa643eac79836baa095517a538d695415458bb48c08b7be7f3c8d1cf1c1c7749a58913a3f + checksum: 10c0/c726478ae36ca078a8b9d61a9b51b83fe32b7af2cfe7ae597828b2ffccbd24858d955c49d0786af13ebd04cfbb9d192067499c410a05c41eb38da57928424076 languageName: node linkType: hard @@ -7677,7 +7677,7 @@ __metadata: "@opentelemetry/sdk-trace-web": "npm:^2.0.0" "@playwright/test": "npm:^1.52.0" "@reduxjs/toolkit": "npm:^2.2.5" - "@shikijs/markdown-it": "npm:^3.7.0" + "@shikijs/markdown-it": "npm:^3.9.1" "@strongtz/win32-arm64-msvc": "npm:^0.4.7" "@swc/plugin-styled-components": "npm:^7.1.5" "@tanstack/react-query": "npm:^5.27.0" @@ -7804,7 +7804,7 @@ __metadata: rollup-plugin-visualizer: "npm:^5.12.0" sass: "npm:^1.88.0" selection-hook: "npm:^1.0.8" - shiki: "npm:^3.7.0" + shiki: "npm:^3.9.1" strict-url-sanitise: "npm:^0.0.1" string-width: "npm:^7.2.0" styled-components: "npm:^6.1.11" @@ -19300,19 +19300,19 @@ __metadata: languageName: node linkType: hard -"shiki@npm:3.7.0, shiki@npm:^3.7.0": - version: 3.7.0 - resolution: "shiki@npm:3.7.0" +"shiki@npm:3.9.1, shiki@npm:^3.9.1": + version: 3.9.1 + resolution: "shiki@npm:3.9.1" dependencies: - "@shikijs/core": "npm:3.7.0" - "@shikijs/engine-javascript": "npm:3.7.0" - "@shikijs/engine-oniguruma": "npm:3.7.0" - "@shikijs/langs": "npm:3.7.0" - "@shikijs/themes": "npm:3.7.0" - "@shikijs/types": "npm:3.7.0" + "@shikijs/core": "npm:3.9.1" + "@shikijs/engine-javascript": "npm:3.9.1" + "@shikijs/engine-oniguruma": "npm:3.9.1" + "@shikijs/langs": "npm:3.9.1" + "@shikijs/themes": "npm:3.9.1" + "@shikijs/types": "npm:3.9.1" "@shikijs/vscode-textmate": "npm:^10.0.2" "@types/hast": "npm:^3.0.4" - checksum: 10c0/a458d06cc0487da5916e10ab9daaf314e3c3c92024664c545bcacb58fad9bd3aa18cde90a200afe4f6632a2487014def57eb6f10f38ab6269e90f3420b724105 + checksum: 10c0/383ca4b91b0ade1df7ce8889c4abeb9bfabead53a808f11de749e44f8400b3967d8bad7aad99a8ecf7991a2e1d1c42a71b73154d12baca6deeb979b9929376cb languageName: node linkType: hard From 63ae211af1c45e9eb62661c77630c71b9cc955f3 Mon Sep 17 00:00:00 2001 From: beyondkmp Date: Fri, 1 Aug 2025 20:54:56 +0800 Subject: [PATCH 02/18] fix(WindowService): comment out dock icon hiding for macOS when closing to tray due to cmd+h behavior issue (#8658) --- src/main/services/WindowService.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/src/main/services/WindowService.ts b/src/main/services/WindowService.ts index 64667cf618..c4a3a3eda9 100644 --- a/src/main/services/WindowService.ts +++ b/src/main/services/WindowService.ts @@ -356,10 +356,13 @@ export class WindowService { mainWindow.hide() - //for mac users, should hide dock icon if close to tray - if (isMac && isTrayOnClose) { - app.dock?.hide() - } + // TODO: don't hide dock icon when close to tray + // will cause the cmd+h behavior not working + // after the electron fix the bug, we can restore this code + // //for mac users, should hide dock icon if close to tray + // if (isMac && isTrayOnClose) { + // app.dock?.hide() + // } }) mainWindow.on('closed', () => { From 2ced1b2d714e73c579c72a9e5d77faf0c3323dea Mon Sep 17 00:00:00 2001 From: Caelan <79105826+jin-wang-c@users.noreply.github.com> Date: Fri, 1 Aug 2025 20:55:57 +0800 Subject: [PATCH 03/18] feature/dmxapi_painting_custom_size (#8689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 修改生成图片尺寸 * fix:known problem * fix:Switching but no recovery occurred * fix:The problem of loading images * fix:text i18n --- src/renderer/src/i18n/locales/en-us.json | 2 + src/renderer/src/i18n/locales/ja-jp.json | 2 + src/renderer/src/i18n/locales/ru-ru.json | 2 + src/renderer/src/i18n/locales/zh-cn.json | 2 + src/renderer/src/i18n/locales/zh-tw.json | 2 + .../src/pages/paintings/DmxapiPage.tsx | 250 ++++++++++++------ .../pages/paintings/config/DmxapiConfig.ts | 48 +--- 7 files changed, 189 insertions(+), 119 deletions(-) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 45bd943398..01c44a3fda 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1507,6 +1507,7 @@ "image": "New Image" } }, + "custom_size": "Custom Size", "edit": { "image_file": "Edited Image", "magic_prompt_option_tip": "Intelligently enhances editing prompts", @@ -1629,6 +1630,7 @@ }, "text_desc_required": "Please enter image description first", "title": "Images", + "top_up": "Top up ", "translating": "Translating...", "uploaded_input": "Uploaded input", "upscale": { diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index e753d8010d..e9e19ae633 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1507,6 +1507,7 @@ "image": "新しい画像" } }, + "custom_size": "カスタムサイズ", "edit": { "image_file": "編集画像", "magic_prompt_option_tip": "編集効果を向上させるための提示詞を最適化します", @@ -1629,6 +1630,7 @@ }, "text_desc_required": "画像の説明を先に入力してください", "title": "画像", + "top_up": "チャージする", "translating": "翻訳中...", "uploaded_input": "アップロード済みの入力", "upscale": { diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 08d1bf6dba..59e965dae5 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1507,6 +1507,7 @@ "image": "Новое изображение" } }, + "custom_size": "Пользовательский размер", "edit": { "image_file": "Изображение для редактирования", "magic_prompt_option_tip": "Интеллектуально оптимизирует подсказки для улучшения эффекта редактирования", @@ -1629,6 +1630,7 @@ }, "text_desc_required": "Пожалуйста, сначала введите описание изображения", "title": "Изображения", + "top_up": "пополнить счёт", "translating": "Перевод...", "uploaded_input": "Загруженный ввод", "upscale": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index fa455db0ee..1d60875212 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1507,6 +1507,7 @@ "image": "新建图片" } }, + "custom_size": "自定义尺寸", "edit": { "image_file": "编辑的图像", "magic_prompt_option_tip": "智能优化编辑提示词", @@ -1629,6 +1630,7 @@ }, "text_desc_required": "请先输入图片描述", "title": "图片", + "top_up": "充值", "translating": "翻译中...", "uploaded_input": "已上传输入", "upscale": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 27657edc2a..0f44b308ac 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1507,6 +1507,7 @@ "image": "新繪圖" } }, + "custom_size": "自訂尺寸", "edit": { "image_file": "編輯圖像", "magic_prompt_option_tip": "智能優化編輯提示詞", @@ -1629,6 +1630,7 @@ }, "text_desc_required": "請先輸入圖片描述", "title": "繪圖", + "top_up": "儲值", "translating": "翻譯中...", "uploaded_input": "已上傳輸入", "upscale": { diff --git a/src/renderer/src/pages/paintings/DmxapiPage.tsx b/src/renderer/src/pages/paintings/DmxapiPage.tsx index e4e9647981..a829320ddd 100644 --- a/src/renderer/src/pages/paintings/DmxapiPage.tsx +++ b/src/renderer/src/pages/paintings/DmxapiPage.tsx @@ -1,11 +1,10 @@ import { PlusOutlined, RedoOutlined } from '@ant-design/icons' import DMXAPIToImg from '@renderer/assets/images/providers/DMXAPI-to-img.webp' import { Navbar, NavbarCenter, NavbarRight } from '@renderer/components/app/Navbar' -import { HStack, VStack } from '@renderer/components/Layout' +import { HStack } from '@renderer/components/Layout' import Scrollbar from '@renderer/components/Scrollbar' import { isMac } from '@renderer/config/constant' import { getProviderLogo } from '@renderer/config/providers' -import { useTheme } from '@renderer/context/ThemeProvider' import { usePaintings } from '@renderer/hooks/usePaintings' import { useAllProviders } from '@renderer/hooks/useProvider' import { useRuntime } from '@renderer/hooks/useRuntime' @@ -16,7 +15,7 @@ import { setGenerating } from '@renderer/store/runtime' import type { FileMetadata, PaintingsState } from '@renderer/types' import { uuid } from '@renderer/utils' import { DmxapiPainting } from '@types' -import { Avatar, Button, Input, Radio, Segmented, Select, Switch, Tooltip } from 'antd' +import { Avatar, Button, Input, InputNumber, Segmented, Select, Switch, Tooltip } from 'antd' import TextArea from 'antd/es/input/TextArea' import { Info } from 'lucide-react' import React, { FC, useEffect, useRef, useState } from 'react' @@ -34,9 +33,9 @@ import { COURSE_URL, DEFAULT_PAINTING, GetModelGroup, - IMAGE_SIZES, MODEOPTIONS, - STYLE_TYPE_OPTIONS + STYLE_TYPE_OPTIONS, + TOP_UP_URL } from './config/DmxapiConfig' const generateRandomSeed = () => Math.floor(Math.random() * 1000000).toString() @@ -45,7 +44,6 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { const [mode] = useState('DMXAPIPaintings') const { DMXAPIPaintings, addPainting, removePainting, updatePainting } = usePaintings() const [painting, setPainting] = useState(DMXAPIPaintings?.[0] || DEFAULT_PAINTING) - const { theme } = useTheme() const { t } = useTranslation() const providers = useAllProviders() const providerOptions = Options.map((option) => { @@ -88,6 +86,11 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { paths: [] }) + // 自定义尺寸相关状态 + const [isCustomSize, setIsCustomSize] = useState(false) + const [customWidth, setCustomWidth] = useState() + const [customHeight, setCustomHeight] = useState() + const modeOptions = MODEOPTIONS.map((ele) => { return { label: t(ele.label), @@ -144,25 +147,45 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { updatePainting('DMXAPIPaintings', updatedPainting) } - const getNewPainting = (params?: Partial) => { - clearImages() - const generationMode = params?.generationMode || painting?.generationMode || MODEOPTIONS[0].value - const modelGroups = getModelOptions(generationMode as generationModeType) - // 获取第一个非空分组的第一个模型 - let firstModel = '' + const getFirstModelInfo = (v: generationModeType) => { + const modelGroups = getModelOptions(v) + + let model = '' + let priceModel = '' + let image_size = '' for (const provider of Object.keys(modelGroups)) { - if (modelGroups[provider].length > 0) { - firstModel = modelGroups[provider][0].id + if (modelGroups[provider] && modelGroups[provider].length > 0) { + model = modelGroups[provider][0].id + priceModel = modelGroups[provider][0].price + image_size = modelGroups[provider][0].image_sizes[0].value break } } + return { + model, + priceModel, + image_size, + modelGroups + } + } + + const getNewPainting = (params?: Partial) => { + clearImages() + + const generationMode = params?.generationMode || painting?.generationMode || MODEOPTIONS[0].value + + const { model, priceModel, image_size, modelGroups } = getFirstModelInfo(generationMode) + return { ...DEFAULT_PAINTING, id: uuid(), seed: generateRandomSeed(), generationMode, - model: firstModel, + model, + modelGroups, + priceModel, + image_size, ...params } } @@ -180,7 +203,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { const onSelectModel = (modelId: string) => { const model = allModels.find((m) => m.id === modelId) if (model) { - updatePaintingState({ model: modelId, priceModel: model.price }) + updatePaintingState({ model: modelId, priceModel: model.price, image_size: model.image_sizes[0].value }) } } @@ -189,8 +212,34 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { } const onSelectImageSize = (v: string) => { - const size = IMAGE_SIZES.find((i) => i.value === v) - size && updatePaintingState({ image_size: size.value, aspect_ratio: size.label }) + if (v === 'custom') { + setIsCustomSize(true) + // 如果有自定义尺寸值,使用它们 + if (customWidth && customHeight) { + updatePaintingState({ image_size: `${customWidth}x${customHeight}`, aspect_ratio: 'custom' }) + } + } else { + setIsCustomSize(false) + const currentModel = allModels.find((m) => m.id === painting.model) + const size = currentModel?.image_sizes?.find((i) => i.value === v) + size && updatePaintingState({ image_size: size.value, aspect_ratio: size.label }) + } + } + + const onCustomSizeChange = (value: number | null, type: string) => { + if (value === null) return + + if (type === 'width') { + setCustomWidth(value) + if (customHeight) { + updatePaintingState({ image_size: `${value}x${customHeight}`, aspect_ratio: 'custom' }) + } + } else if (type === 'height') { + setCustomHeight(value) + if (customWidth) { + updatePaintingState({ image_size: `${customWidth}x${value}`, aspect_ratio: 'custom' }) + } + } } const onSelectStyleType = (v: string) => { @@ -251,27 +300,21 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { } const onGenerationModeChange = (v: generationModeType) => { - clearImages() - const newModelGroups = getModelOptions(v) - setModelOptions(newModelGroups) - - // 获取第一个非空分组的第一个模型 - let firstModel = '' - let priceModel = '' - for (const provider of Object.keys(newModelGroups)) { - if (newModelGroups[provider] && newModelGroups[provider].length > 0) { - firstModel = newModelGroups[provider][0].id - priceModel = newModelGroups[provider][0].price - break - } + if (isLoading) { + return } + clearImages() + + const { model, priceModel, image_size, modelGroups } = getFirstModelInfo(v) + + setModelOptions(modelGroups) + // 如果有urls,创建新的painting if (Array.isArray(painting.urls) && painting.urls.length > 0) { const newPainting = getNewPainting({ generationMode: v, - model: firstModel, // 使用新模式下的第一个模型 - priceModel: priceModel + model }) const addedPainting = addPainting('DMXAPIPaintings', newPainting) setPainting(addedPainting) @@ -279,12 +322,20 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { // 否则更新当前painting updatePaintingState({ generationMode: v, - model: firstModel, // 使用新模式下的第一个模型 + model: model, + image_size: image_size, priceModel: priceModel }) } } + const createNewPainting = () => { + if (isLoading) { + return + } + setPainting(addPainting('DMXAPIPaintings', getNewPainting())) + } + // 检查提供者状态函数 const checkProviderStatus = () => { if (!dmxapiProvider.enabled) { @@ -324,10 +375,6 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { 'Content-Type': 'application/json' } - if (painting.aspect_ratio) { - params['aspect_ratio'] = painting.aspect_ratio - } - if (painting.image_size) { params['size'] = painting.image_size } @@ -360,7 +407,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { } if (painting.image_size) { - params['size'] = '1024x1024' + params['size'] = painting.image_size } if (painting.style_type) { @@ -562,6 +609,10 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { const onDeletePainting = async (paintingToDelete: DmxapiPainting) => { if (paintingToDelete.id === painting.id) { + if (isLoading) { + return + } + const currentIndex = DMXAPIPaintings.findIndex((p) => p.id === paintingToDelete.id) if (currentIndex > 0) { @@ -715,17 +766,21 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [isLoadingModels, dynamicModelGroups]) // 依赖模型加载状态 + // 当模型切换时,检查是否支持自定义尺寸 + useEffect(() => { + const currentModel = allModels.find((m) => m.id === painting.model) + if (currentModel && !currentModel.is_custom_size && isCustomSize) { + setIsCustomSize(false) + } + }, [painting.model, allModels, isCustomSize]) + return ( {t('paintings.title')} {isMac && ( - @@ -735,15 +790,20 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { {t('common.provider')} - - {t('paintings.paint_course')} +
+ + {t('paintings.paint_course')} + + + {t('paintings.top_up')} + - +
+ {t('paintings.image.size')} + + + {/* 自定义尺寸输入框 */} + {isCustomSize && allModels.find((m) => m.id === painting.model)?.is_custom_size && ( +
+ + onCustomSizeChange(value, 'width')} + min={parseInt(allModels.find((m) => m.id === painting.model)?.min_image_size || '512')} + max={parseInt(allModels.find((m) => m.id === painting.model)?.max_image_size || '2048')} + style={{ width: 80, flex: 1 }} + /> + x + onCustomSizeChange(value, 'height')} + min={parseInt(allModels.find((m) => m.id === painting.model)?.min_image_size || 512)} + max={parseInt(allModels.find((m) => m.id === painting.model)?.max_image_size || 2048)} + style={{ width: 80, flex: 1 }} + /> + px + +
+ )} + {painting.generationMode === generationModeType.GENERATION && ( <> - {t('paintings.image.size')} - onSelectImageSize(e.target.value)} - style={{ display: 'flex' }}> - {IMAGE_SIZES.map((size) => ( - - - - {size.label} - - - ))} - - {t('paintings.seed')} @@ -896,7 +999,7 @@ const DmxapiPage: FC<{ Options: string[] }> = ({ Options }) => { selectedPainting={painting} onSelectPainting={onSelectPainting} onDeletePainting={onDeletePainting} - onNewPainting={() => setPainting(addPainting('DMXAPIPaintings', getNewPainting()))} + onNewPainting={createNewPainting} />
@@ -991,22 +1094,6 @@ const ToolbarMenu = styled.div` align-items: center; gap: 6px; ` - -const ImageSizeImage = styled.img<{ theme: string }>` - filter: ${({ theme }) => (theme === 'dark' ? 'invert(100%)' : 'none')}; - margin-top: 8px; -` - -const RadioButton = styled(Radio.Button)` - width: 30px; - height: 55px; - display: flex; - flex-direction: column; - flex: 1; - justify-content: center; - align-items: center; -` - const InfoIcon = styled(Info)` margin-left: 5px; cursor: help; @@ -1078,8 +1165,11 @@ const EmptyImgBox = styled.div` const EmptyImg = styled.div<{ bgUrl?: string }>` width: 70vh; height: 70vh; - background-size: cover; + background-size: contain; + background-repeat: no-repeat; + background-position: center; background-image: ${(props) => (props.bgUrl ? `url(${props.bgUrl})` : `url(${DMXAPIToImg})`)}; + background-color: #ffffff; ` const LoadTextWrap = styled.div` diff --git a/src/renderer/src/pages/paintings/config/DmxapiConfig.ts b/src/renderer/src/pages/paintings/config/DmxapiConfig.ts index 7336b66451..261dcb6a48 100644 --- a/src/renderer/src/pages/paintings/config/DmxapiConfig.ts +++ b/src/renderer/src/pages/paintings/config/DmxapiConfig.ts @@ -1,9 +1,3 @@ -import ImageSize1_1 from '@renderer/assets/images/paintings/image-size-1-1.svg' -import ImageSize1_2 from '@renderer/assets/images/paintings/image-size-1-2.svg' -import ImageSize3_2 from '@renderer/assets/images/paintings/image-size-3-2.svg' -import ImageSize3_4 from '@renderer/assets/images/paintings/image-size-3-4.svg' -import ImageSize9_16 from '@renderer/assets/images/paintings/image-size-9-16.svg' -import ImageSize16_9 from '@renderer/assets/images/paintings/image-size-16-9.svg' import { uuid } from '@renderer/utils' import { t } from 'i18next' @@ -15,6 +9,13 @@ export type DMXApiModelData = { provider: string name: string price: string + image_sizes: Array<{ + label: string + value: string + }> + is_custom_size: boolean + max_image_size?: number + min_image_size?: number } // 模型分组类型 @@ -54,41 +55,10 @@ export const STYLE_TYPE_OPTIONS = [ { label: '巴洛克', value: '巴洛克' } ] -export const IMAGE_SIZES = [ - { - label: '1:1', - value: '1328x1328', - icon: ImageSize1_1 - }, - { - label: '1:2', - value: '800x1600', - icon: ImageSize1_2 - }, - { - label: '3:2', - value: '1584x1056', - icon: ImageSize3_2 - }, - { - label: '3:4', - value: '1104x1472', - icon: ImageSize3_4 - }, - { - label: '16:9', - value: '1664x936', - icon: ImageSize16_9 - }, - { - label: '9:16', - value: '936x1664', - icon: ImageSize9_16 - } -] - export const COURSE_URL = 'http://seedream.dmxapi.cn/' +export const TOP_UP_URL = 'https://www.dmxapi.cn/topup' + export const DEFAULT_PAINTING: DmxapiPainting = { id: uuid(), urls: [], From 3a4803b675f09bc710674e08c9c70eb2407f912a Mon Sep 17 00:00:00 2001 From: Bruce Wang Date: Fri, 1 Aug 2025 21:04:23 +0800 Subject: [PATCH 04/18] fix: release sync git tag (#8755) --- .github/workflows/release.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 33b1529b40..d6581095e9 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,6 +39,13 @@ jobs: echo "tag=${GITHUB_REF#refs/tags/}" >> $GITHUB_OUTPUT fi + - name: Set package.json version + shell: bash + run: | + TAG="${{ steps.get-tag.outputs.tag }}" + VERSION="${TAG#v}" + npm version "$VERSION" --no-git-tag-version --allow-same-version + - name: Install Node.js uses: actions/setup-node@v4 with: From 12119c4fafa8c8c6dc4205915c363b5ba5b2d0d7 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sat, 2 Aug 2025 00:05:15 +0800 Subject: [PATCH 05/18] chore(tsconfig): adjust the path order (#8769) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit chore(tsconfig): 调整路径别名顺序 将@logger路径别名移动到相关路径组顶部 --- tsconfig.node.json | 2 +- tsconfig.web.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.node.json b/tsconfig.node.json index a63dca859a..e18678b562 100644 --- a/tsconfig.node.json +++ b/tsconfig.node.json @@ -18,10 +18,10 @@ ], "baseUrl": ".", "paths": { + "@logger": ["src/main/services/LoggerService"], "@main/*": ["src/main/*"], "@types": ["src/renderer/src/types/index.ts"], "@shared/*": ["packages/shared/*"], - "@logger": ["src/main/services/LoggerService"], "@mcp-trace/*": ["packages/mcp-trace/*"] }, "experimentalDecorators": true, diff --git a/tsconfig.web.json b/tsconfig.web.json index c3c61cb0a7..269f119f3b 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -14,10 +14,10 @@ "baseUrl": ".", "moduleResolution": "bundler", "paths": { + "@logger": ["src/renderer/src/services/LoggerService"], "@renderer/*": ["src/renderer/src/*"], "@shared/*": ["packages/shared/*"], "@types": ["src/renderer/src/types/index.ts"], - "@logger": ["src/renderer/src/services/LoggerService"], "@mcp-trace/*": ["packages/mcp-trace/*"] }, "experimentalDecorators": true, From c52bb47fefbb2612e182430e0c788421d5898132 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sat, 2 Aug 2025 14:45:09 +0800 Subject: [PATCH 06/18] feat(llm): add provider Poe (#8758) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(llm): 添加Poe作为新的LLM提供商 - 在SYSTEM_MODELS中添加Poe的GPT-4o模型 - 在INITIAL_PROVIDERS中新增Poe提供商配置 - 添加Poe提供商logo资源文件 - 更新migrate.ts处理版本127的迁移逻辑 - 增加Poe提供商的相关文档链接配置 * feat(provider): 添加对开发者角色支持提供商的检查功能 在OpenAI客户端中根据提供商支持情况动态设置角色 --- .../aiCore/clients/openai/OpenAIApiClient.ts | 3 ++- .../clients/openai/OpenAIResponseAPIClient.ts | 7 ++++++- .../src/assets/images/providers/poe.svg | 1 + src/renderer/src/config/models.ts | 10 +++++++++- src/renderer/src/config/providers.ts | 20 ++++++++++++++++++- src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/llm.ts | 10 ++++++++++ src/renderer/src/store/migrate.ts | 9 +++++++++ 8 files changed, 57 insertions(+), 5 deletions(-) create mode 100644 src/renderer/src/assets/images/providers/poe.svg diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index b5d1954bc9..d4ad386a4a 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -62,6 +62,7 @@ import { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatComple import { GenericChunk } from '../../middleware/schemas' import { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types' import { OpenAIBaseClient } from './OpenAIBaseClient' +import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' const logger = loggerService.withContext('OpenAIApiClient') @@ -491,7 +492,7 @@ export class OpenAIAPIClient extends OpenAIBaseClient< if (isSupportedReasoningEffortOpenAIModel(model)) { systemMessage = { - role: 'developer', + role: isSupportDeveloperRoleProvider(this.provider) ? 'developer' : 'system', content: `Formatting re-enabled${systemMessage ? '\n' + systemMessage.content : ''}` } } diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index 2cc34ddb97..cc9dba7095 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -44,6 +44,7 @@ import { ResponseInput } from 'openai/resources/responses/responses' import { RequestTransformer, ResponseChunkTransformer } from '../types' import { OpenAIAPIClient } from './OpenAIApiClient' import { OpenAIBaseClient } from './OpenAIBaseClient' +import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' export class OpenAIResponseAPIClient extends OpenAIBaseClient< OpenAI, @@ -369,7 +370,11 @@ export class OpenAIResponseAPIClient extends OpenAIBaseClient< type: 'input_text' } if (isSupportedReasoningEffortOpenAIModel(model)) { - systemMessage.role = 'developer' + if (isSupportDeveloperRoleProvider(this.provider)) { + systemMessage.role = 'developer' + } else { + systemMessage.role = 'system' + } } // 2. 设置工具 diff --git a/src/renderer/src/assets/images/providers/poe.svg b/src/renderer/src/assets/images/providers/poe.svg new file mode 100644 index 0000000000..1083effc31 --- /dev/null +++ b/src/renderer/src/assets/images/providers/poe.svg @@ -0,0 +1 @@ +Poe \ No newline at end of file diff --git a/src/renderer/src/config/models.ts b/src/renderer/src/config/models.ts index a93b6b1b18..45100719dc 100644 --- a/src/renderer/src/config/models.ts +++ b/src/renderer/src/config/models.ts @@ -2346,7 +2346,15 @@ export const SYSTEM_MODELS: Record = { } ], 'new-api': [], - 'aws-bedrock': [] + 'aws-bedrock': [], + poe: [ + { + id: 'gpt-4o', + name: 'GPT-4o', + provider: 'poe', + group: 'poe' + } + ] } export const TEXT_TO_IMAGES_MODELS = [ diff --git a/src/renderer/src/config/providers.ts b/src/renderer/src/config/providers.ts index 693f7a3988..85e53bef56 100644 --- a/src/renderer/src/config/providers.ts +++ b/src/renderer/src/config/providers.ts @@ -15,6 +15,7 @@ import DeepSeekProviderLogo from '@renderer/assets/images/providers/deepseek.png import DmxapiProviderLogo from '@renderer/assets/images/providers/DMXAPI.png' import FireworksProviderLogo from '@renderer/assets/images/providers/fireworks.png' import GiteeAIProviderLogo from '@renderer/assets/images/providers/gitee-ai.png' +import PoeProviderLogo from '@renderer/assets/images/providers/poe.svg' import GithubProviderLogo from '@renderer/assets/images/providers/github.png' import GoogleProviderLogo from '@renderer/assets/images/providers/google.png' import GPUStackProviderLogo from '@renderer/assets/images/providers/gpustack.svg' @@ -53,6 +54,7 @@ import ZeroOneProviderLogo from '@renderer/assets/images/providers/zero-one.png' import ZhipuProviderLogo from '@renderer/assets/images/providers/zhipu.png' import { TOKENFLUX_HOST } from './constant' +import { Provider } from '@renderer/types' const PROVIDER_LOGO_MAP = { ph8: Ph8ProviderLogo, @@ -108,7 +110,8 @@ const PROVIDER_LOGO_MAP = { lanyun: LanyunProviderLogo, vertexai: VertexAIProviderLogo, 'new-api': NewAPIProviderLogo, - 'aws-bedrock': AwsProviderLogo + 'aws-bedrock': AwsProviderLogo, + poe: PoeProviderLogo } as const export function getProviderLogo(providerId: string) { @@ -702,5 +705,20 @@ export const PROVIDER_CONFIG = { docs: 'https://docs.aws.amazon.com/bedrock/', models: 'https://docs.aws.amazon.com/bedrock/latest/userguide/models-supported.html' } + }, + poe: { + api: { + url: 'https://api.poe.com/v1' + }, + websites: { + official: 'https://poe.com/', + apiKey: 'https://poe.com/api_key', + docs: 'https://creator.poe.com/docs/external-applications/openai-compatible-api', + models: 'https://poe.com/' + } } } + +export const isSupportDeveloperRoleProvider = (provider: Provider) => { + return provider.id !== 'poe' +} diff --git a/src/renderer/src/store/index.ts b/src/renderer/src/store/index.ts index f0912b24a3..88df884f74 100644 --- a/src/renderer/src/store/index.ts +++ b/src/renderer/src/store/index.ts @@ -60,7 +60,7 @@ const persistedReducer = persistReducer( { key: 'cherry-studio', storage, - version: 126, + version: 127, blacklist: ['runtime', 'messages', 'messageBlocks', 'tabs'], migrate }, diff --git a/src/renderer/src/store/llm.ts b/src/renderer/src/store/llm.ts index b54c8c67d7..2534b6cdae 100644 --- a/src/renderer/src/store/llm.ts +++ b/src/renderer/src/store/llm.ts @@ -552,6 +552,16 @@ export const INITIAL_PROVIDERS: Provider[] = [ models: SYSTEM_MODELS['aws-bedrock'], isSystem: true, enabled: false + }, + { + id: 'poe', + name: 'Poe', + type: 'openai', + apiKey: '', + apiHost: 'https://api.poe.com/v1/', + models: SYSTEM_MODELS['poe'], + isSystem: true, + enabled: false } ] diff --git a/src/renderer/src/store/migrate.ts b/src/renderer/src/store/migrate.ts index 1a3313cbed..44ba1bcd01 100644 --- a/src/renderer/src/store/migrate.ts +++ b/src/renderer/src/store/migrate.ts @@ -1958,6 +1958,15 @@ const migrateConfig = { logger.error('migrate 126 error', error as Error) return state } + }, + '127': (state: RootState) => { + try { + addProvider(state, 'poe') + return state + } catch (error) { + logger.error('migrate 127 error', error as Error) + return state + } } } From 82923a7c64cd9a7208d740f0b51883c75af2638b Mon Sep 17 00:00:00 2001 From: LiuVaayne <10231735+vaayne@users.noreply.github.com> Date: Sat, 2 Aug 2025 14:54:35 +0800 Subject: [PATCH 07/18] fix(MCP): add missing /mcp suffix to TokenFlux sync URLs (#8777) --- .../settings/MCPSettings/SyncServersPopup.tsx | 21 ++++++---- .../settings/MCPSettings/providers/302ai.ts | 31 +++++++++++---- .../settings/MCPSettings/providers/lanyun.ts | 38 +++++++++++-------- .../modelscope.ts} | 35 ++++++++++++----- .../MCPSettings/providers/tokenflux.ts | 30 +++++++++++---- 5 files changed, 107 insertions(+), 48 deletions(-) rename src/renderer/src/pages/settings/MCPSettings/{modelscopeSyncUtils.ts => providers/modelscope.ts} (75%) diff --git a/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx b/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx index fdde4f49e3..cb4c58f460 100644 --- a/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx +++ b/src/renderer/src/pages/settings/MCPSettings/SyncServersPopup.tsx @@ -6,9 +6,9 @@ import { useCallback, useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' -import { getModelScopeToken, saveModelScopeToken, syncModelScopeServers } from './modelscopeSyncUtils' import { getAI302Token, saveAI302Token, syncAi302Servers } from './providers/302ai' import { getTokenLanYunToken, LANYUN_KEY_HOST, saveTokenLanYunToken, syncTokenLanYunServers } from './providers/lanyun' +import { getModelScopeToken, MODELSCOPE_HOST, saveModelScopeToken, syncModelScopeServers } from './providers/modelscope' import { getTokenFluxToken, saveTokenFluxToken, syncTokenFluxServers, TOKENFLUX_HOST } from './providers/tokenflux' // Provider configuration interface @@ -30,8 +30,8 @@ const providers: ProviderConfig[] = [ key: 'modelscope', name: 'ModelScope', description: 'ModelScope 平台 MCP 服务', - discoverUrl: 'https://www.modelscope.cn/mcp?hosted=1&page=1', - apiKeyUrl: 'https://www.modelscope.cn/my/myaccesstoken', + discoverUrl: `${MODELSCOPE_HOST}/mcp?hosted=1&page=1`, + apiKeyUrl: `${MODELSCOPE_HOST}/my/myaccesstoken`, tokenFieldName: 'modelScopeToken', getToken: getModelScopeToken, saveToken: saveModelScopeToken, @@ -78,7 +78,7 @@ interface Props { } const PopupContainer: React.FC = ({ resolve, existingServers }) => { - const { addMCPServer } = useMCPServers() + const { addMCPServer, updateMCPServer } = useMCPServers() const [open, setOpen] = useState(true) const [isSyncing, setIsSyncing] = useState(false) const [selectedProviderKey, setSelectedProviderKey] = useState(providers[0].key) @@ -128,11 +128,18 @@ const PopupContainer: React.FC = ({ resolve, existingServers }) => { // Sync servers const result = await selectedProvider.syncServers(token, existingServers) - if (result.success && result.addedServers?.length > 0) { - // Add the new servers to the store + if (result.success && (result.addedServers?.length > 0 || (result as any).updatedServers?.length > 0)) { + // Add new servers to the store for (const server of result.addedServers) { addMCPServer(server) } + // Update existing servers with latest info + const updatedServers = (result as any).updatedServers + if (updatedServers?.length > 0) { + for (const server of updatedServers) { + updateMCPServer(server) + } + } window.message.success(result.message) setOpen(false) } else { @@ -148,7 +155,7 @@ const PopupContainer: React.FC = ({ resolve, existingServers }) => { } finally { setIsSyncing(false) } - }, [addMCPServer, existingServers, form, selectedProvider, t]) + }, [addMCPServer, updateMCPServer, existingServers, form, selectedProvider, t]) const onCancel = () => { setOpen(false) diff --git a/src/renderer/src/pages/settings/MCPSettings/providers/302ai.ts b/src/renderer/src/pages/settings/MCPSettings/providers/302ai.ts index 4fbb23c7e9..646b929d47 100644 --- a/src/renderer/src/pages/settings/MCPSettings/providers/302ai.ts +++ b/src/renderer/src/pages/settings/MCPSettings/providers/302ai.ts @@ -29,6 +29,7 @@ interface Ai302SyncResult { success: boolean message: string addedServers: MCPServer[] + updatedServers: MCPServer[] errorDetails?: string } @@ -51,7 +52,8 @@ export const syncAi302Servers = async (token: string, existingServers: MCPServer return { success: false, message: t('settings.mcp.sync.unauthorized', 'Sync Unauthorized'), - addedServers: [] + addedServers: [], + updatedServers: [] } } @@ -61,6 +63,7 @@ export const syncAi302Servers = async (token: string, existingServers: MCPServer success: false, message: t('settings.mcp.sync.error'), addedServers: [], + updatedServers: [], errorDetails: `Status: ${response.status}` } } @@ -74,17 +77,20 @@ export const syncAi302Servers = async (token: string, existingServers: MCPServer return { success: true, message: t('settings.mcp.sync.noServersAvailable', 'No MCP servers available'), - addedServers: [] + addedServers: [], + updatedServers: [] } } - // Transform TokenFlux servers to MCP servers format + // Transform 302ai servers to MCP servers format const addedServers: MCPServer[] = [] + const updatedServers: MCPServer[] = [] for (const server of servers) { try { - // Skip if server already exists - if (existingServers.some((s) => s.id === `@302ai/${server.name}`)) continue + // Check if server already exists + const existingServer = existingServers.find((s) => s.id === `@302ai/${server.name}`) + const mcpServer: MCPServer = { id: `@302ai/${server.name}`, name: server.name || `302ai Server ${nanoid()}`, @@ -98,16 +104,24 @@ export const syncAi302Servers = async (token: string, existingServers: MCPServer logoUrl: server.logoUrl } - addedServers.push(mcpServer) + if (existingServer) { + // Update existing server with latest info + updatedServers.push(mcpServer) + } else { + // Add new server + addedServers.push(mcpServer) + } } catch (err) { logger.error('Error processing 302ai server:', err as Error) } } + const totalServers = addedServers.length + updatedServers.length return { success: true, - message: t('settings.mcp.sync.success', { count: addedServers.length }), - addedServers + message: t('settings.mcp.sync.success', { count: totalServers }), + addedServers, + updatedServers } } catch (error) { logger.error('302ai sync error:', error as Error) @@ -115,6 +129,7 @@ export const syncAi302Servers = async (token: string, existingServers: MCPServer success: false, message: t('settings.mcp.sync.error'), addedServers: [], + updatedServers: [], errorDetails: String(error) } } diff --git a/src/renderer/src/pages/settings/MCPSettings/providers/lanyun.ts b/src/renderer/src/pages/settings/MCPSettings/providers/lanyun.ts index 77f6541b91..f227eb5670 100644 --- a/src/renderer/src/pages/settings/MCPSettings/providers/lanyun.ts +++ b/src/renderer/src/pages/settings/MCPSettings/providers/lanyun.ts @@ -55,6 +55,7 @@ interface TokenLanYunSyncResult { success: boolean message: string addedServers: MCPServer[] + updatedServers: MCPServer[] errorDetails?: string } @@ -80,7 +81,8 @@ export const syncTokenLanYunServers = async ( return { success: false, message: t('settings.mcp.sync.unauthorized', 'Sync Unauthorized'), - addedServers: [] + addedServers: [], + updatedServers: [] } } @@ -90,6 +92,7 @@ export const syncTokenLanYunServers = async ( success: false, message: t('settings.mcp.sync.error'), addedServers: [], + updatedServers: [], errorDetails: `Status: ${response.status}` } } @@ -101,6 +104,7 @@ export const syncTokenLanYunServers = async ( success: false, message: t('settings.mcp.sync.unauthorized', 'Sync Unauthorized'), addedServers: [], + updatedServers: [], errorDetails: `Status: ${response.status}` } } @@ -109,6 +113,7 @@ export const syncTokenLanYunServers = async ( success: false, message: t('settings.mcp.sync.error'), addedServers: [], + updatedServers: [], errorDetails: `Status: ${response.status}` } } @@ -119,27 +124,21 @@ export const syncTokenLanYunServers = async ( return { success: true, message: t('settings.mcp.sync.noServersAvailable', 'No MCP servers available'), - addedServers: [] + addedServers: [], + updatedServers: [] } } // Transform Token servers to MCP servers format const addedServers: MCPServer[] = [] + const updatedServers: MCPServer[] = [] logger.debug('TokenLanYun servers:', servers) for (const server of servers) { try { if (!server.operationalUrls?.[0]?.url) continue - // If any existing server id contains '@lanyun', clear them before adding new ones - // if (existingServers.some((s) => s.id.startsWith('@lanyun'))) { - // for (let i = existingServers.length - 1; i >= 0; i--) { - // if (existingServers[i].id.startsWith('@lanyun')) { - // existingServers.splice(i, 1) - // } - // } - // } - // Skip if server already exists after clearing - if (existingServers.some((s) => s.id === `@lanyun/${server.id}`)) continue + // Check if server already exists + const existingServer = existingServers.find((s) => s.id === `@lanyun/${server.id}`) const mcpServer: MCPServer = { id: `@lanyun/${server.id}`, @@ -158,16 +157,24 @@ export const syncTokenLanYunServers = async ( tags: server.tags ?? (server.chineseName ? [server.chineseName] : []) } - addedServers.push(mcpServer) + if (existingServer) { + // Update existing server with latest info + updatedServers.push(mcpServer) + } else { + // Add new server + addedServers.push(mcpServer) + } } catch (err) { logger.error('Error processing LanYun server:', err as Error) } } + const totalServers = addedServers.length + updatedServers.length return { success: true, - message: t('settings.mcp.sync.success', { count: addedServers.length }), - addedServers + message: t('settings.mcp.sync.success', { count: totalServers }), + addedServers, + updatedServers } } catch (error) { logger.error('TokenLanyun sync error:', error as Error) @@ -175,6 +182,7 @@ export const syncTokenLanYunServers = async ( success: false, message: t('settings.mcp.sync.error'), addedServers: [], + updatedServers: [], errorDetails: String(error) } } diff --git a/src/renderer/src/pages/settings/MCPSettings/modelscopeSyncUtils.ts b/src/renderer/src/pages/settings/MCPSettings/providers/modelscope.ts similarity index 75% rename from src/renderer/src/pages/settings/MCPSettings/modelscopeSyncUtils.ts rename to src/renderer/src/pages/settings/MCPSettings/providers/modelscope.ts index 00a58e3f23..f9a3c0a297 100644 --- a/src/renderer/src/pages/settings/MCPSettings/modelscopeSyncUtils.ts +++ b/src/renderer/src/pages/settings/MCPSettings/providers/modelscope.ts @@ -1,12 +1,13 @@ import { loggerService } from '@logger' import { nanoid } from '@reduxjs/toolkit' -import { MCPServer } from '@renderer/types' +import type { MCPServer } from '@renderer/types' import i18next from 'i18next' const logger = loggerService.withContext('ModelScopeSyncUtils') // Token storage constants and utilities const TOKEN_STORAGE_KEY = 'modelscope_token' +export const MODELSCOPE_HOST = 'https://www.modelscope.cn' export const saveModelScopeToken = (token: string): void => { localStorage.setItem(TOKEN_STORAGE_KEY, token) @@ -38,6 +39,7 @@ interface ModelScopeSyncResult { success: boolean message: string addedServers: MCPServer[] + updatedServers: MCPServer[] errorDetails?: string } @@ -49,7 +51,7 @@ export const syncModelScopeServers = async ( const t = i18next.t try { - const response = await fetch('https://www.modelscope.cn/api/v1/mcp/services/operational', { + const response = await fetch(`${MODELSCOPE_HOST}/api/v1/mcp/services/operational`, { method: 'GET', headers: { 'Content-Type': 'application/json', @@ -63,7 +65,8 @@ export const syncModelScopeServers = async ( return { success: false, message: t('settings.mcp.sync.unauthorized', 'Sync Unauthorized'), - addedServers: [] + addedServers: [], + updatedServers: [] } } @@ -73,6 +76,7 @@ export const syncModelScopeServers = async ( success: false, message: t('settings.mcp.sync.error'), addedServers: [], + updatedServers: [], errorDetails: `Status: ${response.status}` } } @@ -85,19 +89,21 @@ export const syncModelScopeServers = async ( return { success: true, message: t('settings.mcp.sync.noServersAvailable', 'No MCP servers available'), - addedServers: [] + addedServers: [], + updatedServers: [] } } // Transform ModelScope servers to MCP servers format const addedServers: MCPServer[] = [] + const updatedServers: MCPServer[] = [] for (const server of servers) { try { if (!server.operational_urls?.[0]?.url) continue - // Skip if server already exists - if (existingServers.some((s) => s.id === `@modelscope/${server.id}`)) continue + // Check if server already exists + const existingServer = existingServers.find((s) => s.id === `@modelscope/${server.id}`) const mcpServer: MCPServer = { id: `@modelscope/${server.id}`, @@ -110,21 +116,29 @@ export const syncModelScopeServers = async ( env: {}, isActive: true, provider: 'ModelScope', - providerUrl: `https://www.modelscope.cn/mcp/servers/@${server.id}`, + providerUrl: `${MODELSCOPE_HOST}/mcp/servers/@${server.id}`, logoUrl: server.logo_url || '', tags: server.tags || [] } - addedServers.push(mcpServer) + if (existingServer) { + // Update existing server with latest info + updatedServers.push(mcpServer) + } else { + // Add new server + addedServers.push(mcpServer) + } } catch (err) { logger.error('Error processing ModelScope server:', err as Error) } } + const totalServers = addedServers.length + updatedServers.length return { success: true, - message: t('settings.mcp.sync.success', { count: addedServers.length }), - addedServers + message: t('settings.mcp.sync.success', { count: totalServers }), + addedServers, + updatedServers } } catch (error) { logger.error('ModelScope sync error:', error as Error) @@ -132,6 +146,7 @@ export const syncModelScopeServers = async ( success: false, message: t('settings.mcp.sync.error'), addedServers: [], + updatedServers: [], errorDetails: String(error) } } diff --git a/src/renderer/src/pages/settings/MCPSettings/providers/tokenflux.ts b/src/renderer/src/pages/settings/MCPSettings/providers/tokenflux.ts index 384ae17284..e3a10f8ddd 100644 --- a/src/renderer/src/pages/settings/MCPSettings/providers/tokenflux.ts +++ b/src/renderer/src/pages/settings/MCPSettings/providers/tokenflux.ts @@ -45,6 +45,7 @@ interface TokenFluxSyncResult { success: boolean message: string addedServers: MCPServer[] + updatedServers: MCPServer[] errorDetails?: string } @@ -70,7 +71,8 @@ export const syncTokenFluxServers = async ( return { success: false, message: t('settings.mcp.sync.unauthorized', 'Sync Unauthorized'), - addedServers: [] + addedServers: [], + updatedServers: [] } } @@ -80,6 +82,7 @@ export const syncTokenFluxServers = async ( success: false, message: t('settings.mcp.sync.error'), addedServers: [], + updatedServers: [], errorDetails: `Status: ${response.status}` } } @@ -92,17 +95,19 @@ export const syncTokenFluxServers = async ( return { success: true, message: t('settings.mcp.sync.noServersAvailable', 'No MCP servers available'), - addedServers: [] + addedServers: [], + updatedServers: [] } } // Transform TokenFlux servers to MCP servers format const addedServers: MCPServer[] = [] + const updatedServers: MCPServer[] = [] for (const server of servers) { try { - // Skip if server already exists - if (existingServers.some((s) => s.id === `@tokenflux/${server.name}`)) continue + // Check if server already exists + const existingServer = existingServers.find((s) => s.id === `@tokenflux/${server.name}`) const authHeaders = {} if (server.security_schemes && server.security_schemes.api_key) { @@ -117,7 +122,7 @@ export const syncTokenFluxServers = async ( name: server.display_name || server.name || `TokenFlux Server ${nanoid()}`, description: server.description || '', type: 'streamableHttp', - baseUrl: `${TOKENFLUX_HOST}/v1/mcps/${server.name}`, + baseUrl: `${TOKENFLUX_HOST}/v1/mcps/${server.name}/mcp`, isActive: true, provider: 'TokenFlux', providerUrl: `${TOKENFLUX_HOST}/mcps/${server.name}`, @@ -126,16 +131,24 @@ export const syncTokenFluxServers = async ( headers: authHeaders } - addedServers.push(mcpServer) + if (existingServer) { + // Update existing server with corrected URL and latest info + updatedServers.push(mcpServer) + } else { + // Add new server + addedServers.push(mcpServer) + } } catch (err) { logger.error('Error processing TokenFlux server:', err as Error) } } + const totalServers = addedServers.length + updatedServers.length return { success: true, - message: t('settings.mcp.sync.success', { count: addedServers.length }), - addedServers + message: t('settings.mcp.sync.success', { count: totalServers }), + addedServers, + updatedServers } } catch (error) { logger.error('TokenFlux sync error:', error as Error) @@ -143,6 +156,7 @@ export const syncTokenFluxServers = async ( success: false, message: t('settings.mcp.sync.error'), addedServers: [], + updatedServers: [], errorDetails: String(error) } } From 9e405f0604a8379e8e15d7655914ab8fb2b3f83f Mon Sep 17 00:00:00 2001 From: one Date: Sat, 2 Aug 2025 23:17:14 +0800 Subject: [PATCH 08/18] perf: model select popup (#8766) - use DynamicVirtualList in SelectModelPopup - use DynamicVirtualList in QuickPanelView - remove react-window - simplify SelectModelPopup states, improve maintainability --- package.json | 2 - .../Popups/SelectModelPopup/hook.ts | 40 -- .../Popups/SelectModelPopup/popup.tsx | 441 ++++++------------ .../Popups/SelectModelPopup/reducer.ts | 102 ---- .../Popups/SelectModelPopup/searchbar.tsx | 77 +++ .../Popups/SelectModelPopup/types.ts | 21 - .../src/components/QuickPanel/view.tsx | 123 ++--- .../__tests__/QuickPanelView.test.tsx | 26 +- yarn.lock | 33 +- 9 files changed, 304 insertions(+), 561 deletions(-) delete mode 100644 src/renderer/src/components/Popups/SelectModelPopup/hook.ts delete mode 100644 src/renderer/src/components/Popups/SelectModelPopup/reducer.ts create mode 100644 src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx diff --git a/package.json b/package.json index 875515bbf9..28561231b0 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,6 @@ "@types/react": "^19.0.12", "@types/react-dom": "^19.0.4", "@types/react-infinite-scroll-component": "^5.0.0", - "@types/react-window": "^1", "@types/tinycolor2": "^1", "@types/word-extractor": "^1", "@uiw/codemirror-extensions-langs": "^4.23.14", @@ -236,7 +235,6 @@ "react-router": "6", "react-router-dom": "6", "react-spinners": "^0.14.1", - "react-window": "^1.8.11", "redux": "^5.0.1", "redux-persist": "^6.0.0", "reflect-metadata": "0.2.2", diff --git a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts b/src/renderer/src/components/Popups/SelectModelPopup/hook.ts deleted file mode 100644 index 4a8206df69..0000000000 --- a/src/renderer/src/components/Popups/SelectModelPopup/hook.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { useMemo, useReducer } from 'react' - -import { initialScrollState, scrollReducer } from './reducer' -import { FlatListItem, ScrollTrigger } from './types' - -/** - * 管理滚动和焦点状态的 hook - */ -export function useScrollState() { - const [state, dispatch] = useReducer(scrollReducer, initialScrollState) - - const actions = useMemo( - () => ({ - setFocusedItemKey: (key: string) => dispatch({ type: 'SET_FOCUSED_ITEM_KEY', payload: key }), - setScrollTrigger: (trigger: ScrollTrigger) => dispatch({ type: 'SET_SCROLL_TRIGGER', payload: trigger }), - setLastScrollOffset: (offset: number) => dispatch({ type: 'SET_LAST_SCROLL_OFFSET', payload: offset }), - setStickyGroup: (group: FlatListItem | null) => dispatch({ type: 'SET_STICKY_GROUP', payload: group }), - setIsMouseOver: (isMouseOver: boolean) => dispatch({ type: 'SET_IS_MOUSE_OVER', payload: isMouseOver }), - focusNextItem: (modelItems: FlatListItem[], step: number) => - dispatch({ type: 'FOCUS_NEXT_ITEM', payload: { modelItems, step } }), - focusPage: (modelItems: FlatListItem[], currentIndex: number, step: number) => - dispatch({ type: 'FOCUS_PAGE', payload: { modelItems, currentIndex, step } }), - searchChanged: (searchText: string) => dispatch({ type: 'SEARCH_CHANGED', payload: { searchText } }), - focusOnListChange: (modelItems: FlatListItem[]) => - dispatch({ type: 'FOCUS_ON_LIST_CHANGE', payload: { modelItems } }) - }), - [] - ) - - return { - // 状态 - focusedItemKey: state.focusedItemKey, - scrollTrigger: state.scrollTrigger, - lastScrollOffset: state.lastScrollOffset, - stickyGroup: state.stickyGroup, - isMouseOver: state.isMouseOver, - // 操作 - ...actions - } -} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx index c1b04de5df..e7557f5e5e 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx +++ b/src/renderer/src/components/Popups/SelectModelPopup/popup.tsx @@ -1,17 +1,16 @@ import { PushpinOutlined } from '@ant-design/icons' -import { HStack } from '@renderer/components/Layout' import ModelTagsWithLabel from '@renderer/components/ModelTagsWithLabel' import { TopView } from '@renderer/components/TopView' +import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' import { getModelLogo, isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { usePinnedModels } from '@renderer/hooks/usePinnedModels' import { useProviders } from '@renderer/hooks/useProvider' import { getModelUniqId } from '@renderer/services/ModelService' -import { Model } from '@renderer/types' +import { Model, Provider } from '@renderer/types' import { classNames, filterModelsByKeywords, getFancyProviderName } from '@renderer/utils' -import { Avatar, Divider, Empty, Input, InputRef, Modal } from 'antd' +import { Avatar, Divider, Empty, Modal } from 'antd' import { first, sortBy } from 'lodash' -import { Search } from 'lucide-react' -import { +import React, { startTransition, useCallback, useDeferredValue, @@ -21,15 +20,13 @@ import { useRef, useState } from 'react' -import React from 'react' import { useTranslation } from 'react-i18next' -import { FixedSizeList } from 'react-window' import styled from 'styled-components' -import { useScrollState } from './hook' +import SelectModelSearchBar from './searchbar' import { FlatListItem } from './types' -const PAGE_SIZE = 10 +const PAGE_SIZE = 11 const ITEM_HEIGHT = 36 interface PopupParams { @@ -47,8 +44,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { const { providers } = useProviders() const { pinnedModels, togglePinnedModel, loading } = usePinnedModels() const [open, setOpen] = useState(true) - const inputRef = useRef(null) - const listRef = useRef(null) + const listRef = useRef(null) const [_searchText, setSearchText] = useState('') const searchText = useDeferredValue(_searchText) @@ -56,49 +52,19 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { const currentModelId = model ? getModelUniqId(model) : '' // 管理滚动和焦点状态 - const { - focusedItemKey, - scrollTrigger, - lastScrollOffset, - stickyGroup, - isMouseOver, - setFocusedItemKey: _setFocusedItemKey, - setScrollTrigger, - setLastScrollOffset: _setLastScrollOffset, - setStickyGroup: _setStickyGroup, - setIsMouseOver, - focusNextItem, - focusPage, - searchChanged, - focusOnListChange - } = useScrollState() + const [focusedItemKey, _setFocusedItemKey] = useState('') + const [isMouseOver, setIsMouseOver] = useState(false) + const preventScrollToIndex = useRef(false) - const firstGroupRef = useRef(null) - - const setFocusedItemKey = useCallback( - (key: string) => { - startTransition(() => _setFocusedItemKey(key)) - }, - [_setFocusedItemKey] - ) - - const setLastScrollOffset = useCallback( - (offset: number) => { - startTransition(() => _setLastScrollOffset(offset)) - }, - [_setLastScrollOffset] - ) - - const setStickyGroup = useCallback( - (group: FlatListItem | null) => { - startTransition(() => _setStickyGroup(group)) - }, - [_setStickyGroup] - ) + const setFocusedItemKey = useCallback((key: string) => { + startTransition(() => { + _setFocusedItemKey(key) + }) + }, []) // 根据输入的文本筛选模型 const getFilteredModels = useCallback( - (provider) => { + (provider: Provider) => { let models = provider.models.filter((m) => !isEmbeddingModel(m) && !isRerankModel(m)) if (searchText.trim()) { @@ -112,7 +78,7 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 创建模型列表项 const createModelItem = useCallback( - (model: Model, provider: any, isPinned: boolean): FlatListItem => { + (model: Model, provider: Provider, isPinned: boolean): FlatListItem => { const modelId = getModelUniqId(model) const groupName = getFancyProviderName(provider) @@ -143,16 +109,18 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { [currentModelId] ) - // 构建扁平化列表数据 - const listItems = useMemo(() => { + // 构建扁平化列表数据,并派生出可选择的模型项 + const { listItems, modelItems } = useMemo(() => { const items: FlatListItem[] = [] + const pinnedModelIds = new Set(pinnedModels) + const finalModelFilter = modelFilter || (() => true) // 添加置顶模型分组(仅在无搜索文本时) - if (searchText.length === 0 && pinnedModels.length > 0) { + if (searchText.length === 0 && pinnedModelIds.size > 0) { const pinnedItems = providers.flatMap((p) => p.models - .filter((m) => pinnedModels.includes(getModelUniqId(m))) - .filter(modelFilter ? modelFilter : () => true) + .filter((m) => pinnedModelIds.has(getModelUniqId(m))) + .filter(finalModelFilter) .map((m) => createModelItem(m, p, true)) ) @@ -172,8 +140,8 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 添加常规模型分组 providers.forEach((p) => { const filteredModels = getFilteredModels(p) - .filter((m) => searchText.length > 0 || !pinnedModels.includes(getModelUniqId(m))) - .filter(modelFilter ? modelFilter : () => true) + .filter((m) => searchText.length > 0 || !pinnedModelIds.has(getModelUniqId(m))) + .filter(finalModelFilter) if (filteredModels.length === 0) return @@ -185,92 +153,52 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { isSelected: false }) - items.push(...filteredModels.map((m) => createModelItem(m, p, pinnedModels.includes(getModelUniqId(m))))) + items.push(...filteredModels.map((m) => createModelItem(m, p, pinnedModelIds.has(getModelUniqId(m))))) }) - // 移除第一个分组标题,使用 sticky group banner 替代,模拟 sticky 效果 - if (items.length > 0 && items[0].type === 'group') { - firstGroupRef.current = items[0] - items.shift() - } else { - firstGroupRef.current = null - } - return items + // 获取可选择的模型项(过滤掉分组标题) + const modelItems = items.filter((item) => item.type === 'model') as FlatListItem[] + return { listItems: items, modelItems } }, [searchText.length, pinnedModels, providers, modelFilter, createModelItem, t, getFilteredModels]) - // 获取可选择的模型项(过滤掉分组标题) - const modelItems = useMemo(() => { - return listItems.filter((item) => item.type === 'model') - }, [listItems]) + const listHeight = useMemo(() => { + return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT + }, [listItems.length]) - // 当搜索文本变化时更新滚动触发器 - useEffect(() => { - searchChanged(searchText) - }, [searchText, searchChanged]) - - // 基于滚动位置更新sticky分组标题 - const updateStickyGroup = useCallback( - (scrollOffset?: number) => { - if (listItems.length === 0) { - stickyGroup && setStickyGroup(null) - return - } - - let newStickyGroup: FlatListItem | null = null - - // 基于滚动位置计算当前可见的第一个项的索引 - const estimatedIndex = Math.floor((scrollOffset ?? lastScrollOffset) / ITEM_HEIGHT) - - // 从该索引向前查找最近的分组标题 - for (let i = estimatedIndex - 1; i >= 0; i--) { - if (i < listItems.length && listItems[i]?.type === 'group') { - newStickyGroup = listItems[i] - break - } - } - - // 找不到则使用第一个分组标题 - if (!newStickyGroup) newStickyGroup = firstGroupRef.current - - if (stickyGroup?.key !== newStickyGroup?.key) { - setStickyGroup(newStickyGroup) - } - }, - [listItems, lastScrollOffset, setStickyGroup, stickyGroup] - ) - - // 处理列表滚动事件,更新lastScrollOffset并更新sticky分组 - const handleScroll = useCallback( - ({ scrollOffset }) => { - setLastScrollOffset(scrollOffset) - }, - [setLastScrollOffset] - ) - - // 列表项更新时,更新焦点 - useEffect(() => { - if (!loading) focusOnListChange(modelItems) - }, [modelItems, focusOnListChange, loading]) - - // 列表项更新时,更新sticky分组 - useEffect(() => { - if (!loading) updateStickyGroup() - }, [modelItems, updateStickyGroup, loading]) - - // 滚动到聚焦项 + // 处理程序化滚动(加载、搜索开始、搜索清空) useLayoutEffect(() => { - if (scrollTrigger === 'none' || !focusedItemKey) return + if (loading) return - const index = listItems.findIndex((item) => item.key === focusedItemKey) - if (index < 0) return + if (preventScrollToIndex.current) { + preventScrollToIndex.current = false + return + } - // 根据触发源决定滚动对齐方式 - const alignment = scrollTrigger === 'keyboard' ? 'auto' : 'center' - listRef.current?.scrollToItem(index, alignment) + let targetItemKey: string | undefined - // 滚动后重置触发器 - setScrollTrigger('none') - }, [focusedItemKey, scrollTrigger, listItems, setScrollTrigger]) + // 启动搜索时,滚动到第一个 item + if (searchText) { + targetItemKey = modelItems[0]?.key + } + // 初始加载或清空搜索时,滚动到 selected item + else { + targetItemKey = modelItems.find((item) => item.isSelected)?.key + } + + if (targetItemKey) { + setFocusedItemKey(targetItemKey) + const index = listItems.findIndex((item) => item.key === targetItemKey) + if (index >= 0) { + // FIXME: 手动计算偏移量,给 scroller 增加了 scrollPaddingStart 之后, + // scrollToIndex 不能准确滚动到 item 中心,但是又需要 padding 来改善体验。 + const targetScrollTop = index * ITEM_HEIGHT - listHeight / 2 + listRef.current?.scrollToOffset(targetScrollTop, { + align: 'start', + behavior: 'auto' + }) + } + } + }, [searchText, listItems, modelItems, loading, setFocusedItemKey, listHeight]) const handleItemClick = useCallback( (item: FlatListItem) => { @@ -285,7 +213,9 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { // 处理键盘导航 const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if (!open || modelItems.length === 0 || e.isComposing) return + const modelCount = modelItems.length + + if (!open || modelCount === 0 || e.isComposing) return // 键盘操作时禁用鼠标 hover if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) { @@ -294,25 +224,31 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { setIsMouseOver(false) } + // 当前聚焦的模型 index const currentIndex = modelItems.findIndex((item) => item.key === focusedItemKey) - const normalizedIndex = currentIndex < 0 ? 0 : currentIndex + + let nextIndex = -1 switch (e.key) { - case 'ArrowUp': - focusNextItem(modelItems, -1) + case 'ArrowUp': { + nextIndex = (currentIndex < 0 ? 0 : currentIndex - 1 + modelCount) % modelCount break - case 'ArrowDown': - focusNextItem(modelItems, 1) + } + case 'ArrowDown': { + nextIndex = (currentIndex < 0 ? 0 : currentIndex + 1) % modelCount break - case 'PageUp': - focusPage(modelItems, normalizedIndex, -PAGE_SIZE) + } + case 'PageUp': { + nextIndex = Math.max(0, (currentIndex < 0 ? 0 : currentIndex) - PAGE_SIZE) break - case 'PageDown': - focusPage(modelItems, normalizedIndex, PAGE_SIZE) + } + case 'PageDown': { + nextIndex = Math.min(modelCount - 1, (currentIndex < 0 ? 0 : currentIndex) + PAGE_SIZE) break + } case 'Enter': - if (focusedItemKey) { - const selectedItem = modelItems.find((item) => item.key === focusedItemKey) + if (currentIndex >= 0) { + const selectedItem = modelItems[currentIndex] if (selectedItem) { handleItemClick(selectedItem) } @@ -324,8 +260,20 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { resolve(undefined) break } + + // 没有键盘导航,直接返回 + if (nextIndex < 0) return + + const nextKey = modelItems[nextIndex]?.key || '' + if (nextKey) { + setFocusedItemKey(nextKey) + const index = listItems.findIndex((item) => item.key === nextKey) + if (index >= 0) { + listRef.current?.scrollToIndex(index, { align: 'auto' }) + } + } }, - [focusedItemKey, modelItems, handleItemClick, open, resolve, setIsMouseOver, focusNextItem, focusPage] + [modelItems, open, focusedItemKey, resolve, handleItemClick, setFocusedItemKey, listItems] ) useEffect(() => { @@ -338,40 +286,57 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { }, []) const onAfterClose = useCallback(async () => { - setScrollTrigger('initial') resolve(undefined) SelectModelPopup.hide() - }, [resolve, setScrollTrigger]) - - // 初始化焦点和滚动位置 - useEffect(() => { - if (!open) return - const timer = setTimeout(() => inputRef.current?.focus(), 0) - return () => clearTimeout(timer) - }, [open]) + }, [resolve]) const togglePin = useCallback( async (modelId: string) => { await togglePinnedModel(modelId) + preventScrollToIndex.current = true }, [togglePinnedModel] ) - const RowData = useMemo( - (): VirtualizedRowData => ({ - listItems, - focusedItemKey, - setFocusedItemKey, - stickyGroup, - handleItemClick, - togglePin - }), - [stickyGroup, focusedItemKey, handleItemClick, listItems, togglePin, setFocusedItemKey] - ) + const getItemKey = useCallback((index: number) => listItems[index].key, [listItems]) + const estimateSize = useCallback(() => ITEM_HEIGHT, []) + const isSticky = useCallback((index: number) => listItems[index].type === 'group', [listItems]) - const listHeight = useMemo(() => { - return Math.min(PAGE_SIZE, listItems.length) * ITEM_HEIGHT - }, [listItems.length]) + const rowRenderer = useCallback( + (item: FlatListItem) => { + const isFocused = item.key === focusedItemKey + if (item.type === 'group') { + return {item.name} + } + return ( + handleItemClick(item)} + onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}> + + {item.icon} + {item.name} + {item.tags} + + { + e.stopPropagation() + if (item.model) { + togglePin(getModelUniqId(item.model)) + } + }} + data-pinned={item.isPinned} + $isPinned={item.isPinned}> + + + + ) + }, + [focusedItemKey, handleItemClick, setFocusedItemKey, togglePin] + ) return ( = ({ model, resolve, modelFilter }) => { closeIcon={null} footer={null}> {/* 搜索框 */} - - - - - } - ref={inputRef} - placeholder={t('models.search')} - value={_searchText} // 使用 _searchText,需要实时更新 - onChange={(e) => setSearchText(e.target.value)} - allowClear - autoFocus - spellCheck={false} - style={{ paddingLeft: 0 }} - variant="borderless" - size="middle" - onKeyDown={(e) => { - // 防止上下键移动光标 - if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') { - e.preventDefault() - } - }} - /> - + {listItems.length > 0 ? ( - !isMouseOver && startTransition(() => setIsMouseOver(true))}> - {/* Sticky Group Banner,它会替换第一个分组名称 */} - {stickyGroup?.name} - !isMouseOver && setIsMouseOver(true)}> + data.listItems[index].key} - overscanCount={4} - onScroll={handleScroll} - style={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}> - {VirtualizedRow} - + list={listItems} + size={listHeight} + getItemKey={getItemKey} + estimateSize={estimateSize} + isSticky={isSticky} + scrollPaddingStart={ITEM_HEIGHT} // 留出 sticky header 高度 + overscan={5} + scrollerStyle={{ pointerEvents: isMouseOver ? 'auto' : 'none' }}> + {rowRenderer} + ) : ( @@ -450,73 +388,12 @@ const PopupContainer: React.FC = ({ model, resolve, modelFilter }) => { ) } -interface VirtualizedRowData { - listItems: FlatListItem[] - focusedItemKey: string - setFocusedItemKey: (key: string) => void - stickyGroup: FlatListItem | null - handleItemClick: (item: FlatListItem) => void - togglePin: (modelId: string) => void -} - -/** - * 虚拟化列表行组件,用于避免重新渲染 - */ -const VirtualizedRow = React.memo( - ({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => { - const { listItems, focusedItemKey, setFocusedItemKey, handleItemClick, togglePin, stickyGroup } = data - - const item = listItems[index] - - if (!item) { - return
- } - - const isFocused = item.key === focusedItemKey - - return ( -
- {item.type === 'group' ? ( - {item.name} - ) : ( - handleItemClick(item)} - onMouseOver={() => !isFocused && setFocusedItemKey(item.key)}> - - {item.icon} - {item.name} - {item.tags} - - { - e.stopPropagation() - if (item.model) { - togglePin(getModelUniqId(item.model)) - } - }} - data-pinned={item.isPinned} - $isPinned={item.isPinned}> - - - - )} -
- ) - } -) - -VirtualizedRow.displayName = 'VirtualizedRow' - const ListContainer = styled.div` position: relative; overflow: hidden; ` -const GroupItem = styled.div<{ $isSticky?: boolean }>` +const GroupItem = styled.div` display: flex; align-items: center; position: relative; @@ -526,12 +403,6 @@ const GroupItem = styled.div<{ $isSticky?: boolean }>` padding: 5px 10px 5px 18px; color: var(--color-text-3); z-index: 1; - - visibility: ${(props) => (props.$isSticky ? 'hidden' : 'visible')}; -` - -const StickyGroupBanner = styled(GroupItem)` - position: sticky; background: var(--modal-background); ` @@ -613,18 +484,6 @@ const EmptyState = styled.div` height: 200px; ` -const SearchIcon = styled.div` - width: 32px; - height: 32px; - border-radius: 50%; - display: flex; - flex-direction: row; - justify-content: center; - align-items: center; - background-color: var(--color-background-soft); - margin-right: 2px; -` - const PinIconWrapper = styled.div.attrs({ className: 'pin-icon' })<{ $isPinned?: boolean }>` margin-left: auto; padding: 0 10px; diff --git a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts b/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts deleted file mode 100644 index 974fc5b509..0000000000 --- a/src/renderer/src/components/Popups/SelectModelPopup/reducer.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { ScrollAction, ScrollState } from './types' - -/** - * 初始状态 - */ -export const initialScrollState: ScrollState = { - focusedItemKey: '', - scrollTrigger: 'initial', - lastScrollOffset: 0, - stickyGroup: null, - isMouseOver: false -} - -/** - * 滚动状态的 reducer,用于避免复杂依赖可能带来的状态更新问题 - * @param state 当前状态 - * @param action 动作 - * @returns 新的状态 - */ -export const scrollReducer = (state: ScrollState, action: ScrollAction): ScrollState => { - switch (action.type) { - case 'SET_FOCUSED_ITEM_KEY': - return { ...state, focusedItemKey: action.payload } - - case 'SET_SCROLL_TRIGGER': - return { ...state, scrollTrigger: action.payload } - - case 'SET_LAST_SCROLL_OFFSET': - return { ...state, lastScrollOffset: action.payload } - - case 'SET_STICKY_GROUP': - return { ...state, stickyGroup: action.payload } - - case 'SET_IS_MOUSE_OVER': - return { ...state, isMouseOver: action.payload } - - case 'FOCUS_NEXT_ITEM': { - const { modelItems, step } = action.payload - - if (modelItems.length === 0) { - return { - ...state, - focusedItemKey: '', - scrollTrigger: 'keyboard' - } - } - - const currentIndex = modelItems.findIndex((item) => item.key === state.focusedItemKey) - const nextIndex = (currentIndex < 0 ? 0 : currentIndex + step + modelItems.length) % modelItems.length - - return { - ...state, - focusedItemKey: modelItems[nextIndex].key, - scrollTrigger: 'keyboard' - } - } - - case 'FOCUS_PAGE': { - const { modelItems, currentIndex, step } = action.payload - const nextIndex = Math.max(0, Math.min(currentIndex + step, modelItems.length - 1)) - - return { - ...state, - focusedItemKey: modelItems.length > 0 ? modelItems[nextIndex].key : '', - scrollTrigger: 'keyboard' - } - } - - case 'SEARCH_CHANGED': - return { - ...state, - scrollTrigger: action.payload.searchText ? 'search' : 'initial' - } - - case 'FOCUS_ON_LIST_CHANGE': { - const { modelItems } = action.payload - - // 在列表变化时尝试聚焦一个模型: - // - 如果是 initial 状态,先尝试聚焦当前选中的模型 - // - 如果是 search 状态,尝试聚焦第一个模型 - let newFocusedKey = '' - if (state.scrollTrigger === 'initial' || state.scrollTrigger === 'search') { - const selectedItem = modelItems.find((item) => item.isSelected) - if (selectedItem && state.scrollTrigger === 'initial') { - newFocusedKey = selectedItem.key - } else if (modelItems.length > 0) { - newFocusedKey = modelItems[0].key - } - } else { - newFocusedKey = state.focusedItemKey - } - - return { - ...state, - focusedItemKey: newFocusedKey - } - } - - default: - return state - } -} diff --git a/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx b/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx new file mode 100644 index 0000000000..1b4d1dbb02 --- /dev/null +++ b/src/renderer/src/components/Popups/SelectModelPopup/searchbar.tsx @@ -0,0 +1,77 @@ +import { HStack } from '@renderer/components/Layout' +import { Input, InputRef } from 'antd' +import { Search } from 'lucide-react' +import React, { memo, useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import styled from 'styled-components' + +interface SelectModelSearchBarProps { + onSearch: (text: string) => void +} + +const SelectModelSearchBar: React.FC = ({ onSearch }) => { + const { t } = useTranslation() + const [searchText, setSearchText] = useState('') + const inputRef = useRef(null) + + const handleTextChange = useCallback( + (text: string) => { + setSearchText(text) + onSearch(text) + }, + [onSearch] + ) + + const handleClear = useCallback(() => { + setSearchText('') + onSearch('') + }, [onSearch]) + + useEffect(() => { + const timer = setTimeout(() => inputRef.current?.focus(), 0) + return () => clearTimeout(timer) + }, []) + + return ( + + + + + } + ref={inputRef} + placeholder={t('models.search')} + value={searchText} + onChange={(e) => handleTextChange(e.target.value)} + onClear={handleClear} + allowClear + autoFocus + spellCheck={false} + style={{ paddingLeft: 0 }} + variant="borderless" + size="middle" + onKeyDown={(e) => { + // 防止上下键移动光标 + if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') { + e.preventDefault() + } + }} + /> + + ) +} + +const SearchIcon = styled.div` + width: 32px; + height: 32px; + border-radius: 50%; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + background-color: var(--color-background-soft); + margin-right: 2px; +` + +export default memo(SelectModelSearchBar) diff --git a/src/renderer/src/components/Popups/SelectModelPopup/types.ts b/src/renderer/src/components/Popups/SelectModelPopup/types.ts index 745e9688bb..954a8e0d37 100644 --- a/src/renderer/src/components/Popups/SelectModelPopup/types.ts +++ b/src/renderer/src/components/Popups/SelectModelPopup/types.ts @@ -18,24 +18,3 @@ export interface FlatListItem { isPinned?: boolean isSelected?: boolean } - -// 滚动和焦点相关的状态类型 -export interface ScrollState { - focusedItemKey: string - scrollTrigger: ScrollTrigger - lastScrollOffset: number - stickyGroup: FlatListItem | null - isMouseOver: boolean -} - -// 滚动和焦点相关的 action 类型 -export type ScrollAction = - | { type: 'SET_FOCUSED_ITEM_KEY'; payload: string } - | { type: 'SET_SCROLL_TRIGGER'; payload: ScrollTrigger } - | { type: 'SET_LAST_SCROLL_OFFSET'; payload: number } - | { type: 'SET_STICKY_GROUP'; payload: FlatListItem | null } - | { type: 'SET_IS_MOUSE_OVER'; payload: boolean } - | { type: 'FOCUS_NEXT_ITEM'; payload: { modelItems: FlatListItem[]; step: number } } - | { type: 'FOCUS_PAGE'; payload: { modelItems: FlatListItem[]; currentIndex: number; step: number } } - | { type: 'SEARCH_CHANGED'; payload: { searchText: string } } - | { type: 'FOCUS_ON_LIST_CHANGE'; payload: { modelItems: FlatListItem[] } } diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 4f91a729ae..36957aaf63 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -1,4 +1,5 @@ import { RightOutlined } from '@ant-design/icons' +import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' import { isMac } from '@renderer/config/constant' import useUserTheme from '@renderer/hooks/useUserTheme' import { classNames } from '@renderer/utils' @@ -6,7 +7,6 @@ import { Flex } from 'antd' import { t } from 'i18next' import { Check } from 'lucide-react' import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' -import { FixedSizeList } from 'react-window' import styled from 'styled-components' import * as tinyPinyin from 'tiny-pinyin' @@ -55,7 +55,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const [historyPanel, setHistoryPanel] = useState([]) const bodyRef = useRef(null) - const listRef = useRef(null) + const listRef = useRef(null) const footerRef = useRef(null) const [_searchText, setSearchText] = useState('') @@ -306,8 +306,8 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { useLayoutEffect(() => { if (!listRef.current || index < 0 || scrollTriggerRef.current === 'none') return - const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'smart' - listRef.current?.scrollToItem(index, alignment) + const alignment = scrollTriggerRef.current === 'keyboard' ? 'auto' : 'center' + listRef.current?.scrollToIndex(index, { align: alignment }) scrollTriggerRef.current = 'none' }, [index]) @@ -470,13 +470,45 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT }, [ctx.pageSize, list.length]) - const RowData = useMemo( - (): VirtualizedRowData => ({ - list, - focusedIndex: index, - handleItemAction - }), - [list, index, handleItemAction] + const estimateSize = useCallback(() => ITEM_HEIGHT, []) + + const rowRenderer = useCallback( + (item: QuickPanelListItem, itemIndex: number) => { + if (!item) return null + + return ( + { + e.stopPropagation() + handleItemAction(item, 'click') + }}> + + {item.icon} + {item.label} + + + + {item.description && {item.description}} + + {item.suffix ? ( + item.suffix + ) : item.isSelected ? ( + + ) : ( + item.isMenu && !item.disabled && + )} + + + + ) + }, + [index, handleItemAction] ) return ( @@ -494,19 +526,17 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { return prev ? prev : true }) }> - - {VirtualizedRow} - + {rowRenderer} + {ctx.title || ''} @@ -546,57 +576,6 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { ) } -interface VirtualizedRowData { - list: QuickPanelListItem[] - focusedIndex: number - handleItemAction: (item: QuickPanelListItem, action?: QuickPanelCloseAction) => void -} - -/** - * 虚拟化列表行组件,用于避免重新渲染 - */ -const VirtualizedRow = React.memo( - ({ data, index, style }: { data: VirtualizedRowData; index: number; style: React.CSSProperties }) => { - const { list, focusedIndex, handleItemAction } = data - const item = list[index] - if (!item) return null - - return ( -
- { - e.stopPropagation() - handleItemAction(item, 'click') - }}> - - {item.icon} - {item.label} - - - - {item.description && {item.description}} - - {item.suffix ? ( - item.suffix - ) : item.isSelected ? ( - - ) : ( - item.isMenu && !item.disabled && - )} - - - -
- ) - } -) - const QuickPanelContainer = styled.div<{ $pageSize: number $selectedColor: string diff --git a/src/renderer/src/components/__tests__/QuickPanelView.test.tsx b/src/renderer/src/components/__tests__/QuickPanelView.test.tsx index 995c5c5d0b..4e904efeb8 100644 --- a/src/renderer/src/components/__tests__/QuickPanelView.test.tsx +++ b/src/renderer/src/components/__tests__/QuickPanelView.test.tsx @@ -1,12 +1,35 @@ import { configureStore } from '@reduxjs/toolkit' import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' -import { useEffect } from 'react' +import React, { useEffect } from 'react' import { Provider } from 'react-redux' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { QuickPanelListItem, QuickPanelProvider, QuickPanelView, useQuickPanel } from '../QuickPanel' +// Mock the DynamicVirtualList component +vi.mock('@renderer/components/VirtualList', async (importOriginal) => { + const mod = await importOriginal() + return { + ...mod, + DynamicVirtualList: ({ ref, list, children, scrollerStyle }: any & { ref?: React.RefObject }) => { + // Expose a mock function for scrollToIndex + React.useImperativeHandle(ref, () => ({ + scrollToIndex: vi.fn() + })) + + // Render all items, not virtualized + return ( +
+ {list.map((item: any, index: number) => ( +
{children(item, index)}
+ ))} +
+ ) + } + } +}) + // Mock Redux store const mockStore = configureStore({ reducer: { @@ -16,6 +39,7 @@ const mockStore = configureStore({ function createList(length: number, prefix = 'Item', extra: Partial = {}) { return Array.from({ length }, (_, i) => ({ + id: `${prefix}-${i + 1}`, label: `${prefix} ${i + 1}`, description: `${prefix} Description ${i + 1}`, icon: `${prefix} Icon ${i + 1}`, diff --git a/yarn.lock b/yarn.lock index a1643ab873..17f2d0dfac 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1461,7 +1461,7 @@ __metadata: languageName: node linkType: hard -"@babel/runtime@npm:^7.0.0, @babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.9.2": +"@babel/runtime@npm:^7.10.1, @babel/runtime@npm:^7.10.4, @babel/runtime@npm:^7.11.1, @babel/runtime@npm:^7.11.2, @babel/runtime@npm:^7.12.1, @babel/runtime@npm:^7.12.5, @babel/runtime@npm:^7.16.7, @babel/runtime@npm:^7.18.0, @babel/runtime@npm:^7.18.3, @babel/runtime@npm:^7.18.6, @babel/runtime@npm:^7.20.0, @babel/runtime@npm:^7.20.13, @babel/runtime@npm:^7.20.7, @babel/runtime@npm:^7.21.0, @babel/runtime@npm:^7.22.5, @babel/runtime@npm:^7.23.2, @babel/runtime@npm:^7.23.6, @babel/runtime@npm:^7.23.9, @babel/runtime@npm:^7.24.1, @babel/runtime@npm:^7.24.4, @babel/runtime@npm:^7.24.7, @babel/runtime@npm:^7.24.8, @babel/runtime@npm:^7.25.7, @babel/runtime@npm:^7.26.0, @babel/runtime@npm:^7.9.2": version: 7.27.4 resolution: "@babel/runtime@npm:7.27.4" checksum: 10c0/ca99e964179c31615e1352e058cc9024df7111c829631c90eec84caba6703cc32acc81503771847c306b3c70b815609fe82dde8682936debe295b0b283b2dc6e @@ -6625,15 +6625,6 @@ __metadata: languageName: node linkType: hard -"@types/react-window@npm:^1": - version: 1.8.8 - resolution: "@types/react-window@npm:1.8.8" - dependencies: - "@types/react": "npm:*" - checksum: 10c0/2170a3957752603e8b994840c5d31b72ddf94c427c0f42b0175b343cc54f50fe66161d8871e11786ec7a59906bd33861945579a3a8f745455a3744268ec1069f - languageName: node - linkType: hard - "@types/react@npm:*, @types/react@npm:^19.0.12": version: 19.1.2 resolution: "@types/react@npm:19.1.2" @@ -7698,7 +7689,6 @@ __metadata: "@types/react": "npm:^19.0.12" "@types/react-dom": "npm:^19.0.4" "@types/react-infinite-scroll-component": "npm:^5.0.0" - "@types/react-window": "npm:^1" "@types/tinycolor2": "npm:^1" "@types/word-extractor": "npm:^1" "@uiw/codemirror-extensions-langs": "npm:^4.23.14" @@ -7790,7 +7780,6 @@ __metadata: react-router: "npm:6" react-router-dom: "npm:6" react-spinners: "npm:^0.14.1" - react-window: "npm:^1.8.11" redux: "npm:^5.0.1" redux-persist: "npm:^6.0.0" reflect-metadata: "npm:0.2.2" @@ -15223,13 +15212,6 @@ __metadata: languageName: node linkType: hard -"memoize-one@npm:>=3.1.1 <6": - version: 5.2.1 - resolution: "memoize-one@npm:5.2.1" - checksum: 10c0/fd22dbe9a978a2b4f30d6a491fc02fb90792432ad0dab840dc96c1734d2bd7c9cdeb6a26130ec60507eb43230559523615873168bcbe8fafab221c30b11d54c1 - languageName: node - linkType: hard - "memoize-one@npm:^6.0.0": version: 6.0.0 resolution: "memoize-one@npm:6.0.0" @@ -18410,19 +18392,6 @@ __metadata: languageName: node linkType: hard -"react-window@npm:^1.8.11": - version: 1.8.11 - resolution: "react-window@npm:1.8.11" - dependencies: - "@babel/runtime": "npm:^7.0.0" - memoize-one: "npm:>=3.1.1 <6" - peerDependencies: - react: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - checksum: 10c0/5ae8da1bc5c47d8f0a428b28a600256e2db511975573e52cb65a9b27ed1a0e5b9f7b3bee5a54fb0da93956d782c24010be434be451072f46ba5a89159d2b3944 - languageName: node - linkType: hard - "react@npm:^19.0.0": version: 19.1.0 resolution: "react@npm:19.1.0" From fb2dccc7ff351444c3b86e6e826c0f6b406be4f8 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sat, 2 Aug 2025 23:23:08 +0800 Subject: [PATCH 09/18] fix(Inputbar): input bar auto focus (#8756) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(Inputbar): 简化输入框自动聚焦逻辑 * fix(Inputbar): 修复依赖数组缺失导致的焦点问题 添加 assistant.mcpServers 和 mentionedModels 到依赖数组,确保 textarea 在相关数据变化时能正确获取焦点 * fix(Inputbar): 添加knowledge_bases到useEffect依赖数组以修复潜在问题 * fix(Inputbar): 添加缺失的依赖项到useEffect中 * fix(Inputbar): 清空消息时自动聚焦输入框 确保当消息列表为空时,输入框自动获得焦点,提升用户体验 * refactor(Inputbar): 提取聚焦文本域逻辑到单独的回调函数 将多处直接操作textareaRef.current?.focus()的逻辑提取到focusTextarea回调函数中,提高代码复用性和可维护性 --- .../src/pages/home/Inputbar/Inputbar.tsx | 32 ++++++++++++------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 11775e5ad7..daab28aac3 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -189,6 +189,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = _text = text _files = files + const focusTextarea = useCallback(() => { + textareaRef.current?.focus() + }, []) + const resizeTextArea = useCallback( (force: boolean = false) => { const textArea = textareaRef.current?.resizableTextArea?.textArea @@ -470,9 +474,9 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setTimeout(() => resizeTextArea(), 0) return newText }) - textareaRef.current?.focus() + focusTextarea() }, - [resizeTextArea] + [resizeTextArea, focusTextarea] ) const onPause = async () => { @@ -485,6 +489,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = await delay(1) } EventEmitter.emit(EVENT_NAMES.CLEAR_MESSAGES, topic) + focusTextarea() } const onNewContext = () => { @@ -670,7 +675,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = useShortcut('new_topic', () => { addNewTopic() EventEmitter.emit(EVENT_NAMES.SHOW_TOPIC_SIDEBAR) - textareaRef.current?.focus() + focusTextarea() }) useShortcut('clear_topic', clearTopic) @@ -704,12 +709,17 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = useEffect(() => { if (!document.querySelector('.topview-fullscreen-container')) { - const lastFocusedComponent = PasteService.getLastFocusedComponent() - if (lastFocusedComponent === 'inputbar') { - textareaRef.current?.focus() - } + focusTextarea() } - }, [assistant, topic]) + }, [ + topic.id, + assistant.mcpServers, + assistant.knowledge_bases, + assistant.enableWebSearch, + assistant.webSearchProviderId, + mentionedModels, + focusTextarea + ]) useEffect(() => { const timerId = requestAnimationFrame(() => resizeTextArea()) @@ -734,12 +744,12 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const lastFocusedComponent = PasteService.getLastFocusedComponent() if (!lastFocusedComponent || lastFocusedComponent === 'inputbar') { - textareaRef.current?.focus() + focusTextarea() } } window.addEventListener('focus', onFocus) return () => window.removeEventListener('focus', onFocus) - }, []) + }, [focusTextarea]) useEffect(() => { // if assistant knowledge bases are undefined return [] @@ -819,7 +829,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = }) } - textareaRef.current?.focus() + focusTextarea() } const isExpended = expended || !!textareaHeight From 63198ee3d2a1b20b43ffd289ca1e346d90ec7946 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sun, 3 Aug 2025 11:00:04 +0800 Subject: [PATCH 10/18] refactor(ModelList): improve group style (#8761) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(ModelList): 重构模型列表组件结构,优化分组渲染逻辑 将扁平化列表结构改为嵌套结构,提升分组模型的渲染性能 移除不必要的状态依赖,简化组件逻辑 添加分组容器样式,改善视觉呈现 * Revert "refactor(ModelList): 重构模型列表组件结构,优化分组渲染逻辑" This reverts commit f60f6267e624aa91153150cabd0861fd5431be02. * refactor(ModelList): 优化模型列表的渲染和样式 - 使用startTransition优化折叠/展开性能 - 重构数据结构,将单个模型渲染改为批量渲染 - 改进组头和模型项的样式和布局 * Revert "refactor(ModelList): 优化模型列表的渲染和样式" This reverts commit e18286c70e63b7e38f5a84e7569079eec0c67aca. * feat(模型列表): 优化模型列表项的样式和分组显示 添加last属性标记列表最后一项,优化分组标题和列表项的样式 移除多余的底部间距,调整边框圆角以提升视觉一致性 * refactor: 移除调试用的console.log语句 * style(ManageModelsList): 调整分组标题的内边距以改善视觉间距 * style(ModelList): 移动按钮位置 * style(ManageModelsList): 调整列表项和分组标题的高度以优化空间使用 * style(ManageModelsList): 为滚动容器添加圆角边框样式 --- .../components/ModelList/ManageModelsList.tsx | 125 +++++++++++------- .../src/components/ModelList/ModelList.tsx | 16 +-- 2 files changed, 85 insertions(+), 56 deletions(-) diff --git a/src/renderer/src/components/ModelList/ManageModelsList.tsx b/src/renderer/src/components/ModelList/ManageModelsList.tsx index 1100f6575b..92ea801348 100644 --- a/src/renderer/src/components/ModelList/ManageModelsList.tsx +++ b/src/renderer/src/components/ModelList/ManageModelsList.tsx @@ -26,6 +26,7 @@ interface GroupRowData { interface ModelRowData { type: 'model' model: Model + last?: boolean } type RowData = GroupRowData | ModelRowData @@ -62,9 +63,16 @@ const ManageModelsList: React.FC = ({ modelGroups, provid // 只添加非空组 rows.push({ type: 'group', groupName, models }) if (!collapsedGroups.has(groupName)) { - models.forEach((model) => { - rows.push({ type: 'model', model }) - }) + rows.push( + ...models.map( + (model, index) => + ({ + type: 'model', + model, + last: index === models.length - 1 ? true : undefined + }) as const + ) + ) } } }) @@ -131,37 +139,41 @@ const ManageModelsList: React.FC = ({ modelGroups, provid isSticky={useCallback((index: number) => flatRows[index].type === 'group', [flatRows])} overscan={5} scrollerStyle={{ - paddingRight: '10px' - }} - itemContainerStyle={{ - paddingBottom: '8px' + paddingRight: '10px', + borderRadius: '8px' }}> {(row) => { if (row.type === 'group') { const isCollapsed = collapsedGroups.has(row.groupName) return ( - handleGroupToggle(row.groupName)}> - - - {row.groupName} - - {row.models.length} - - - {renderGroupTools(row.models)} - + + handleGroupToggle(row.groupName)}> + + + {row.groupName} + + {row.models.length} + + + {renderGroupTools(row.models)} + + ) } return ( - + ) }} @@ -174,41 +186,58 @@ interface ModelListItemProps { provider: Provider onAddModel: (model: Model) => void onRemoveModel: (model: Model) => void + last?: boolean } -const ModelListItem: React.FC = memo(({ model, provider, onAddModel, onRemoveModel }) => { +const ModelListItem: React.FC = memo(({ model, provider, onAddModel, onRemoveModel, last }) => { const isAdded = useMemo(() => isModelInProvider(provider, model.id), [provider, model.id]) - return ( - {model?.name?.[0]?.toUpperCase()}, - name: , - extra: model.description && , - ext: '.model', - actions: isAdded ? ( - - - + + + + ) From a4854a883b20c0d4ce551d072e8951b2da2beda0 Mon Sep 17 00:00:00 2001 From: Phantom <59059173+EurFelux@users.noreply.github.com> Date: Sun, 3 Aug 2025 12:51:37 +0800 Subject: [PATCH 11/18] Chore/issue template (#8789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore(ISSUE_TEMPLATE): 更新错误报告模板的标签和类型字段 将labels字段从'kind/bug'改为'bug'并添加type字段 * Revert "chore(ISSUE_TEMPLATE): 更新错误报告模板的标签和类型字段" This reverts commit f1195e210a433373bec23075beacfee0151a7fec. * docs(issue模板): 更新issue模板中的labels字段 将kind/前缀的labels更新为更简洁的格式,例如将kind/bug改为bug,kind/enhancement改为feature * docs(ISSUE_TEMPLATE): 统一错误报告模板中的标签大小写 将中文和英文错误报告模板中的标签从 'bug' 统一改为大写 'BUG',保持一致性 * docs(ISSUE_TEMPLATE): 在bug报告模板中添加版本确认选项 --- .github/ISSUE_TEMPLATE/#0_bug_report.yml | 4 +++- .github/ISSUE_TEMPLATE/#1_feature_request.yml | 2 +- .github/ISSUE_TEMPLATE/#2_question.yml | 2 +- .github/ISSUE_TEMPLATE/0_bug_report.yml | 4 +++- .github/ISSUE_TEMPLATE/1_feature_request.yml | 2 +- .github/ISSUE_TEMPLATE/2_question.yml | 2 +- 6 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/#0_bug_report.yml b/.github/ISSUE_TEMPLATE/#0_bug_report.yml index fb48c4b390..a2f71d6a7a 100644 --- a/.github/ISSUE_TEMPLATE/#0_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/#0_bug_report.yml @@ -1,7 +1,7 @@ name: 🐛 错误报告 (中文) description: 创建一个报告以帮助我们改进 title: '[错误]: ' -labels: ['kind/bug'] +labels: ['BUG'] body: - type: markdown attributes: @@ -24,6 +24,8 @@ body: required: true - label: 我填写了简短且清晰明确的标题,以便开发者在翻阅 Issue 列表时能快速确定大致问题。而不是“一个建议”、“卡住了”等。 required: true + - label: 我确认我正在使用最新版本的 Cherry Studio。 + required: true - type: dropdown id: platform diff --git a/.github/ISSUE_TEMPLATE/#1_feature_request.yml b/.github/ISSUE_TEMPLATE/#1_feature_request.yml index 0649a0ce87..15ed7df097 100644 --- a/.github/ISSUE_TEMPLATE/#1_feature_request.yml +++ b/.github/ISSUE_TEMPLATE/#1_feature_request.yml @@ -1,7 +1,7 @@ name: 💡 功能建议 (中文) description: 为项目提出新的想法 title: '[功能]: ' -labels: ['kind/enhancement'] +labels: ['feature'] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/#2_question.yml b/.github/ISSUE_TEMPLATE/#2_question.yml index 1b595883de..5b9660d921 100644 --- a/.github/ISSUE_TEMPLATE/#2_question.yml +++ b/.github/ISSUE_TEMPLATE/#2_question.yml @@ -1,7 +1,7 @@ name: ❓ 提问 & 讨论 (中文) description: 寻求帮助、讨论问题、提出疑问等... title: '[讨论]: ' -labels: ['kind/question'] +labels: ['discussion', 'help wanted'] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/0_bug_report.yml b/.github/ISSUE_TEMPLATE/0_bug_report.yml index b0af11456d..c50cdef530 100644 --- a/.github/ISSUE_TEMPLATE/0_bug_report.yml +++ b/.github/ISSUE_TEMPLATE/0_bug_report.yml @@ -1,7 +1,7 @@ name: 🐛 Bug Report (English) description: Create a report to help us improve title: '[Bug]: ' -labels: ['kind/bug'] +labels: ['BUG'] body: - type: markdown attributes: @@ -24,6 +24,8 @@ body: required: true - label: I've filled in short, clear headings so that developers can quickly identify a rough idea of what to expect when flipping through the list of issues. And not "a suggestion", "stuck", etc. required: true + - label: I've confirmed that I am using the latest version of Cherry Studio. + required: true - type: dropdown id: platform diff --git a/.github/ISSUE_TEMPLATE/1_feature_request.yml b/.github/ISSUE_TEMPLATE/1_feature_request.yml index af95801cf6..0822742704 100644 --- a/.github/ISSUE_TEMPLATE/1_feature_request.yml +++ b/.github/ISSUE_TEMPLATE/1_feature_request.yml @@ -1,7 +1,7 @@ name: 💡 Feature Request (English) description: Suggest an idea for this project title: '[Feature]: ' -labels: ['kind/enhancement'] +labels: ['feature'] body: - type: markdown attributes: diff --git a/.github/ISSUE_TEMPLATE/2_question.yml b/.github/ISSUE_TEMPLATE/2_question.yml index 789ee80318..7baa828fb4 100644 --- a/.github/ISSUE_TEMPLATE/2_question.yml +++ b/.github/ISSUE_TEMPLATE/2_question.yml @@ -1,7 +1,7 @@ name: ❓ Questions & Discussion description: Seeking help, discussing issues, asking questions, etc... title: '[Discussion]: ' -labels: ['kind/question'] +labels: ['discussion', 'help wanted'] body: - type: markdown attributes: From f9365dfa143fceb96654c6f5f48ba4c8716cdf06 Mon Sep 17 00:00:00 2001 From: one Date: Sun, 3 Aug 2025 21:40:59 +0800 Subject: [PATCH 12/18] refactor(ManageModelsPopup): better animation and feedback (#8797) * refactor(ManageModelsPopup): pass providerId, add loadModels, rename loading state * feat: add a button to reload models * refactor: better transition for ManageModelsPopup * style: fix lint --- .../aiCore/clients/openai/OpenAIApiClient.ts | 2 +- .../clients/openai/OpenAIResponseAPIClient.ts | 2 +- .../ModelList/ManageModelsPopup.tsx | 236 +++++++++--------- .../src/components/ModelList/ModelList.tsx | 4 +- src/renderer/src/config/providers.ts | 4 +- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + 10 files changed, 131 insertions(+), 122 deletions(-) diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts index d4ad386a4a..fe5d9f18ad 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIApiClient.ts @@ -21,6 +21,7 @@ import { isSupportedThinkingTokenZhipuModel, isVisionModel } from '@renderer/config/models' +import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' import { processPostsuffixQwen3Model, processReqMessages } from '@renderer/services/ModelMessageService' import { estimateTextTokens } from '@renderer/services/TokenService' // For Copilot token @@ -62,7 +63,6 @@ import { ChatCompletionContentPart, ChatCompletionContentPartRefusal, ChatComple import { GenericChunk } from '../../middleware/schemas' import { RequestTransformer, ResponseChunkTransformer, ResponseChunkTransformerContext } from '../types' import { OpenAIBaseClient } from './OpenAIBaseClient' -import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' const logger = loggerService.withContext('OpenAIApiClient') diff --git a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts index cc9dba7095..970dd1399f 100644 --- a/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts +++ b/src/renderer/src/aiCore/clients/openai/OpenAIResponseAPIClient.ts @@ -6,6 +6,7 @@ import { isSupportedReasoningEffortOpenAIModel, isVisionModel } from '@renderer/config/models' +import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' import { estimateTextTokens } from '@renderer/services/TokenService' import { FileMetadata, @@ -44,7 +45,6 @@ import { ResponseInput } from 'openai/resources/responses/responses' import { RequestTransformer, ResponseChunkTransformer } from '../types' import { OpenAIAPIClient } from './OpenAIApiClient' import { OpenAIBaseClient } from './OpenAIBaseClient' -import { isSupportDeveloperRoleProvider } from '@renderer/config/providers' export class OpenAIResponseAPIClient extends OpenAIBaseClient< OpenAI, diff --git a/src/renderer/src/components/ModelList/ManageModelsPopup.tsx b/src/renderer/src/components/ModelList/ManageModelsPopup.tsx index acaf0a3a00..5346f341e9 100644 --- a/src/renderer/src/components/ModelList/ManageModelsPopup.tsx +++ b/src/renderer/src/components/ModelList/ManageModelsPopup.tsx @@ -18,40 +18,35 @@ import { import { useProvider } from '@renderer/hooks/useProvider' import { fetchModels } from '@renderer/services/ApiService' import { Model, Provider } from '@renderer/types' -import { - filterModelsByKeywords, - getDefaultGroupName, - getFancyProviderName, - isFreeModel, - runAsyncFunction -} from '@renderer/utils' +import { filterModelsByKeywords, getDefaultGroupName, getFancyProviderName, isFreeModel } from '@renderer/utils' import { Button, Empty, Flex, Modal, Spin, Tabs, Tooltip } from 'antd' import Input from 'antd/es/input/Input' import { groupBy, isEmpty, uniqBy } from 'lodash' import { debounce } from 'lodash' -import { Search } from 'lucide-react' +import { RefreshCcw, Search } from 'lucide-react' import { useCallback, useEffect, useMemo, useOptimistic, useRef, useState, useTransition } from 'react' import { useTranslation } from 'react-i18next' import styled from 'styled-components' +import { HStack } from '../Layout' import ManageModelsList from './ManageModelsList' import { isModelInProvider, isValidNewApiModel } from './utils' const logger = loggerService.withContext('ManageModelsPopup') interface ShowParams { - provider: Provider + providerId: string } interface Props extends ShowParams { resolve: (data: any) => void } -const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { +const PopupContainer: React.FC = ({ providerId, resolve }) => { const [open, setOpen] = useState(true) - const { provider, models, addModel, removeModel } = useProvider(_provider.id) + const { provider, models, addModel, removeModel } = useProvider(providerId) const [listModels, setListModels] = useState([]) - const [loading, setLoading] = useState(false) + const [loadingModels, setLoadingModels] = useState(false) const [searchText, setSearchText] = useState('') const [filterSearchText, setFilterSearchText] = useState('') const debouncedSetFilterText = useMemo( @@ -78,9 +73,14 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { const { t, i18n } = useTranslation() const searchInputRef = useRef(null) - const systemModels = SYSTEM_MODELS[_provider.id] || [] + const systemModels = SYSTEM_MODELS[provider.id] || [] const allModels = uniqBy([...systemModels, ...listModels, ...models], 'id') + const isLoading = useMemo( + () => loadingModels || isFilterTypePending || isSearchPending, + [loadingModels, isFilterTypePending, isSearchPending] + ) + const list = useMemo( () => filterModelsByKeywords(filterSearchText, allModels).filter((model) => { @@ -149,48 +149,66 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { const onRemoveModel = useCallback((model: Model) => removeModel(model), [removeModel]) - useEffect(() => { - let timer: NodeJS.Timeout - let mounted = true + const onRemoveAll = useCallback(() => { + list.filter((model) => isModelInProvider(provider, model.id)).forEach(onRemoveModel) + }, [list, onRemoveModel, provider]) - runAsyncFunction(async () => { - try { - setLoading(true) - const models = await fetchModels(_provider) - setListModels( - models - .map((model) => ({ - // @ts-ignore modelId - id: model?.id || model?.name, - // @ts-ignore name - name: model?.display_name || model?.displayName || model?.name || model?.id, - provider: _provider.id, - // @ts-ignore group - group: getDefaultGroupName(model?.id || model?.name, _provider.id), - // @ts-ignore description - description: model?.description || '', - // @ts-ignore owned_by - owned_by: model?.owned_by || '', - // @ts-ignore supported_endpoint_types - supported_endpoint_types: model?.supported_endpoint_types - })) - .filter((model) => !isEmpty(model.name)) - ) - } catch (error) { - logger.error('Failed to fetch models', error as Error) - } finally { - if (mounted) { - timer = setTimeout(() => setLoading(false), 300) + const onAddAll = useCallback(() => { + const wouldAddModel = list.filter((model) => !isModelInProvider(provider, model.id)) + window.modal.confirm({ + title: t('settings.models.manage.add_listed.label'), + content: t('settings.models.manage.add_listed.confirm'), + centered: true, + onOk: () => { + if (provider.id === 'new-api') { + if (models.every(isValidNewApiModel)) { + wouldAddModel.forEach(onAddModel) + } else { + NewApiBatchAddModelPopup.show({ + title: t('settings.models.add.batch_add_models'), + batchModels: wouldAddModel, + provider + }) + } + } else { + wouldAddModel.forEach(onAddModel) } } }) + }, [list, models, onAddModel, provider, t]) - return () => { - mounted = false - if (timer) { - clearTimeout(timer) - } + const loadModels = useCallback(async (provider: Provider) => { + setLoadingModels(true) + try { + const models = await fetchModels(provider) + const filteredModels = models + .map((model) => ({ + // @ts-ignore modelId + id: model?.id || model?.name, + // @ts-ignore name + name: model?.display_name || model?.displayName || model?.name || model?.id, + provider: provider.id, + // @ts-ignore group + group: getDefaultGroupName(model?.id || model?.name, provider.id), + // @ts-ignore description + description: model?.description || '', + // @ts-ignore owned_by + owned_by: model?.owned_by || '', + // @ts-ignore supported_endpoint_types + supported_endpoint_types: model?.supported_endpoint_types + })) + .filter((model) => !isEmpty(model.name)) + + setListModels(filteredModels) + } catch (error) { + logger.error(`Failed to load models for provider ${getFancyProviderName(provider)}`, error as Error) + } finally { + setLoadingModels(false) } + }, []) + + useEffect(() => { + loadModels(provider) // eslint-disable-next-line react-hooks/exhaustive-deps }, []) @@ -222,57 +240,39 @@ const PopupContainer: React.FC = ({ provider: _provider, resolve }) => { const renderTopTools = useCallback(() => { const isAllFilteredInProvider = list.length > 0 && list.every((model) => isModelInProvider(provider, model.id)) - const onRemoveAll = () => { - list.filter((model) => isModelInProvider(provider, model.id)).forEach(onRemoveModel) - } - - const onAddAll = () => { - const wouldAddModel = list.filter((model) => !isModelInProvider(provider, model.id)) - window.modal.confirm({ - title: t('settings.models.manage.add_listed.label'), - content: t('settings.models.manage.add_listed.confirm'), - centered: true, - onOk: () => { - if (provider.id === 'new-api') { - if (models.every(isValidNewApiModel)) { - wouldAddModel.forEach(onAddModel) - } else { - NewApiBatchAddModelPopup.show({ - title: t('settings.models.add.batch_add_models'), - batchModels: wouldAddModel, - provider - }) - } - } else { - wouldAddModel.forEach(onAddModel) - } - } - }) - } - return ( - -
+ ), + children: ( + + {options.map((item) => ( + + + + + + + + ))} + + ) + } + ]} + ghost + expandIconPosition="end" + /> + + ) +} + +export default ApiOptionsSettings diff --git a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx index 93f2a87604..c1772b6e5e 100644 --- a/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx +++ b/src/renderer/src/pages/settings/ProviderSettings/ProviderSetting.tsx @@ -29,6 +29,7 @@ import { SettingSubtitle, SettingTitle } from '..' +import ApiOptionsSettings from './ApiOptionsSettings' import AwsBedrockSettings from './AwsBedrockSettings' import CustomHeaderPopup from './CustomHeaderPopup' import DMXAPISettings from './DMXAPISettings' @@ -36,7 +37,6 @@ import GithubCopilotSettings from './GithubCopilotSettings' import GPUStackSettings from './GPUStackSettings' import LMStudioSettings from './LMStudioSettings' import ProviderOAuth from './ProviderOAuth' -import ProviderSettingsPopup from './ProviderSettingsPopup' import SelectProviderModelPopup from './SelectProviderModelPopup' import VertexAISettings from './VertexAISettings' @@ -236,14 +236,6 @@ const ProviderSetting: FC = ({ providerId }) => {