Compare commits

...

3 Commits

Author SHA1 Message Date
icarus 8d598ee3a2 feat(translate): support document files and increase size limit
Add support for document file types in translation file selection. Increase maximum file size limit to 20MB for documents while keeping text files at 5MB. Implement separate handling for document and text file reading.
2025-12-01 20:36:50 +08:00
icarus dd71c7cee3 refactor(FileStorage): extract file reading logic into reusable method
Move common file reading functionality from readFile and readExternalFile into a new private readFileCore method
Improve error logging by distinguishing between document and text file failures
Add comprehensive JSDoc documentation for all file reading methods
2025-12-01 20:29:13 +08:00
Phantom 3e6dc56196 fix(api): add withoutTrailingSharp utility and fix # handling in formatApiHost (#11604)
* docs(providerConfig): improve jsdoc for formatProviderApiHost function

* refactor(aiCore): improve provider handling with adaptProvider function

Introduce adaptProvider to centralize provider transformations and replace direct usage of handleSpecialProviders and formatProviderApiHost. This improves maintainability and provides consistent behavior across all provider usage scenarios.

* refactor(ProviderSettings): simplify api host formatting logic by using adaptProvider

Replace multiple format functions with a single adaptProvider utility to centralize host formatting logic and improve maintainability

* feat(api): add withoutTrailingSharp utility and update formatApiHost

add utility function to remove trailing # from URLs and update formatApiHost to use it
add comprehensive tests for new functionality

* feat(ProviderSetting): add help tooltip for api url selector

