Compare commits
17 Commits
feat/copy-
...
fix/check-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f8d0b06ee | ||
|
|
3e6dc56196 | ||
|
|
4be5fedeec | ||
|
|
163e016759 | ||
|
|
b3a58ec321 | ||
|
|
0097ca80e2 | ||
|
|
d968df4612 | ||
|
|
2bd680361a | ||
|
|
cc676d4bef | ||
|
|
3b1155b538 | ||
|
|
03ff6e1ca6 | ||
|
|
706fac898a | ||
|
|
f5c144404d | ||
|
|
50a217a638 | ||
|
|
444c13e1e3 | ||
|
|
255b19d6ee | ||
|
|
f1f4831157 |
10
CLAUDE.md
10
CLAUDE.md
@@ -12,7 +12,15 @@ This file provides guidance to AI coding assistants when working with code in th
|
||||
- **Always propose before executing**: Before making any changes, clearly explain your planned approach and wait for explicit user approval to ensure alignment and prevent unwanted modifications.
|
||||
- **Lint, test, and format before completion**: Coding tasks are only complete after running `yarn lint`, `yarn test`, and `yarn format` successfully.
|
||||
- **Write conventional commits**: Commit small, focused changes using Conventional Commit messages (e.g., `feat:`, `fix:`, `refactor:`, `docs:`).
|
||||
- **Follow PR template**: When submitting pull requests, follow the template in `.github/pull_request_template.md` to ensure complete context and documentation.
|
||||
|
||||
## Pull Request Workflow (CRITICAL)
|
||||
|
||||
When creating a Pull Request, you MUST:
|
||||
|
||||
1. **Read the PR template first**: Always read `.github/pull_request_template.md` before creating the PR
|
||||
2. **Follow ALL template sections**: Structure the `--body` parameter to include every section from the template
|
||||
3. **Never skip sections**: Include all sections even if marking them as N/A or "None"
|
||||
4. **Use proper formatting**: Match the template's markdown structure exactly (headings, checkboxes, code blocks)
|
||||
|
||||
## Development Commands
|
||||
|
||||
|
||||
@@ -134,9 +134,9 @@ artifactBuildCompleted: scripts/artifact-build-completed.js
|
||||
releaseInfo:
|
||||
releaseNotes: |
|
||||
<!--LANG:en-->
|
||||
A New Era of Intelligence with Cherry Studio 1.7.0
|
||||
A New Era of Intelligence with Cherry Studio 1.7.1
|
||||
|
||||
Today we're releasing Cherry Studio 1.7.0 — our most ambitious update yet, introducing Agent: autonomous AI that thinks, plans, and acts.
|
||||
Today we're releasing Cherry Studio 1.7.1 — our most ambitious update yet, introducing Agent: autonomous AI that thinks, plans, and acts.
|
||||
|
||||
For years, AI assistants have been reactive — waiting for your commands, responding to your questions. With Agent, we're changing that. Now, AI can truly work alongside you: understanding complex goals, breaking them into steps, and executing them independently.
|
||||
|
||||
@@ -187,9 +187,9 @@ releaseInfo:
|
||||
The Agent Era is here. We can't wait to see what you'll create.
|
||||
|
||||
<!--LANG:zh-CN-->
|
||||
Cherry Studio 1.7.0:开启智能新纪元
|
||||
Cherry Studio 1.7.1:开启智能新纪元
|
||||
|
||||
今天,我们正式发布 Cherry Studio 1.7.0 —— 迄今最具雄心的版本,带来全新的 Agent:能够自主思考、规划和行动的 AI。
|
||||
今天,我们正式发布 Cherry Studio 1.7.1 —— 迄今最具雄心的版本,带来全新的 Agent:能够自主思考、规划和行动的 AI。
|
||||
|
||||
多年来,AI 助手一直是被动的——等待你的指令,回应你的问题。Agent 改变了这一切。现在,AI 能够真正与你并肩工作:理解复杂目标,将其拆解为步骤,并独立执行。
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "CherryStudio",
|
||||
"version": "1.7.0",
|
||||
"version": "1.7.1",
|
||||
"private": true,
|
||||
"description": "A powerful AI assistant for producer.",
|
||||
"main": "./out/main/index.js",
|
||||
|
||||
@@ -69,6 +69,7 @@ export interface CherryInProviderSettings {
|
||||
headers?: HeadersInput
|
||||
/**
|
||||
* Optional endpoint type to distinguish different endpoint behaviors.
|
||||
* "image-generation" is also openai endpoint, but specifically for image generation.
|
||||
*/
|
||||
endpointType?: 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'image-generation' | 'jina-rerank'
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// src/main/services/agents/services/claudecode/index.ts
|
||||
import { EventEmitter } from 'node:events'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
|
||||
import type {
|
||||
CanUseTool,
|
||||
@@ -121,7 +122,11 @@ class ClaudeCodeService implements AgentServiceInterface {
|
||||
// TODO: support set small model in UI
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: modelInfo.modelId,
|
||||
ELECTRON_RUN_AS_NODE: '1',
|
||||
ELECTRON_NO_ATTACH_CONSOLE: '1'
|
||||
ELECTRON_NO_ATTACH_CONSOLE: '1',
|
||||
// Set CLAUDE_CONFIG_DIR to app's userData directory to avoid path encoding issues
|
||||
// on Windows when the username contains non-ASCII characters (e.g., Chinese characters)
|
||||
// This prevents the SDK from using the user's home directory which may have encoding problems
|
||||
CLAUDE_CONFIG_DIR: path.join(app.getPath('userData'), '.claude')
|
||||
}
|
||||
|
||||
const errorChunks: string[] = []
|
||||
|
||||
@@ -27,6 +27,7 @@ import { buildAiSdkMiddlewares } from './middleware/AiSdkMiddlewareBuilder'
|
||||
import { buildPlugins } from './plugins/PluginBuilder'
|
||||
import { createAiSdkProvider } from './provider/factory'
|
||||
import {
|
||||
adaptProvider,
|
||||
getActualProvider,
|
||||
isModernSdkSupported,
|
||||
prepareSpecialProviderConfig,
|
||||
@@ -64,12 +65,11 @@ export default class ModernAiProvider {
|
||||
* - URL will be automatically formatted via `formatProviderApiHost`, adding version suffixes like `/v1`
|
||||
*
|
||||
* 2. When called with `(model, provider)`:
|
||||
* - **Directly uses the provided provider WITHOUT going through `getActualProvider`**
|
||||
* - **URL will NOT be automatically formatted, `/v1` suffix will NOT be added**
|
||||
* - This is legacy behavior kept for backward compatibility
|
||||
* - The provided provider will be adapted via `adaptProvider`
|
||||
* - URL formatting behavior depends on the adapted result
|
||||
*
|
||||
* 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)
|
||||
*
|
||||
* @example
|
||||
@@ -77,7 +77,7 @@ export default class ModernAiProvider {
|
||||
* // Recommended: Auto-format URL
|
||||
* const ai = new ModernAiProvider(model)
|
||||
*
|
||||
* // Not recommended: Skip URL formatting (only for special cases)
|
||||
* // Provider will be adapted
|
||||
* const ai = new ModernAiProvider(model, customProvider)
|
||||
*
|
||||
* // For operations that don't need a model
|
||||
@@ -91,12 +91,12 @@ export default class ModernAiProvider {
|
||||
if (this.isModel(modelOrProvider)) {
|
||||
// 传入的是 Model
|
||||
this.model = modelOrProvider
|
||||
this.actualProvider = provider || getActualProvider(modelOrProvider)
|
||||
this.actualProvider = provider ? adaptProvider({ provider }) : getActualProvider(modelOrProvider)
|
||||
// 只保存配置,不预先创建executor
|
||||
this.config = providerToAiSdkConfig(this.actualProvider, modelOrProvider)
|
||||
} else {
|
||||
// 传入的是 Provider
|
||||
this.actualProvider = modelOrProvider
|
||||
this.actualProvider = adaptProvider({ provider: modelOrProvider })
|
||||
// model为可选,某些操作(如fetchModels)不需要model
|
||||
}
|
||||
|
||||
@@ -120,9 +120,12 @@ export default class ModernAiProvider {
|
||||
throw new Error('Model is required for completions. Please use constructor with model parameter.')
|
||||
}
|
||||
|
||||
// 每次请求时重新生成配置以确保API key轮换生效
|
||||
this.config = providerToAiSdkConfig(this.actualProvider, this.model)
|
||||
logger.debug('Generated provider config for completions', this.config)
|
||||
// Config is now set in constructor, ApiService handles key rotation before passing provider
|
||||
if (!this.config) {
|
||||
// If config wasn't set in constructor (when provider only), generate it now
|
||||
this.config = providerToAiSdkConfig(this.actualProvider, this.model!)
|
||||
}
|
||||
logger.debug('Using provider config for completions', this.config)
|
||||
|
||||
// 检查 config 是否存在
|
||||
if (!this.config) {
|
||||
|
||||
@@ -29,32 +29,6 @@ import { azureAnthropicProviderCreator } from './config/azure-anthropic'
|
||||
import { COPILOT_DEFAULT_HEADERS } from './constants'
|
||||
import { getAiSdkProviderId } from './factory'
|
||||
|
||||
/**
|
||||
* 获取轮询的API key
|
||||
* 复用legacy架构的多key轮询逻辑
|
||||
*/
|
||||
function getRotatedApiKey(provider: Provider): string {
|
||||
const keys = provider.apiKey.split(',').map((key) => key.trim())
|
||||
const keyName = `provider:${provider.id}:last_used_key`
|
||||
|
||||
if (keys.length === 1) {
|
||||
return keys[0]
|
||||
}
|
||||
|
||||
const lastUsedKey = window.keyv.get(keyName)
|
||||
if (!lastUsedKey) {
|
||||
window.keyv.set(keyName, keys[0])
|
||||
return keys[0]
|
||||
}
|
||||
|
||||
const currentIndex = keys.indexOf(lastUsedKey)
|
||||
const nextIndex = (currentIndex + 1) % keys.length
|
||||
const nextKey = keys[nextIndex]
|
||||
window.keyv.set(keyName, nextKey)
|
||||
|
||||
return nextKey
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理特殊provider的转换逻辑
|
||||
*/
|
||||
@@ -78,11 +52,13 @@ function handleSpecialProviders(model: Model, provider: Provider): Provider {
|
||||
}
|
||||
|
||||
/**
|
||||
* 主要用来对齐AISdk的BaseURL格式
|
||||
* @param provider
|
||||
* @returns
|
||||
* Format and normalize the API host URL for a provider.
|
||||
* Handles provider-specific URL formatting rules (e.g., appending version paths, Azure formatting).
|
||||
*
|
||||
* @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 }
|
||||
if (formatted.anthropicApiHost) {
|
||||
formatted.anthropicApiHost = formatApiHost(formatted.anthropicApiHost)
|
||||
@@ -114,18 +90,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 {
|
||||
const baseProvider = getProviderByModel(model)
|
||||
|
||||
// 按顺序处理各种转换
|
||||
let actualProvider = cloneDeep(baseProvider)
|
||||
actualProvider = handleSpecialProviders(model, actualProvider)
|
||||
actualProvider = formatProviderApiHost(actualProvider)
|
||||
return adaptProvider({ provider: baseProvider, model })
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,7 +135,7 @@ export function providerToAiSdkConfig(actualProvider: Provider, model: Model): A
|
||||
const { baseURL, endpoint } = routeToEndpoint(actualProvider.apiHost)
|
||||
const baseConfig = {
|
||||
baseURL: baseURL,
|
||||
apiKey: getRotatedApiKey(actualProvider)
|
||||
apiKey: actualProvider.apiKey
|
||||
}
|
||||
|
||||
const isCopilotProvider = actualProvider.id === SystemProviderIds.copilot
|
||||
|
||||
@@ -245,8 +245,8 @@ export class AiSdkSpanAdapter {
|
||||
'gen_ai.usage.output_tokens'
|
||||
]
|
||||
|
||||
const completionTokens = attributes[inputsTokenKeys.find((key) => attributes[key]) || '']
|
||||
const promptTokens = attributes[outputTokenKeys.find((key) => attributes[key]) || '']
|
||||
const promptTokens = attributes[inputsTokenKeys.find((key) => attributes[key]) || '']
|
||||
const completionTokens = attributes[outputTokenKeys.find((key) => attributes[key]) || '']
|
||||
|
||||
if (completionTokens !== undefined || promptTokens !== undefined) {
|
||||
const usage: TokenUsage = {
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Span } from '@opentelemetry/api'
|
||||
import { SpanKind, SpanStatusCode } from '@opentelemetry/api'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { AiSdkSpanAdapter } from '../AiSdkSpanAdapter'
|
||||
|
||||
vi.mock('@logger', () => ({
|
||||
loggerService: {
|
||||
withContext: () => ({
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn()
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
describe('AiSdkSpanAdapter', () => {
|
||||
const createMockSpan = (attributes: Record<string, unknown>): Span => {
|
||||
const span = {
|
||||
spanContext: () => ({
|
||||
traceId: 'trace-id',
|
||||
spanId: 'span-id'
|
||||
}),
|
||||
_attributes: attributes,
|
||||
_events: [],
|
||||
name: 'test span',
|
||||
status: { code: SpanStatusCode.OK },
|
||||
kind: SpanKind.CLIENT,
|
||||
startTime: [0, 0] as [number, number],
|
||||
endTime: [0, 1] as [number, number],
|
||||
ended: true,
|
||||
parentSpanId: '',
|
||||
links: []
|
||||
}
|
||||
return span as unknown as Span
|
||||
}
|
||||
|
||||
it('maps prompt and completion usage tokens to the correct fields', () => {
|
||||
const attributes = {
|
||||
'ai.usage.promptTokens': 321,
|
||||
'ai.usage.completionTokens': 654
|
||||
}
|
||||
|
||||
const span = createMockSpan(attributes)
|
||||
const result = AiSdkSpanAdapter.convertToSpanEntity({ span })
|
||||
|
||||
expect(result.usage).toBeDefined()
|
||||
expect(result.usage?.prompt_tokens).toBe(321)
|
||||
expect(result.usage?.completion_tokens).toBe(654)
|
||||
expect(result.usage?.total_tokens).toBe(975)
|
||||
})
|
||||
})
|
||||
@@ -144,7 +144,7 @@ describe('reasoning utils', () => {
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should disable reasoning for OpenRouter when no reasoning effort set', async () => {
|
||||
it('should not override reasoning for OpenRouter when reasoning effort undefined', async () => {
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
@@ -161,6 +161,29 @@ describe('reasoning utils', () => {
|
||||
settings: {}
|
||||
} as Assistant
|
||||
|
||||
const result = getReasoningEffort(assistant, model)
|
||||
expect(result).toEqual({})
|
||||
})
|
||||
|
||||
it('should disable reasoning for OpenRouter when reasoning effort explicitly none', async () => {
|
||||
const { isReasoningModel } = await import('@renderer/config/models')
|
||||
|
||||
vi.mocked(isReasoningModel).mockReturnValue(true)
|
||||
|
||||
const model: Model = {
|
||||
id: 'anthropic/claude-sonnet-4',
|
||||
name: 'Claude Sonnet 4',
|
||||
provider: SystemProviderIds.openrouter
|
||||
} as Model
|
||||
|
||||
const assistant: Assistant = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
settings: {
|
||||
reasoning_effort: 'none'
|
||||
}
|
||||
} as Assistant
|
||||
|
||||
const result = getReasoningEffort(assistant, model)
|
||||
expect(result).toEqual({ reasoning: { enabled: false, exclude: true } })
|
||||
})
|
||||
@@ -269,7 +292,9 @@ describe('reasoning utils', () => {
|
||||
const assistant: Assistant = {
|
||||
id: 'test',
|
||||
name: 'Test',
|
||||
settings: {}
|
||||
settings: {
|
||||
reasoning_effort: 'none'
|
||||
}
|
||||
} as Assistant
|
||||
|
||||
const result = getReasoningEffort(assistant, model)
|
||||
|
||||
@@ -16,10 +16,8 @@ import {
|
||||
isGPT5SeriesModel,
|
||||
isGPT51SeriesModel,
|
||||
isGrok4FastReasoningModel,
|
||||
isGrokReasoningModel,
|
||||
isOpenAIDeepResearchModel,
|
||||
isOpenAIModel,
|
||||
isOpenAIReasoningModel,
|
||||
isQwenAlwaysThinkModel,
|
||||
isQwenReasoningModel,
|
||||
isReasoningModel,
|
||||
@@ -64,30 +62,22 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
}
|
||||
const reasoningEffort = assistant?.settings?.reasoning_effort
|
||||
|
||||
// Handle undefined and 'none' reasoningEffort.
|
||||
// TODO: They should be separated.
|
||||
if (!reasoningEffort || reasoningEffort === 'none') {
|
||||
// reasoningEffort is not set, no extra reasoning setting
|
||||
// Generally, for every model which supports reasoning control, the reasoning effort won't be undefined.
|
||||
// It's for some reasoning models that don't support reasoning control, such as deepseek reasoner.
|
||||
if (!reasoningEffort) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// Handle 'none' reasoningEffort. It's explicitly off.
|
||||
if (reasoningEffort === 'none') {
|
||||
// openrouter: use reasoning
|
||||
if (model.provider === SystemProviderIds.openrouter) {
|
||||
// Don't disable reasoning for Gemini models that support thinking tokens
|
||||
if (isSupportedThinkingTokenGeminiModel(model) && !GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
return {}
|
||||
}
|
||||
// 'none' is not an available value for effort for now.
|
||||
// I think they should resolve this issue soon, so I'll just go ahead and use this value.
|
||||
if (isGPT51SeriesModel(model) && reasoningEffort === 'none') {
|
||||
return { reasoning: { effort: 'none' } }
|
||||
}
|
||||
// Don't disable reasoning for models that require it
|
||||
if (
|
||||
isGrokReasoningModel(model) ||
|
||||
isOpenAIReasoningModel(model) ||
|
||||
isQwenAlwaysThinkModel(model) ||
|
||||
model.id.includes('seed-oss') ||
|
||||
model.id.includes('minimax-m2')
|
||||
) {
|
||||
return {}
|
||||
}
|
||||
return { reasoning: { enabled: false, exclude: true } }
|
||||
}
|
||||
|
||||
@@ -101,11 +91,6 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
return { enable_thinking: false }
|
||||
}
|
||||
|
||||
// claude
|
||||
if (isSupportedThinkingTokenClaudeModel(model)) {
|
||||
return {}
|
||||
}
|
||||
|
||||
// gemini
|
||||
if (isSupportedThinkingTokenGeminiModel(model)) {
|
||||
if (GEMINI_FLASH_MODEL_REGEX.test(model.id)) {
|
||||
@@ -118,8 +103,10 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
logger.warn(`Model ${model.id} cannot disable reasoning. Fallback to empty reasoning param.`)
|
||||
return {}
|
||||
}
|
||||
return {}
|
||||
}
|
||||
|
||||
// use thinking, doubao, zhipu, etc.
|
||||
@@ -139,6 +126,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
}
|
||||
}
|
||||
|
||||
logger.warn(`Model ${model.id} doesn't match any disable reasoning behavior. Fallback to empty reasoning param.`)
|
||||
return {}
|
||||
}
|
||||
|
||||
@@ -293,6 +281,7 @@ export function getReasoningEffort(assistant: Assistant, model: Model): Reasonin
|
||||
}
|
||||
|
||||
// OpenRouter models, use reasoning
|
||||
// FIXME: duplicated openrouter handling. remove one
|
||||
if (model.provider === SystemProviderIds.openrouter) {
|
||||
if (isSupportedReasoningEffortModel(model) || isSupportedThinkingTokenModel(model)) {
|
||||
return {
|
||||
|
||||
@@ -215,6 +215,10 @@
|
||||
border-top: none !important;
|
||||
}
|
||||
|
||||
.ant-collapse-header-text {
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.ant-slider .ant-slider-handle::after {
|
||||
box-shadow: 0 1px 4px 0px rgb(128 128 128 / 50%) !important;
|
||||
}
|
||||
|
||||
@@ -460,16 +460,19 @@ export const isSupportedThinkingTokenZhipuModel = (model: Model): boolean => {
|
||||
}
|
||||
|
||||
export const isDeepSeekHybridInferenceModel = (model: Model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
|
||||
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险
|
||||
// Matches: "deepseek-v3" followed by ".digit" or "-digit".
|
||||
// Optionally, this can be followed by ".alphanumeric_sequence" or "-alphanumeric_sequence"
|
||||
// until the end of the string.
|
||||
// Examples: deepseek-v3.1, deepseek-v3-1, deepseek-v3.1.2, deepseek-v3.1-alpha
|
||||
// Does NOT match: deepseek-v3.123 (missing separator after '1'), deepseek-v3.x (x isn't a digit)
|
||||
// TODO: move to utils and add test cases
|
||||
return /deepseek-v3(?:\.\d|-\d)(?:(\.|-)\w+)?$/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
|
||||
const { idResult, nameResult } = withModelIdAndNameAsId(model, (model) => {
|
||||
const modelId = getLowerBaseModelName(model.id)
|
||||
// deepseek官方使用chat和reasoner做推理控制,其他provider需要单独判断,id可能会有所差别
|
||||
// openrouter: deepseek/deepseek-chat-v3.1 不知道会不会有其他provider仿照ds官方分出一个同id的作为非思考模式的模型,这里有风险
|
||||
// Matches: "deepseek-v3" followed by ".digit" or "-digit".
|
||||
// Optionally, this can be followed by ".alphanumeric_sequence" or "-alphanumeric_sequence"
|
||||
// until the end of the string.
|
||||
// Examples: deepseek-v3.1, deepseek-v3-1, deepseek-v3.1.2, deepseek-v3.1-alpha
|
||||
// Does NOT match: deepseek-v3.123 (missing separator after '1'), deepseek-v3.x (x isn't a digit)
|
||||
// TODO: move to utils and add test cases
|
||||
return /deepseek-v3(?:\.\d|-\d)(?:(\.|-)\w+)?$/.test(modelId) || modelId.includes('deepseek-chat-v3.1')
|
||||
})
|
||||
return idResult || nameResult
|
||||
}
|
||||
|
||||
export const isLingReasoningModel = (model?: Model): boolean => {
|
||||
@@ -523,7 +526,6 @@ export function isReasoningModel(model?: Model): boolean {
|
||||
REASONING_REGEX.test(model.name) ||
|
||||
isSupportedThinkingTokenDoubaoModel(model) ||
|
||||
isDeepSeekHybridInferenceModel(model) ||
|
||||
isDeepSeekHybridInferenceModel({ ...model, id: model.name }) ||
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { throttle } from 'lodash'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
|
||||
import { useTimer } from './useTimer'
|
||||
|
||||
@@ -12,13 +12,18 @@ import { useTimer } from './useTimer'
|
||||
*/
|
||||
export default function useScrollPosition(key: string, throttleWait?: number) {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const scrollKey = `scroll:${key}`
|
||||
const scrollKey = useMemo(() => `scroll:${key}`, [key])
|
||||
const scrollKeyRef = useRef(scrollKey)
|
||||
const { setTimeoutTimer } = useTimer()
|
||||
|
||||
useEffect(() => {
|
||||
scrollKeyRef.current = scrollKey
|
||||
}, [scrollKey])
|
||||
|
||||
const handleScroll = throttle(() => {
|
||||
const position = containerRef.current?.scrollTop ?? 0
|
||||
window.requestAnimationFrame(() => {
|
||||
window.keyv.set(scrollKey, position)
|
||||
window.keyv.set(scrollKeyRef.current, position)
|
||||
})
|
||||
}, throttleWait ?? 100)
|
||||
|
||||
@@ -28,5 +33,9 @@ export default function useScrollPosition(key: string, throttleWait?: number) {
|
||||
setTimeoutTimer('scrollEffect', scroll, 50)
|
||||
}, [scrollKey, setTimeoutTimer])
|
||||
|
||||
useEffect(() => {
|
||||
return () => handleScroll.cancel()
|
||||
}, [handleScroll])
|
||||
|
||||
return { containerRef, handleScroll }
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
/**
|
||||
* 定时器管理 Hook,用于管理 setTimeout 和 setInterval 定时器,支持通过 key 来标识不同的定时器
|
||||
@@ -43,10 +43,38 @@ export const useTimer = () => {
|
||||
const timeoutMapRef = useRef(new Map<string, NodeJS.Timeout>())
|
||||
const intervalMapRef = useRef(new Map<string, NodeJS.Timeout>())
|
||||
|
||||
/**
|
||||
* 清除指定 key 的 setTimeout 定时器
|
||||
* @param key - 定时器标识符
|
||||
*/
|
||||
const clearTimeoutTimer = useCallback((key: string) => {
|
||||
clearTimeout(timeoutMapRef.current.get(key))
|
||||
timeoutMapRef.current.delete(key)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 清除指定 key 的 setInterval 定时器
|
||||
* @param key - 定时器标识符
|
||||
*/
|
||||
const clearIntervalTimer = useCallback((key: string) => {
|
||||
clearInterval(intervalMapRef.current.get(key))
|
||||
intervalMapRef.current.delete(key)
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 清除所有定时器,包括 setTimeout 和 setInterval
|
||||
*/
|
||||
const clearAllTimers = useCallback(() => {
|
||||
timeoutMapRef.current.forEach((timer) => clearTimeout(timer))
|
||||
intervalMapRef.current.forEach((timer) => clearInterval(timer))
|
||||
timeoutMapRef.current.clear()
|
||||
intervalMapRef.current.clear()
|
||||
}, [])
|
||||
|
||||
// 组件卸载时自动清理所有定时器
|
||||
useEffect(() => {
|
||||
return () => clearAllTimers()
|
||||
}, [])
|
||||
}, [clearAllTimers])
|
||||
|
||||
/**
|
||||
* 设置一个 setTimeout 定时器
|
||||
@@ -65,12 +93,15 @@ export const useTimer = () => {
|
||||
* cleanup();
|
||||
* ```
|
||||
*/
|
||||
const setTimeoutTimer = (key: string, ...args: Parameters<typeof setTimeout>) => {
|
||||
clearTimeout(timeoutMapRef.current.get(key))
|
||||
const timer = setTimeout(...args)
|
||||
timeoutMapRef.current.set(key, timer)
|
||||
return () => clearTimeoutTimer(key)
|
||||
}
|
||||
const setTimeoutTimer = useCallback(
|
||||
(key: string, ...args: Parameters<typeof setTimeout>) => {
|
||||
clearTimeout(timeoutMapRef.current.get(key))
|
||||
const timer = setTimeout(...args)
|
||||
timeoutMapRef.current.set(key, timer)
|
||||
return () => clearTimeoutTimer(key)
|
||||
},
|
||||
[clearTimeoutTimer]
|
||||
)
|
||||
|
||||
/**
|
||||
* 设置一个 setInterval 定时器
|
||||
@@ -89,56 +120,31 @@ export const useTimer = () => {
|
||||
* cleanup();
|
||||
* ```
|
||||
*/
|
||||
const setIntervalTimer = (key: string, ...args: Parameters<typeof setInterval>) => {
|
||||
clearInterval(intervalMapRef.current.get(key))
|
||||
const timer = setInterval(...args)
|
||||
intervalMapRef.current.set(key, timer)
|
||||
return () => clearIntervalTimer(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定 key 的 setTimeout 定时器
|
||||
* @param key - 定时器标识符
|
||||
*/
|
||||
const clearTimeoutTimer = (key: string) => {
|
||||
clearTimeout(timeoutMapRef.current.get(key))
|
||||
timeoutMapRef.current.delete(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定 key 的 setInterval 定时器
|
||||
* @param key - 定时器标识符
|
||||
*/
|
||||
const clearIntervalTimer = (key: string) => {
|
||||
clearInterval(intervalMapRef.current.get(key))
|
||||
intervalMapRef.current.delete(key)
|
||||
}
|
||||
const setIntervalTimer = useCallback(
|
||||
(key: string, ...args: Parameters<typeof setInterval>) => {
|
||||
clearInterval(intervalMapRef.current.get(key))
|
||||
const timer = setInterval(...args)
|
||||
intervalMapRef.current.set(key, timer)
|
||||
return () => clearIntervalTimer(key)
|
||||
},
|
||||
[clearIntervalTimer]
|
||||
)
|
||||
|
||||
/**
|
||||
* 清除所有 setTimeout 定时器
|
||||
*/
|
||||
const clearAllTimeoutTimers = () => {
|
||||
const clearAllTimeoutTimers = useCallback(() => {
|
||||
timeoutMapRef.current.forEach((timer) => clearTimeout(timer))
|
||||
timeoutMapRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
/**
|
||||
* 清除所有 setInterval 定时器
|
||||
*/
|
||||
const clearAllIntervalTimers = () => {
|
||||
const clearAllIntervalTimers = useCallback(() => {
|
||||
intervalMapRef.current.forEach((timer) => clearInterval(timer))
|
||||
intervalMapRef.current.clear()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有定时器,包括 setTimeout 和 setInterval
|
||||
*/
|
||||
const clearAllTimers = () => {
|
||||
timeoutMapRef.current.forEach((timer) => clearTimeout(timer))
|
||||
intervalMapRef.current.forEach((timer) => clearInterval(timer))
|
||||
timeoutMapRef.current.clear()
|
||||
intervalMapRef.current.clear()
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
setTimeoutTimer,
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"denied": "Tool request was denied.",
|
||||
"timeout": "Tool request timed out before receiving approval."
|
||||
},
|
||||
"toolPendingFallback": "Tool",
|
||||
"waiting": "Waiting for tool permission decision..."
|
||||
},
|
||||
"type": {
|
||||
@@ -1208,7 +1209,7 @@
|
||||
"endpoint_type": {
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Gemini",
|
||||
"image-generation": "Image Generation",
|
||||
"image-generation": "Image Generation (OpenAI)",
|
||||
"jina-rerank": "Jina Rerank",
|
||||
"openai": "OpenAI",
|
||||
"openai-response": "OpenAI-Response"
|
||||
@@ -4371,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Preview: {{url}}",
|
||||
"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",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"denied": "工具请求已被拒绝。",
|
||||
"timeout": "工具请求在收到批准前超时。"
|
||||
},
|
||||
"toolPendingFallback": "工具",
|
||||
"waiting": "等待工具权限决定..."
|
||||
},
|
||||
"type": {
|
||||
@@ -1208,7 +1209,7 @@
|
||||
"endpoint_type": {
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Gemini",
|
||||
"image-generation": "图片生成",
|
||||
"image-generation": "图像生成 (OpenAI)",
|
||||
"jina-rerank": "Jina 重排序",
|
||||
"openai": "OpenAI",
|
||||
"openai-response": "OpenAI-Response"
|
||||
@@ -4371,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "预览: {{url}}",
|
||||
"reset": "重置",
|
||||
"tip": "# 结尾强制使用输入地址"
|
||||
"tip": "在末尾添加 # 以禁用自动附加的API版本。"
|
||||
}
|
||||
},
|
||||
"api_host": "API 地址",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"denied": "工具請求已被拒絕。",
|
||||
"timeout": "工具請求在收到核准前逾時。"
|
||||
},
|
||||
"toolPendingFallback": "工具",
|
||||
"waiting": "等待工具權限決定..."
|
||||
},
|
||||
"type": {
|
||||
@@ -1208,7 +1209,7 @@
|
||||
"endpoint_type": {
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Gemini",
|
||||
"image-generation": "圖片生成",
|
||||
"image-generation": "圖像生成 (OpenAI)",
|
||||
"jina-rerank": "Jina Rerank",
|
||||
"openai": "OpenAI",
|
||||
"openai-response": "OpenAI-Response"
|
||||
@@ -4371,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "預覽:{{url}}",
|
||||
"reset": "重設",
|
||||
"tip": "# 結尾強制使用輸入位址"
|
||||
"tip": "在末尾添加 # 以停用自動附加的 API 版本。"
|
||||
}
|
||||
},
|
||||
"api_host": "API 主機地址",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"denied": "Tool-Anfrage wurde abgelehnt.",
|
||||
"timeout": "Tool-Anfrage ist abgelaufen, bevor eine Genehmigung eingegangen ist."
|
||||
},
|
||||
"toolPendingFallback": "Werkzeug",
|
||||
"waiting": "Warten auf Entscheidung über Tool-Berechtigung..."
|
||||
},
|
||||
"type": {
|
||||
@@ -1208,7 +1209,7 @@
|
||||
"endpoint_type": {
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Gemini",
|
||||
"image-generation": "Bildgenerierung",
|
||||
"image-generation": "Bilderzeugung (OpenAI)",
|
||||
"jina-rerank": "Jina Reranking",
|
||||
"openai": "OpenAI",
|
||||
"openai-response": "OpenAI-Response"
|
||||
@@ -4371,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Vorschau: {{url}}",
|
||||
"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",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"denied": "Το αίτημα για εργαλείο απορρίφθηκε.",
|
||||
"timeout": "Το αίτημα για το εργαλείο έληξε πριν λάβει έγκριση."
|
||||
},
|
||||
"toolPendingFallback": "Εργαλείο",
|
||||
"waiting": "Αναμονή για απόφαση άδειας εργαλείου..."
|
||||
},
|
||||
"type": {
|
||||
@@ -1208,7 +1209,7 @@
|
||||
"endpoint_type": {
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Gemini",
|
||||
"image-generation": "Δημιουργία Εικόνας",
|
||||
"image-generation": "Δημιουργία Εικόνων (OpenAI)",
|
||||
"jina-rerank": "Επαναταξινόμηση Jina",
|
||||
"openai": "OpenAI",
|
||||
"openai-response": "Απάντηση OpenAI"
|
||||
@@ -4371,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Προεπισκόπηση: {{url}}",
|
||||
"reset": "Επαναφορά",
|
||||
"tip": "#τέλος ενδεχόμενη χρήση της εισαγωγής διευθύνσεως"
|
||||
"tip": "Προσθέστε το σύμβολο # στο τέλος για να απενεργοποιήσετε την αυτόματα προστιθέμενη έκδοση API."
|
||||
}
|
||||
},
|
||||
"api_host": "Διεύθυνση API",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"denied": "La solicitud de herramienta fue denegada.",
|
||||
"timeout": "La solicitud de herramienta expiró antes de recibir la aprobación."
|
||||
},
|
||||
"toolPendingFallback": "Herramienta",
|
||||
"waiting": "Esperando la decisión de permiso de la herramienta..."
|
||||
},
|
||||
"type": {
|
||||
@@ -1208,7 +1209,7 @@
|
||||
"endpoint_type": {
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Gemini",
|
||||
"image-generation": "Generación de imágenes",
|
||||
"image-generation": "Generación de Imágenes (OpenAI)",
|
||||
"jina-rerank": "Reordenamiento Jina",
|
||||
"openai": "OpenAI",
|
||||
"openai-response": "Respuesta de OpenAI"
|
||||
@@ -4371,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Vista previa: {{url}}",
|
||||
"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",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"denied": "La demande d'outil a été refusée.",
|
||||
"timeout": "La demande d'outil a expiré avant d'obtenir l'approbation."
|
||||
},
|
||||
"toolPendingFallback": "Outil",
|
||||
"waiting": "En attente de la décision d'autorisation de l'outil..."
|
||||
},
|
||||
"type": {
|
||||
@@ -1208,7 +1209,7 @@
|
||||
"endpoint_type": {
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Gemini",
|
||||
"image-generation": "Génération d'images",
|
||||
"image-generation": "Génération d'images (OpenAI)",
|
||||
"jina-rerank": "Reclassement Jina",
|
||||
"openai": "OpenAI",
|
||||
"openai-response": "Réponse OpenAI"
|
||||
@@ -4371,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Aperçu : {{url}}",
|
||||
"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",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"denied": "ツールリクエストは拒否されました。",
|
||||
"timeout": "ツールリクエストは承認を受ける前にタイムアウトしました。"
|
||||
},
|
||||
"toolPendingFallback": "ツール",
|
||||
"waiting": "ツールの許可決定を待っています..."
|
||||
},
|
||||
"type": {
|
||||
@@ -1208,7 +1209,7 @@
|
||||
"endpoint_type": {
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Gemini",
|
||||
"image-generation": "画像生成",
|
||||
"image-generation": "画像生成 (OpenAI)",
|
||||
"jina-rerank": "Jina Rerank",
|
||||
"openai": "OpenAI",
|
||||
"openai-response": "OpenAI-Response"
|
||||
@@ -4371,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "プレビュー: {{url}}",
|
||||
"reset": "リセット",
|
||||
"tip": "#で終わる場合、入力されたアドレスを強制的に使用します"
|
||||
"tip": "自動的に付加されるAPIバージョンを無効にするには、末尾に#を追加します。"
|
||||
}
|
||||
},
|
||||
"api_host": "APIホスト",
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
"error": {
|
||||
"failed": "Falha ao excluir o agente"
|
||||
},
|
||||
"title": "删除代理"
|
||||
"title": "Excluir Agente"
|
||||
},
|
||||
"edit": {
|
||||
"title": "Agent Editor"
|
||||
@@ -111,7 +111,7 @@
|
||||
"label": "Modo de permissão",
|
||||
"options": {
|
||||
"acceptEdits": "Aceitar edições automaticamente",
|
||||
"bypassPermissions": "忽略检查 de permissão",
|
||||
"bypassPermissions": "Ignorar verificações de permissão",
|
||||
"default": "Padrão (perguntar antes de continuar)",
|
||||
"plan": "Modo de planejamento (plano sujeito a aprovação)"
|
||||
},
|
||||
@@ -150,7 +150,7 @@
|
||||
},
|
||||
"success": {
|
||||
"install": "Plugin instalado com sucesso",
|
||||
"uninstall": "插件 desinstalado com sucesso"
|
||||
"uninstall": "Plugin desinstalado com sucesso"
|
||||
},
|
||||
"tab": "plug-in",
|
||||
"type": {
|
||||
@@ -280,6 +280,7 @@
|
||||
"denied": "Solicitação de ferramenta foi negada.",
|
||||
"timeout": "A solicitação da ferramenta expirou antes de receber aprovação."
|
||||
},
|
||||
"toolPendingFallback": "Ferramenta",
|
||||
"waiting": "Aguardando decisão de permissão da ferramenta..."
|
||||
},
|
||||
"type": {
|
||||
@@ -1134,7 +1135,7 @@
|
||||
"duplicate": "Duplicar",
|
||||
"edit": "Editar",
|
||||
"enabled": "Ativado",
|
||||
"error": "错误",
|
||||
"error": "Erro",
|
||||
"errors": {
|
||||
"create_message": "Falha ao criar mensagem",
|
||||
"validation": "Falha na verificação"
|
||||
@@ -1208,7 +1209,7 @@
|
||||
"endpoint_type": {
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Gemini",
|
||||
"image-generation": "Geração de Imagem",
|
||||
"image-generation": "Geração de Imagens (OpenAI)",
|
||||
"jina-rerank": "Jina Reordenar",
|
||||
"openai": "OpenAI",
|
||||
"openai-response": "Resposta OpenAI"
|
||||
@@ -4371,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Pré-visualização: {{url}}",
|
||||
"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",
|
||||
|
||||
@@ -280,6 +280,7 @@
|
||||
"denied": "Запрос на инструмент был отклонён.",
|
||||
"timeout": "Запрос на инструмент превысил время ожидания до получения подтверждения."
|
||||
},
|
||||
"toolPendingFallback": "Инструмент",
|
||||
"waiting": "Ожидание решения о разрешении на использование инструмента..."
|
||||
},
|
||||
"type": {
|
||||
@@ -1208,7 +1209,7 @@
|
||||
"endpoint_type": {
|
||||
"anthropic": "Anthropic",
|
||||
"gemini": "Gemini",
|
||||
"image-generation": "Изображение",
|
||||
"image-generation": "Генерация изображений (OpenAI)",
|
||||
"jina-rerank": "Jina Rerank",
|
||||
"openai": "OpenAI",
|
||||
"openai-response": "OpenAI-Response"
|
||||
@@ -4371,7 +4372,7 @@
|
||||
"url": {
|
||||
"preview": "Предпросмотр: {{url}}",
|
||||
"reset": "Сброс",
|
||||
"tip": "заканчивая на # принудительно использует введенный адрес"
|
||||
"tip": "Добавьте # в конце, чтобы отключить автоматически добавляемую версию API."
|
||||
}
|
||||
},
|
||||
"api_host": "Хост API",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { getModel } from '@renderer/hooks/useModel'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTextareaResize } from '@renderer/hooks/useTextareaResize'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import { CacheService } from '@renderer/services/CacheService'
|
||||
import { pauseTrace } from '@renderer/services/SpanManagerService'
|
||||
import { estimateUserPromptUsage } from '@renderer/services/TokenService'
|
||||
import { useAppDispatch, useAppSelector } from '@renderer/store'
|
||||
@@ -41,19 +42,10 @@ import { getInputbarConfig } from './registry'
|
||||
import { TopicType } from './types'
|
||||
|
||||
const logger = loggerService.withContext('AgentSessionInputbar')
|
||||
const agentSessionDraftCache = new Map<string, string>()
|
||||
|
||||
const readDraftFromCache = (key: string): string => {
|
||||
return agentSessionDraftCache.get(key) ?? ''
|
||||
}
|
||||
const DRAFT_CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
|
||||
|
||||
const writeDraftToCache = (key: string, value: string) => {
|
||||
if (!value) {
|
||||
agentSessionDraftCache.delete(key)
|
||||
} else {
|
||||
agentSessionDraftCache.set(key, value)
|
||||
}
|
||||
}
|
||||
const getAgentDraftCacheKey = (agentId: string) => `agent-session-draft-${agentId}`
|
||||
|
||||
type Props = {
|
||||
agentId: string
|
||||
@@ -170,16 +162,15 @@ const AgentSessionInputbarInner: FC<InnerProps> = ({ assistant, agentId, session
|
||||
const scope = TopicType.Session
|
||||
const config = getInputbarConfig(scope)
|
||||
|
||||
// Use shared hooks for text and textarea management
|
||||
const initialDraft = useMemo(() => readDraftFromCache(agentId), [agentId])
|
||||
const persistDraft = useCallback((next: string) => writeDraftToCache(agentId, next), [agentId])
|
||||
// Use shared hooks for text and textarea management with draft persistence
|
||||
const draftCacheKey = getAgentDraftCacheKey(agentId)
|
||||
const {
|
||||
text,
|
||||
setText,
|
||||
isEmpty: inputEmpty
|
||||
} = useInputText({
|
||||
initialValue: initialDraft,
|
||||
onChange: persistDraft
|
||||
initialValue: CacheService.get<string>(draftCacheKey) ?? '',
|
||||
onChange: (value) => CacheService.set(draftCacheKey, value, DRAFT_CACHE_TTL)
|
||||
})
|
||||
const {
|
||||
textareaRef,
|
||||
@@ -431,6 +422,7 @@ const AgentSessionInputbarInner: FC<InnerProps> = ({ assistant, agentId, session
|
||||
})
|
||||
)
|
||||
|
||||
// Clear text after successful send (draft is cleared automatically via onChange)
|
||||
setText('')
|
||||
setTimeoutTimer('agentSession_sendMessage', () => setText(''), 500)
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,7 +14,6 @@ import { useInputText } from '@renderer/hooks/useInputText'
|
||||
import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations'
|
||||
import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useShortcut } from '@renderer/hooks/useShortcuts'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { useTextareaResize } from '@renderer/hooks/useTextareaResize'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import {
|
||||
@@ -24,6 +23,7 @@ import {
|
||||
useInputbarToolsState
|
||||
} from '@renderer/pages/home/Inputbar/context/InputbarToolsProvider'
|
||||
import { getDefaultTopic } from '@renderer/services/AssistantService'
|
||||
import { CacheService } from '@renderer/services/CacheService'
|
||||
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
|
||||
import FileManager from '@renderer/services/FileManager'
|
||||
import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService'
|
||||
@@ -39,7 +39,7 @@ import { getSendMessageShortcutLabel } from '@renderer/utils/input'
|
||||
import { documentExts, imageExts, textExts } from '@shared/config/constant'
|
||||
import { debounce } from 'lodash'
|
||||
import type { FC } from 'react'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import React, { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
import { InputbarCore } from './components/InputbarCore'
|
||||
@@ -51,6 +51,17 @@ import TokenCount from './TokenCount'
|
||||
|
||||
const logger = loggerService.withContext('Inputbar')
|
||||
|
||||
const INPUTBAR_DRAFT_CACHE_KEY = 'inputbar-draft'
|
||||
const DRAFT_CACHE_TTL = 24 * 60 * 60 * 1000 // 24 hours
|
||||
|
||||
const getMentionedModelsCacheKey = (assistantId: string) => `inputbar-mentioned-models-${assistantId}`
|
||||
|
||||
const getValidatedCachedModels = (assistantId: string): Model[] => {
|
||||
const cached = CacheService.get<Model[]>(getMentionedModelsCacheKey(assistantId))
|
||||
if (!Array.isArray(cached)) return []
|
||||
return cached.filter((model) => model?.id && model?.name)
|
||||
}
|
||||
|
||||
interface Props {
|
||||
assistant: Assistant
|
||||
setActiveTopic: (topic: Topic) => void
|
||||
@@ -80,16 +91,18 @@ const Inputbar: FC<Props> = ({ assistant: initialAssistant, setActiveTopic, topi
|
||||
toggleExpanded: () => {}
|
||||
})
|
||||
|
||||
const [initialMentionedModels] = useState(() => getValidatedCachedModels(initialAssistant.id))
|
||||
|
||||
const initialState = useMemo(
|
||||
() => ({
|
||||
files: [] as FileType[],
|
||||
mentionedModels: [] as Model[],
|
||||
mentionedModels: initialMentionedModels,
|
||||
selectedKnowledgeBases: initialAssistant.knowledge_bases ?? [],
|
||||
isExpanded: false,
|
||||
couldAddImageFile: false,
|
||||
extensions: [] as string[]
|
||||
}),
|
||||
[initialAssistant.knowledge_bases]
|
||||
[initialMentionedModels, initialAssistant.knowledge_bases]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -121,7 +134,10 @@ const InputbarInner: FC<InputbarInnerProps> = ({ assistant: initialAssistant, se
|
||||
const { setFiles, setMentionedModels, setSelectedKnowledgeBases } = useInputbarToolsDispatch()
|
||||
const { setCouldAddImageFile } = useInputbarToolsInternalDispatch()
|
||||
|
||||
const { text, setText } = useInputText()
|
||||
const { text, setText } = useInputText({
|
||||
initialValue: CacheService.get<string>(INPUTBAR_DRAFT_CACHE_KEY) ?? '',
|
||||
onChange: (value) => CacheService.set(INPUTBAR_DRAFT_CACHE_KEY, value, DRAFT_CACHE_TTL)
|
||||
})
|
||||
const {
|
||||
textareaRef,
|
||||
resize: resizeTextArea,
|
||||
@@ -133,7 +149,6 @@ const InputbarInner: FC<InputbarInnerProps> = ({ assistant: initialAssistant, se
|
||||
minHeight: 30
|
||||
})
|
||||
|
||||
const showKnowledgeIcon = useSidebarIconShow('knowledge')
|
||||
const { assistant, addTopic, model, setModel, updateAssistant } = useAssistant(initialAssistant.id)
|
||||
const { sendMessageShortcut, showInputEstimatedTokens, enableQuickPanelTriggers } = useSettings()
|
||||
const [estimateTokenCount, setEstimateTokenCount] = useState(0)
|
||||
@@ -190,6 +205,15 @@ const InputbarInner: FC<InputbarInnerProps> = ({ assistant: initialAssistant, se
|
||||
setCouldAddImageFile(canAddImageFile)
|
||||
}, [canAddImageFile, setCouldAddImageFile])
|
||||
|
||||
const onUnmount = useEffectEvent((id: string) => {
|
||||
CacheService.set(getMentionedModelsCacheKey(id), mentionedModels, DRAFT_CACHE_TTL)
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
return () => onUnmount(assistant.id)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [assistant.id])
|
||||
|
||||
const placeholderText = enableQuickPanelTriggers
|
||||
? t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) })
|
||||
: t('chat.input.placeholder_without_triggers', {
|
||||
@@ -381,9 +405,10 @@ const InputbarInner: FC<InputbarInnerProps> = ({ assistant: initialAssistant, se
|
||||
focusTextarea
|
||||
])
|
||||
|
||||
// TODO: Just use assistant.knowledge_bases as selectedKnowledgeBases. context state is overdesigned.
|
||||
useEffect(() => {
|
||||
setSelectedKnowledgeBases(showKnowledgeIcon ? (assistant.knowledge_bases ?? []) : [])
|
||||
}, [assistant.knowledge_bases, setSelectedKnowledgeBases, showKnowledgeIcon])
|
||||
setSelectedKnowledgeBases(assistant.knowledge_bases ?? [])
|
||||
}, [assistant.knowledge_bases, setSelectedKnowledgeBases])
|
||||
|
||||
useEffect(() => {
|
||||
// Disable web search if model doesn't support it
|
||||
|
||||
@@ -156,11 +156,8 @@ export const InputbarCore: FC<InputbarCoreProps> = ({
|
||||
|
||||
const setText = useCallback<React.Dispatch<React.SetStateAction<string>>>(
|
||||
(value) => {
|
||||
if (typeof value === 'function') {
|
||||
onTextChange(value(textRef.current))
|
||||
} else {
|
||||
onTextChange(value)
|
||||
}
|
||||
const newText = typeof value === 'function' ? value(textRef.current) : value
|
||||
onTextChange(newText)
|
||||
},
|
||||
[onTextChange]
|
||||
)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useAssistant } from '@renderer/hooks/useAssistant'
|
||||
import { useSidebarIconShow } from '@renderer/hooks/useSidebarIcon'
|
||||
import { defineTool, registerTool, TopicType } from '@renderer/pages/home/Inputbar/types'
|
||||
import type { KnowledgeBase } from '@renderer/types'
|
||||
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||
@@ -30,7 +29,6 @@ const knowledgeBaseTool = defineTool({
|
||||
render: function KnowledgeBaseToolRender(context) {
|
||||
const { assistant, state, actions, quickPanel } = context
|
||||
|
||||
const knowledgeSidebarEnabled = useSidebarIconShow('knowledge')
|
||||
const { updateAssistant } = useAssistant(assistant.id)
|
||||
|
||||
const handleSelect = useCallback(
|
||||
@@ -41,10 +39,6 @@ const knowledgeBaseTool = defineTool({
|
||||
[updateAssistant, actions]
|
||||
)
|
||||
|
||||
if (!knowledgeSidebarEnabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<KnowledgeBaseButton
|
||||
quickPanel={quickPanel}
|
||||
|
||||
@@ -102,10 +102,12 @@ const ThinkingBlock: React.FC<Props> = ({ block }) => {
|
||||
)
|
||||
}
|
||||
|
||||
const normalizeThinkingTime = (value?: number) => (typeof value === 'number' && Number.isFinite(value) ? value : 0)
|
||||
|
||||
const ThinkingTimeSeconds = memo(
|
||||
({ blockThinkingTime, isThinking }: { blockThinkingTime: number; isThinking: boolean }) => {
|
||||
const { t } = useTranslation()
|
||||
const [displayTime, setDisplayTime] = useState(blockThinkingTime)
|
||||
const [displayTime, setDisplayTime] = useState(normalizeThinkingTime(blockThinkingTime))
|
||||
|
||||
const timer = useRef<NodeJS.Timeout | null>(null)
|
||||
|
||||
@@ -121,7 +123,7 @@ const ThinkingTimeSeconds = memo(
|
||||
clearInterval(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
setDisplayTime(blockThinkingTime)
|
||||
setDisplayTime(normalizeThinkingTime(blockThinkingTime))
|
||||
}
|
||||
|
||||
return () => {
|
||||
@@ -132,10 +134,10 @@ const ThinkingTimeSeconds = memo(
|
||||
}
|
||||
}, [isThinking, blockThinkingTime])
|
||||
|
||||
const thinkingTimeSeconds = useMemo(
|
||||
() => ((displayTime < 1000 ? 100 : displayTime) / 1000).toFixed(1),
|
||||
[displayTime]
|
||||
)
|
||||
const thinkingTimeSeconds = useMemo(() => {
|
||||
const safeTime = normalizeThinkingTime(displayTime)
|
||||
return ((safeTime < 1000 ? 100 : safeTime) / 1000).toFixed(1)
|
||||
}, [displayTime])
|
||||
|
||||
return isThinking
|
||||
? t('chat.thinking', {
|
||||
|
||||
@@ -255,6 +255,20 @@ describe('ThinkingBlock', () => {
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
|
||||
it('should clamp invalid thinking times to a safe default', () => {
|
||||
const testCases = [undefined, Number.NaN, Number.POSITIVE_INFINITY]
|
||||
|
||||
testCases.forEach((thinking_millsec) => {
|
||||
const block = createThinkingBlock({
|
||||
thinking_millsec: thinking_millsec as any,
|
||||
status: MessageBlockStatus.SUCCESS
|
||||
})
|
||||
const { unmount } = renderThinkingBlock(block)
|
||||
expect(getThinkingTimeText()).toHaveTextContent('0.1s')
|
||||
unmount()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('collapse behavior', () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
|
||||
import { useTimer } from '@renderer/hooks/useTimer'
|
||||
import type { RootState } from '@renderer/store'
|
||||
// import { selectCurrentTopicId } from '@renderer/store/newMessage'
|
||||
import { scrollIntoView } from '@renderer/utils/dom'
|
||||
import { Button, Drawer, Tooltip } from 'antd'
|
||||
import type { FC } from 'react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
@@ -118,7 +119,8 @@ const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
}
|
||||
|
||||
const scrollToMessage = (element: HTMLElement) => {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
// Use container: 'nearest' to keep scroll within the chat pane (Chromium-only, see #11565, #11567)
|
||||
scrollIntoView(element, { behavior: 'smooth', block: 'start', container: 'nearest' })
|
||||
}
|
||||
|
||||
const scrollToTop = () => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { estimateMessageUsage } from '@renderer/services/TokenService'
|
||||
import type { Assistant, Topic } from '@renderer/types'
|
||||
import type { Message, MessageBlock } from '@renderer/types/newMessage'
|
||||
import { classNames, cn } from '@renderer/utils'
|
||||
import { scrollIntoView } from '@renderer/utils/dom'
|
||||
import { isMessageProcessing } from '@renderer/utils/messageUtils/is'
|
||||
import { Divider } from 'antd'
|
||||
import type { Dispatch, FC, SetStateAction } from 'react'
|
||||
@@ -79,9 +80,10 @@ const MessageItem: FC<Props> = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing && messageContainerRef.current) {
|
||||
messageContainerRef.current.scrollIntoView({
|
||||
scrollIntoView(messageContainerRef.current, {
|
||||
behavior: 'smooth',
|
||||
block: 'center'
|
||||
block: 'center',
|
||||
container: 'nearest'
|
||||
})
|
||||
}
|
||||
}, [isEditing])
|
||||
@@ -124,7 +126,7 @@ const MessageItem: FC<Props> = ({
|
||||
const messageHighlightHandler = useCallback(
|
||||
(highlight: boolean = true) => {
|
||||
if (messageContainerRef.current) {
|
||||
messageContainerRef.current.scrollIntoView({ behavior: 'smooth' })
|
||||
scrollIntoView(messageContainerRef.current, { behavior: 'smooth', block: 'center', container: 'nearest' })
|
||||
if (highlight) {
|
||||
setTimeoutTimer(
|
||||
'messageHighlightHandler',
|
||||
|
||||
@@ -12,6 +12,7 @@ import { newMessagesActions } from '@renderer/store/newMessage'
|
||||
// import { updateMessageThunk } from '@renderer/store/thunk/messageThunk'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { isEmoji, removeLeadingEmoji } from '@renderer/utils'
|
||||
import { scrollIntoView } from '@renderer/utils/dom'
|
||||
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { Avatar } from 'antd'
|
||||
import { CircleChevronDown } from 'lucide-react'
|
||||
@@ -119,7 +120,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
() => {
|
||||
const messageElement = document.getElementById(`message-${message.id}`)
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ behavior: 'auto', block: 'start' })
|
||||
scrollIntoView(messageElement, { behavior: 'auto', block: 'start', container: 'nearest' })
|
||||
}
|
||||
},
|
||||
100
|
||||
@@ -141,7 +142,7 @@ const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
|
||||
return
|
||||
}
|
||||
|
||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
scrollIntoView(messageElement, { behavior: 'smooth', block: 'start', container: 'nearest' })
|
||||
},
|
||||
[setSelectedMessage]
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { MultiModelMessageStyle } from '@renderer/store/settings'
|
||||
import type { Topic } from '@renderer/types'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { classNames } from '@renderer/utils'
|
||||
import { scrollIntoView } from '@renderer/utils/dom'
|
||||
import { Popover } from 'antd'
|
||||
import type { ComponentProps } from 'react'
|
||||
import { memo, useCallback, useEffect, useMemo, useState } from 'react'
|
||||
@@ -73,7 +74,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
() => {
|
||||
const messageElement = document.getElementById(`message-${message.id}`)
|
||||
if (messageElement) {
|
||||
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
scrollIntoView(messageElement, { behavior: 'smooth', block: 'start', container: 'nearest' })
|
||||
}
|
||||
},
|
||||
200
|
||||
@@ -132,7 +133,7 @@ const MessageGroup = ({ messages, topic, registerMessageElement }: Props) => {
|
||||
setSelectedMessage(message)
|
||||
} else {
|
||||
// 直接滚动
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
scrollIntoView(element, { behavior: 'smooth', block: 'start', container: 'nearest' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { RootState } from '@renderer/store'
|
||||
import { messageBlocksSelectors } from '@renderer/store/messageBlock'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import { MessageBlockType } from '@renderer/types/newMessage'
|
||||
import { scrollIntoView } from '@renderer/utils/dom'
|
||||
import type { FC } from 'react'
|
||||
import React, { useMemo, useRef } from 'react'
|
||||
import { useSelector } from 'react-redux'
|
||||
@@ -72,10 +73,10 @@ const MessageOutline: FC<MessageOutlineProps> = ({ message }) => {
|
||||
const parent = messageOutlineContainerRef.current?.parentElement
|
||||
const messageContentContainer = parent?.querySelector('.message-content-container')
|
||||
if (messageContentContainer) {
|
||||
const headingElement = messageContentContainer.querySelector(`#${id}`)
|
||||
const headingElement = messageContentContainer.querySelector<HTMLElement>(`#${id}`)
|
||||
if (headingElement) {
|
||||
const scrollBlock = ['horizontal', 'grid'].includes(message.multiModelMessageStyle ?? '') ? 'nearest' : 'start'
|
||||
headingElement.scrollIntoView({ behavior: 'smooth', block: scrollBlock })
|
||||
scrollIntoView(headingElement, { behavior: 'smooth', block: scrollBlock, container: 'nearest' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import { Terminal } from 'lucide-react'
|
||||
import { ToolTitle } from './GenericTools'
|
||||
import type { BashToolInput as BashToolInputType, BashToolOutput as BashToolOutputType } from './types'
|
||||
|
||||
const MAX_TAG_LENGTH = 100
|
||||
|
||||
export function BashTool({
|
||||
input,
|
||||
output
|
||||
@@ -17,12 +15,10 @@ export function BashTool({
|
||||
// 如果有输出,计算输出行数
|
||||
const outputLines = output ? output.split('\n').length : 0
|
||||
|
||||
// 处理命令字符串的截断,添加空值检查
|
||||
// 处理命令字符串,添加空值检查
|
||||
const command = input?.command ?? ''
|
||||
const needsTruncate = command.length > MAX_TAG_LENGTH
|
||||
const displayCommand = needsTruncate ? `${command.slice(0, MAX_TAG_LENGTH)}...` : command
|
||||
|
||||
const tagContent = <Tag className="whitespace-pre-wrap break-all font-mono">{displayCommand}</Tag>
|
||||
const tagContent = <Tag className="!m-0 max-w-full truncate font-mono">{command}</Tag>
|
||||
|
||||
return {
|
||||
key: 'tool',
|
||||
@@ -34,16 +30,12 @@ export function BashTool({
|
||||
params={input?.description}
|
||||
stats={output ? `${outputLines} ${outputLines === 1 ? 'line' : 'lines'}` : undefined}
|
||||
/>
|
||||
<div className="mt-1">
|
||||
{needsTruncate ? (
|
||||
<Popover
|
||||
content={<div className="max-w-xl whitespace-pre-wrap break-all font-mono">{command}</div>}
|
||||
trigger="hover">
|
||||
{tagContent}
|
||||
</Popover>
|
||||
) : (
|
||||
tagContent
|
||||
)}
|
||||
<div className="mt-1 max-w-full">
|
||||
<Popover
|
||||
content={<div className="max-w-xl whitespace-pre-wrap break-all font-mono text-xs">{command}</div>}
|
||||
trigger="hover">
|
||||
{tagContent}
|
||||
</Popover>
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -18,9 +18,9 @@ export function ToolTitle({
|
||||
}) {
|
||||
return (
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
{icon}
|
||||
{label && <span className="font-medium text-sm">{label}</span>}
|
||||
{params && <span className="flex-shrink-0 text-muted-foreground text-xs">{params}</span>}
|
||||
{icon && <span className="flex flex-shrink-0">{icon}</span>}
|
||||
{label && <span className="flex-shrink-0 font-medium text-sm">{label}</span>}
|
||||
{params && <span className="min-w-0 truncate text-muted-foreground text-xs">{params}</span>}
|
||||
{stats && <span className="flex-shrink-0 text-muted-foreground text-xs">{stats}</span>}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { loggerService } from '@logger'
|
||||
import { useAppSelector } from '@renderer/store'
|
||||
import { selectPendingPermission } from '@renderer/store/toolPermissions'
|
||||
import type { NormalToolResponse } from '@renderer/types'
|
||||
import type { CollapseProps } from 'antd'
|
||||
import { Collapse } from 'antd'
|
||||
import { Collapse, Spin } from 'antd'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
// 导出所有类型
|
||||
export * from './types'
|
||||
@@ -83,17 +86,41 @@ function ToolContent({ toolName, input, output }: { toolName: AgentToolsType; in
|
||||
// 统一的组件渲染入口
|
||||
export function MessageAgentTools({ toolResponse }: { toolResponse: NormalToolResponse }) {
|
||||
const { arguments: args, response, tool, status } = toolResponse
|
||||
logger.info('Rendering agent tool response', {
|
||||
logger.debug('Rendering agent tool response', {
|
||||
tool: tool,
|
||||
arguments: args,
|
||||
status,
|
||||
response
|
||||
})
|
||||
|
||||
const pendingPermission = useAppSelector((state) =>
|
||||
selectPendingPermission(state.toolPermissions, toolResponse.toolCallId)
|
||||
)
|
||||
|
||||
if (status === 'pending') {
|
||||
return <ToolPermissionRequestCard toolResponse={toolResponse} />
|
||||
if (pendingPermission) {
|
||||
return <ToolPermissionRequestCard toolResponse={toolResponse} />
|
||||
}
|
||||
return <ToolPendingIndicator toolName={tool?.name} description={tool?.description} />
|
||||
}
|
||||
|
||||
return (
|
||||
<ToolContent toolName={tool.name as AgentToolsType} input={args as ToolInput} output={response as ToolOutput} />
|
||||
)
|
||||
}
|
||||
|
||||
function ToolPendingIndicator({ toolName, description }: { toolName?: string; description?: string }) {
|
||||
const { t } = useTranslation()
|
||||
const label = toolName || t('agent.toolPermission.toolPendingFallback', 'Tool')
|
||||
const detail = description?.trim() || t('agent.toolPermission.executing')
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-xl items-center gap-3 rounded-xl border border-default-200 bg-default-100 px-4 py-3 shadow-sm">
|
||||
<Spin size="small" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="font-semibold text-default-700 text-sm">{label}</span>
|
||||
<span className="text-default-500 text-xs">{detail}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { adaptProvider } from '@renderer/aiCore/provider/providerConfig'
|
||||
import OpenAIAlert from '@renderer/components/Alert/OpenAIAlert'
|
||||
import { LoadingIcon } from '@renderer/components/Icons'
|
||||
import { HStack } from '@renderer/components/Layout'
|
||||
import { ApiKeyListPopup } from '@renderer/components/Popups/ApiKeyListPopup'
|
||||
import Selector from '@renderer/components/Selector'
|
||||
import { HelpTooltip } from '@renderer/components/TooltipIcons'
|
||||
import { isEmbeddingModel, isRerankModel } from '@renderer/config/models'
|
||||
import { PROVIDER_URLS } from '@renderer/config/providers'
|
||||
import { useTheme } from '@renderer/context/ThemeProvider'
|
||||
@@ -19,14 +21,7 @@ import type { SystemProviderId } from '@renderer/types'
|
||||
import { isSystemProvider, isSystemProviderId, SystemProviderIds } from '@renderer/types'
|
||||
import type { ApiKeyConnectivity } from '@renderer/types/healthCheck'
|
||||
import { HealthStatus } from '@renderer/types/healthCheck'
|
||||
import {
|
||||
formatApiHost,
|
||||
formatApiKeys,
|
||||
formatAzureOpenAIApiHost,
|
||||
formatVertexApiHost,
|
||||
getFancyProviderName,
|
||||
validateApiHost
|
||||
} from '@renderer/utils'
|
||||
import { formatApiHost, formatApiKeys, getFancyProviderName, validateApiHost } from '@renderer/utils'
|
||||
import { formatErrorMessage } from '@renderer/utils/error'
|
||||
import {
|
||||
isAIGatewayProvider,
|
||||
@@ -36,7 +31,6 @@ import {
|
||||
isNewApiProvider,
|
||||
isOpenAICompatibleProvider,
|
||||
isOpenAIProvider,
|
||||
isSupportAPIVersionProvider,
|
||||
isVertexProvider
|
||||
} from '@renderer/utils/provider'
|
||||
import { Button, Divider, Flex, Input, Select, Space, Switch, Tooltip } from 'antd'
|
||||
@@ -281,12 +275,10 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
}, [configuredApiHost, apiHost])
|
||||
|
||||
const hostPreview = () => {
|
||||
if (apiHost.endsWith('#')) {
|
||||
return apiHost.replace('#', '')
|
||||
}
|
||||
const formattedApiHost = adaptProvider({ provider: { ...provider, apiHost } }).apiHost
|
||||
|
||||
if (isOpenAICompatibleProvider(provider)) {
|
||||
return formatApiHost(apiHost, isSupportAPIVersionProvider(provider)) + '/chat/completions'
|
||||
return formattedApiHost + '/chat/completions'
|
||||
}
|
||||
|
||||
if (isAzureOpenAIProvider(provider)) {
|
||||
@@ -294,29 +286,26 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
const path = !['preview', 'v1'].includes(apiVersion)
|
||||
? `/v1/chat/completion?apiVersion=v1`
|
||||
: `/v1/responses?apiVersion=v1`
|
||||
return formatAzureOpenAIApiHost(apiHost) + path
|
||||
return formattedApiHost + path
|
||||
}
|
||||
|
||||
if (isAnthropicProvider(provider)) {
|
||||
// AI SDK uses the baseURL with /v1, then appends /messages
|
||||
// formatApiHost adds /v1 automatically if not present
|
||||
const normalizedHost = formatApiHost(apiHost)
|
||||
return normalizedHost + '/messages'
|
||||
return formattedApiHost + '/messages'
|
||||
}
|
||||
|
||||
if (isGeminiProvider(provider)) {
|
||||
return formatApiHost(apiHost, true, 'v1beta') + '/models'
|
||||
return formattedApiHost + '/models'
|
||||
}
|
||||
if (isOpenAIProvider(provider)) {
|
||||
return formatApiHost(apiHost) + '/responses'
|
||||
return formattedApiHost + '/responses'
|
||||
}
|
||||
if (isVertexProvider(provider)) {
|
||||
return formatVertexApiHost(provider) + '/publishers/google'
|
||||
return formattedApiHost + '/publishers/google'
|
||||
}
|
||||
if (isAIGatewayProvider(provider)) {
|
||||
return formatApiHost(apiHost) + '/language-model'
|
||||
return formattedApiHost + '/language-model'
|
||||
}
|
||||
return formatApiHost(apiHost)
|
||||
return formattedApiHost
|
||||
}
|
||||
|
||||
// API key 连通性检查状态指示器,目前仅在失败时显示
|
||||
@@ -494,16 +483,21 @@ const ProviderSetting: FC<Props> = ({ providerId }) => {
|
||||
{!isDmxapi && (
|
||||
<>
|
||||
<SettingSubtitle style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Tooltip title={hostSelectorTooltip} mouseEnterDelay={0.3}>
|
||||
<Selector
|
||||
size={14}
|
||||
value={activeHostField}
|
||||
onChange={(value) => setActiveHostField(value as HostField)}
|
||||
options={hostSelectorOptions}
|
||||
style={{ paddingLeft: 1, fontWeight: 'bold' }}
|
||||
placement="bottomLeft"
|
||||
/>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-1">
|
||||
<Tooltip title={hostSelectorTooltip} mouseEnterDelay={0.3}>
|
||||
<div>
|
||||
<Selector
|
||||
size={14}
|
||||
value={activeHostField}
|
||||
onChange={(value) => setActiveHostField(value as HostField)}
|
||||
options={hostSelectorOptions}
|
||||
style={{ paddingLeft: 1, fontWeight: 'bold' }}
|
||||
placement="bottomLeft"
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
<HelpTooltip title={t('settings.provider.api.url.tip')}></HelpTooltip>
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
||||
<Button
|
||||
type="text"
|
||||
|
||||
@@ -8,11 +8,11 @@ import { isDedicatedImageGenerationModel, isEmbeddingModel, isFunctionCallingMod
|
||||
import { getStoreSetting } from '@renderer/hooks/useSettings'
|
||||
import i18n from '@renderer/i18n'
|
||||
import store from '@renderer/store'
|
||||
import type { FetchChatCompletionParams } from '@renderer/types'
|
||||
import type { Assistant, MCPServer, MCPTool, Model, Provider } from '@renderer/types'
|
||||
import { type FetchChatCompletionParams, isSystemProvider } from '@renderer/types'
|
||||
import type { StreamTextParams } from '@renderer/types/aiCoreTypes'
|
||||
import { type Chunk, ChunkType } from '@renderer/types/chunk'
|
||||
import type { Message } from '@renderer/types/newMessage'
|
||||
import type { Message, ResponseError } from '@renderer/types/newMessage'
|
||||
import type { SdkModel } from '@renderer/types/sdk'
|
||||
import { removeSpecialCharactersForTopicName, uuid } from '@renderer/utils'
|
||||
import { abortCompletion, readyToAbort } from '@renderer/utils/abortController'
|
||||
@@ -22,7 +22,8 @@ import { purifyMarkdownImages } from '@renderer/utils/markdown'
|
||||
import { isPromptToolUse, isSupportedToolUse } from '@renderer/utils/mcp-tools'
|
||||
import { findFileBlocks, getMainTextContent } from '@renderer/utils/messageUtils/find'
|
||||
import { containsSupportedVariables, replacePromptVariables } from '@renderer/utils/prompt'
|
||||
import { isEmpty, takeRight } from 'lodash'
|
||||
import { NOT_SUPPORT_API_KEY_PROVIDERS } from '@renderer/utils/provider'
|
||||
import { cloneDeep, isEmpty, takeRight } from 'lodash'
|
||||
|
||||
import type { ModernAiProviderConfig } from '../aiCore/index_new'
|
||||
import AiProviderNew from '../aiCore/index_new'
|
||||
@@ -43,6 +44,8 @@ import {
|
||||
// } from './MessagesService'
|
||||
// import WebSearchService from './WebSearchService'
|
||||
|
||||
// FIXME: 这里太多重复逻辑,需要重构
|
||||
|
||||
const logger = loggerService.withContext('ApiService')
|
||||
|
||||
export async function fetchMcpTools(assistant: Assistant) {
|
||||
@@ -95,7 +98,15 @@ export async function fetchChatCompletion({
|
||||
modelId: assistant.model?.id,
|
||||
modelName: assistant.model?.name
|
||||
})
|
||||
const AI = new AiProviderNew(assistant.model || getDefaultModel())
|
||||
|
||||
// Get base provider and apply API key rotation
|
||||
const baseProvider = getProviderByModel(assistant.model || getDefaultModel())
|
||||
const providerWithRotatedKey = {
|
||||
...cloneDeep(baseProvider),
|
||||
apiKey: getRotatedApiKey(baseProvider)
|
||||
}
|
||||
|
||||
const AI = new AiProviderNew(assistant.model || getDefaultModel(), providerWithRotatedKey)
|
||||
const provider = AI.getActualProvider()
|
||||
|
||||
const mcpTools: MCPTool[] = []
|
||||
@@ -172,7 +183,13 @@ export async function fetchMessagesSummary({ messages, assistant }: { messages:
|
||||
return null
|
||||
}
|
||||
|
||||
const AI = new AiProviderNew(model)
|
||||
// Apply API key rotation
|
||||
const providerWithRotatedKey = {
|
||||
...cloneDeep(provider),
|
||||
apiKey: getRotatedApiKey(provider)
|
||||
}
|
||||
|
||||
const AI = new AiProviderNew(model, providerWithRotatedKey)
|
||||
|
||||
const topicId = messages?.find((message) => message.topicId)?.topicId || ''
|
||||
|
||||
@@ -271,7 +288,13 @@ export async function fetchNoteSummary({ content, assistant }: { content: string
|
||||
return null
|
||||
}
|
||||
|
||||
const AI = new AiProviderNew(model)
|
||||
// Apply API key rotation
|
||||
const providerWithRotatedKey = {
|
||||
...cloneDeep(provider),
|
||||
apiKey: getRotatedApiKey(provider)
|
||||
}
|
||||
|
||||
const AI = new AiProviderNew(model, providerWithRotatedKey)
|
||||
|
||||
// only 2000 char and no images
|
||||
const truncatedContent = content.substring(0, 2000)
|
||||
@@ -359,7 +382,13 @@ export async function fetchGenerate({
|
||||
return ''
|
||||
}
|
||||
|
||||
const AI = new AiProviderNew(model)
|
||||
// Apply API key rotation
|
||||
const providerWithRotatedKey = {
|
||||
...cloneDeep(provider),
|
||||
apiKey: getRotatedApiKey(provider)
|
||||
}
|
||||
|
||||
const AI = new AiProviderNew(model, providerWithRotatedKey)
|
||||
|
||||
const assistant = getDefaultAssistant()
|
||||
assistant.model = model
|
||||
@@ -404,28 +433,44 @@ export async function fetchGenerate({
|
||||
|
||||
export function hasApiKey(provider: Provider) {
|
||||
if (!provider) return false
|
||||
if (['ollama', 'lmstudio', 'vertexai', 'cherryai'].includes(provider.id)) return true
|
||||
if (isSystemProvider(provider) && NOT_SUPPORT_API_KEY_PROVIDERS.includes(provider.id)) return true
|
||||
return !isEmpty(provider.apiKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first available embedding model from enabled providers
|
||||
* 获取轮询的API key
|
||||
* 复用legacy架构的多key轮询逻辑
|
||||
*/
|
||||
// function getFirstEmbeddingModel() {
|
||||
// const providers = store.getState().llm.providers.filter((p) => p.enabled)
|
||||
function getRotatedApiKey(provider: Provider): string {
|
||||
const keys = provider.apiKey.split(',').map((key) => key.trim())
|
||||
const keyName = `provider:${provider.id}:last_used_key`
|
||||
|
||||
// for (const provider of providers) {
|
||||
// const embeddingModel = provider.models.find((model) => isEmbeddingModel(model))
|
||||
// if (embeddingModel) {
|
||||
// return embeddingModel
|
||||
// }
|
||||
// }
|
||||
if (keys.length === 1) {
|
||||
return keys[0]
|
||||
}
|
||||
|
||||
// return undefined
|
||||
// }
|
||||
const lastUsedKey = window.keyv.get(keyName)
|
||||
if (!lastUsedKey) {
|
||||
window.keyv.set(keyName, keys[0])
|
||||
return keys[0]
|
||||
}
|
||||
|
||||
const currentIndex = keys.indexOf(lastUsedKey)
|
||||
const nextIndex = (currentIndex + 1) % keys.length
|
||||
const nextKey = keys[nextIndex]
|
||||
window.keyv.set(keyName, nextKey)
|
||||
|
||||
return nextKey
|
||||
}
|
||||
|
||||
export async function fetchModels(provider: Provider): Promise<SdkModel[]> {
|
||||
const AI = new AiProviderNew(provider)
|
||||
// Apply API key rotation
|
||||
const providerWithRotatedKey = {
|
||||
...cloneDeep(provider),
|
||||
apiKey: getRotatedApiKey(provider)
|
||||
}
|
||||
|
||||
const AI = new AiProviderNew(providerWithRotatedKey)
|
||||
|
||||
try {
|
||||
return await AI.models()
|
||||
@@ -435,12 +480,7 @@ export async function fetchModels(provider: Provider): Promise<SdkModel[]> {
|
||||
}
|
||||
|
||||
export function checkApiProvider(provider: Provider): void {
|
||||
if (
|
||||
provider.id !== 'ollama' &&
|
||||
provider.id !== 'lmstudio' &&
|
||||
provider.type !== 'vertexai' &&
|
||||
provider.id !== 'copilot'
|
||||
) {
|
||||
if (isSystemProvider(provider) && !NOT_SUPPORT_API_KEY_PROVIDERS.includes(provider.id)) {
|
||||
if (!provider.apiKey) {
|
||||
window.toast.error(i18n.t('message.error.enter.api.label'))
|
||||
throw new Error(i18n.t('message.error.enter.api.label'))
|
||||
@@ -461,8 +501,7 @@ export function checkApiProvider(provider: Provider): void {
|
||||
export async function checkApi(provider: Provider, model: Model, timeout = 15000): Promise<void> {
|
||||
checkApiProvider(provider)
|
||||
|
||||
// Don't pass in provider parameter. We need auto-format URL
|
||||
const ai = new AiProviderNew(model)
|
||||
const ai = new AiProviderNew(model, provider)
|
||||
|
||||
const assistant = getDefaultAssistant()
|
||||
assistant.model = model
|
||||
@@ -476,7 +515,7 @@ export async function checkApi(provider: Provider, model: Model, timeout = 15000
|
||||
} else {
|
||||
const abortId = uuid()
|
||||
const signal = readyToAbort(abortId)
|
||||
let chunkError
|
||||
let streamError: ResponseError | undefined
|
||||
const params: StreamTextParams = {
|
||||
system: assistant.prompt,
|
||||
prompt: 'hi',
|
||||
@@ -495,19 +534,18 @@ export async function checkApi(provider: Provider, model: Model, timeout = 15000
|
||||
callType: 'check',
|
||||
onChunk: (chunk: Chunk) => {
|
||||
if (chunk.type === ChunkType.ERROR) {
|
||||
chunkError = chunk.error
|
||||
streamError = chunk.error
|
||||
} else {
|
||||
abortCompletion(abortId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try streaming check
|
||||
try {
|
||||
await ai.completions(model.id, params, config)
|
||||
} catch (e) {
|
||||
if (!isAbortError(e) && !isAbortError(chunkError)) {
|
||||
throw e
|
||||
if (!isAbortError(e) && !isAbortError(streamError)) {
|
||||
throw streamError ?? e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,6 +239,7 @@ export type ModelType = 'text' | 'vision' | 'embedding' | 'reasoning' | 'functio
|
||||
|
||||
export type ModelTag = Exclude<ModelType, 'text'> | 'free'
|
||||
|
||||
// "image-generation" is also openai endpoint, but specifically for image generation.
|
||||
export type EndpointType = 'openai' | 'openai-response' | 'anthropic' | 'gemini' | 'image-generation' | 'jina-rerank'
|
||||
|
||||
export type ModelPricing = {
|
||||
|
||||
@@ -234,6 +234,7 @@ export interface Response {
|
||||
error?: ResponseError
|
||||
}
|
||||
|
||||
// FIXME: Weak type safety. It may be a specific class instance which inherits Error in runtime.
|
||||
export type ResponseError = Record<string, any>
|
||||
|
||||
export interface MessageInputBaseParams {
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
routeToEndpoint,
|
||||
splitApiKeyString,
|
||||
validateApiHost,
|
||||
withoutTrailingApiVersion
|
||||
withoutTrailingApiVersion,
|
||||
withoutTrailingSharp
|
||||
} from '../api'
|
||||
|
||||
vi.mock('@renderer/store', () => {
|
||||
@@ -81,6 +82,27 @@ describe('api', () => {
|
||||
it('keeps host untouched when api version unsupported', () => {
|
||||
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', () => {
|
||||
@@ -404,4 +426,56 @@ describe('api', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -62,6 +62,23 @@ export function withoutTrailingSlash<T extends string>(url: T): 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.
|
||||
*
|
||||
@@ -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'`.
|
||||
*
|
||||
* @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.
|
||||
*
|
||||
* @example
|
||||
* 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'
|
||||
*/
|
||||
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 ''
|
||||
}
|
||||
|
||||
if (normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost)) {
|
||||
return normalizedHost
|
||||
const shouldAppendApiVersion = !(normalizedHost.endsWith('#') || !supportApiVersion || hasAPIVersion(normalizedHost))
|
||||
|
||||
if (shouldAppendApiVersion) {
|
||||
return `${normalizedHost}/${apiVersion}`
|
||||
} else {
|
||||
return withoutTrailingSharp(normalizedHost)
|
||||
}
|
||||
return `${normalizedHost}/${apiVersion}`
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
import { loggerService } from '@logger'
|
||||
|
||||
const logger = loggerService.withContext('utils/dom')
|
||||
|
||||
interface ChromiumScrollIntoViewOptions extends ScrollIntoViewOptions {
|
||||
/**
|
||||
* @see https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollIntoView#container
|
||||
* @see https://github.com/microsoft/TypeScript/issues/62803
|
||||
*/
|
||||
container?: 'all' | 'nearest'
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple wrapper for scrollIntoView with common default options.
|
||||
* Provides a unified interface with sensible defaults.
|
||||
@@ -5,7 +17,12 @@
|
||||
* @param element - The target element to scroll into view
|
||||
* @param options - Scroll options. If not provided, uses { behavior: 'smooth', block: 'center', inline: 'nearest' }
|
||||
*/
|
||||
export function scrollIntoView(element: HTMLElement, options?: ScrollIntoViewOptions): void {
|
||||
export function scrollIntoView(element: HTMLElement, options?: ChromiumScrollIntoViewOptions): void {
|
||||
if (!element) {
|
||||
logger.warn('[scrollIntoView] Unexpected falsy element. Do nothing as fallback.')
|
||||
return
|
||||
}
|
||||
|
||||
const defaultOptions: ScrollIntoViewOptions = {
|
||||
behavior: 'smooth',
|
||||
block: 'center',
|
||||
|
||||
@@ -183,3 +183,11 @@ export const isSupportAPIVersionProvider = (provider: Provider) => {
|
||||
}
|
||||
return provider.apiOptions?.isNotSupportAPIVersion !== false
|
||||
}
|
||||
|
||||
export const NOT_SUPPORT_API_KEY_PROVIDERS: readonly SystemProviderId[] = [
|
||||
'ollama',
|
||||
'lmstudio',
|
||||
'vertexai',
|
||||
'aws-bedrock',
|
||||
'copilot'
|
||||
]
|
||||
|
||||
@@ -254,6 +254,17 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
|
||||
let blockId: string | null = null
|
||||
let thinkingBlockId: string | null = null
|
||||
let thinkingStartTime: number | null = null
|
||||
|
||||
const resolveThinkingDuration = (duration?: number) => {
|
||||
if (typeof duration === 'number' && Number.isFinite(duration)) {
|
||||
return duration
|
||||
}
|
||||
if (thinkingStartTime !== null) {
|
||||
return Math.max(0, performance.now() - thinkingStartTime)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
setIsLoading(true)
|
||||
setIsOutputted(false)
|
||||
@@ -291,6 +302,7 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
case ChunkType.THINKING_START:
|
||||
{
|
||||
setIsOutputted(true)
|
||||
thinkingStartTime = performance.now()
|
||||
if (thinkingBlockId) {
|
||||
store.dispatch(
|
||||
updateOneBlock({ id: thinkingBlockId, changes: { status: MessageBlockStatus.STREAMING } })
|
||||
@@ -315,9 +327,13 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
{
|
||||
setIsOutputted(true)
|
||||
if (thinkingBlockId) {
|
||||
if (thinkingStartTime === null) {
|
||||
thinkingStartTime = performance.now()
|
||||
}
|
||||
const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec)
|
||||
throttledBlockUpdate(thinkingBlockId, {
|
||||
content: chunk.text,
|
||||
thinking_millsec: chunk.thinking_millsec
|
||||
thinking_millsec: thinkingDuration
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -325,14 +341,17 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
case ChunkType.THINKING_COMPLETE:
|
||||
{
|
||||
if (thinkingBlockId) {
|
||||
const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec)
|
||||
cancelThrottledBlockUpdate(thinkingBlockId)
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
id: thinkingBlockId,
|
||||
changes: { status: MessageBlockStatus.SUCCESS, thinking_millsec: chunk.thinking_millsec }
|
||||
changes: { status: MessageBlockStatus.SUCCESS, thinking_millsec: thinkingDuration }
|
||||
})
|
||||
)
|
||||
}
|
||||
thinkingStartTime = null
|
||||
thinkingBlockId = null
|
||||
}
|
||||
break
|
||||
case ChunkType.TEXT_START:
|
||||
@@ -404,6 +423,8 @@ const HomeWindow: FC<{ draggable?: boolean }> = ({ draggable = true }) => {
|
||||
if (!isAborted) {
|
||||
throw new Error(chunk.error.message)
|
||||
}
|
||||
thinkingStartTime = null
|
||||
thinkingBlockId = null
|
||||
}
|
||||
//fall through
|
||||
case ChunkType.BLOCK_COMPLETE:
|
||||
|
||||
@@ -41,8 +41,19 @@ export const processMessages = async (
|
||||
|
||||
let textBlockId: string | null = null
|
||||
let thinkingBlockId: string | null = null
|
||||
let thinkingStartTime: number | null = null
|
||||
let textBlockContent: string = ''
|
||||
|
||||
const resolveThinkingDuration = (duration?: number) => {
|
||||
if (typeof duration === 'number' && Number.isFinite(duration)) {
|
||||
return duration
|
||||
}
|
||||
if (thinkingStartTime !== null) {
|
||||
return Math.max(0, performance.now() - thinkingStartTime)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
const assistantMessage = getAssistantMessage({
|
||||
assistant,
|
||||
topic
|
||||
@@ -79,6 +90,7 @@ export const processMessages = async (
|
||||
switch (chunk.type) {
|
||||
case ChunkType.THINKING_START:
|
||||
{
|
||||
thinkingStartTime = performance.now()
|
||||
if (thinkingBlockId) {
|
||||
store.dispatch(
|
||||
updateOneBlock({ id: thinkingBlockId, changes: { status: MessageBlockStatus.STREAMING } })
|
||||
@@ -102,9 +114,13 @@ export const processMessages = async (
|
||||
case ChunkType.THINKING_DELTA:
|
||||
{
|
||||
if (thinkingBlockId) {
|
||||
if (thinkingStartTime === null) {
|
||||
thinkingStartTime = performance.now()
|
||||
}
|
||||
const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec)
|
||||
throttledBlockUpdate(thinkingBlockId, {
|
||||
content: chunk.text,
|
||||
thinking_millsec: chunk.thinking_millsec
|
||||
thinking_millsec: thinkingDuration
|
||||
})
|
||||
}
|
||||
onStream()
|
||||
@@ -113,6 +129,7 @@ export const processMessages = async (
|
||||
case ChunkType.THINKING_COMPLETE:
|
||||
{
|
||||
if (thinkingBlockId) {
|
||||
const thinkingDuration = resolveThinkingDuration(chunk.thinking_millsec)
|
||||
cancelThrottledBlockUpdate(thinkingBlockId)
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
@@ -120,12 +137,13 @@ export const processMessages = async (
|
||||
changes: {
|
||||
content: chunk.text,
|
||||
status: MessageBlockStatus.SUCCESS,
|
||||
thinking_millsec: chunk.thinking_millsec
|
||||
thinking_millsec: thinkingDuration
|
||||
}
|
||||
})
|
||||
)
|
||||
thinkingBlockId = null
|
||||
}
|
||||
thinkingStartTime = null
|
||||
}
|
||||
break
|
||||
case ChunkType.TEXT_START:
|
||||
@@ -190,6 +208,7 @@ export const processMessages = async (
|
||||
case ChunkType.ERROR:
|
||||
{
|
||||
const blockId = textBlockId || thinkingBlockId
|
||||
thinkingStartTime = null
|
||||
if (blockId) {
|
||||
store.dispatch(
|
||||
updateOneBlock({
|
||||
|
||||
@@ -284,6 +284,54 @@ describe('processMessages', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('thinking timer fallback', () => {
|
||||
it('should use local timer when thinking_millsec is missing', async () => {
|
||||
const nowValues = [1000, 1500, 2000]
|
||||
let nowIndex = 0
|
||||
const performanceSpy = vi.spyOn(performance, 'now').mockImplementation(() => {
|
||||
const value = nowValues[Math.min(nowIndex, nowValues.length - 1)]
|
||||
nowIndex += 1
|
||||
return value
|
||||
})
|
||||
|
||||
const mockChunks = [
|
||||
{ type: ChunkType.THINKING_START },
|
||||
{ type: ChunkType.THINKING_DELTA, text: 'Thinking...' },
|
||||
{ type: ChunkType.THINKING_COMPLETE, text: 'Done thinking' },
|
||||
{ type: ChunkType.TEXT_START },
|
||||
{ type: ChunkType.TEXT_COMPLETE, text: 'Final answer' },
|
||||
{ type: ChunkType.BLOCK_COMPLETE }
|
||||
]
|
||||
|
||||
vi.mocked(fetchChatCompletion).mockImplementation(async ({ onChunkReceived }: any) => {
|
||||
for (const chunk of mockChunks) {
|
||||
await onChunkReceived(chunk)
|
||||
}
|
||||
})
|
||||
|
||||
await processMessages(
|
||||
mockAssistant,
|
||||
mockTopic,
|
||||
'test prompt',
|
||||
mockSetAskId,
|
||||
mockOnStream,
|
||||
mockOnFinish,
|
||||
mockOnError
|
||||
)
|
||||
|
||||
const thinkingDeltaCall = vi.mocked(throttledBlockUpdate).mock.calls.find(([id]) => id === 'thinking-block-1')
|
||||
const deltaPayload = thinkingDeltaCall?.[1] as { thinking_millsec?: number } | undefined
|
||||
expect(deltaPayload?.thinking_millsec).toBe(500)
|
||||
|
||||
const thinkingCompleteUpdate = vi
|
||||
.mocked(updateOneBlock)
|
||||
.mock.calls.find(([payload]) => (payload as any)?.changes?.thinking_millsec !== undefined)
|
||||
expect((thinkingCompleteUpdate?.[0] as any)?.changes?.thinking_millsec).toBe(1000)
|
||||
|
||||
performanceSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
|
||||
describe('stream with exceptions', () => {
|
||||
it('should handle error chunks properly', async () => {
|
||||
const mockError = new Error('Stream processing error')
|
||||
|
||||
Reference in New Issue
Block a user