diff --git a/app/client/platforms/anthropic.ts b/app/client/platforms/anthropic.ts index fa79b24f8..11b1c6a2c 100644 --- a/app/client/platforms/anthropic.ts +++ b/app/client/platforms/anthropic.ts @@ -25,9 +25,10 @@ import { ANTHROPIC_BASE_URL } from "@/app/constant"; import { getMessageTextContent, getWebReferenceMessageTextContent, + isClaudeThinkingModel, isVisionModel, } from "@/app/utils"; -import { preProcessImageContent, stream } from "@/app/utils/chat"; +import { preProcessImageContent, streamWithThink } from "@/app/utils/chat"; import { cloudflareAIGatewayUrl } from "@/app/utils/cloudflare"; import { RequestPayload } from "./openai"; import { fetch } from "@/app/utils/stream"; @@ -62,6 +63,10 @@ export interface AnthropicChatRequest { top_k?: number; // Only sample from the top K options for each subsequent token. metadata?: object; // An object describing metadata about the request. stream?: boolean; // Whether to incrementally stream the response using server-sent events. + thinking?: { + type: "enabled"; + budget_tokens: number; + }; } export interface ChatRequest { @@ -269,10 +274,9 @@ export class ClaudeApi implements LLMApi { return res?.content?.[0]?.text; } async chat(options: ChatOptions): Promise { + const thinkingModel = isClaudeThinkingModel(options.config.model); const visionModel = isVisionModel(options.config.model); - const accessStore = useAccessStore.getState(); - const shouldStream = !!options.config.stream; const modelConfig = { @@ -376,6 +380,21 @@ export class ClaudeApi implements LLMApi { top_k: 5, }; + // extended-thinking + // https://docs.anthropic.com/zh-CN/docs/build-with-claude/extended-thinking + if ( + thinkingModel && + useChatStore.getState().currentSession().mask.claudeThinking + ) { + requestBody.thinking = { + type: "enabled", + budget_tokens: modelConfig.budget_tokens, + }; + requestBody.temperature = undefined; + requestBody.top_p = undefined; + requestBody.top_k = undefined; + } + const path = this.path(Anthropic.ChatPath); const controller = new AbortController(); @@ -390,7 +409,7 @@ export class ClaudeApi implements LLMApi { // .getAsTools( // useChatStore.getState().currentSession().mask?.plugin || [], // ); - return stream( + return streamWithThink( path, requestBody, { @@ -418,8 +437,9 @@ export class ClaudeApi implements LLMApi { name: string; }; delta?: { - type: "text_delta" | "input_json_delta"; + type: "text_delta" | "input_json_delta" | "thinking_delta"; text?: string; + thinking?: string; partial_json?: string; }; index: number; @@ -447,7 +467,24 @@ export class ClaudeApi implements LLMApi { runTools[index]["function"]["arguments"] += chunkJson?.delta?.partial_json; } - return chunkJson?.delta?.text; + + console.log("chunkJson", chunkJson); + + const isThinking = chunkJson?.delta?.type === "thinking_delta"; + const content = isThinking + ? chunkJson?.delta?.thinking + : chunkJson?.delta?.text; + + if (!content || content.trim().length === 0) { + return { + isThinking: false, + content: "", + }; + } + return { + isThinking, + content, + }; }, // processToolMessage, include tool_calls message and tool call results ( diff --git a/app/components/chat.tsx b/app/components/chat.tsx index e8e24b397..00f724b5c 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -54,6 +54,8 @@ import ReloadIcon from "../icons/reload.svg"; import HeadphoneIcon from "../icons/headphone.svg"; import SearchCloseIcon from "../icons/search_close.svg"; import SearchOpenIcon from "../icons/search_open.svg"; +import EnableThinkingIcon from "../icons/thinking_enable.svg"; +import DisableThinkingIcon from "../icons/thinking_disable.svg"; import { ChatMessage, SubmitKey, @@ -82,6 +84,7 @@ import { isSupportRAGModel, isFunctionCallModel, isFirefox, + isClaudeThinkingModel, } from "../utils"; import { uploadImage as uploadImageRemote } from "@/app/utils/chat"; @@ -511,6 +514,14 @@ export function ChatActions(props: { const pluginStore = usePluginStore(); const session = chatStore.currentSession(); + // switch thinking mode + const claudeThinking = chatStore.currentSession().mask.claudeThinking; + function switchClaudeThinking() { + chatStore.updateTargetSession(session, (session) => { + session.mask.claudeThinking = !session.mask.claudeThinking; + }); + } + // switch web search const webSearch = chatStore.currentSession().mask.webSearch; function switchWebSearch() { @@ -741,6 +752,7 @@ export function ChatActions(props: { text={currentModelName} icon={} /> + {!isFunctionCallModel(currentModel) && isEnableWebSearch && ( )} + {isClaudeThinkingModel(currentModel) && ( + : + } + /> + )} + {showModelSelector && ( + {props.modelConfig?.providerName === ServiceProvider.Anthropic && ( + + + props.updateConfig( + (config) => + (config.budget_tokens = ModalConfigValidator.budget_tokens( + e.currentTarget.valueAsNumber, + )), + ) + } + > + + )} + {props.modelConfig?.providerName == ServiceProvider.Google ? null : ( <> \ No newline at end of file diff --git a/app/icons/thinking_enable.svg b/app/icons/thinking_enable.svg new file mode 100644 index 000000000..534f2c23b --- /dev/null +++ b/app/icons/thinking_enable.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 7141c0632..2ab7ddbea 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -75,6 +75,8 @@ const cn = { UploadFle: "上传文件", OpenWebSearch: "开启联网", CloseWebSearch: "关闭联网", + EnableThinking: "开启思考", + DisableThinking: "关闭思考", }, Rename: "重命名对话", Typing: "正在输入…", @@ -545,6 +547,11 @@ const cn = { Title: "单次回复限制 (max_tokens)", SubTitle: "单次交互所用的最大 Token 数", }, + BudgetTokens: { + Title: "扩展思考预算限制 (budget_tokens)", + SubTitle: + "内部推理过程中允许使用的最大令牌数,budget_tokens 必须始终小于 max_tokens。", + }, PresencePenalty: { Title: "话题新鲜度 (presence_penalty)", SubTitle: "值越大,越有可能扩展到新话题", diff --git a/app/locales/en.ts b/app/locales/en.ts index c2468a6b8..53fa57612 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -77,6 +77,8 @@ const en: LocaleType = { UploadFle: "Upload Files", OpenWebSearch: "Enable Web Search", CloseWebSearch: "Disable Web Search", + EnableThinking: "Enable Thinking", + DisableThinking: "Disable Thinking", }, Rename: "Rename Chat", Typing: "Typing…", @@ -550,6 +552,11 @@ const en: LocaleType = { Title: "Max Tokens", SubTitle: "Maximum length of input tokens and generated tokens", }, + BudgetTokens: { + Title: "Budget Tokens", + SubTitle: + "The budget_tokens parameter determines the maximum number of tokens Claude is allowed use for its internal reasoning process. budget_tokens must always be less than the max_tokens specified.", + }, PresencePenalty: { Title: "Presence Penalty", SubTitle: diff --git a/app/store/config.ts b/app/store/config.ts index 88076f8a2..306930925 100644 --- a/app/store/config.ts +++ b/app/store/config.ts @@ -73,6 +73,7 @@ export const DEFAULT_CONFIG = { temperature: 0.5, top_p: 1, max_tokens: 4000, + budget_tokens: 1024, presence_penalty: 0, frequency_penalty: 0, sendMemory: true, @@ -170,6 +171,9 @@ export const ModalConfigValidator = { max_tokens(x: number) { return limitNumber(x, 0, 512000, 1024); }, + budget_tokens(x: number) { + return limitNumber(x, 0, 32000, 1024); + }, presence_penalty(x: number) { return limitNumber(x, -2, 2, 0); }, diff --git a/app/store/mask.ts b/app/store/mask.ts index 1cf1a7da8..867aa44df 100644 --- a/app/store/mask.ts +++ b/app/store/mask.ts @@ -19,6 +19,7 @@ export type Mask = { builtin: boolean; usePlugins?: boolean; webSearch?: boolean; + claudeThinking?: boolean; // 上游插件业务参数 plugin?: string[]; enableArtifacts?: boolean; diff --git a/app/utils.ts b/app/utils.ts index b613dc95b..a2a695339 100644 --- a/app/utils.ts +++ b/app/utils.ts @@ -401,6 +401,8 @@ export function isFunctionCallModel(modelName: string) { "claude-3-5-sonnet-20241022", "claude-3-5-sonnet-latest", "claude-3-5-haiku-latest", + "claude-3-7-sonnet-20250219", + "claude-3-7-sonnet-latest", ]; if (specialModels.some((keyword) => modelName === keyword)) return true; return DEFAULT_MODELS.filter( @@ -408,6 +410,14 @@ export function isFunctionCallModel(modelName: string) { ).some((model) => model.name === modelName); } +export function isClaudeThinkingModel(modelName: string) { + const specialModels = [ + "claude-3-7-sonnet-20250219", + "claude-3-7-sonnet-latest", + ]; + return specialModels.some((keyword) => modelName === keyword); +} + export function fetch( url: string, options?: Record,