Add HelpTooltip component next to host selector to provide additional guidance about API URL configuration
2025-12-01 16:27:33 +08:00
17 changed files with 295 additions and 130 deletions
+74 -43
View File
@@ -478,13 +478,16 @@ class FileStorage {
} }
} }
public readFile = async ( /**
_: Electron.IpcMainInvokeEvent, * Core file reading logic that handles both documents and text files.
id: string, *
detectEncoding: boolean = false * @private
): Promise<string> => { * @param filePath - Full path to the file
const filePath = path.join(this.storageDir, id) * @param detectEncoding - Whether to auto-detect text file encoding
* @returns Promise resolving to the extracted text content
* @throws Error if file reading fails
*/
private async readFileCore(filePath: string, detectEncoding: boolean = false): Promise<string> {
const fileExtension = path.extname(filePath) const fileExtension = path.extname(filePath)
if (documentExts.includes(fileExtension)) { if (documentExts.includes(fileExtension)) {
@@ -504,7 +507,7 @@ class FileStorage {
return data return data
} catch (error) { } catch (error) {
chdir(originalCwd) chdir(originalCwd)
logger.error('Failed to read file:', error as Error) logger.error('Failed to read document file:', error as Error)
throw error throw error
} }
} }
@@ -516,11 +519,72 @@ class FileStorage {
return fs.readFileSync(filePath, 'utf-8') return fs.readFileSync(filePath, 'utf-8')
} }
} catch (error) { } catch (error) {
logger.error('Failed to read file:', error as Error) logger.error('Failed to read text file:', error as Error)
throw new Error(`Failed to read file: ${filePath}.`) throw new Error(`Failed to read file: ${filePath}.`)
} }
} }
/**
* Reads and extracts content from a stored file.
*
* Supports multiple file formats including:
* - Complex documents: .pdf, .doc, .docx, .pptx, .xlsx, .odt, .odp, .ods
* - Text files: .txt, .md, .json, .csv, etc.
* - Code files: .js, .ts, .py, .java, etc.
*
* For document formats, extracts text content using specialized parsers:
* - .doc files: Uses word-extractor library
* - Other Office formats: Uses officeparser library
*
* For text files, can optionally detect encoding automatically.
*
* @param _ - Electron IPC invoke event (unused)
* @param id - File identifier with extension (e.g., "uuid.docx")
* @param detectEncoding - Whether to auto-detect text file encoding (default: false)
* @returns Promise resolving to the extracted text content of the file
* @throws Error if file reading fails or file is not found
*
* @example
* // Read a DOCX file
* const content = await readFile(event, "document.docx");
*
* @example
* // Read a text file with encoding detection
* const content = await readFile(event, "text.txt", true);
*
* @example
* // Read a PDF file
* const content = await readFile(event, "manual.pdf");
*/
public readFile = async (
_: Electron.IpcMainInvokeEvent,
id: string,
detectEncoding: boolean = false
): Promise<string> => {
const filePath = path.join(this.storageDir, id)
return this.readFileCore(filePath, detectEncoding)
}
/**
* Reads and extracts content from an external file path.
*
* Similar to readFile, but operates on external file paths instead of stored files.
* Supports the same file formats including complex documents and text files.
*
* @param _ - Electron IPC invoke event (unused)
* @param filePath - Absolute path to the external file
* @param detectEncoding - Whether to auto-detect text file encoding (default: false)
* @returns Promise resolving to the extracted text content of the file
* @throws Error if file does not exist or reading fails
*
* @example
* // Read an external DOCX file
* const content = await readExternalFile(event, "/path/to/document.docx");
*
* @example
* // Read an external text file with encoding detection
* const content = await readExternalFile(event, "/path/to/text.txt", true);
*/
public readExternalFile = async ( public readExternalFile = async (
_: Electron.IpcMainInvokeEvent, _: Electron.IpcMainInvokeEvent,
filePath: string, filePath: string,
@@ -530,40 +594,7 @@ class FileStorage {
throw new Error(`File does not exist: ${filePath}`) throw new Error(`File does not exist: ${filePath}`)
} }
const fileExtension = path.extname(filePath) return this.readFileCore(filePath, detectEncoding)
if (documentExts.includes(fileExtension)) {
const originalCwd = process.cwd()
try {
chdir(this.tempDir)
if (fileExtension === '.doc') {
const extractor = new WordExtractor()
const extracted = await extractor.extract(filePath)
chdir(originalCwd)
return extracted.getBody()
}
const data = await officeParser.parseOfficeAsync(filePath)
chdir(originalCwd)
return data
} catch (error) {
chdir(originalCwd)
logger.error('Failed to read file:', error as Error)
throw error
}
}
try {
if (detectEncoding) {
return readTextFileWithAutoEncoding(filePath)
} else {
return fs.readFileSync(filePath, 'utf-8')
}
} catch (error) {
logger.error('Failed to read file:', error as Error)
throw new Error(`Failed to read file: ${filePath}.`)
}
} }
public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => { public createTempFile = async (_: Electron.IpcMainInvokeEvent, fileName: string): Promise<string> => {
+7 -7
View File
@@ -27,6 +27,7 @@ import { buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
import { buildPlugins } from './plugins/PluginBuilder' import { buildPlugins } from './plugins/PluginBuilder'
import { createAiSdkProvider } from './provider/factory' import { createAiSdkProvider } from './provider/factory'
import { import {
adaptProvider,
getActualProvider, getActualProvider,
isModernSdkSupported, isModernSdkSupported,
prepareSpecialProviderConfig, prepareSpecialProviderConfig,
@@ -64,12 +65,11 @@ export default class ModernAiProvider {
* - URL will be automatically formatted via `formatProviderApiHost`, adding version suffixes like `/v1` * - URL will be automatically formatted via `formatProviderApiHost`, adding version suffixes like `/v1`
* *
* 2. When called with `(model, provider)`: * 2. When called with `(model, provider)`:
* - **Directly uses the provided provider WITHOUT going through `getActualProvider`** * - The provided provider will be adapted via `adaptProvider`
* - **URL will NOT be automatically formatted, `/v1` suffix will NOT be added** * - URL formatting behavior depends on the adapted result
* - This is legacy behavior kept for backward compatibility
* *
* 3. When called with `(provider)`: * 3. When called with `(provider)`:
* - Directly uses the provider without requiring a model * - The provider will be adapted via `adaptProvider`
* - Used for operations that don't need a model (e.g., fetchModels) * - Used for operations that don't need a model (e.g., fetchModels)
* *
* @example * @example
@@ -77,7 +77,7 @@ export default class ModernAiProvider {
* // Recommended: Auto-format URL * // Recommended: Auto-format URL
* const ai = new ModernAiProvider(model) * const ai = new ModernAiProvider(model)
* *
* // Not recommended: Skip URL formatting (only for special cases) * // Provider will be adapted
* const ai = new ModernAiProvider(model, customProvider) * const ai = new ModernAiProvider(model, customProvider)
* *
* // For operations that don't need a model * // For operations that don't need a model
@@ -91,12 +91,12 @@ export default class ModernAiProvider {
if (this.isModel(modelOrProvider)) { if (this.isModel(modelOrProvider)) {
// 传入的是 Model // 传入的是 Model
this.model = modelOrProvider this.model = modelOrProvider
this.actualProvider = provider || getActualProvider(modelOrProvider) this.actualProvider = provider ? adaptProvider({ provider }) : getActualProvider(modelOrProvider)
// 只保存配置,不预先创建executor // 只保存配置,不预先创建executor
this.config = providerToAiSdkConfig(this.actualProvider, modelOrProvider) this.config = providerToAiSdkConfig(this.actualProvider, modelOrProvider)
} else { } else {
// 传入的是 Provider // 传入的是 Provider
this.actualProvider = modelOrProvider this.actualProvider = adaptProvider({ provider: modelOrProvider })
// model为可选,某些操作(如fetchModels)不需要model // model为可选,某些操作(如fetchModels)不需要model
} }
@@ -78,11 +78,13 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
} }
/** /**
* 主要用来对齐AISdk的BaseURL格式 * Format and normalize the API host URL for a provider.
* @param provider * Handles provider-specific URL formatting rules (e.g., appending version paths, Azure formatting).
* @returns *
* @param provider - The provider whose API host is to be formatted.
* @returns A new provider instance with the formatted API host.
*/ */
function formatProviderApiHost(provider: Provider): Provider { export function formatProviderApiHost(provider: Provider): Provider {
const formatted = { ...provider } const formatted = { ...provider }
if (formatted.anthropicApiHost) { if (formatted.anthropicApiHost) {
formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost) formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost)
@@ -114,18 +116,38 @@ function formatProviderApiHost(provider: Provider): Provider {
} }
/** /**
* 获取实际的Provider配置 * Retrieve the effective Provider configuration for the given model.
* 简化版:将逻辑分解为小函数 * Applies all necessary transformations (special-provider handling, URL formatting, etc.).
*
* @param model - The model whose provider is to be resolved.
* @returns A new Provider instance with all adaptations applied.
*/ */
export function getActualProvider(model: Model): Provider { export function getActualProvider(model: Model): Provider {
const baseProvider = getProviderByModel(model) const baseProvider = getProviderByModel(model)
// 按顺序处理各种转换 return adaptProvider({ provider: baseProvider, model })
let actualProvider = cloneDeep(baseProvider) }
actualProvider = handleSpecialProviders(model, actualProvider)
actualProvider = formatProviderApiHost(actualProvider)
return actualProvider /**
* Transforms a provider configuration by applying model-specific adaptations and normalizing its API host.
* The transformations are applied in the following order:
* 1. Model-specific provider handling (e.g., New-API, system providers, Azure OpenAI)
* 2. API host formatting (provider-specific URL normalization)
*
* @param provider - The base provider configuration to transform.
* @param model - The model associated with the provider; optional but required for special-provider handling.
* @returns A new Provider instance with all transformations applied.
*/
export function adaptProvider({ provider, model }: { provider: Provider; model?: Model }): Provider {
let adaptedProvider = cloneDeep(provider)
// Apply transformations in order
if (model) {
adaptedProvider = handleSpecialProviders(model, adaptedProvider)
}
adaptedProvider = formatProviderApiHost(adaptedProvider)
return adaptedProvider
} }
/** /**
+1 -1
View File
@@ -4372,7 +4372,7 @@
"url": { "url": {
"preview": "Preview: {{url}}", "preview": "Preview: {{url}}",
"reset": "Reset", "reset": "Reset",
"tip": "ending with # forces use of input address" "tip": "Add # at the end to disable the automatically appended API version."
} }
}, },
"api_host": "API Host", "api_host": "API Host",
+1 -1
View File
@@ -4372,7 +4372,7 @@
"url": { "url": {
"preview": "预览: {{url}}", "preview": "预览: {{url}}",
"reset": "重置", "reset": "重置",
"tip": "# 结尾强制使用输入地址" "tip": "在末尾添加 # 以禁用自动附加的API版本。"
} }
}, },
"api_host": "API 地址", "api_host": "API 地址",
+1 -1
View File
@@ -4372,7 +4372,7 @@
"url": { "url": {
"preview": "預覽:{{url}}", "preview": "預覽:{{url}}",
"reset": "重設", "reset": "重設",
"tip": "# 結尾強制使用輸入位址" "tip": "在末尾添加 # 以停用自動附加的 API 版本。"
} }
}, },
"api_host": "API 主機地址", "api_host": "API 主機地址",
+1 -1
View File
@@ -4372,7 +4372,7 @@
"url": { "url": {
"preview": "Vorschau: {{url}}", "preview": "Vorschau: {{url}}",
"reset": "Zurücksetzen", "reset": "Zurücksetzen",
"tip": "# am Ende erzwingt die Verwendung der Eingabe-Adresse" "tip": "Fügen Sie am Ende ein # hinzu, um die automatisch angehängte API-Version zu deaktivieren."
} }
}, },
"api_host": "API-Adresse", "api_host": "API-Adresse",
+1 -1
View File
@@ -4372,7 +4372,7 @@
"url": { "url": {
"preview": "Προεπισκόπηση: {{url}}", "preview": "Προεπισκόπηση: {{url}}",
"reset": "Επαναφορά", "reset": "Επαναφορά",
"tip": "#τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως" "tip": "Προσθέστε το σύμβολο # στο τέλος για να απενεργοποιήσετε την αυτόματα προστιθέμενη έκδοση API."
} }
}, },
"api_host": "Διεύθυνση API", "api_host": "Διεύθυνση API",
+1 -1
View File
@@ -4372,7 +4372,7 @@
"url": { "url": {
"preview": "Vista previa: {{url}}", "preview": "Vista previa: {{url}}",
"reset": "Restablecer", "reset": "Restablecer",
"tip": "forzar uso de dirección de entrada con # al final" "tip": "Añada # al final para deshabilitar la versión de la API que se añade automáticamente."
} }
}, },
"api_host": "Dirección API", "api_host": "Dirección API",
+1 -1
View File
@@ -4372,7 +4372,7 @@
"url": { "url": {
"preview": "Aperçu : {{url}}", "preview": "Aperçu : {{url}}",
"reset": "Réinitialiser", "reset": "Réinitialiser",
"tip": "forcer l'utilisation de l'adresse d'entrée si terminé par #" "tip": "Ajoutez # à la fin pour désactiver la version d'API ajoutée automatiquement."
} }
}, },
"api_host": "Adresse API", "api_host": "Adresse API",
+1 -1
View File
@@ -4372,7 +4372,7 @@
"url": { "url": {
"preview": "プレビュー: {{url}}", "preview": "プレビュー: {{url}}",
"reset": "リセット", "reset": "リセット",
"tip": "#で終わる場合、入力されたアドレスを強制的に使用します" "tip": "自動的に付加されるAPIバージョンを無効にするには、末尾に#を追加します"
} }
}, },
"api_host": "APIホスト", "api_host": "APIホスト",
+1 -1
View File
@@ -4372,7 +4372,7 @@
"url": { "url": {
"preview": "Pré-visualização: {{url}}", "preview": "Pré-visualização: {{url}}",
"reset": "Redefinir", "reset": "Redefinir",
"tip": "e forçar o uso do endereço original quando terminar com '#'" "tip": "Adicione # no final para desativar a versão da API adicionada automaticamente."
} }
}, },
"api_host": "Endereço API", "api_host": "Endereço API",
+1 -1
View File
@@ -4372,7 +4372,7 @@
"url": { "url": {
"preview": "Предпросмотр: {{url}}", "preview": "Предпросмотр: {{url}}",
"reset": "Сброс", "reset": "Сброс",
"tip": "заканчивая на # принудительно использует введенный адрес" "tip": "Добавьте # в конце, чтобы отключить автоматически добавляемую версию API."
} }
}, },
"api_host": "Хост API", "api_host": "Хост API",
@@ -1,8 +1,10 @@
import { adaptProvider } from '@renderer/aiCore/provider/providerConfig'
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert' import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
import { LoadingIcon } from '@renderer/components/Icons' import { LoadingIcon } from '@renderer/components/Icons'
import { HStack } from '@renderer/components/Layout' import { HStack } from '@renderer/components/Layout'
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup' import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
import Selector from '@renderer/components/Selector' import Selector from '@renderer/components/Selector'
import { HelpTooltip } from '@renderer/components/TooltipIcons'
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models' import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
import { PROVIDER_URLS } from '@renderer/config/providers' import { PROVIDER_URLS } from '@renderer/config/providers'
import { useTheme } from '@renderer/context/ThemeProvider' import { useTheme } from '@renderer/context/ThemeProvider'
@@ -19,14 +21,7 @@ import type { SystemProviderId } from '@renderer/types'
import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types' import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types'
import type { ApiKeyConnectivity } from '@renderer/types/healthCheck' import type { ApiKeyConnectivity } from '@renderer/types/healthCheck'
import { HealthStatus } from '@renderer/types/healthCheck' import { HealthStatus } from '@renderer/types/healthCheck'
import { import { formatApiHost, formatApiKeys, getFancyProviderName, validateApiHost } from '@renderer/utils'
formatApiHost,
formatApiKeys,
formatAzureOpenAIApiHost,
formatVertexApiHost,
getFancyProviderName,
validateApiHost
} from '@renderer/utils'
import { formatErrorMessage } from '@renderer/utils/error' import { formatErrorMessage } from '@renderer/utils/error'
import { import {
isAIGatewayProvider, isAIGatewayProvider,
@@ -36,7 +31,6 @@ import {
isNewApiProvider, isNewApiProvider,
isOpenAICompatibleProvider, isOpenAICompatibleProvider,
isOpenAIProvider, isOpenAIProvider,
isSupportAPIVersionProvider,
isVertexProvider isVertexProvider
} from '@renderer/utils/provider' } from '@renderer/utils/provider'
import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd' import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd'
@@ -281,12 +275,10 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
}, [configuredApiHost, apiHost]) }, [configuredApiHost, apiHost])
const hostPreview = () => { const hostPreview = () => {
if (apiHost.endsWith('#')) { const formattedApiHost = adaptProvider({ provider: { ...provider, apiHost } }).apiHost
return apiHost.replace('#', '')
}
if (isOpenAICompatibleProvider(provider)) { if (isOpenAICompatibleProvider(provider)) {
return formatApiHost(apiHost, isSupportAPIVersionProvider(provider)) + '/chat/completions' return formattedApiHost + '/chat/completions'
} }
if (isAzureOpenAIProvider(provider)) { if (isAzureOpenAIProvider(provider)) {
@@ -294,29 +286,26 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
const path = !['preview', 'v1'].includes(apiVersion) const path = !['preview', 'v1'].includes(apiVersion)
? `/v1/chat/completion?apiVersion=v1` ? `/v1/chat/completion?apiVersion=v1`
: `/v1/responses?apiVersion=v1` : `/v1/responses?apiVersion=v1`
return formatAzureOpenAIApiHost(apiHost) + path return formattedApiHost + path
} }
if (isAnthropicProvider(provider)) { if (isAnthropicProvider(provider)) {
// AI SDK uses the baseURL with /v1, then appends /messages return formattedApiHost + '/messages'
// formatApiHost adds /v1 automatically if not present
const normalizedHost = formatApiHost(apiHost)
return normalizedHost + '/messages'
} }
if (isGeminiProvider(provider)) { if (isGeminiProvider(provider)) {
return formatApiHost(apiHost, true, 'v1beta') + '/models' return formattedApiHost + '/models'
} }
if (isOpenAIProvider(provider)) { if (isOpenAIProvider(provider)) {
return formatApiHost(apiHost) + '/responses' return formattedApiHost + '/responses'
} }
if (isVertexProvider(provider)) { if (isVertexProvider(provider)) {
return formatVertexApiHost(provider) + '/publishers/google' return formattedApiHost + '/publishers/google'
} }
if (isAIGatewayProvider(provider)) { if (isAIGatewayProvider(provider)) {
return formatApiHost(apiHost) + '/language-model' return formattedApiHost + '/language-model'
} }
return formatApiHost(apiHost) return formattedApiHost
} }
// API key 连通性检查状态指示器,目前仅在失败时显示 // API key 连通性检查状态指示器,目前仅在失败时显示
@@ -494,16 +483,21 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
{!isDmxapi && ( {!isDmxapi && (
<> <>
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}> <SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Tooltip title={hostSelectorTooltip} mouseEnterDelay={0.3}> <div className="flex items-center gap-1">
<Selector <Tooltip title={hostSelectorTooltip} mouseEnterDelay={0.3}>
size={14} <div>
value={activeHostField} <Selector
onChange={(value) => setActiveHostField(value as HostField)} size={14}
options={hostSelectorOptions} value={activeHostField}
style={{ paddingLeft: 1, fontWeight: 'bold' }} onChange={(value) => setActiveHostField(value as HostField)}
placement="bottomLeft" options={hostSelectorOptions}
/> style={{ paddingLeft: 1, fontWeight: 'bold' }}
</Tooltip> placement="bottomLeft"
/>
</div>
</Tooltip>
<HelpTooltip title={t('settings.provider.api.url.tip')}></HelpTooltip>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Button <Button
type="text" type="text"
@@ -39,6 +39,7 @@ import {
detectLanguage, detectLanguage,
determineTargetLanguage determineTargetLanguage
} from '@renderer/utils/translate' } from '@renderer/utils/translate'
import { documentExts } from '@shared/config/constant'
import { imageExts, MB, textExts } from '@shared/config/constant' import { imageExts, MB, textExts } from '@shared/config/constant'
import { Button, Flex, FloatButton, Popover, Tooltip, Typography } from 'antd' import { Button, Flex, FloatButton, Popover, Tooltip, Typography } from 'antd'
import type { TextAreaRef } from 'antd/es/input/TextArea' import type { TextAreaRef } from 'antd/es/input/TextArea'
@@ -66,7 +67,7 @@ const TranslatePage: FC = () => {
const { prompt, getLanguageByLangcode, settings } = useTranslate() const { prompt, getLanguageByLangcode, settings } = useTranslate()
const { autoCopy } = settings const { autoCopy } = settings
const { shikiMarkdownIt } = useCodeStyle() const { shikiMarkdownIt } = useCodeStyle()
const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts] }) const { onSelectFile, selecting, clearFiles } = useFiles({ extensions: [...imageExts, ...textExts, ...documentExts] })
const { ocr } = useOcr() const { ocr } = useOcr()
const { setTimeoutTimer } = useTimer() const { setTimeoutTimer } = useTimer()
@@ -484,33 +485,56 @@ const TranslatePage: FC = () => {
const readFile = useCallback( const readFile = useCallback(
async (file: FileMetadata) => { async (file: FileMetadata) => {
const _readFile = async () => { const _readFile = async () => {
let isText: boolean
try { try {
// 检查文件是否为文本文件 const fileExtension = getFileExtension(file.path)
isText = await isTextFile(file.path)
} catch (e) {
logger.error('Failed to check if file is text.', e as Error)
window.toast.error(t('translate.files.error.check_type') + ': ' + formatErrorMessage(e))
return
}
if (!isText) { // Check if file is supported format (text file or document file)
window.toast.error(t('common.file.not_supported', { type: getFileExtension(file.path) })) let isText: boolean
logger.error('Unsupported file type.') const isDocument: boolean = documentExts.includes(fileExtension)
return
}
// the threshold may be too large if (!isDocument) {
if (file.size > 5 * MB) { try {
window.toast.error(t('translate.files.error.too_large') + ' (0 ~ 5 MB)') // For non-document files, check if it's a text file
} else { isText = await isTextFile(file.path)
} catch (e) {
logger.error('Failed to check file type.', e as Error)
window.toast.error(t('translate.files.error.check_type') + ': ' + formatErrorMessage(e))
return
}
} else {
isText = false
}
if (!isText && !isDocument) {
window.toast.error(t('common.file.not_supported', { type: fileExtension }))
logger.error('Unsupported file type.')
return
}
// File size check - document files allowed to be larger
const maxSize = isDocument ? 20 * MB : 5 * MB
if (file.size > maxSize) {
window.toast.error(t('translate.files.error.too_large') + ` (0 ~ ${maxSize / MB} MB)`)
return
}
let result: string
try { try {
const result = await window.api.fs.readText(file.path) if (isDocument) {
// Use the new document reading API
result = await window.api.file.readExternal(file.path, true)
} else {
// Read text file
result = await window.api.fs.readText(file.path)
}
setText(text + result) setText(text + result)
} catch (e) { } catch (e) {
logger.error('Failed to read text file.', e as Error) logger.error('Failed to read file.', e as Error)
window.toast.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e)) window.toast.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e))
} }
} catch (e) {
logger.error('Failed to read file.', e as Error)
window.toast.error(t('translate.files.error.unknown') + ': ' + formatErrorMessage(e))
} }
} }
const promise = _readFile() const promise = _readFile()
+75 -1
View File
@@ -13,7 +13,8 @@ import {
routeToEndpoint, routeToEndpoint,
splitApiKeyString, splitApiKeyString,
validateApiHost, validateApiHost,
withoutTrailingApiVersion withoutTrailingApiVersion,
withoutTrailingSharp
} from '../api' } from '../api'
vi.mock('@renderer/store', () => { vi.mock('@renderer/store', () => {
@@ -81,6 +82,27 @@ describe('api', () => {
it('keeps host untouched when api version unsupported', () => { it('keeps host untouched when api version unsupported', () => {
expect(formatApiHost('https://api.example.com', false)).toBe('https://api.example.com') expect(formatApiHost('https://api.example.com', false)).toBe('https://api.example.com')
}) })
it('removes trailing # and does not append api version when host ends with #', () => {
expect(formatApiHost('https://api.example.com#')).toBe('https://api.example.com')
expect(formatApiHost('http://localhost:5173/#')).toBe('http://localhost:5173/')
expect(formatApiHost(' https://api.openai.com/# ')).toBe('https://api.openai.com/')
})
it('handles trailing # with custom api version settings', () => {
expect(formatApiHost('https://api.example.com#', true, 'v2')).toBe('https://api.example.com')
expect(formatApiHost('https://api.example.com#', false, 'v2')).toBe('https://api.example.com')
})
it('handles host with both trailing # and existing api version', () => {
expect(formatApiHost('https://api.example.com/v2#')).toBe('https://api.example.com/v2')
expect(formatApiHost('https://api.example.com/v3beta#')).toBe('https://api.example.com/v3beta')
})
it('trims whitespace before processing trailing #', () => {
expect(formatApiHost(' https://api.example.com# ')).toBe('https://api.example.com')
expect(formatApiHost('\thttps://api.example.com#\n')).toBe('https://api.example.com')
})
}) })
describe('hasAPIVersion', () => { describe('hasAPIVersion', () => {
@@ -404,4 +426,56 @@ describe('api', () => {
expect(withoutTrailingApiVersion('')).toBe('') expect(withoutTrailingApiVersion('')).toBe('')
}) })
}) })
describe('withoutTrailingSharp', () => {
it('removes trailing # from URL', () => {
expect(withoutTrailingSharp('https://api.example.com#')).toBe('https://api.example.com')
expect(withoutTrailingSharp('http://localhost:3000#')).toBe('http://localhost:3000')
})
it('returns URL unchanged when no trailing #', () => {
expect(withoutTrailingSharp('https://api.example.com')).toBe('https://api.example.com')
expect(withoutTrailingSharp('http://localhost:3000')).toBe('http://localhost:3000')
})
it('handles URLs with multiple # characters but only removes trailing one', () => {
expect(withoutTrailingSharp('https://api.example.com#path#')).toBe('https://api.example.com#path')
})
it('handles URLs with # in the middle (not trailing)', () => {
expect(withoutTrailingSharp('https://api.example.com#section/path')).toBe('https://api.example.com#section/path')
expect(withoutTrailingSharp('https://api.example.com/v1/chat/completions#')).toBe(
'https://api.example.com/v1/chat/completions'
)
})
it('handles empty string', () => {
expect(withoutTrailingSharp('')).toBe('')
})
it('handles single character #', () => {
expect(withoutTrailingSharp('#')).toBe('')
})
it('preserves whitespace around the URL (pure function)', () => {
expect(withoutTrailingSharp(' https://api.example.com# ')).toBe(' https://api.example.com# ')
expect(withoutTrailingSharp('\thttps://api.example.com#\n')).toBe('\thttps://api.example.com#\n')
})
it('only removes exact trailing # character', () => {
expect(withoutTrailingSharp('https://api.example.com# ')).toBe('https://api.example.com# ')
expect(withoutTrailingSharp(' https://api.example.com#')).toBe(' https://api.example.com')
expect(withoutTrailingSharp('https://api.example.com#\t')).toBe('https://api.example.com#\t')
})
it('handles URLs ending with multiple # characters', () => {
expect(withoutTrailingSharp('https://api.example.com##')).toBe('https://api.example.com#')
expect(withoutTrailingSharp('https://api.example.com###')).toBe('https://api.example.com##')
})
it('preserves URL with trailing # and other content', () => {
expect(withoutTrailingSharp('https://api.example.com/v1#')).toBe('https://api.example.com/v1')
expect(withoutTrailingSharp('https://api.example.com/v2beta#')).toBe('https://api.example.com/v2beta')
})
})
}) })
+25 -5
View File
@@ -62,6 +62,23 @@ export function withoutTrailingSlash<T extends string>(url: T): T {
return url.replace(/\/$/, '') as T return url.replace(/\/$/, '') as T
} }
/**
* Removes the trailing '#' from a URL string if it exists.
*
* @template T - The string type to preserve type safety
* @param {T} url - The URL string to process
* @returns {T} The URL string without a trailing '#'
*
* @example
* ```ts
* withoutTrailingSharp('https://example.com#') // 'https://example.com'
* withoutTrailingSharp('https://example.com') // 'https://example.com'
* ```
*/
export function withoutTrailingSharp<T extends string>(url: T): T {
return url.replace(/#$/, '') as T
}
/** /**
* Formats an API host URL by normalizing it and optionally appending an API version. * Formats an API host URL by normalizing it and optionally appending an API version.
* *
@@ -70,12 +87,12 @@ export function withoutTrailingSlash<T extends string>(url: T): T {
* @param apiVersion - The API version to append if needed. Defaults to `'v1'`. * @param apiVersion - The API version to append if needed. Defaults to `'v1'`.
* *
* @returns The formatted API host URL. If the host is empty after normalization, returns an empty string. * @returns The formatted API host URL. If the host is empty after normalization, returns an empty string.
* If the host ends with '#', API version is not supported, or the host already contains a version, returns the normalized host as-is. * If the host ends with '#', API version is not supported, or the host already contains a version, returns the normalized host with trailing '#' removed.
* Otherwise, returns the host with the API version appended. * Otherwise, returns the host with the API version appended.
* *
* @example * @example
* formatApiHost('https://api.example.com/') // Returns 'https://api.example.com/v1' * formatApiHost('https://api.example.com/') // Returns 'https://api.example.com/v1'
* formatApiHost('https://api.example.com#') // Returns 'https://api.example.com#' * formatApiHost('https://api.example.com#') // Returns 'https://api.example.com'
* formatApiHost('https://api.example.com/v2', true, 'v1') // Returns 'https://api.example.com/v2' * formatApiHost('https://api.example.com/v2', true, 'v1') // Returns 'https://api.example.com/v2'
*/ */
export function formatApiHost(host?: string, supportApiVersion: boolean = true, apiVersion: string = 'v1'): string { export function formatApiHost(host?: string, supportApiVersion: boolean = true, apiVersion: string = 'v1'): string {
@@ -84,10 +101,13 @@ export function formatApiHost(host?: string, supportApiVersion: boolean = true,
return '' return ''
} }
if (normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost)) { const shouldAppendApiVersion = !(normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost))
return normalizedHost
if (shouldAppendApiVersion) {
return `${normalizedHost}/${apiVersion}`
} else {
return withoutTrailingSharp(normalizedHost)
} }
return `${normalizedHost}/${apiVersion}`
} }
/** /**