From afb11a30631b2581a5194efc33062dd3f31afb8e Mon Sep 17 00:00:00 2001 From: "huzijie.sea" Date: Fri, 19 Dec 2025 19:16:03 +0800 Subject: [PATCH 1/2] feat: add thinking block UI and model detection, enhance chat features - Add ThinkingBlock component for displaying reasoning process - Add model detection utility for provider-specific features - Enhance OpenAI service with reasoning support - Update store to manage thinking states - Improve command and input handling - Update UI components for better message display --- .blade/settings.local.json | 4 +- src/agent/Agent.ts | 5 ++ src/config/types.ts | 4 + src/services/ChatServiceInterface.ts | 3 + src/services/OpenAIChatService.ts | 25 ++++++ src/store/selectors/index.ts | 20 +++++ src/store/slices/appSlice.ts | 21 +++++ src/store/slices/sessionSlice.ts | 52 ++++++++++++- src/store/types.ts | 14 +++- src/ui/components/ChatStatusBar.tsx | 17 ++++ src/ui/components/MessageArea.tsx | 16 ++++ src/ui/components/ThinkingBlock.tsx | 111 +++++++++++++++++++++++++++ src/ui/hooks/useCommandHandler.ts | 11 +++ src/ui/hooks/useMainInput.ts | 67 +++++++++++----- src/utils/modelDetection.ts | 77 +++++++++++++++++++ 15 files changed, 423 insertions(+), 24 deletions(-) create mode 100644 src/ui/components/ThinkingBlock.tsx create mode 100644 src/utils/modelDetection.ts diff --git a/.blade/settings.local.json b/.blade/settings.local.json index 2aa0cb96..5c320e7d 100644 --- a/.blade/settings.local.json +++ b/.blade/settings.local.json @@ -1,7 +1,9 @@ { "permissions": { "allow": [ - "TestTool" + "TestTool", + "Bash(git status)", + "Bash(git diff *)" ], "ask": [], "deny": [] diff --git a/src/agent/Agent.ts b/src/agent/Agent.ts index a62bc922..1e9e34a5 100644 --- a/src/agent/Agent.ts +++ b/src/agent/Agent.ts @@ -669,6 +669,11 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl logger.debug('当前权限模式:', context.permissionMode); logger.debug('================================\n'); + // 🆕 如果 LLM 返回了 thinking 内容(DeepSeek R1 等),通知 UI + if (turnResult.reasoningContent && options?.onThinking) { + options.onThinking(turnResult.reasoningContent); + } + // 🆕 如果 LLM 返回了 content,立即显示 if (turnResult.content && turnResult.content.trim() && options?.onContent) { options.onContent(turnResult.content); diff --git a/src/config/types.ts b/src/config/types.ts index f7c2238a..925a1a87 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -61,6 +61,10 @@ export interface ModelConfig { maxContextTokens?: number; // 上下文窗口大小 topP?: number; topK?: number; + + // Thinking 模型配置(如 DeepSeek R1) + supportsThinking?: boolean; // 手动覆盖自动检测结果 + thinkingBudget?: number; // 思考 token 预算(可选) } export interface BladeConfig { diff --git a/src/services/ChatServiceInterface.ts b/src/services/ChatServiceInterface.ts index b12a21ab..b101bdcc 100644 --- a/src/services/ChatServiceInterface.ts +++ b/src/services/ChatServiceInterface.ts @@ -41,11 +41,13 @@ export interface ChatConfig { */ export interface ChatResponse { content: string; + reasoningContent?: string; // Thinking 模型的推理过程(如 DeepSeek R1) toolCalls?: ChatCompletionMessageToolCall[]; usage?: { promptTokens: number; completionTokens: number; totalTokens: number; + reasoningTokens?: number; // Thinking 模型消耗的推理 tokens }; } @@ -54,6 +56,7 @@ export interface ChatResponse { */ export interface StreamChunk { content?: string; + reasoningContent?: string; // Thinking 模型的推理过程片段 // biome-ignore lint/suspicious/noExplicitAny: 不同 provider 的 tool call 类型不同 toolCalls?: any[]; finishReason?: string; diff --git a/src/services/OpenAIChatService.ts b/src/services/OpenAIChatService.ts index 36b845da..bfa4369a 100644 --- a/src/services/OpenAIChatService.ts +++ b/src/services/OpenAIChatService.ts @@ -193,13 +193,26 @@ export class OpenAIChatService implements IChatService { (tc): tc is ChatCompletionMessageToolCall => tc.type === 'function' ); + // 提取 reasoning_content(DeepSeek R1 等 thinking 模型的扩展字段) + const extendedMessage = choice.message as typeof choice.message & { + reasoning_content?: string; + }; + const reasoningContent = extendedMessage.reasoning_content || undefined; + + // 提取 reasoning_tokens(thinking 模型的扩展 usage 字段) + const extendedUsage = completion.usage as typeof completion.usage & { + reasoning_tokens?: number; + }; + const response = { content: choice.message.content || '', + reasoningContent, toolCalls: toolCalls, usage: { promptTokens: completion.usage?.prompt_tokens || 0, completionTokens: completion.usage?.completion_tokens || 0, totalTokens: completion.usage?.total_tokens || 0, + reasoningTokens: extendedUsage?.reasoning_tokens, }, }; @@ -309,6 +322,7 @@ export class OpenAIChatService implements IChatService { let chunkCount = 0; let totalContent = ''; + let totalReasoningContent = ''; let toolCallsReceived = false; for await (const chunk of stream) { @@ -326,10 +340,19 @@ export class OpenAIChatService implements IChatService { continue; } + // 提取 reasoning_content(DeepSeek R1 等 thinking 模型的扩展字段) + const extendedDelta = delta as typeof delta & { + reasoning_content?: string; + }; + if (delta.content) { totalContent += delta.content; } + if (extendedDelta.reasoning_content) { + totalReasoningContent += extendedDelta.reasoning_content; + } + if (delta.tool_calls && !toolCallsReceived) { toolCallsReceived = true; _logger.debug('🔧 [ChatService] Tool calls detected in stream'); @@ -341,6 +364,7 @@ export class OpenAIChatService implements IChatService { _logger.debug('📊 [ChatService] Stream summary:', { totalChunks: chunkCount, totalContentLength: totalContent.length, + totalReasoningContentLength: totalReasoningContent.length, hadToolCalls: toolCallsReceived, duration: Date.now() - startTime + 'ms', }); @@ -348,6 +372,7 @@ export class OpenAIChatService implements IChatService { yield { content: delta.content || undefined, + reasoningContent: extendedDelta.reasoning_content || undefined, toolCalls: delta.tool_calls, finishReason: finishReason || undefined, }; diff --git a/src/store/selectors/index.ts b/src/store/selectors/index.ts index f5f0d0c5..5167796a 100644 --- a/src/store/selectors/index.ts +++ b/src/store/selectors/index.ts @@ -342,3 +342,23 @@ export const useIsModal = (modal: ActiveModal) => */ export const useIsBusy = () => useBladeStore((state) => state.session.isThinking || state.command.isProcessing); + +// ==================== Thinking 模式选择器 ==================== + +/** + * 获取 Thinking 模式是否启用 + */ +export const useThinkingModeEnabled = () => + useBladeStore((state) => state.app.thinkingModeEnabled); + +/** + * 获取当前 Thinking 内容(流式接收中) + */ +export const useCurrentThinkingContent = () => + useBladeStore((state) => state.session.currentThinkingContent); + +/** + * 获取 Thinking 内容是否展开 + */ +export const useThinkingExpanded = () => + useBladeStore((state) => state.session.thinkingExpanded); diff --git a/src/store/slices/appSlice.ts b/src/store/slices/appSlice.ts index 0f9cffcc..0c22527a 100644 --- a/src/store/slices/appSlice.ts +++ b/src/store/slices/appSlice.ts @@ -32,6 +32,7 @@ const initialAppState: AppState = { modelEditorTarget: null, todos: [], awaitingSecondCtrlC: false, + thinkingModeEnabled: false, // Thinking 模式默认关闭 }; /** @@ -137,5 +138,25 @@ export const createAppSlice: StateCreator = (set) app: { ...state.app, awaitingSecondCtrlC: awaiting }, })); }, + + // ==================== Thinking 模式相关 actions ==================== + + /** + * 设置 Thinking 模式开关状态 + */ + setThinkingModeEnabled: (enabled: boolean) => { + set((state) => ({ + app: { ...state.app, thinkingModeEnabled: enabled }, + })); + }, + + /** + * 切换 Thinking 模式开关 + */ + toggleThinkingMode: () => { + set((state) => ({ + app: { ...state.app, thinkingModeEnabled: !state.app.thinkingModeEnabled }, + })); + }, }, }); diff --git a/src/store/slices/sessionSlice.ts b/src/store/slices/sessionSlice.ts index a43887fe..e845096f 100644 --- a/src/store/slices/sessionSlice.ts +++ b/src/store/slices/sessionSlice.ts @@ -41,6 +41,8 @@ const initialSessionState: SessionState = { error: null, isActive: true, tokenUsage: { ...initialTokenUsage }, + currentThinkingContent: null, + thinkingExpanded: false, }; /** @@ -83,13 +85,16 @@ export const createSessionSlice: StateCreator< /** * 添加助手消息 + * @param content 消息内容 + * @param thinkingContent 可选的 thinking 内容(如 DeepSeek R1 的推理过程) */ - addAssistantMessage: (content: string) => { + addAssistantMessage: (content: string, thinkingContent?: string) => { const message: SessionMessage = { id: `assistant-${Date.now()}-${Math.random()}`, role: 'assistant', content, timestamp: Date.now(), + thinkingContent, }; get().session.actions.addMessage(message); }, @@ -208,5 +213,50 @@ export const createSessionSlice: StateCreator< }, })); }, + + // ==================== Thinking 相关 actions ==================== + + /** + * 设置当前 thinking 内容(用于流式接收) + */ + setCurrentThinkingContent: (content: string | null) => { + set((state) => ({ + session: { ...state.session, currentThinkingContent: content }, + })); + }, + + /** + * 追加 thinking 内容(用于流式接收增量) + */ + appendThinkingContent: (delta: string) => { + set((state) => ({ + session: { + ...state.session, + currentThinkingContent: + (state.session.currentThinkingContent || '') + delta, + }, + })); + }, + + /** + * 设置 thinking 内容是否展开 + */ + setThinkingExpanded: (expanded: boolean) => { + set((state) => ({ + session: { ...state.session, thinkingExpanded: expanded }, + })); + }, + + /** + * 切换 thinking 内容展开/折叠状态 + */ + toggleThinkingExpanded: () => { + set((state) => ({ + session: { + ...state.session, + thinkingExpanded: !state.session.thinkingExpanded, + }, + })); + }, }, }); diff --git a/src/store/types.ts b/src/store/types.ts index 91539799..b19d6025 100644 --- a/src/store/types.ts +++ b/src/store/types.ts @@ -40,6 +40,7 @@ export interface SessionMessage { content: string; timestamp: number; metadata?: Record | ToolMessageMetadata; + thinkingContent?: string; // Thinking 模型的推理过程内容 } /** @@ -64,6 +65,8 @@ export interface SessionState { error: string | null; isActive: boolean; tokenUsage: TokenUsage; // Token 使用量统计 + currentThinkingContent: string | null; // 当前正在接收的 thinking 内容(流式) + thinkingExpanded: boolean; // thinking 内容是否展开显示 } /** @@ -72,7 +75,7 @@ export interface SessionState { export interface SessionActions { addMessage: (message: SessionMessage) => void; addUserMessage: (content: string) => void; - addAssistantMessage: (content: string) => void; + addAssistantMessage: (content: string, thinkingContent?: string) => void; addToolMessage: (content: string, metadata?: ToolMessageMetadata) => void; setThinking: (isThinking: boolean) => void; setCompacting: (isCompacting: boolean) => void; @@ -83,6 +86,11 @@ export interface SessionActions { restoreSession: (sessionId: string, messages: SessionMessage[]) => void; updateTokenUsage: (usage: Partial) => void; resetTokenUsage: () => void; + // Thinking 相关 actions + setCurrentThinkingContent: (content: string | null) => void; + appendThinkingContent: (delta: string) => void; + setThinkingExpanded: (expanded: boolean) => void; + toggleThinkingExpanded: () => void; } /** @@ -155,6 +163,7 @@ export interface AppState { modelEditorTarget: ModelConfig | null; todos: TodoItem[]; awaitingSecondCtrlC: boolean; // 是否等待第二次 Ctrl+C 退出 + thinkingModeEnabled: boolean; // Thinking 模式是否启用(Tab 切换) } /** @@ -170,6 +179,9 @@ export interface AppActions { setTodos: (todos: TodoItem[]) => void; updateTodo: (todo: TodoItem) => void; setAwaitingSecondCtrlC: (awaiting: boolean) => void; + // Thinking 模式相关 + setThinkingModeEnabled: (enabled: boolean) => void; + toggleThinkingMode: () => void; } /** diff --git a/src/ui/components/ChatStatusBar.tsx b/src/ui/components/ChatStatusBar.tsx index 7b2fef8e..bbca78c1 100644 --- a/src/ui/components/ChatStatusBar.tsx +++ b/src/ui/components/ChatStatusBar.tsx @@ -10,7 +10,9 @@ import { useIsReady, useIsThinking, usePermissionMode, + useThinkingModeEnabled, } from '../../store/selectors/index.js'; +import { isThinkingModel } from '../../utils/modelDetection.js'; import { useGitBranch } from '../hooks/useGitBranch.js'; /** @@ -32,6 +34,10 @@ export const ChatStatusBar: React.FC = React.memo(() => { const currentModel = useCurrentModel(); const contextRemaining = useContextRemaining(); const isCompacting = useIsCompacting(); + const thinkingModeEnabled = useThinkingModeEnabled(); + + // 检查当前模型是否支持 thinking + const supportsThinking = currentModel ? isThinkingModel(currentModel) : false; // 渲染模式提示(仅非 DEFAULT 模式显示) const renderModeIndicator = () => { if (permissionMode === PermissionMode.DEFAULT) { @@ -116,6 +122,17 @@ export const ChatStatusBar: React.FC = React.memo(() => { ⚠ API 密钥未配置 ) : ( <> + {/* Thinking 模式指示器(仅当模型支持时显示) */} + {supportsThinking && ( + <> + {thinkingModeEnabled ? ( + Thinking on + ) : ( + Tab:Thinking + )} + · + + )} {currentModel && {currentModel.model}} · {isCompacting ? ( diff --git a/src/ui/components/MessageArea.tsx b/src/ui/components/MessageArea.tsx index 1d5e3957..fa29a27e 100644 --- a/src/ui/components/MessageArea.tsx +++ b/src/ui/components/MessageArea.tsx @@ -1,16 +1,19 @@ import { Box, Static } from 'ink'; import React, { ReactNode, useMemo } from 'react'; import { + useCurrentThinkingContent, useIsThinking, useMessages, usePendingCommands, useShowTodoPanel, + useThinkingExpanded, useTodos, } from '../../store/selectors/index.js'; import type { SessionMessage } from '../../store/types.js'; import { useTerminalWidth } from '../hooks/useTerminalWidth.js'; import { Header } from './Header.js'; import { MessageRenderer } from './MessageRenderer.js'; +import { ThinkingBlock } from './ThinkingBlock.js'; import { TodoPanel } from './TodoPanel.js'; /** @@ -37,6 +40,8 @@ export const MessageArea: React.FC = React.memo(() => { const todos = useTodos(); const showTodoPanel = useShowTodoPanel(); const pendingCommands = usePendingCommands(); + const currentThinkingContent = useCurrentThinkingContent(); + const thinkingExpanded = useThinkingExpanded(); // 使用 useTerminalWidth hook 获取终端宽度 const terminalWidth = useTerminalWidth(); @@ -99,6 +104,17 @@ export const MessageArea: React.FC = React.memo(() => { {/* 静态区域:Header + 已完成的消息永不重新渲染 */} {(item) => item} + {/* 流式接收的 Thinking 内容(在消息之前显示) */} + {currentThinkingContent && ( + + + + )} + {/* 动态区域:只有流式传输的消息会重新渲染 */} {streamingMessage && renderMessage(streamingMessage, completedMessages.length, true)} diff --git a/src/ui/components/ThinkingBlock.tsx b/src/ui/components/ThinkingBlock.tsx new file mode 100644 index 00000000..397c3714 --- /dev/null +++ b/src/ui/components/ThinkingBlock.tsx @@ -0,0 +1,111 @@ +/** + * ThinkingBlock 组件 + * 显示 Thinking 模型(如 DeepSeek R1)的推理过程内容 + * + * 特性: + * - 可折叠显示(默认折叠) + * - 流式接收时显示 "Thinking..." 加载状态 + * - 折叠状态显示摘要(首行前60字符) + * - Ctrl+T 快捷键控制展开/折叠(在父组件处理) + */ + +import { Box, Text } from 'ink'; +import React, { useMemo } from 'react'; +import { themeManager } from '../themes/ThemeManager.js'; + +interface ThinkingBlockProps { + /** 思考过程内容 */ + content: string; + /** 是否正在流式接收 */ + isStreaming?: boolean; + /** 是否展开显示 */ + isExpanded: boolean; + /** 展开/折叠回调(可选,用于显示提示) */ + onToggle?: () => void; +} + +/** + * 生成内容摘要 + * @param content 完整内容 + * @param maxLength 最大长度 + * @returns 摘要字符串 + */ +function generateSummary(content: string, maxLength = 60): string { + if (!content) return ''; + + // 取第一行 + const firstLine = content.split('\n')[0] || ''; + // 截断到最大长度 + if (firstLine.length <= maxLength) { + return firstLine; + } + return `${firstLine.slice(0, maxLength)}...`; +} + +/** + * 计算行数 + */ +function countLines(content: string): number { + if (!content) return 0; + return content.split('\n').length; +} + +/** + * ThinkingBlock 组件 + */ +export const ThinkingBlock: React.FC = React.memo( + ({ content, isStreaming = false, isExpanded }) => { + const theme = themeManager.getTheme(); + + const lineCount = useMemo(() => countLines(content), [content]); + const summary = useMemo(() => generateSummary(content), [content]); + + // 标题栏颜色:使用 cyan 表示思考过程 + const thinkingColor = theme.colors.info; + const mutedColor = theme.colors.muted; + + return ( + + {/* 标题栏 */} + + {/* 展开/折叠指示符 */} + {isExpanded ? '▼' : '▶'} + + + {/* 标题文本 */} + + Thinking + {isStreaming ? '...' : ` (${lineCount} lines)`} + + + {/* 折叠状态下显示摘要 */} + {!isExpanded && !isStreaming && summary && ( + - {summary} + )} + + {/* 快捷键提示 */} + + {' '} + [Ctrl+T] + + + + {/* 展开内容 */} + {isExpanded && content && ( + + {content} + + )} + + ); + } +); + +ThinkingBlock.displayName = 'ThinkingBlock'; diff --git a/src/ui/hooks/useCommandHandler.ts b/src/ui/hooks/useCommandHandler.ts index fdb5819b..6fccc654 100644 --- a/src/ui/hooks/useCommandHandler.ts +++ b/src/ui/hooks/useCommandHandler.ts @@ -216,10 +216,19 @@ export const useCommandHandler = ( }; const loopOptions = { + // LLM 推理内容(Thinking 模型如 DeepSeek R1) + onThinking: (content: string) => { + // 设置 thinking 内容(流式显示) + sessionActions.setCurrentThinkingContent(content); + }, // LLM 输出内容 onContent: (content: string) => { + // 获取当前 thinking 内容,保存到消息中 + // 注意:这里需要在 addAssistantMessage 之前获取,因为 addMessage 会清空 currentThinkingContent if (content.trim()) { sessionActions.addAssistantMessage(content); + // 清空流式 thinking 内容(已保存到消息中) + sessionActions.setCurrentThinkingContent(null); } }, // 工具调用开始 @@ -345,6 +354,8 @@ export const useCommandHandler = ( commandActions.setProcessing(false); sessionActions.setThinking(false); commandActions.clearAbortController(); + // 清理 thinking 内容(防止遗留) + sessionActions.setCurrentThinkingContent(null); // 处理队列中的下一个命令 const nextCommand = commandActions.dequeueCommand(); diff --git a/src/ui/hooks/useMainInput.ts b/src/ui/hooks/useMainInput.ts index 4385920d..15302e49 100644 --- a/src/ui/hooks/useMainInput.ts +++ b/src/ui/hooks/useMainInput.ts @@ -4,7 +4,13 @@ import { useEffect, useRef, useState } from 'react'; import { createLogger, LogCategory } from '../../logging/Logger.js'; import { getFuzzyCommandSuggestions } from '../../slash-commands/index.js'; import type { CommandSuggestion } from '../../slash-commands/types.js'; -import { useCurrentFocus, useSessionActions } from '../../store/selectors/index.js'; +import { + useAppActions, + useCurrentFocus, + useCurrentModel, + useSessionActions, +} from '../../store/selectors/index.js'; +import { isThinkingModel } from '../../utils/modelDetection.js'; import { FocusId } from '../../store/types.js'; import { applySuggestion, useAtCompletion } from './useAtCompletion.js'; import { useCtrlCHandler } from './useCtrlCHandler.js'; @@ -41,6 +47,13 @@ export const useMainInput = ( // 使用 Zustand store 的 session actions const sessionActions = useSessionActions(); + // 使用 Zustand store 的 app actions (for thinking mode toggle) + const appActions = useAppActions(); + + // 获取当前模型配置(用于检测是否支持 thinking) + const currentModel = useCurrentModel(); + const supportsThinking = currentModel ? isThinkingModel(currentModel) : false; + const [showSuggestions, setShowSuggestions] = useState(false); const [suggestions, setSuggestions] = useState([]); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(0); @@ -183,6 +196,11 @@ export const useMainInput = ( handleClear(); return; } + // Ctrl+T - 切换 thinking 内容展开/折叠 + if ((key.ctrl && inputKey === 't') || (key.meta && inputKey === 't')) { + sessionActions.toggleThinkingExpanded(); + return; + } // Esc - 关闭快捷键帮助 > 停止任务 > 隐藏建议 > 双击清空输入 if (key.escape) { if (isShortcutsModalOpen) { @@ -214,27 +232,34 @@ export const useMainInput = ( onTogglePermissionMode?.(); return; } - // Tab - 选中建议 - if (key.tab && showSuggestions && suggestions.length > 0) { - const selectedCommand = suggestions[selectedSuggestionIndex].command; - if ( - atCompletion.hasQuery && - atCompletion.suggestions.includes(selectedCommand) - ) { - const { newInput, newCursorPos } = applySuggestion( - input, - atCompletion, - selectedCommand - ); - setInput(newInput); - buffer.setCursorPosition(newCursorPos); - } else { - const newInput = selectedCommand + ' '; - setInput(newInput); - buffer.setCursorPosition(newInput.length); + // Tab - 选中建议(有建议时),或切换 thinking 模式(无建议时,且模型支持) + if (key.tab) { + if (showSuggestions && suggestions.length > 0) { + // 有建议时:选中建议 + const selectedCommand = suggestions[selectedSuggestionIndex].command; + if ( + atCompletion.hasQuery && + atCompletion.suggestions.includes(selectedCommand) + ) { + const { newInput, newCursorPos } = applySuggestion( + input, + atCompletion, + selectedCommand + ); + setInput(newInput); + buffer.setCursorPosition(newCursorPos); + } else { + const newInput = selectedCommand + ' '; + setInput(newInput); + buffer.setCursorPosition(newInput.length); + } + setShowSuggestions(false); + setSuggestions([]); + } else if (supportsThinking) { + // 无建议时:仅当模型支持 thinking 时才切换 thinking 模式 + appActions.toggleThinkingMode(); } - setShowSuggestions(false); - setSuggestions([]); + // 如果模型不支持 thinking,Tab 键无效果(静默忽略) return; } // Enter - 选中建议或提交命令 diff --git a/src/utils/modelDetection.ts b/src/utils/modelDetection.ts new file mode 100644 index 00000000..61946212 --- /dev/null +++ b/src/utils/modelDetection.ts @@ -0,0 +1,77 @@ +/** + * 模型能力检测工具 + * 用于自动检测模型是否支持特定功能(如 thinking/reasoning) + */ + +import type { ModelConfig } from '../config/types.js'; + +/** + * 支持 thinking/reasoning 的模型名称模式 + */ +const THINKING_MODEL_PATTERNS = [ + // DeepSeek + /deepseek.*r1/i, // DeepSeek R1 + /deepseek.*reasoner/i, // DeepSeek Reasoner + // OpenAI + /o1-preview/i, // OpenAI o1-preview + /o1-mini/i, // OpenAI o1-mini + /o1/i, // OpenAI o1 + // 通义千问 + /qwen.*qwq/i, // 通义千问 QwQ (thinking model) + /qwen.*think/i, // 通义千问 thinking 系列 + // Kimi (月之暗面) + /kimi.*k1/i, // Kimi k1 + /moonshot.*think/i, // Moonshot thinking 系列 + /k1-32k/i, // k1-32k-preview 等 + // 豆包 (字节跳动) + /doubao.*think/i, // 豆包 thinking 系列 + /doubao.*pro.*think/i, // doubao-pro-thinking + // Claude + /claude.*opus.*4/i, // Claude Opus 4 支持 extended thinking +]; + +/** + * 根据模型名称自动检测是否支持 thinking + * + * @param modelName 模型名称(如 "deepseek-r1", "o1-preview") + * @returns 是否支持 thinking + */ +export function detectThinkingSupport(modelName: string): boolean { + return THINKING_MODEL_PATTERNS.some((pattern) => pattern.test(modelName)); +} + +/** + * 获取模型的 thinking 配置 + * 优先使用用户手动配置,其次使用自动检测结果 + * + * @param model ModelConfig 配置 + * @returns thinking 配置 + */ +export function getThinkingConfig(model: ModelConfig): { + supportsThinking: boolean; + thinkingBudget?: number; +} { + // 用户显式配置优先 + if (model.supportsThinking !== undefined) { + return { + supportsThinking: model.supportsThinking, + thinkingBudget: model.thinkingBudget, + }; + } + + // 自动检测 + return { + supportsThinking: detectThinkingSupport(model.model), + thinkingBudget: undefined, + }; +} + +/** + * 检查当前模型是否为 thinking 模型 + * + * @param model ModelConfig 配置 + * @returns 是否为 thinking 模型 + */ +export function isThinkingModel(model: ModelConfig): boolean { + return getThinkingConfig(model).supportsThinking; +} From 0323f5423f41333e604b1eef89d1560e07effa10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9D=92=E9=9B=B2?= <137844255@qq.com> Date: Sat, 20 Dec 2025 11:36:11 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E5=A2=9E=E5=BC=BA=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E9=85=8D=E7=BD=AE=E5=92=8C=E7=89=88=E6=9C=AC=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=8D=87=E7=BA=A7=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加模型配置的 maxOutputTokens 支持并优化自动升级流程 改进用户取消操作时的提示信息 优化模型添加后的自动切换逻辑 缩短版本检查缓存时间至1小时 --- src/agent/Agent.ts | 11 ++--- src/agent/types.ts | 2 +- src/config/types.ts | 1 + src/services/VersionChecker.ts | 55 ++++++++++++++++++++++--- src/store/slices/sessionSlice.ts | 1 + src/tools/execution/PipelineStages.ts | 5 ++- src/tools/types/ExecutionTypes.ts | 3 +- src/ui/App.tsx | 8 ++-- src/ui/components/BladeInterface.tsx | 9 +++- src/ui/components/ModelConfigWizard.tsx | 5 ++- src/ui/hooks/useCommandHandler.ts | 5 ++- 11 files changed, 81 insertions(+), 24 deletions(-) diff --git a/src/agent/Agent.ts b/src/agent/Agent.ts index 1e9e34a5..d4f2b26a 100644 --- a/src/agent/Agent.ts +++ b/src/agent/Agent.ts @@ -185,7 +185,7 @@ export class Agent { baseUrl: modelConfig.baseUrl, temperature: modelConfig.temperature ?? this.config.temperature, maxContextTokens: modelConfig.maxContextTokens ?? this.config.maxContextTokens, // 上下文窗口(压缩判断) - maxOutputTokens: this.config.maxOutputTokens, // 输出限制(API max_tokens) + maxOutputTokens: modelConfig.maxOutputTokens ?? this.config.maxOutputTokens, // 输出限制(API max_tokens) timeout: this.config.timeout, }); @@ -278,8 +278,8 @@ export class Agent { : await this.runLoop(enhancedMessage, context, loopOptions); if (!result.success) { - // 如果是用户中止,返回空字符串(不抛出异常) - if (result.error?.type === 'aborted') { + // 如果是用户中止或用户拒绝,返回空字符串(不抛出异常) + if (result.error?.type === 'aborted' || result.metadata?.shouldExitLoop) { return ''; // 返回空字符串,让调用方自行处理 } // 其他错误则抛出异常 @@ -530,7 +530,6 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl // checkAndCompactInLoop 返回是否发生了压缩 // 🆕 传入上一轮 LLM 返回的真实 prompt tokens(比估算更准确) const didCompact = await this.checkAndCompactInLoop( - messages, context, turnsCount, lastPromptTokens, // 首轮为 undefined,使用估算;后续轮次使用真实值 @@ -863,7 +862,7 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl } logger.debug('==================================\n'); - // 🆕 检查是否应该退出循环(ExitPlanMode 返回时设置此标记) + // 🆕 检查是否应该退出循环(ExitPlanMode 或用户拒绝时设置此标记) if (result.metadata?.shouldExitLoop) { logger.debug('🚪 检测到退出循环标记,结束 Agent 循环'); @@ -1366,7 +1365,6 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl * 在 Agent 循环中检查并执行压缩 * 仅使用 LLM 返回的真实 usage.promptTokens 进行判断(不再估算) * - * @param messages - 实际发送给 LLM 的消息数组 * @param context - 聊天上下文 * @param currentTurn - 当前轮次 * @param actualPromptTokens - LLM 返回的真实 prompt tokens(必须,来自上一轮响应) @@ -1374,7 +1372,6 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl * @returns 是否发生了压缩 */ private async checkAndCompactInLoop( - messages: Message[], context: ChatContext, currentTurn: number, actualPromptTokens?: number, diff --git a/src/agent/types.ts b/src/agent/types.ts index 63617601..53c3c9bf 100644 --- a/src/agent/types.ts +++ b/src/agent/types.ts @@ -169,7 +169,7 @@ export interface LoopResult { configuredMaxTurns?: number; actualMaxTurns?: number; hitSafetyLimit?: boolean; - shouldExitLoop?: boolean; // ExitPlanMode 设置此标记以退出循环 + shouldExitLoop?: boolean; // ExitPlanMode 或用户拒绝时设置此标记以退出循环 targetMode?: PermissionMode; // Plan 模式批准后的目标权限模式 planContent?: string; // Plan 模式批准后的方案内容 }; diff --git a/src/config/types.ts b/src/config/types.ts index 925a1a87..4d172ee6 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -59,6 +59,7 @@ export interface ModelConfig { // 可选:模型特定参数 temperature?: number; maxContextTokens?: number; // 上下文窗口大小 + maxOutputTokens?: number; // 输出 token 限制 topP?: number; topK?: number; diff --git a/src/services/VersionChecker.ts b/src/services/VersionChecker.ts index fba28966..76ff4005 100644 --- a/src/services/VersionChecker.ts +++ b/src/services/VersionChecker.ts @@ -18,8 +18,8 @@ const CACHE_DIR = path.join( ); const CACHE_FILE = path.join(CACHE_DIR, 'version-cache.json'); -// 缓存有效期:24 小时 -const CACHE_TTL = 24 * 60 * 60 * 1000; +// 缓存有效期:1 小时 +const CACHE_TTL = 1 * 60 * 60 * 1000; // npm registry URL const NPM_REGISTRY_URL = `https://registry.npmmirror.com/${PACKAGE_NAME}/latest`; @@ -224,13 +224,58 @@ export function formatUpdateMessage(result: VersionCheckResult): string | null { } /** - * 启动时版本检查(后台执行,不阻塞) + * 执行自动升级(后台进程,不阻塞主进程) + * @returns 升级提示消息 + */ +async function performUpgrade( + currentVersion: string, + latestVersion: string +): Promise { + const { spawn } = await import('child_process'); + + try { + const updateCommand = `npm install -g blade-code@${latestVersion} --registry https://registry.npmjs.org`; + + // 使用 spawn + detached + unref 在后台运行升级 + // 这样主进程退出后,升级进程会继续运行完成安装 + const updateProcess = spawn(updateCommand, { + stdio: 'ignore', + shell: true, + detached: true, + }); + updateProcess.unref(); + + return `⬆️ 正在后台升级 ${currentVersion} → ${latestVersion},下次启动生效`; + } catch { + return ( + `\x1b[33m⚠️ Update available: ${currentVersion} → ${latestVersion}\x1b[0m\n` + + ` Run \x1b[36mnpm install -g ${PACKAGE_NAME}@latest\x1b[0m to update` + ); + } +} + +/** + * 启动时版本检查并自动升级 * - * @returns Promise 更新提示消息,如果没有更新则返回 null + * @param autoUpgrade - 是否自动升级(默认 true) + * @returns Promise 提示消息,如果没有更新则返回 null */ -export async function checkVersionOnStartup(): Promise { +export async function checkVersionOnStartup( + autoUpgrade = true +): Promise { try { const result = await checkVersion(); + + if (!result.hasUpdate || !result.latestVersion) { + return null; + } + + // 自动升级 + if (autoUpgrade) { + return await performUpgrade(result.currentVersion, result.latestVersion); + } + + // 仅显示提示 return formatUpdateMessage(result); } catch { return null; diff --git a/src/store/slices/sessionSlice.ts b/src/store/slices/sessionSlice.ts index e845096f..448bb1f4 100644 --- a/src/store/slices/sessionSlice.ts +++ b/src/store/slices/sessionSlice.ts @@ -258,5 +258,6 @@ export const createSessionSlice: StateCreator< }, })); }, + }, }); diff --git a/src/tools/execution/PipelineStages.ts b/src/tools/execution/PipelineStages.ts index 4eb201be..312baee8 100644 --- a/src/tools/execution/PipelineStages.ts +++ b/src/tools/execution/PipelineStages.ts @@ -359,7 +359,10 @@ export class ConfirmationStage implements PipelineStage { await confirmationHandler.requestConfirmation(confirmationDetails); if (!response.approved) { - execution.abort(`User rejected execution: ${response.reason || 'No reason provided'}`); + execution.abort( + `User rejected execution: ${response.reason || 'No reason provided'}`, + { shouldExitLoop: true } + ); return; } diff --git a/src/tools/types/ExecutionTypes.ts b/src/tools/types/ExecutionTypes.ts index 000aa2f3..4824981a 100644 --- a/src/tools/types/ExecutionTypes.ts +++ b/src/tools/types/ExecutionTypes.ts @@ -96,7 +96,7 @@ export class ToolExecution { return this.aborted || (this.context.signal?.aborted ?? false); } - abort(reason?: string): void { + abort(reason?: string, options?: { shouldExitLoop?: boolean }): void { this.aborted = true; this.result = { success: false, @@ -106,6 +106,7 @@ export class ToolExecution { type: ToolErrorType.EXECUTION_ERROR, message: reason || 'Execution aborted', }, + metadata: options?.shouldExitLoop ? { shouldExitLoop: true } : undefined, }; } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index c0930015..cd8f7e7e 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -126,11 +126,11 @@ export const AppWrapper: React.FC = (props) => { } } - // 8. 后台检查版本更新(不阻塞启动) - checkVersionOnStartup().then((updateMessage) => { - if (updateMessage) { + // 8. 后台检查版本更新并自动升级(不阻塞启动) + checkVersionOnStartup().then((message) => { + if (message) { console.log(''); - console.log(updateMessage); + console.log(message); console.log(''); } }); diff --git a/src/ui/components/BladeInterface.tsx b/src/ui/components/BladeInterface.tsx index e27f95ca..a0525a55 100644 --- a/src/ui/components/BladeInterface.tsx +++ b/src/ui/components/BladeInterface.tsx @@ -310,6 +310,13 @@ export const BladeInterface: React.FC = ({ appActions.showModelEditWizard(model); }); + const handleModelAddComplete = useMemoizedFn((addedConfig: SetupConfig) => { + sessionActions.addAssistantMessage( + `✅ 已添加模型配置: ${addedConfig.name},并已切换到该模型` + ); + closeModal(); + }); + const handleModelEditComplete = useMemoizedFn((updatedConfig: SetupConfig) => { sessionActions.addAssistantMessage(`✅ 已更新模型配置: ${updatedConfig.name}`); closeModal(); @@ -537,7 +544,7 @@ export const BladeInterface: React.FC = ({ onComplete={ inlineModelWizardMode === 'edit' ? handleModelEditComplete - : closeModal + : handleModelAddComplete } onCancel={closeModal} /> diff --git a/src/ui/components/ModelConfigWizard.tsx b/src/ui/components/ModelConfigWizard.tsx index 50977829..45ac7690 100644 --- a/src/ui/components/ModelConfigWizard.tsx +++ b/src/ui/components/ModelConfigWizard.tsx @@ -464,8 +464,9 @@ export const ModelConfigWizard: React.FC = ({ // setup 模式:由父组件(BladeInterface)负责创建模型 onComplete(setupConfig); } else if (mode === 'add') { - // add 模式:直接在这里创建模型,然后通知父组件关闭 - await configActions().addModel(setupConfig); + // add 模式:直接在这里创建模型,然后自动切换到新模型 + const newModel = await configActions().addModel(setupConfig); + await configActions().setCurrentModel(newModel.id); onComplete(setupConfig); } else { if (!modelId) { diff --git a/src/ui/hooks/useCommandHandler.ts b/src/ui/hooks/useCommandHandler.ts index 6fccc654..15947b91 100644 --- a/src/ui/hooks/useCommandHandler.ts +++ b/src/ui/hooks/useCommandHandler.ts @@ -290,11 +290,12 @@ export const useCommandHandler = ( const output = await agent.chat(command, chatContext, loopOptions); - // 如果返回空字符串,可能是用户中止 + // 如果返回空字符串,可能是用户取消 if (!output || output.trim() === '') { + sessionActions.addAssistantMessage('⏹ 已取消'); return { success: true, - output: '任务已停止', + output: '已取消', }; }