Merge remote-tracking branch 'origin/main' into feat/user-guide

This commit is contained in:
suyao 2026-01-16 14:46:10 +08:00
commit ece1bddfd6
No known key found for this signature in database
26 changed files with 536 additions and 206 deletions

View File

@ -33,7 +33,6 @@
"agents:push": "NODE_ENV='development' drizzle-kit push --config src/main/services/agents/drizzle.config.ts",
"agents:studio": "NODE_ENV='development' drizzle-kit studio --config src/main/services/agents/drizzle.config.ts",
"agents:drop": "NODE_ENV='development' drizzle-kit drop --config src/main/services/agents/drizzle.config.ts",
"generate:icons": "electron-icon-builder --input=./build/logo.png --output=build",
"analyze:renderer": "VISUALIZER_RENDERER=true pnpm build",
"analyze:main": "VISUALIZER_MAIN=true pnpm build",
"typecheck": "concurrently -n \"node,web\" -c \"cyan,magenta\" \"npm run typecheck:node\" \"npm run typecheck:web\"",

View File

@ -56,8 +56,6 @@ type QuotaResponse = {
export default class MineruPreprocessProvider extends BasePreprocessProvider {
constructor(provider: PreprocessProvider, userId?: string) {
super(provider, userId)
// TODO: remove after free period ends
this.provider.apiKey = this.provider.apiKey || import.meta.env.MAIN_VITE_MINERU_API_KEY
}
public async parseFile(
@ -65,6 +63,10 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
file: FileMetadata
): Promise<{ processedFile: FileMetadata; quota: number }> {
try {
if (!this.provider.apiKey) {
throw new Error('MinerU API key is required')
}
const filePath = fileStorage.getFilePathById(file)
logger.info(`MinerU preprocess processing started: ${filePath}`)
await this.validateFile(filePath)
@ -96,6 +98,10 @@ export default class MineruPreprocessProvider extends BasePreprocessProvider {
public async checkQuota() {
try {
if (!this.provider.apiKey) {
throw new Error('MinerU API key is required')
}
const quota = await net.fetch(`${this.provider.apiHost}/api/v4/quota`, {
method: 'GET',
headers: {

View File

@ -2,6 +2,7 @@
* Anthropic Prompt Caching Middleware
* @see https://ai-sdk.dev/providers/ai-sdk-providers/anthropic#cache-control
*/
import type { LanguageModelV2Message } from '@ai-sdk/provider'
import { estimateTextTokens } from '@renderer/services/TokenService'
import type { Provider } from '@renderer/types'
import type { LanguageModelMiddleware } from 'ai'
@ -10,11 +11,11 @@ const cacheProviderOptions = {
anthropic: { cacheControl: { type: 'ephemeral' } }
}
function estimateContentTokens(content: unknown): number {
function estimateContentTokens(content: LanguageModelV2Message['content']): number {
if (typeof content === 'string') return estimateTextTokens(content)
if (Array.isArray(content)) {
return content.reduce((acc, part) => {
if (typeof part === 'object' && part !== null && 'text' in part) {
if (part.type === 'text') {
return acc + estimateTextTokens(part.text as string)
}
return acc
@ -23,21 +24,6 @@ function estimateContentTokens(content: unknown): number {
return 0
}
function addCacheToContentParts(content: unknown): unknown {
if (typeof content === 'string') {
return [{ type: 'text', text: content, providerOptions: cacheProviderOptions }]
}
if (Array.isArray(content) && content.length > 0) {
const result = [...content]
const last = result[result.length - 1]
if (typeof last === 'object' && last !== null) {
result[result.length - 1] = { ...last, providerOptions: cacheProviderOptions }
}
return result
}
return content
}
export function anthropicCacheMiddleware(provider: Provider): LanguageModelMiddleware {
return {
middlewareVersion: 'v2',
@ -54,7 +40,7 @@ export function anthropicCacheMiddleware(provider: Provider): LanguageModelMiddl
// Cache system message (providerOptions on message object)
if (cacheSystemMessage) {
for (let i = 0; i < messages.length; i++) {
const msg = messages[i] as any
const msg = messages[i] as LanguageModelV2Message
if (msg.role === 'system' && estimateContentTokens(msg.content) >= tokenThreshold) {
messages[i] = { ...msg, providerOptions: cacheProviderOptions }
break
@ -64,12 +50,32 @@ export function anthropicCacheMiddleware(provider: Provider): LanguageModelMiddl
// Cache last N non-system messages (providerOptions on content parts)
if (cacheLastNMessages > 0) {
const cumsumTokens = [] as Array<number>
let tokenSum = 0 as number
for (let i = 0; i < messages.length; i++) {
const msg = messages[i] as LanguageModelV2Message
tokenSum += estimateContentTokens(msg.content)
cumsumTokens.push(tokenSum)
}
for (let i = messages.length - 1; i >= 0 && cachedCount < cacheLastNMessages; i--) {
const msg = messages[i] as any
if (msg.role !== 'system' && estimateContentTokens(msg.content) >= tokenThreshold) {
messages[i] = { ...msg, content: addCacheToContentParts(msg.content) }
cachedCount++
const msg = messages[i] as LanguageModelV2Message
if (msg.role === 'system' || cumsumTokens[i] < tokenThreshold || msg.content.length === 0) {
continue
}
const newContent = [...msg.content]
const lastIndex = newContent.length - 1
newContent[lastIndex] = {
...newContent[lastIndex],
providerOptions: cacheProviderOptions
}
messages[i] = {
...msg,
content: newContent
} as LanguageModelV2Message
cachedCount++
}
}

View File

@ -263,7 +263,7 @@ describe('messageConverter', () => {
})
describe('convertMessagesToSdkMessages', () => {
it('collapses to [system?, user(image)] for image enhancement models', async () => {
it('preserves conversation history and merges images for image enhancement models', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const initialUser = createMessage('user')
initialUser.__mockContent = 'Start editing'
@ -277,7 +277,16 @@ describe('messageConverter', () => {
const result = await convertMessagesToSdkMessages([initialUser, assistant, finalUser], model)
// Preserves all conversation history, only merges images into the last user message
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Start editing' }]
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Here is the current preview' }]
},
{
role: 'user',
content: [
@ -288,7 +297,7 @@ describe('messageConverter', () => {
])
})
it('preserves system messages and collapses others for enhancement payloads', async () => {
it('preserves system messages and conversation history for enhancement payloads', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const fileUser = createMessage('user')
fileUser.__mockContent = 'Use this document as inspiration'
@ -309,8 +318,17 @@ describe('messageConverter', () => {
const result = await convertMessagesToSdkMessages([fileUser, assistant, finalUser], model)
// Preserves system message, conversation history, and merges images into the last user message
expect(result).toEqual([
{ role: 'system', content: 'fileid://reference' },
{
role: 'user',
content: [{ type: 'text', text: 'Use this document as inspiration' }]
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Generated previews ready' }]
},
{
role: 'user',
content: [
@ -321,7 +339,7 @@ describe('messageConverter', () => {
])
})
it('handles no previous assistant message with images', async () => {
it('returns messages as-is when no previous assistant message with images', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const user1 = createMessage('user')
user1.__mockContent = 'Start'
@ -331,7 +349,12 @@ describe('messageConverter', () => {
const result = await convertMessagesToSdkMessages([user1, user2], model)
// No images to merge, returns all messages as-is
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Start' }]
},
{
role: 'user',
content: [{ type: 'text', text: 'Continue without images' }]
@ -339,7 +362,7 @@ describe('messageConverter', () => {
])
})
it('handles assistant message without images', async () => {
it('returns messages as-is when assistant message has no images', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const user1 = createMessage('user')
user1.__mockContent = 'Start'
@ -353,7 +376,16 @@ describe('messageConverter', () => {
const result = await convertMessagesToSdkMessages([user1, assistant, user2], model)
// No images to merge, returns all messages as-is
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Start' }]
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Text only response' }]
},
{
role: 'user',
content: [{ type: 'text', text: 'Follow up' }]
@ -361,7 +393,7 @@ describe('messageConverter', () => {
])
})
it('handles multiple assistant messages by using the most recent one', async () => {
it('merges images from the most recent assistant message', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const user1 = createMessage('user')
user1.__mockContent = 'Start'
@ -382,7 +414,24 @@ describe('messageConverter', () => {
const result = await convertMessagesToSdkMessages([user1, assistant1, user2, assistant2, user3], model)
// Preserves all history, merges only the most recent assistant's images
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Start' }]
},
{
role: 'assistant',
content: [{ type: 'text', text: 'First response' }]
},
{
role: 'user',
content: [{ type: 'text', text: 'Continue' }]
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Second response' }]
},
{
role: 'user',
content: [
@ -393,7 +442,7 @@ describe('messageConverter', () => {
])
})
it('handles conversation ending with assistant message', async () => {
it('returns messages as-is when conversation ends with assistant message', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const user = createMessage('user')
user.__mockContent = 'Start'
@ -406,15 +455,20 @@ describe('messageConverter', () => {
// The user message is the last user message, but since the assistant comes after,
// there's no "previous" assistant message (search starts from messages.length-2 backwards)
// So no images to merge, returns all messages as-is
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Start' }]
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Response with image' }]
}
])
})
it('handles empty content in last user message', async () => {
it('merges images even when last user message has empty content', async () => {
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
const user1 = createMessage('user')
user1.__mockContent = 'Start'
@ -428,12 +482,79 @@ describe('messageConverter', () => {
const result = await convertMessagesToSdkMessages([user1, assistant, user2], model)
// Preserves history, merges images into last user message (even if empty)
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Start' }]
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Here is the preview' }]
},
{
role: 'user',
content: [{ type: 'image', image: 'https://example.com/preview.png' }]
}
])
})
it('allows using LLM conversation context for image generation', async () => {
// This test verifies the key use case: switching from LLM to image enhancement model
// and using the previous conversation as context for image generation
const model = createModel({ id: 'qwen-image-edit', name: 'Qwen Image Edit', provider: 'qwen', group: 'qwen' })
// Simulate a conversation that started with a regular LLM
const user1 = createMessage('user')
user1.__mockContent = 'Help me design a futuristic robot with blue lights'
const assistant1 = createMessage('assistant')
assistant1.__mockContent =
'Great idea! The robot could have a sleek metallic body with glowing blue LED strips...'
assistant1.__mockImageBlocks = [] // LLM response, no images
const user2 = createMessage('user')
user2.__mockContent = 'Yes, and add some chrome accents'
const assistant2 = createMessage('assistant')
assistant2.__mockContent = 'Perfect! Chrome accents would complement the blue lights beautifully...'
assistant2.__mockImageBlocks = [] // Still LLM response, no images
// User switches to image enhancement model and asks for image generation
const user3 = createMessage('user')
user3.__mockContent = 'Now generate an image based on our discussion'
const result = await convertMessagesToSdkMessages([user1, assistant1, user2, assistant2, user3], model)
// All conversation history should be preserved for context
// No images to merge since previous assistant had no images
expect(result).toEqual([
{
role: 'user',
content: [{ type: 'text', text: 'Help me design a futuristic robot with blue lights' }]
},
{
role: 'assistant',
content: [
{
type: 'text',
text: 'Great idea! The robot could have a sleek metallic body with glowing blue LED strips...'
}
]
},
{
role: 'user',
content: [{ type: 'text', text: 'Yes, and add some chrome accents' }]
},
{
role: 'assistant',
content: [{ type: 'text', text: 'Perfect! Chrome accents would complement the blue lights beautifully...' }]
},
{
role: 'user',
content: [{ type: 'text', text: 'Now generate an image based on our discussion' }]
}
])
})
})
})

View File

@ -229,23 +229,15 @@ export async function convertMessagesToSdkMessages(messages: Message[], model: M
sdkMessages.push(...(Array.isArray(sdkMessage) ? sdkMessage : [sdkMessage]))
}
// Special handling for image enhancement models
// Target behavior: Collapse the conversation into [system?, user(image)].
// Explanation of why we don't simply use slice:
// 1) We need to preserve all system messages: During the convertMessageToSdkParam process, native file uploads may insert `system(fileid://...)`.
// Directly slicing the original messages or already converted sdkMessages could easily result in missing these system instructions.
// Therefore, we first perform a full conversion and then aggregate the system messages afterward.
// 2) The conversion process may split messages: A single user message might be broken into two SDK messages—[system, user].
// Slicing either side could lead to obtaining semantically incorrect fragments (e.g., only the split-out system message).
// 3) The “previous assistant message” is not necessarily the second-to-last one: There might be system messages or other message blocks inserted in between,
// making a simple slice(-2) assumption too rigid. Here, we trace back from the end of the original messages to locate the most recent assistant message, which better aligns with business semantics.
// 4) This is a “collapse” rather than a simple “slice”: Ultimately, we need to synthesize a new user message
// (with text from the last user message and images from the previous assistant message). Using slice can only extract subarrays,
// which still require reassembly; constructing directly according to the target structure is clearer and more reliable.
// These models support multi-turn conversations but need images from previous assistant messages
// to be merged into the current user message for editing/enhancement operations.
//
// Key behaviors:
// 1. Preserve all conversation history for context
// 2. Find images from the previous assistant message and merge them into the last user message
// 3. This allows users to switch from LLM conversations and use that context for image generation
if (isImageEnhancementModel(model)) {
// Collect all system messages (including ones generated from file uploads)
const systemMessages = sdkMessages.filter((m): m is SystemModelMessage => m.role === 'system')
// Find the last user message (SDK converted)
// Find the last user SDK message index
const lastUserSdkIndex = (() => {
for (let i = sdkMessages.length - 1; i >= 0; i--) {
if (sdkMessages[i].role === 'user') return i
@ -253,7 +245,10 @@ export async function convertMessagesToSdkMessages(messages: Message[], model: M
return -1
})()
const lastUserSdk = lastUserSdkIndex >= 0 ? (sdkMessages[lastUserSdkIndex] as UserModelMessage) : null
// If no user message found, return messages as-is
if (lastUserSdkIndex < 0) {
return sdkMessages
}
// Find the nearest preceding assistant message in original messages
let prevAssistant: Message | null = null
@ -264,31 +259,33 @@ export async function convertMessagesToSdkMessages(messages: Message[], model: M
}
}
// Build the final user content parts
// Check if there are images from the previous assistant message
const imageBlocks = prevAssistant ? findImageBlocks(prevAssistant) : []
const imageParts = await convertImageBlockToImagePart(imageBlocks)
// If no images to merge, return messages as-is
if (imageParts.length === 0) {
return sdkMessages
}
// Build the new last user message with merged images
const lastUserSdk = sdkMessages[lastUserSdkIndex] as UserModelMessage
let finalUserParts: Array<TextPart | FilePart | ImagePart> = []
if (lastUserSdk) {
if (typeof lastUserSdk.content === 'string') {
finalUserParts.push({ type: 'text', text: lastUserSdk.content })
} else if (Array.isArray(lastUserSdk.content)) {
finalUserParts = [...lastUserSdk.content]
}
if (typeof lastUserSdk.content === 'string') {
finalUserParts.push({ type: 'text', text: lastUserSdk.content })
} else if (Array.isArray(lastUserSdk.content)) {
finalUserParts = [...lastUserSdk.content]
}
// Append images from the previous assistant message if any
if (prevAssistant) {
const imageBlocks = findImageBlocks(prevAssistant)
const imageParts = await convertImageBlockToImagePart(imageBlocks)
if (imageParts.length > 0) {
finalUserParts.push(...imageParts)
}
}
// Append images from the previous assistant message
finalUserParts.push(...imageParts)
// If we couldn't find a last user message, fall back to returning collected system messages only
if (!lastUserSdk) {
return systemMessages
}
// Replace the last user message with the merged version
const result = [...sdkMessages]
result[lastUserSdkIndex] = { role: 'user', content: finalUserParts }
return [...systemMessages, { role: 'user', content: finalUserParts }]
return result
}
return sdkMessages

View File

@ -325,9 +325,9 @@ function createDeveloperToSystemFetch(originalFetch?: typeof fetch): typeof fetc
if (options?.body && typeof options.body === 'string') {
try {
const body = JSON.parse(options.body)
if (body.messages && Array.isArray(body.messages)) {
if (body.input && Array.isArray(body.input)) {
let hasChanges = false
body.messages = body.messages.map((msg: { role: string }) => {
body.input = body.input.map((msg: { role: string }) => {
if (msg.role === 'developer') {
hasChanges = true
return { ...msg, role: 'system' }

View File

@ -145,7 +145,7 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
{
id: 'dashscope',
name: i18n.t('minapps.qwen'),
url: 'https://www.tongyi.com/',
url: 'https://www.qianwen.com',
logo: QwenModelLogo
},
{
@ -328,9 +328,9 @@ const ORIGIN_DEFAULT_MIN_APPS: MinAppType[] = [
},
{
id: 'qwenlm',
name: 'QwenLM',
name: 'QwenChat',
logo: QwenlmAppLogo,
url: 'https://qwenlm.ai/'
url: 'https://chat.qwen.ai'
},
{
id: 'flowith',

View File

@ -1370,7 +1370,9 @@ describe('findTokenLimit', () => {
{ modelId: 'qwen-flash-lite', expected: { min: 0, max: 81_920 } },
{ modelId: 'qwen3-7b', expected: { min: 1_024, max: 38_912 } },
{ modelId: 'Baichuan-M2', expected: { min: 0, max: 30_000 } },
{ modelId: 'baichuan-m2', expected: { min: 0, max: 30_000 } }
{ modelId: 'baichuan-m2', expected: { min: 0, max: 30_000 } },
{ modelId: 'Baichuan-M3', expected: { min: 0, max: 30_000 } },
{ modelId: 'baichuan-m3', expected: { min: 0, max: 30_000 } }
]
it.each(cases)('returns correct limits for $modelId', ({ modelId, expected }) => {

View File

@ -737,6 +737,12 @@ export const SYSTEM_MODELS: Record<SystemProviderId | 'defaultModel', Model[]> =
provider: 'baichuan',
name: 'Baichuan M2 Plus',
group: 'Baichuan-M2'
},
{
id: 'Baichuan-M3',
provider: 'baichuan',
name: 'Baichuan M3',
group: 'Baichuan-M3'
}
],
modelscope: [

View File

@ -646,8 +646,8 @@ export const isBaichuanReasoningModel = (model?: Model): boolean => {
}
const modelId = getLowerBaseModelName(model.id, '/')
// 只有 Baichuan-M2 是推理模型注意M2-Plus 不是推理模型)
return modelId.includes('baichuan-m2') && !modelId.includes('plus')
// Baichuan-M2 和 Baichuan-M3 是推理模型注意M2-Plus 不是推理模型)
return (modelId.includes('baichuan-m2') && !modelId.includes('plus')) || modelId.includes('baichuan-m3')
}
export function isReasoningModel(model?: Model): boolean {
@ -732,7 +732,8 @@ const THINKING_TOKEN_MAP: Record<string, { min: number; max: number }> = {
},
// Baichuan models
'baichuan-m2$': { min: 0, max: 30_000 }
'baichuan-m2$': { min: 0, max: 30_000 },
'baichuan-m3$': { min: 0, max: 30_000 }
}
export const findTokenLimit = (modelId: string): { min: number; max: number } | undefined => {

View File

@ -57,7 +57,7 @@ export const useKnowledgeBaseForm = (base?: KnowledgeBase) => {
label: t('settings.tool.preprocess.provider'),
title: t('settings.tool.preprocess.provider'),
options: preprocessProviders
.filter((p) => p.apiKey !== '' || ['mineru', 'open-mineru'].includes(p.id))
.filter((p) => p.apiKey !== '' || ['open-mineru'].includes(p.id))
.map((p) => ({ value: p.id, label: p.name }))
}
return [preprocessOptions]

View File

@ -4264,9 +4264,6 @@
"title": "Message Settings",
"use_serif_font": "Use serif font"
},
"mineru": {
"api_key": "Mineru now offers a daily free quota of 500 pages, and you do not need to enter a key."
},
"miniapps": {
"cache_change_notice": "Changes will take effect when the number of open mini apps reaches the set value",
"cache_description": "Set the maximum number of active mini apps to keep in memory",

View File

@ -4264,9 +4264,6 @@
"title": "消息设置",
"use_serif_font": "使用衬线字体"
},
"mineru": {
"api_key": "MinerU现在提供每日500页的免费额度您不需要填写密钥。"
},
"miniapps": {
"cache_change_notice": "更改将在打开的小程序增减至设定值后生效",
"cache_description": "设置同时保持活跃状态的小程序最大数量",

View File

@ -4264,9 +4264,6 @@
"title": "訊息設定",
"use_serif_font": "使用襯線字型"
},
"mineru": {
"api_key": "Mineru 現在每天提供 500 頁的免費配額,且無需輸入金鑰。"
},
"miniapps": {
"cache_change_notice": "變更會在開啟的小程式數量調整至設定值後生效",
"cache_description": "設定同時保持活躍狀態的小程式最大數量",

View File

@ -4264,9 +4264,6 @@
"title": "Nachrichteneinstellungen",
"use_serif_font": "Serifenschrift verwenden"
},
"mineru": {
"api_key": "MinerU bietet täglich 500 Seiten kostenlos an, Sie müssen keinen Schlüssel eingeben."
},
"miniapps": {
"cache_change_notice": "Änderung wird wirksam wenn Anzahl geöffneter Mini-Apps auf festgelegten Wert angepasst wird",
"cache_description": "Maximale Anzahl gleichzeitig aktiver Mini-Apps festlegen",

View File

@ -4264,9 +4264,6 @@
"title": "Ρυθμίσεις μηνυμάτων",
"use_serif_font": "Χρήση μορφής Serif"
},
"mineru": {
"api_key": "Το MinerU παρέχει δωρεάν χρήση 500 σελίδων ημερησίως, δεν χρειάζεται να συμπληρώσετε κλειδί."
},
"miniapps": {
"cache_change_notice": "Η αλλαγή θα τεθεί σε ισχύ αφού το πλήθος των ανοιχτών μικροπρογραμμάτων φτάσει τη ρυθμισμένη τιμή",
"cache_description": "Ορίστε τον μέγιστο αριθμό των μικροπρογραμμάτων που μπορούν να είναι ενεργά ταυτόχρονα",

View File

@ -4264,9 +4264,6 @@
"title": "Configuración de mensajes",
"use_serif_font": "Usar fuente serif"
},
"mineru": {
"api_key": "MinerU ahora ofrece un cupo gratuito de 500 páginas diarias, no es necesario que ingrese una clave."
},
"miniapps": {
"cache_change_notice": "Los cambios surtirán efecto cuando el número de miniaplicaciones abiertas aumente o disminuya hasta alcanzar el valor configurado",
"cache_description": "Establece el número máximo de miniaplicaciones que pueden permanecer activas simultáneamente",

View File

@ -4264,9 +4264,6 @@
"title": "Paramètres des messages",
"use_serif_font": "Utiliser une police serif"
},
"mineru": {
"api_key": "MinerU propose désormais un quota gratuit de 500 pages par jour, vous n'avez donc pas besoin de saisir de clé."
},
"miniapps": {
"cache_change_notice": "Les modifications prendront effet après l'ajout ou la suppression d'applications ouvertes jusqu'à atteindre la valeur définie",
"cache_description": "Définir le nombre maximum d'applications pouvant rester actives simultanément",

View File

@ -4264,9 +4264,6 @@
"title": "メッセージ設定",
"use_serif_font": "セリフフォントを使用"
},
"mineru": {
"api_key": "Mineruでは現在、1日500ページの無料クォータを提供しており、キーを入力する必要はありません。"
},
"miniapps": {
"cache_change_notice": "設定値に達するまでミニアプリの開閉が行われた後に変更が適用されます",
"cache_description": "メモリに保持するアクティブなミニアプリの最大数を設定します",

View File

@ -4264,9 +4264,6 @@
"title": "Configurações de mensagem",
"use_serif_font": "Usar fonte serif"
},
"mineru": {
"api_key": "O MinerU agora oferece uma cota diária gratuita de 500 páginas; você não precisa preencher uma chave."
},
"miniapps": {
"cache_change_notice": "As alterações entrarão em vigor após a abertura ou remoção dos mini aplicativos até atingir o número definido",
"cache_description": "Defina o número máximo de mini aplicativos que permanecerão ativos simultaneamente",

View File

@ -275,7 +275,7 @@
"denyRequest": "Refuză cererea instrumentului",
"hideDetails": "Ascunde detaliile instrumentului",
"runWithOptions": "Rulează cu opțiuni suplimentare",
"showDetails": "Arată detaliile instrumentului"
"showDetails": "Afișează detaliile instrumentului"
},
"button": {
"cancel": "Anulează",
@ -378,7 +378,7 @@
"about": "Despre",
"close": "Închide fereastra",
"copy": "Copiază",
"cut": "Taie",
"cut": "Decupează",
"delete": "Șterge",
"documentation": "Documentație",
"edit": "Editare",
@ -401,7 +401,7 @@
"toggleDevTools": "Comută instrumentele pentru dezvoltatori",
"toggleFullscreen": "Comută ecranul complet",
"undo": "Anulează",
"unhide": "Arată toate",
"unhide": "Afișează toate",
"view": "Vizualizare",
"website": "Site web",
"window": "Fereastră",
@ -649,7 +649,7 @@
"manage": "Gestionează",
"select_model": "Selectează modelul",
"show": {
"all": "Arată tot"
"all": "Afișează tot"
},
"update_available": "Actualizare disponibilă",
"whole_word": "Cuvânt întreg"
@ -975,7 +975,7 @@
},
"reset": "Resetează",
"set_as_default": "Aplică la asistentul implicit",
"show_line_numbers": "Arată numerele de linie în cod",
"show_line_numbers": "Afișează numerele liniilor de cod",
"temperature": {
"label": "Temperatură",
"tip": "Valorile mai mari fac modelul mai creativ și imprevizibil, în timp ce valorile mai mici îl fac mai determinist și precis."
@ -1191,7 +1191,7 @@
"agent_one": "Agent",
"agent_other": "Agenți",
"and": "și",
"assistant": "Agent",
"assistant": "Asistent",
"assistant_one": "Asistent",
"assistant_other": "Asistenți",
"avatar": "Avatar",
@ -1208,7 +1208,7 @@
"copy": "Copiază",
"copy_failed": "Copiere eșuată",
"current": "Curent",
"cut": "Taie",
"cut": "Decupează",
"default": "Implicit",
"delete": "Șterge",
"delete_confirm": "Ești sigur că vrei să ștergi?",
@ -1222,10 +1222,10 @@
"duplicate": "Duplică",
"edit": "Editează",
"enabled": "Activat",
"error": "eroare",
"error": "Eroare",
"errors": {
"create_message": "Nu s-a putut crea mesajul",
"validation": "Verificarea a eșuat"
"validation": "Validarea a eșuat"
},
"expand": "Extinde",
"file": {
@ -1611,7 +1611,7 @@
"title": "Setări bază de cunoștințe"
},
"sitemap_added": "Adăugat cu succes",
"sitemap_placeholder": "Introdu URL-ul hărții site-ului",
"sitemap_placeholder": "Introdu URL-ul sitemap-ului",
"sitemaps": "Site-uri web",
"source": "Sursă",
"status": "Stare",
@ -1623,7 +1623,7 @@
"status_pending": "În așteptare",
"status_preprocess_completed": "Preprocesare finalizată",
"status_preprocess_failed": "Preprocesare eșuată",
"status_processing": "Se procesează",
"status_processing": "În procesare",
"subtitle_file": "fișier subtitrare",
"threshold": "Prag de potrivire",
"threshold_placeholder": "Nesetat",
@ -1633,9 +1633,9 @@
"topN": "Număr rezultate returnate",
"topN_placeholder": "Nesetat",
"topN_too_large_or_small": "Numărul de rezultate returnate nu poate fi mai mare de 30 sau mai mic de 1.",
"topN_tooltip": "Numărul de rezultate potrivite returnate; cu cât valoarea este mai mare, cu atât mai multe rezultate, dar și mai mulți tokeni consumați.",
"topN_tooltip": "Numărul de rezultate potrivite returnate; cu cât valoarea este mai mare, cu atât mai multe rezultate, dar și un consum mai mare de tokeni.",
"url_added": "URL adăugat",
"url_placeholder": "Introdu URL, separă URL-urile multiple prin Enter",
"url_placeholder": "Introdu URL-ul; separă mai multe URL-uri prin Enter",
"urls": "URL-uri",
"videos": "video",
"videos_file": "fișier video"
@ -1760,7 +1760,7 @@
"switch_user_confirm": "Schimbi contextul de utilizator la {{user}}?",
"time": "Timp",
"title": "Amintiri",
"total_memories": "total amintiri",
"total_memories": "Total amintiri",
"try_different_filters": "Încearcă să ajustezi criteriile de căutare",
"update_failed": "Nu s-a putut actualiza amintirea",
"update_success": "Amintire actualizată cu succes",
@ -1779,7 +1779,7 @@
"user_memories_reset": "Toate amintirile pentru {{user}} au fost resetate",
"user_switch_failed": "Nu s-a putut schimba utilizatorul",
"user_switched": "Contextul de utilizator a fost schimbat la {{user}}",
"users": "utilizatori"
"users": "Utilizatori"
},
"message": {
"agents": {
@ -2196,7 +2196,7 @@
"navbar": {
"expand": "Extinde dialogul",
"hide_sidebar": "Ascunde bara laterală",
"show_sidebar": "Arată bara laterală",
"show_sidebar": "Afișează bara laterală",
"window": {
"close": "Închide",
"maximize": "Maximizează",
@ -2216,27 +2216,27 @@
},
"characters": "Caractere",
"collapse": "Restrânge",
"content_placeholder": "Te rugăm să introduci conținutul notiței...",
"content_placeholder": "Introdu conținutul notiței...",
"copyContent": "Copiază conținutul",
"crossPlatformRestoreWarning": "Configurația multi-platformă a fost restaurată, dar directorul de notițe este gol. Te rugăm să copiezi fișierele notițelor în: {{path}}",
"delete": "șterge",
"delete": "Șterge",
"delete_confirm": "Ești sigur că vrei să ștergi acest {{type}}?",
"delete_folder_confirm": "Ești sigur că vrei să ștergi dosarul \"{{name}}\" și tot conținutul său?",
"delete_note_confirm": "Ești sigur că vrei să ștergi notița \"{{name}}\"?",
"drop_markdown_hint": "Trage fișiere sau dosare .md aici pentru a importa",
"empty": "Încă nu există notițe disponibile",
"expand": "desfășoară",
"expand": "Extinde",
"export_failed": "Exportul în baza de cunoștințe a eșuat",
"export_knowledge": "Exportă notițele în baza de cunoștințe",
"export_success": "Exportat cu succes în baza de cunoștințe",
"folder": "dosar",
"folder": "Dosar",
"new_folder": "Dosar nou",
"new_note": "Creează o notiță nouă",
"no_content_to_copy": "Niciun conținut de copiat",
"no_file_selected": "Te rugăm să selectezi fișierul de încărcat",
"no_valid_files": "Nu a fost încărcat niciun fișier valid",
"open_folder": "Deschide un dosar extern",
"open_outside": "Deschide din exterior",
"open_outside": "Deschide extern",
"rename": "Redenumește",
"rename_changed": "Din cauza politicilor de securitate, numele fișierului a fost schimbat din {{original}} în {{final}}",
"save": "Salvează în Notițe",
@ -2246,7 +2246,7 @@
"found_results": "S-au găsit {{count}} rezultate (Nume: {{nameCount}}, Conținut: {{contentCount}})",
"more_matches": "mai multe potriviri",
"searching": "Se caută...",
"show_less": "Arată mai puțin"
"show_less": "Afișează mai puțin"
},
"settings": {
"data": {
@ -2275,7 +2275,7 @@
"font_size_small": "Mic",
"font_title": "Setări font",
"serif_font": "Font cu serife",
"show_table_of_contents": "Arată cuprinsul",
"show_table_of_contents": "Afișează cuprinsul",
"show_table_of_contents_description": "Afișează o bară laterală cu cuprinsul pentru o navigare ușoară în documente",
"title": "Setări afișare"
},
@ -2297,7 +2297,7 @@
},
"title": "Notițe"
},
"show_starred": "Arată notițele favorite",
"show_starred": "Afișează notițele favorite",
"sort_a2z": "Nume fișier (A-Z)",
"sort_created_asc": "Ora creării (cele mai vechi întâi)",
"sort_created_desc": "Ora creării (cele mai noi întâi)",
@ -2993,7 +2993,7 @@
"opacity": "Opacitate fereastră",
"original_copy": "Copiază originalul",
"original_hide": "Ascunde originalul",
"original_show": "Arată originalul",
"original_show": "Afișează originalul",
"pin": "Fixează",
"pinned": "Fixat",
"r_regenerate": "R: Regenerează"
@ -3091,7 +3091,7 @@
"windows": "Unele aplicații nu acceptă selectarea textului cu tasta Ctrl. Dacă ai remapat tasta Ctrl folosind instrumente precum AHK, acest lucru poate cauza eșecul selecției textului în unele aplicații."
},
"selected": "Selecție",
"selected_note": "Arată bara de instrumente imediat ce textul este selectat",
"selected_note": "Afișează bara de instrumente la selectarea textului",
"shortcut": "Comandă rapidă",
"shortcut_link": "Mergi la Setările comenzilor rapide",
"shortcut_note": "După selecție, folosește comanda rapidă pentru a afișa bara de instrumente. Te rugăm să setezi comanda rapidă în pagina de setări și să o activezi. ",
@ -3486,7 +3486,7 @@
},
"show_model_provider": {
"help": "Afișează furnizorul modelului (de ex., OpenAI, Gemini) la exportul în Markdown",
"title": "Arată furnizorul modelului"
"title": "Afișează furnizorul modelului"
},
"standardize_citations": {
"help": "Când este activat, marcatorii de citare vor fi convertiți în format standard de notă de subsol Markdown [^1], iar listele de citare vor fi formatate.",
@ -3871,22 +3871,22 @@
"disabled": "Ascunde pictograme",
"empty": "Trage funcția ascunsă din partea stângă aici",
"files": {
"icon": "Arată pictograma Fișiere"
"icon": "Afișează pictograma Fișiere"
},
"knowledge": {
"icon": "Arată pictograma Cunoștințe"
"icon": "Afișează pictograma Cunoștințe"
},
"minapp": {
"icon": "Arată pictograma MinApp"
"icon": "Afișează pictograma MinApp"
},
"painting": {
"icon": "Arată pictograma Pictură"
"icon": "Afișează pictograma Imagini"
},
"title": "Setări bară laterală",
"translate": {
"icon": "Arată pictograma Traducere"
"icon": "Afișează pictograma Traducere"
},
"visible": "Arată pictograme"
"visible": "Afișează pictograme"
},
"title": "Setări afișare",
"topic": {
@ -3962,7 +3962,7 @@
"knowledge_base": "Golește bazele de cunoștințe selectate",
"models": "Golește toate modelele"
},
"show_translate_confirm": "Arată dialogul de confirmare a traducerii",
"show_translate_confirm": "Afișează fereastra de confirmare a traducerii",
"target_language": {
"chinese": "Chineză simplificată",
"chinese-traditional": "Chineză tradițională",
@ -4229,7 +4229,7 @@
},
"messages": {
"divider": {
"label": "Arată divizor între mesaje",
"label": "Afișează divizor între mesaje",
"tooltip": "Nu se aplică mesajelor stil bulă"
},
"grid_columns": "Coloane afișare grilă mesaje",
@ -4245,7 +4245,7 @@
"paste_long_text_as_file": "Lipește text lung ca fișier",
"paste_long_text_threshold": "Lungime lipire text lung",
"send_shortcuts": "Comenzi rapide trimitere",
"show_estimated_tokens": "Arată tokeni estimați",
"show_estimated_tokens": "Afișează numărul estimat de tokeni",
"title": "Setări intrare"
},
"markdown_rendering_input_message": "Randare Markdown mesaj intrare",
@ -4259,14 +4259,11 @@
"label": "Bară navigare",
"none": "Niciunul"
},
"prompt": "Arată prompt",
"show_message_outline": "Arată contur mesaj",
"prompt": "Afișează prompt",
"show_message_outline": "Afișează conturul mesajului",
"title": "Setări mesaje",
"use_serif_font": "Folosește font serif"
},
"mineru": {
"api_key": "Mineru oferă acum o cotă zilnică gratuită de 500 de pagini și nu este nevoie să introduci o cheie."
},
"miniapps": {
"cache_change_notice": "Modificările vor intra în vigoare când numărul de mini-aplicații deschise atinge valoarea setată",
"cache_description": "Setează numărul maxim de mini-aplicații active de păstrat în memorie",
@ -4307,10 +4304,10 @@
"display_title": "Setări afișare mini-aplicații",
"empty": "Trage mini-aplicațiile din stânga pentru a le ascunde",
"open_link_external": {
"title": "Deschide linkurile de fereastră nouă în browser"
"title": "Deschide în browser linkurile care deschid ferestre noi"
},
"reset_tooltip": "Resetează la implicit",
"sidebar_description": "Arată mini-aplicațiile active în bara laterală",
"sidebar_description": "Afișează mini-aplicațiile active în bara laterală",
"sidebar_title": "Afișare mini-aplicații active în bara laterală",
"title": "Setări mini-aplicații",
"visible": "Mini-aplicații vizibile"
@ -4407,7 +4404,7 @@
"description": "Model folosit pentru sarcini simple, cum ar fi numirea subiectelor și extragerea cuvintelor cheie",
"label": "Model rapid",
"setting_title": "Configurare model rapid",
"tooltip": "Se recomandă alegerea unui model ușor și nu se recomandă alegerea unui model de gândire."
"tooltip": "Se recomandă alegerea unui model ușor, nu a unui model de raționament complex."
},
"topic_naming": {
"auto": "Numire automată subiect",
@ -4747,7 +4744,7 @@
},
"shortcuts": {
"action": "Acțiune",
"actions": "operațiune",
"actions": "Comandă",
"clear_shortcut": "Șterge comanda rapidă",
"clear_topic": "Șterge mesajele",
"copy_last_message": "Copiază ultimul mesaj",
@ -4766,7 +4763,7 @@
"search_message_in_chat": "Caută mesaj în chat-ul curent",
"selection_assistant_select_text": "Asistent de selecție: Selectează text",
"selection_assistant_toggle": "Comută Asistentul de selecție",
"show_app": "Arată/Ascunde aplicația",
"show_app": "Afișează/Ascunde aplicația",
"show_settings": "Deschide setările",
"title": "Comenzi rapide de la tastatură",
"toggle_new_context": "Șterge contextul",
@ -4778,8 +4775,8 @@
},
"theme": {
"color_primary": "Culoare primară",
"dark": "Întunecat",
"light": "Luminos",
"dark": "Întunecată",
"light": "Luminoasă",
"system": "Sistem",
"title": "Temă",
"window": {
@ -4935,27 +4932,27 @@
"right": "Dreapta"
},
"show": {
"time": "Arată ora subiectului"
"time": "Afișează ora subiectului"
}
},
"translate": {
"custom": {
"delete": {
"description": "Ești sigur că vrei să ștergi?",
"description": "Ești sigur că vrei să ștergi această limbă?",
"title": "Șterge limba personalizată"
},
"error": {
"add": "Adăugarea a eșuat",
"delete": "Ștergerea a eșuat",
"langCode": {
"builtin": "Limba are suport integrat",
"empty": "Codul limbii este gol",
"builtin": "Limbă deja integrată",
"empty": "Codul limbii lipsește",
"exists": "Limba există deja",
"invalid": "Cod limbă invalid"
},
"update": "Actualizarea a eșuat",
"value": {
"empty": "Numele limbii nu poate fi gol",
"empty": "Numele limbii este obligatoriu",
"too_long": "Numele limbii este prea lung"
}
},
@ -4965,13 +4962,13 @@
"placeholder": "en-us"
},
"success": {
"add": "Adăugat cu succes",
"delete": "Șters cu succes",
"update": "Actualizare reușită"
"add": "Adăugată cu succes",
"delete": "Ștearsă cu succes",
"update": "Actualiza cu succes"
},
"table": {
"action": {
"title": "Operațiune"
"title": "Acțiuni"
}
},
"value": {
@ -4985,7 +4982,7 @@
},
"tray": {
"onclose": "Minimizează în zona de notificare la închidere",
"show": "Arată pictograma în zona de notificare",
"show": "Afișează pictograma în zona de notificare",
"title": "Zonă de notificare"
},
"zoom": {
@ -5003,7 +5000,7 @@
"mcp-servers": "Servere MCP",
"memories": "Amintiri",
"notes": "Notițe",
"paintings": "Picturi",
"paintings": "Imagini",
"settings": "Setări",
"store": "Bibliotecă asistenți",
"translate": "Traducere"
@ -5047,7 +5044,7 @@
"detect": {
"method": {
"algo": {
"label": "algoritm",
"label": "Algoritm",
"tip": "Folosește biblioteca franc pentru detectarea limbii"
},
"auto": {
@ -5154,7 +5151,7 @@
"tray": {
"quit": "Ieșire",
"show_mini_window": "Asistent rapid",
"show_window": "Arată fereastra"
"show_window": "Afișează fereastra"
},
"update": {
"install": "Instalează",
@ -5170,7 +5167,7 @@
"words": {
"knowledgeGraph": "Grafic de cunoștințe",
"quit": "Ieșire",
"show_window": "Arată fereastra",
"show_window": "Afișează fereastra",
"visualization": "Vizualizare"
}
}

View File

@ -4264,9 +4264,6 @@
"title": "Настройки сообщений",
"use_serif_font": "Использовать serif шрифт"
},
"mineru": {
"api_key": "Mineru теперь предлагает ежедневную бесплатную квоту в 500 страниц, и вам не нужно вводить ключ."
},
"miniapps": {
"cache_change_notice": "Изменения вступят в силу, когда количество открытых мини-приложений достигнет установленного значения",
"cache_description": "Установить максимальное количество активных мини-приложений в памяти",

View File

@ -0,0 +1,231 @@
import type { Model, Provider } from '@renderer/types'
import { codeTools } from '@shared/config/constant'
import { beforeEach, describe, expect, it, vi } from 'vitest'
// Mock CodeToolsPage which is the default export
vi.mock('../CodeToolsPage', () => ({ default: () => null }))
// Mock dependencies needed by CodeToolsPage
vi.mock('@renderer/hooks/useCodeTools', () => ({
useCodeTools: () => ({
selectedCliTool: codeTools.qwenCode,
selectedModel: null,
selectedTerminal: 'systemDefault',
environmentVariables: '',
directories: [],
currentDirectory: '',
canLaunch: true,
setCliTool: vi.fn(),
setModel: vi.fn(),
setTerminal: vi.fn(),
setEnvVars: vi.fn(),
setCurrentDir: vi.fn(),
removeDir: vi.fn(),
selectFolder: vi.fn()
})
}))
vi.mock('@renderer/hooks/useProvider', () => ({
useProviders: () => ({ providers: [] }),
useAllProviders: () => []
}))
vi.mock('@renderer/services/AssistantService', () => ({
getProviderByModel: vi.fn()
}))
vi.mock('@renderer/services/LoggerService', () => ({
loggerService: {
withContext: () => ({
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
warn: vi.fn()
})
}
}))
vi.mock('@renderer/store', () => ({
useAppDispatch: () => vi.fn(),
useAppSelector: () => false
}))
vi.mock('@renderer/aiCore', () => ({
default: class {
getBaseURL() {
return ''
}
getApiKey() {
return ''
}
}
}))
vi.mock('@renderer/utils/api', () => ({
formatApiHost: vi.fn((host) => {
if (!host) return ''
const normalized = host.replace(/\/$/, '').trim()
if (normalized.endsWith('#')) {
return normalized.replace(/#$/, '')
}
if (/\/v\d+(?:alpha|beta)?(?=\/|$)/i.test(normalized)) {
return normalized
}
return `${normalized}/v1`
})
}))
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key })
}))
describe('generateToolEnvironment', () => {
const createMockModel = (id: string, provider: string): Model => ({
id,
name: id,
provider,
group: provider
})
const createMockProvider = (id: string, apiHost: string): Provider => ({
id,
type: 'openai',
name: id,
apiKey: 'test-key',
apiHost,
models: [],
isSystem: true
})
beforeEach(() => {
vi.clearAllMocks()
})
it('should format baseUrl with /v1 for qwenCode when missing', async () => {
const { generateToolEnvironment } = await import('../index')
const model = createMockModel('qwen-turbo', 'dashscope')
const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/compatible-mode')
const env = generateToolEnvironment({
tool: codeTools.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode'
})
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
})
it('should not duplicate /v1 when already present for qwenCode', async () => {
const { generateToolEnvironment } = await import('../index')
const model = createMockModel('qwen-turbo', 'dashscope')
const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/compatible-mode/v1')
const env = generateToolEnvironment({
tool: codeTools.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1'
})
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
})
it('should handle empty baseUrl gracefully', async () => {
const { generateToolEnvironment } = await import('../index')
const model = createMockModel('qwen-turbo', 'dashscope')
const provider = createMockProvider('dashscope', '')
const env = generateToolEnvironment({
tool: codeTools.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: ''
})
expect(env.OPENAI_BASE_URL).toBe('')
})
it('should preserve other API versions when present', async () => {
const { generateToolEnvironment } = await import('../index')
const model = createMockModel('qwen-plus', 'dashscope')
const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/v2')
const env = generateToolEnvironment({
tool: codeTools.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://dashscope.aliyuncs.com/v2'
})
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/v2')
})
it('should format baseUrl with /v1 for openaiCodex when missing', async () => {
const { generateToolEnvironment } = await import('../index')
const model = createMockModel('gpt-4', 'openai')
const provider = createMockProvider('openai', 'https://api.openai.com')
const env = generateToolEnvironment({
tool: codeTools.openaiCodex,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://api.openai.com'
})
expect(env.OPENAI_BASE_URL).toBe('https://api.openai.com/v1')
})
it('should format baseUrl with /v1 for iFlowCli when missing', async () => {
const { generateToolEnvironment } = await import('../index')
const model = createMockModel('gpt-4', 'iflow')
const provider = createMockProvider('iflow', 'https://api.iflow.cn')
const env = generateToolEnvironment({
tool: codeTools.iFlowCli,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://api.iflow.cn'
})
expect(env.IFLOW_BASE_URL).toBe('https://api.iflow.cn/v1')
})
it('should handle trailing slash correctly', async () => {
const { generateToolEnvironment } = await import('../index')
const model = createMockModel('qwen-turbo', 'dashscope')
const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/compatible-mode/')
const env = generateToolEnvironment({
tool: codeTools.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/'
})
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
})
it('should handle v2beta version correctly', async () => {
const { generateToolEnvironment } = await import('../index')
const model = createMockModel('qwen-plus', 'dashscope')
const provider = createMockProvider('dashscope', 'https://dashscope.aliyuncs.com/v2beta')
const env = generateToolEnvironment({
tool: codeTools.qwenCode,
model,
modelProvider: provider,
apiKey: 'test-key',
baseUrl: 'https://dashscope.aliyuncs.com/v2beta'
})
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/v2beta')
})
})

View File

@ -1,4 +1,5 @@
import { type EndpointType, type Model, type Provider, SystemProviderIds } from '@renderer/types'
import { formatApiHost } from '@renderer/utils/api'
import { codeTools } from '@shared/config/constant'
export interface LaunchValidationResult {
@ -145,6 +146,7 @@ export const generateToolEnvironment = ({
baseUrl: string
}): Record<string, string> => {
const env: Record<string, string> = {}
const formattedBaseUrl = formatApiHost(baseUrl)
switch (tool) {
case codeTools.claudeCode:
@ -169,19 +171,19 @@ export const generateToolEnvironment = ({
case codeTools.qwenCode:
env.OPENAI_API_KEY = apiKey
env.OPENAI_BASE_URL = baseUrl
env.OPENAI_BASE_URL = formattedBaseUrl
env.OPENAI_MODEL = model.id
break
case codeTools.openaiCodex:
env.OPENAI_API_KEY = apiKey
env.OPENAI_BASE_URL = baseUrl
env.OPENAI_BASE_URL = formattedBaseUrl
env.OPENAI_MODEL = model.id
env.OPENAI_MODEL_PROVIDER = modelProvider.id
break
case codeTools.iFlowCli:
env.IFLOW_API_KEY = apiKey
env.IFLOW_BASE_URL = baseUrl
env.IFLOW_BASE_URL = formattedBaseUrl
env.IFLOW_MODEL_NAME = model.id
break

View File

@ -10,8 +10,6 @@ import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('QuotaTag')
const QUOTA_UNLIMITED = -9999
const QuotaTag: FC<{ base: KnowledgeBase; providerId: PreprocessProviderId; quota?: number }> = ({
base,
providerId,
@ -23,44 +21,40 @@ const QuotaTag: FC<{ base: KnowledgeBase; providerId: PreprocessProviderId; quot
useEffect(() => {
const checkQuota = async () => {
if (provider.id !== 'mineru') return
// 使用用户的key时quota为无限
if (provider.apiKey) {
setQuota(QUOTA_UNLIMITED)
updateProvider({ quota: QUOTA_UNLIMITED })
return
const userId = getStoreSetting('userId')
const baseParams = getKnowledgeBaseParams(base)
try {
const response = await window.api.knowledgeBase.checkQuota({
base: baseParams,
userId: userId as string
})
setQuota(response)
updateProvider({ quota: response })
} catch (error) {
logger.error('[KnowledgeContent] Error checking quota:', error as Error)
}
if (quota === undefined) {
const userId = getStoreSetting('userId')
const baseParams = getKnowledgeBaseParams(base)
try {
const response = await window.api.knowledgeBase.checkQuota({
base: baseParams,
userId: userId as string
})
setQuota(response)
} catch (error) {
logger.error('[KnowledgeContent] Error checking quota:', error as Error)
}
}
if (provider.id !== 'mineru') return
if (!provider.apiKey) {
if (quota !== undefined) {
setQuota(undefined)
updateProvider({ quota: undefined })
}
return
}
if (_quota !== undefined) {
setQuota(_quota)
updateProvider({ quota: _quota })
return
}
checkQuota()
}, [_quota, base, provider.id, provider.apiKey, provider, quota, updateProvider])
if (quota === undefined) {
checkQuota()
}
}, [_quota, base, provider.id, provider.apiKey, quota, updateProvider])
const getQuotaDisplay = () => {
if (quota === undefined) return null
if (quota === QUOTA_UNLIMITED) {
return (
<Tag color="orange" style={{ borderRadius: 20, margin: 0 }}>
{t('knowledge.quota_infinity', { name: provider.name })}
</Tag>
)
}
if (quota === 0) {
return (
<Tag color="red" style={{ borderRadius: 20, margin: 0 }}>

View File

@ -99,9 +99,7 @@ const PreprocessProviderSettings: FC<Props> = ({ provider: _provider }) => {
<Flex gap={8}>
<Input.Password
value={apiKey}
placeholder={
preprocessProvider.id === 'mineru' ? t('settings.mineru.api_key') : t('settings.provider.api_key.label')
}
placeholder={t('settings.provider.api_key.label')}
onChange={(e) => setApiKey(formatApiKeys(e.target.value))}
onBlur={onUpdateApiKey}
spellCheck={false}