Skip to content

Commit 62b5a4a

Browse files
authored
Merge pull request #4 from echoVic/feat/thinking-block-reasoning
Feat/thinking block reasoning
2 parents 107d074 + 0323f54 commit 62b5a4a

22 files changed

Lines changed: 504 additions & 48 deletions

.blade/settings.local.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
{
22
"permissions": {
33
"allow": [
4-
"TestTool"
4+
"TestTool",
5+
"Bash(git status)",
6+
"Bash(git diff *)"
57
],
68
"ask": [],
79
"deny": []

src/agent/Agent.ts

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ export class Agent {
185185
baseUrl: modelConfig.baseUrl,
186186
temperature: modelConfig.temperature ?? this.config.temperature,
187187
maxContextTokens: modelConfig.maxContextTokens ?? this.config.maxContextTokens, // 上下文窗口(压缩判断)
188-
maxOutputTokens: this.config.maxOutputTokens, // 输出限制(API max_tokens)
188+
maxOutputTokens: modelConfig.maxOutputTokens ?? this.config.maxOutputTokens, // 输出限制(API max_tokens)
189189
timeout: this.config.timeout,
190190
});
191191

@@ -278,8 +278,8 @@ export class Agent {
278278
: await this.runLoop(enhancedMessage, context, loopOptions);
279279

280280
if (!result.success) {
281-
// 如果是用户中止,返回空字符串(不抛出异常)
282-
if (result.error?.type === 'aborted') {
281+
// 如果是用户中止或用户拒绝,返回空字符串(不抛出异常)
282+
if (result.error?.type === 'aborted' || result.metadata?.shouldExitLoop) {
283283
return ''; // 返回空字符串,让调用方自行处理
284284
}
285285
// 其他错误则抛出异常
@@ -530,7 +530,6 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl
530530
// checkAndCompactInLoop 返回是否发生了压缩
531531
// 🆕 传入上一轮 LLM 返回的真实 prompt tokens(比估算更准确)
532532
const didCompact = await this.checkAndCompactInLoop(
533-
messages,
534533
context,
535534
turnsCount,
536535
lastPromptTokens, // 首轮为 undefined,使用估算;后续轮次使用真实值
@@ -669,6 +668,11 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl
669668
logger.debug('当前权限模式:', context.permissionMode);
670669
logger.debug('================================\n');
671670

671+
// 🆕 如果 LLM 返回了 thinking 内容(DeepSeek R1 等),通知 UI
672+
if (turnResult.reasoningContent && options?.onThinking) {
673+
options.onThinking(turnResult.reasoningContent);
674+
}
675+
672676
// 🆕 如果 LLM 返回了 content,立即显示
673677
if (turnResult.content && turnResult.content.trim() && options?.onContent) {
674678
options.onContent(turnResult.content);
@@ -858,7 +862,7 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl
858862
}
859863
logger.debug('==================================\n');
860864

861-
// 🆕 检查是否应该退出循环(ExitPlanMode 返回时设置此标记
865+
// 🆕 检查是否应该退出循环(ExitPlanMode 或用户拒绝时设置此标记
862866
if (result.metadata?.shouldExitLoop) {
863867
logger.debug('🚪 检测到退出循环标记,结束 Agent 循环');
864868

@@ -1361,15 +1365,13 @@ IMPORTANT: Execute according to the approved plan above. Follow the steps exactl
13611365
* 在 Agent 循环中检查并执行压缩
13621366
* 仅使用 LLM 返回的真实 usage.promptTokens 进行判断(不再估算)
13631367
*
1364-
* @param messages - 实际发送给 LLM 的消息数组
13651368
* @param context - 聊天上下文
13661369
* @param currentTurn - 当前轮次
13671370
* @param actualPromptTokens - LLM 返回的真实 prompt tokens(必须,来自上一轮响应)
13681371
* @param onCompacting - 压缩状态回调
13691372
* @returns 是否发生了压缩
13701373
*/
13711374
private async checkAndCompactInLoop(
1372-
messages: Message[],
13731375
context: ChatContext,
13741376
currentTurn: number,
13751377
actualPromptTokens?: number,

src/agent/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,7 @@ export interface LoopResult {
169169
configuredMaxTurns?: number;
170170
actualMaxTurns?: number;
171171
hitSafetyLimit?: boolean;
172-
shouldExitLoop?: boolean; // ExitPlanMode 设置此标记以退出循环
172+
shouldExitLoop?: boolean; // ExitPlanMode 或用户拒绝时设置此标记以退出循环
173173
targetMode?: PermissionMode; // Plan 模式批准后的目标权限模式
174174
planContent?: string; // Plan 模式批准后的方案内容
175175
};

src/config/types.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,13 @@ export interface ModelConfig {
5959
// 可选:模型特定参数
6060
temperature?: number;
6161
maxContextTokens?: number; // 上下文窗口大小
62+
maxOutputTokens?: number; // 输出 token 限制
6263
topP?: number;
6364
topK?: number;
65+
66+
// Thinking 模型配置(如 DeepSeek R1)
67+
supportsThinking?: boolean; // 手动覆盖自动检测结果
68+
thinkingBudget?: number; // 思考 token 预算(可选)
6469
}
6570

6671
export interface BladeConfig {

src/services/ChatServiceInterface.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,11 +41,13 @@ export interface ChatConfig {
4141
*/
4242
export interface ChatResponse {
4343
content: string;
44+
reasoningContent?: string; // Thinking 模型的推理过程(如 DeepSeek R1)
4445
toolCalls?: ChatCompletionMessageToolCall[];
4546
usage?: {
4647
promptTokens: number;
4748
completionTokens: number;
4849
totalTokens: number;
50+
reasoningTokens?: number; // Thinking 模型消耗的推理 tokens
4951
};
5052
}
5153

@@ -54,6 +56,7 @@ export interface ChatResponse {
5456
*/
5557
export interface StreamChunk {
5658
content?: string;
59+
reasoningContent?: string; // Thinking 模型的推理过程片段
5760
// biome-ignore lint/suspicious/noExplicitAny: 不同 provider 的 tool call 类型不同
5861
toolCalls?: any[];
5962
finishReason?: string;

src/services/OpenAIChatService.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,26 @@ export class OpenAIChatService implements IChatService {
193193
(tc): tc is ChatCompletionMessageToolCall => tc.type === 'function'
194194
);
195195

196+
// 提取 reasoning_content(DeepSeek R1 等 thinking 模型的扩展字段)
197+
const extendedMessage = choice.message as typeof choice.message & {
198+
reasoning_content?: string;
199+
};
200+
const reasoningContent = extendedMessage.reasoning_content || undefined;
201+
202+
// 提取 reasoning_tokens(thinking 模型的扩展 usage 字段)
203+
const extendedUsage = completion.usage as typeof completion.usage & {
204+
reasoning_tokens?: number;
205+
};
206+
196207
const response = {
197208
content: choice.message.content || '',
209+
reasoningContent,
198210
toolCalls: toolCalls,
199211
usage: {
200212
promptTokens: completion.usage?.prompt_tokens || 0,
201213
completionTokens: completion.usage?.completion_tokens || 0,
202214
totalTokens: completion.usage?.total_tokens || 0,
215+
reasoningTokens: extendedUsage?.reasoning_tokens,
203216
},
204217
};
205218

@@ -309,6 +322,7 @@ export class OpenAIChatService implements IChatService {
309322

310323
let chunkCount = 0;
311324
let totalContent = '';
325+
let totalReasoningContent = '';
312326
let toolCallsReceived = false;
313327

314328
for await (const chunk of stream) {
@@ -326,10 +340,19 @@ export class OpenAIChatService implements IChatService {
326340
continue;
327341
}
328342

343+
// 提取 reasoning_content(DeepSeek R1 等 thinking 模型的扩展字段)
344+
const extendedDelta = delta as typeof delta & {
345+
reasoning_content?: string;
346+
};
347+
329348
if (delta.content) {
330349
totalContent += delta.content;
331350
}
332351

352+
if (extendedDelta.reasoning_content) {
353+
totalReasoningContent += extendedDelta.reasoning_content;
354+
}
355+
333356
if (delta.tool_calls && !toolCallsReceived) {
334357
toolCallsReceived = true;
335358
_logger.debug('🔧 [ChatService] Tool calls detected in stream');
@@ -341,13 +364,15 @@ export class OpenAIChatService implements IChatService {
341364
_logger.debug('📊 [ChatService] Stream summary:', {
342365
totalChunks: chunkCount,
343366
totalContentLength: totalContent.length,
367+
totalReasoningContentLength: totalReasoningContent.length,
344368
hadToolCalls: toolCallsReceived,
345369
duration: Date.now() - startTime + 'ms',
346370
});
347371
}
348372

349373
yield {
350374
content: delta.content || undefined,
375+
reasoningContent: extendedDelta.reasoning_content || undefined,
351376
toolCalls: delta.tool_calls,
352377
finishReason: finishReason || undefined,
353378
};

src/services/VersionChecker.ts

Lines changed: 50 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ const CACHE_DIR = path.join(
1818
);
1919
const CACHE_FILE = path.join(CACHE_DIR, 'version-cache.json');
2020

21-
// 缓存有效期:24 小时
22-
const CACHE_TTL = 24 * 60 * 60 * 1000;
21+
// 缓存有效期:1 小时
22+
const CACHE_TTL = 1 * 60 * 60 * 1000;
2323

2424
// npm registry URL
2525
const NPM_REGISTRY_URL = `https://registry.npmmirror.com/${PACKAGE_NAME}/latest`;
@@ -224,13 +224,58 @@ export function formatUpdateMessage(result: VersionCheckResult): string | null {
224224
}
225225

226226
/**
227-
* 启动时版本检查(后台执行,不阻塞)
227+
* 执行自动升级(后台进程,不阻塞主进程)
228+
* @returns 升级提示消息
229+
*/
230+
async function performUpgrade(
231+
currentVersion: string,
232+
latestVersion: string
233+
): Promise<string> {
234+
const { spawn } = await import('child_process');
235+
236+
try {
237+
const updateCommand = `npm install -g blade-code@${latestVersion} --registry https://registry.npmjs.org`;
238+
239+
// 使用 spawn + detached + unref 在后台运行升级
240+
// 这样主进程退出后,升级进程会继续运行完成安装
241+
const updateProcess = spawn(updateCommand, {
242+
stdio: 'ignore',
243+
shell: true,
244+
detached: true,
245+
});
246+
updateProcess.unref();
247+
248+
return `⬆️ 正在后台升级 ${currentVersion}${latestVersion},下次启动生效`;
249+
} catch {
250+
return (
251+
`\x1b[33m⚠️ Update available: ${currentVersion}${latestVersion}\x1b[0m\n` +
252+
` Run \x1b[36mnpm install -g ${PACKAGE_NAME}@latest\x1b[0m to update`
253+
);
254+
}
255+
}
256+
257+
/**
258+
* 启动时版本检查并自动升级
228259
*
229-
* @returns Promise<string | null> 更新提示消息,如果没有更新则返回 null
260+
* @param autoUpgrade - 是否自动升级(默认 true)
261+
* @returns Promise<string | null> 提示消息,如果没有更新则返回 null
230262
*/
231-
export async function checkVersionOnStartup(): Promise<string | null> {
263+
export async function checkVersionOnStartup(
264+
autoUpgrade = true
265+
): Promise<string | null> {
232266
try {
233267
const result = await checkVersion();
268+
269+
if (!result.hasUpdate || !result.latestVersion) {
270+
return null;
271+
}
272+
273+
// 自动升级
274+
if (autoUpgrade) {
275+
return await performUpgrade(result.currentVersion, result.latestVersion);
276+
}
277+
278+
// 仅显示提示
234279
return formatUpdateMessage(result);
235280
} catch {
236281
return null;

src/store/selectors/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -342,3 +342,23 @@ export const useIsModal = (modal: ActiveModal) =>
342342
*/
343343
export const useIsBusy = () =>
344344
useBladeStore((state) => state.session.isThinking || state.command.isProcessing);
345+
346+
// ==================== Thinking 模式选择器 ====================
347+
348+
/**
349+
* 获取 Thinking 模式是否启用
350+
*/
351+
export const useThinkingModeEnabled = () =>
352+
useBladeStore((state) => state.app.thinkingModeEnabled);
353+
354+
/**
355+
* 获取当前 Thinking 内容(流式接收中)
356+
*/
357+
export const useCurrentThinkingContent = () =>
358+
useBladeStore((state) => state.session.currentThinkingContent);
359+
360+
/**
361+
* 获取 Thinking 内容是否展开
362+
*/
363+
export const useThinkingExpanded = () =>
364+
useBladeStore((state) => state.session.thinkingExpanded);

src/store/slices/appSlice.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ const initialAppState: AppState = {
3232
modelEditorTarget: null,
3333
todos: [],
3434
awaitingSecondCtrlC: false,
35+
thinkingModeEnabled: false, // Thinking 模式默认关闭
3536
};
3637

3738
/**
@@ -137,5 +138,25 @@ export const createAppSlice: StateCreator<BladeStore, [], [], AppSlice> = (set)
137138
app: { ...state.app, awaitingSecondCtrlC: awaiting },
138139
}));
139140
},
141+
142+
// ==================== Thinking 模式相关 actions ====================
143+
144+
/**
145+
* 设置 Thinking 模式开关状态
146+
*/
147+
setThinkingModeEnabled: (enabled: boolean) => {
148+
set((state) => ({
149+
app: { ...state.app, thinkingModeEnabled: enabled },
150+
}));
151+
},
152+
153+
/**
154+
* 切换 Thinking 模式开关
155+
*/
156+
toggleThinkingMode: () => {
157+
set((state) => ({
158+
app: { ...state.app, thinkingModeEnabled: !state.app.thinkingModeEnabled },
159+
}));
160+
},
140161
},
141162
});

0 commit comments

Comments
 (0)