feat(agent): add deep thinking toggle (RFC-001)

This commit is contained in:
matevip 2026-04-12 11:29:32 +08:00
parent c60d637e33
commit 2a8b90365b
14 changed files with 206 additions and 15 deletions

View File

@ -103,20 +103,40 @@ public class AgentService {
}
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId) {
return chatStructuredStream(agentId, message, conversationId, "");
return chatStructuredStream(agentId, message, conversationId, "", null);
}
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
String requesterId) {
return chatStructuredStream(agentId, message, conversationId, requesterId, null);
}
public Flux<StreamDelta> chatStructuredStream(Long agentId, String message, String conversationId,
String requesterId, String thinkingLevel) {
memoryRecallTracker.trackRecalls(agentId, message);
BaseAgent agent = getOrBuildAgent(agentId);
// 设置请求级思考深度通过 ThreadLocal 传递到 StateGraph 执行
if (thinkingLevel != null && !thinkingLevel.isBlank()) {
ThinkingLevelHolder.set(thinkingLevel);
} else {
// 尝试从 Agent 默认配置读取
AgentEntity entity = getAgent(agentId);
if (entity != null && entity.getDefaultThinkingLevel() != null) {
ThinkingLevelHolder.set(entity.getDefaultThinkingLevel());
} else {
ThinkingLevelHolder.clear();
}
}
if (agent instanceof StructuredStreamCapable capable) {
return capable.chatStructuredStream(message, conversationId,
requesterId != null ? requesterId : "");
requesterId != null ? requesterId : "")
.doFinally(signal -> ThinkingLevelHolder.clear());
}
// 降级不支持结构化流的 Agent包装为纯内容流
ThinkingLevelHolder.clear();
return agent.chatStream(message, conversationId)
.map(chunk -> new StreamDelta(chunk, null));
}

View File

@ -0,0 +1,33 @@
package vip.mate.agent;
/**
* 请求级思考深度的 ThreadLocal 持有器
* <p>
* 用于将前端选择的思考级别从 AgentService 传递到 ReasoningNode
* 避免修改 Agent 缓存实例或 StructuredStreamCapable 接口
* <p>
* 支持的值off / low / medium / high / maxnull 表示跟随模型默认
*
* @author MateClaw Team
*/
public final class ThinkingLevelHolder {
private static final ThreadLocal<String> HOLDER = new ThreadLocal<>();
private ThinkingLevelHolder() {}
public static void set(String level) {
HOLDER.set(level);
}
/**
* 获取当前请求的思考级别null 表示未设置跟随模型默认
*/
public static String get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}

View File

@ -320,7 +320,10 @@ public class NodeStreamingChatHelper {
return;
}
thinkingAccum.append(thinkingDelta);
if (broadcast) {
// thinkingLevel=off 时不广播 thinking模型仍可能产生但前端不展示
boolean suppressThinking = "off".equalsIgnoreCase(
vip.mate.agent.ThinkingLevelHolder.get());
if (broadcast && !suppressThinking) {
broadcastDelta(conversationId, "thinking_delta", thinkingDelta);
}
}

View File

@ -17,6 +17,7 @@ import org.springframework.ai.tool.ToolCallback;
import org.springframework.util.StringUtils;
import vip.mate.agent.AgentToolSet;
import vip.mate.agent.GraphEventPublisher;
import vip.mate.agent.ThinkingLevelHolder;
import vip.mate.agent.graph.NodeStreamingChatHelper;
import vip.mate.agent.context.ConversationWindowManager;
import vip.mate.agent.context.RuntimeContextInjector;
@ -177,11 +178,16 @@ public class ReasoningNode implements NodeAction {
promptMessages.add(new UserMessage(RuntimeContextInjector.buildContextMessage(workspaceBasePath)));
promptMessages.addAll(messages);
// 请求级思考深度覆盖ThinkingLevelHolder AgentService 设置
String effectiveReasoning = resolveEffectiveReasoningEffort();
log.info("[ReasoningNode] thinkingLevel={}, effectiveReasoningEffort={}, nodeDefault={}",
ThinkingLevelHolder.get(), effectiveReasoning, this.reasoningEffort);
ChatOptions options;
if (StringUtils.hasText(reasoningEffort)) {
if (StringUtils.hasText(effectiveReasoning)) {
OpenAiChatOptions oaiOpts = OpenAiChatOptions.builder()
.toolCallbacks(toolCallbacks)
.reasoningEffort(reasoningEffort)
.reasoningEffort(effectiveReasoning)
.maxTokens(maxOutputTokens)
.build();
oaiOpts.setInternalToolExecutionEnabled(false);
@ -373,4 +379,28 @@ public class ReasoningNode implements NodeAction {
streamTracker.updatePhase(conversationId, phase);
streamTracker.broadcastObject(conversationId, "phase", GraphEventPublisher.phase(phase, extra).data());
}
/**
* 解析有效的 reasoningEffort
* 优先级ThinkingLevelHolder请求级 > 构造时的 reasoningEffortAgent/模型默认
* "off" 会清除 reasoningEffort返回 null
*/
private String resolveEffectiveReasoningEffort() {
String requestLevel = ThinkingLevelHolder.get();
if (requestLevel != null) {
if ("off".equalsIgnoreCase(requestLevel)) {
return null;
}
// thinkingLevel reasoningEffort 映射
return switch (requestLevel.toLowerCase()) {
case "low" -> "low";
case "medium" -> "medium";
case "high" -> "high";
case "max" -> "high"; // OpenAI 最高支持 high
default -> requestLevel; // 透传未知值
};
}
// 无请求级覆盖使用构造时的默认值
return this.reasoningEffort;
}
}

View File

@ -52,6 +52,9 @@ public class AgentEntity {
/** 所属工作区 ID默认 1 = default */
private Long workspaceId;
/** 默认思考深度off / low / medium / high / maxnull 表示跟随模型默认 */
private String defaultThinkingLevel;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;

View File

@ -418,7 +418,7 @@ public class ChatController {
));
streamTracker.incrementFlux(conversationId);
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username)
Disposable disposable = agentService.chatStructuredStream(agentId, promptText, conversationId, username, request.getThinkingLevel())
.doOnNext(delta -> {
if (emitterDone.get()) return;
try {
@ -922,6 +922,8 @@ public class ChatController {
private List<MessageContentPart> contentParts;
/** true 表示断线重连,不发送新消息,只附着到已有的流 */
private Boolean reconnect;
/** 思考深度off / low / medium / high / maxnull 表示跟随 Agent 默认 */
private String thinkingLevel;
}
/**

View File

@ -0,0 +1,3 @@
-- V4: Add default_thinking_level to mate_agent
-- Supports: off / low / medium / high / max (null = follow model default)
ALTER TABLE mate_agent ADD COLUMN IF NOT EXISTS default_thinking_level VARCHAR(32) DEFAULT NULL;

View File

@ -29,6 +29,7 @@ CREATE TABLE IF NOT EXISTS mate_agent (
icon VARCHAR(256),
tags VARCHAR(256),
workspace_id BIGINT NOT NULL DEFAULT 1,
default_thinking_level VARCHAR(32) DEFAULT NULL,
create_time DATETIME NOT NULL,
update_time DATETIME NOT NULL,
deleted INT NOT NULL DEFAULT 0

View File

@ -136,6 +136,18 @@
<el-icon><Paperclip /></el-icon>
</button>
<!-- 深度思考开关 -->
<button
type="button"
class="action-btn thinking-btn"
:class="{ active: thinkingEnabled }"
:disabled="disabled"
@click="emit('toggle-thinking')"
:title="thinkingEnabled ? t('chat.thinkingOn') : t('chat.thinkingOff')"
>
<el-icon><MagicStick /></el-icon>
</button>
<!-- Talk Mode 按钮 -->
<button
v-if="enableTalkMode"
@ -190,7 +202,7 @@
<script setup lang="ts">
import { ref, computed, nextTick, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { CloseBold, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue'
import { CloseBold, MagicStick, Microphone, Paperclip, Promotion, Select, Timer, WarningFilled } from '@element-plus/icons-vue'
import type { ChatAttachment, PendingApprovalMeta, StreamPhase, QueuedMessage } from '@/types'
interface Props {
@ -224,6 +236,8 @@ interface Props {
queueSize?: number
/** 是否启用 Talk Mode 按钮 */
enableTalkMode?: boolean
/** 深度思考开关状态 */
thinkingEnabled?: boolean
}
const props = withDefaults(defineProps<Props>(), {
@ -241,6 +255,7 @@ const props = withDefaults(defineProps<Props>(), {
queuedMessage: null,
queueSize: 0,
enableTalkMode: false,
thinkingEnabled: false,
})
const emit = defineEmits<{
@ -253,6 +268,7 @@ const emit = defineEmits<{
approve: [pendingId: string]
deny: [pendingId: string]
talk: []
'toggle-thinking': []
}>()
const { t } = useI18n()
@ -563,6 +579,28 @@ defineExpose({
cursor: not-allowed;
}
.thinking-btn {
position: relative;
}
.thinking-btn.active {
color: var(--el-color-primary, #409eff);
}
.thinking-btn.active::after {
content: '';
position: absolute;
bottom: 4px;
left: 50%;
transform: translateX(-50%);
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--el-color-primary, #409eff);
}
.thinking-btn:hover:not(:disabled) {
color: var(--el-color-primary, #409eff);
background: var(--el-color-primary-light-9, rgba(64, 158, 255, 0.08));
}
.talk-btn:hover:not(:disabled) {
color: var(--mc-primary, #D97757);
background: var(--mc-primary-light, rgba(217, 119, 87, 0.08));

View File

@ -21,6 +21,8 @@ export interface UseChatOptions {
baseUrl: string
/** 认证 Token */
token?: string
/** 当前思考深度(响应式 refoff 时抑制 thinking 展示 */
thinkingLevel?: import('vue').Ref<string>
/**
* done/error/stopped
* reconcile
@ -86,10 +88,13 @@ export interface SendMessageOptions {
attachments?: MessageContentPart[]
/** 消息内容 */
contentParts?: MessageContentPart[]
/** 思考深度off / low / medium / high / max */
thinkingLevel?: string
}
export function useChat(options: UseChatOptions): UseChatReturn {
const { baseUrl, token, onStreamEnd } = options
const thinkingLevelRef = options.thinkingLevel
/**
* fetch localStorage token useStream / http.ts
@ -274,10 +279,12 @@ export function useChat(options: UseChatOptions): UseChatReturn {
stream.on('thinking_delta', (data) => {
if (isStaleEvent(data)) return
// thinkingLevel=off 时抑制 thinking 展示
if (options.thinkingLevel?.value === 'off') return
if (currentAssistantId.value) {
appendMessageContent(currentAssistantId.value, data.delta || '', 'thinking')
if (streamPhase.value !== 'summarizing_observations') {
streamPhase.value = 'thinking'
streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking'
}
// 分段:追加到当前 thinking segment 或创建新的
const segs = currentSegments.value
@ -818,7 +825,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const assistantMessage = createAssistantMessage('', convId2)
;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string
streamPhase.value = 'thinking'
streamPhase.value = options.thinkingLevel?.value === 'off' ? 'streaming' : 'thinking'
phaseInfo.value = null
})
@ -932,7 +939,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
error.value = null
errorFired = false
streamConversationId = conversationId
streamPhase.value = 'thinking'
streamPhase.value = thinkingLevelRef?.value === 'off' ? 'streaming' : 'thinking'
phaseInfo.value = null
try {
@ -946,12 +953,16 @@ export function useChat(options: UseChatOptions): UseChatReturn {
currentAssistantId.value = assistantMessage.id as string
// contentParts 已由 buildOutgoingParts 包含 file entries不要重复合并 attachments
await stream.connect({
const body: Record<string, any> = {
agentId,
message: content,
conversationId,
contentParts,
})
}
if (options.thinkingLevel) {
body.thinkingLevel = options.thinkingLevel
}
await stream.connect(body)
} catch (e) {
error.value = e instanceof Error ? e : new Error(String(e))
streamPhase.value = 'idle'
@ -998,7 +1009,7 @@ export function useChat(options: UseChatOptions): UseChatReturn {
const assistantMessage = createAssistantMessage('', conversationId)
;(assistantMessage as any)._turnId = activeTurnId
currentAssistantId.value = assistantMessage.id as string
streamPhase.value = 'thinking'
streamPhase.value = thinkingLevelRef?.value === 'off' ? 'streaming' : 'thinking'
phaseInfo.value = null
await stream.connect({
agentId,

View File

@ -146,6 +146,8 @@ export default {
approvalWaiting: 'Awaiting confirmation below...',
pendingApprovalPlaceholder: 'Type /approve or /deny to respond...',
messagePlaceholder: 'Type a message... (Enter to send, Shift+Enter for new line)',
thinkingOn: 'Deep thinking enabled',
thinkingOff: 'Click to enable deep thinking',
subtitle: 'Your intelligent AI assistant powered by Spring AI Alibaba',
// Queue related
queuedSending: 'Sending queued message...',
@ -613,9 +615,18 @@ export default {
description: 'Description',
systemPrompt: 'System Prompt',
maxIterations: 'Max Iterations',
defaultThinkingLevel: 'Default Thinking Level',
tags: 'Tags',
enabled: 'Enabled',
},
thinkingLevels: {
auto: 'Follow Model',
off: 'Off',
low: 'Low',
medium: 'Standard',
high: 'Deep',
max: 'Maximum',
},
types: {
react: 'ReAct (Tool Calling)',
planExecute: 'Plan-and-Execute',

View File

@ -146,6 +146,8 @@ export default {
approvalWaiting: '等待输入框确认...',
pendingApprovalPlaceholder: '输入 /approve 或 /deny 来回复...',
messagePlaceholder: '输入消息... (Enter 发送, Shift+Enter 换行)',
thinkingOn: '深度思考已开启',
thinkingOff: '点击开启深度思考',
subtitle: '基于 Spring AI Alibaba 的智能 AI 助手',
// 排队相关
queuedSending: '正在发送排队消息...',
@ -613,9 +615,18 @@ export default {
description: '描述',
systemPrompt: '系统提示词',
maxIterations: '最大迭代次数',
defaultThinkingLevel: '默认思考深度',
tags: '标签',
enabled: '启用',
},
thinkingLevels: {
auto: '跟随模型',
off: '关闭',
low: '低',
medium: '标准',
high: '深度',
max: '极限',
},
types: {
react: 'ReAct工具调用',
planExecute: 'Plan-and-Execute',

View File

@ -185,6 +185,17 @@
<label class="form-label">{{ t('agents.fields.maxIterations') }}</label>
<input v-model.number="form.maxIterations" type="number" min="1" max="50" class="form-input" />
</div>
<div class="form-group">
<label class="form-label">{{ t('agents.fields.defaultThinkingLevel') }}</label>
<select v-model="form.defaultThinkingLevel" class="form-input">
<option :value="null">{{ t('agents.thinkingLevels.auto') }}</option>
<option value="off">{{ t('agents.thinkingLevels.off') }}</option>
<option value="low">{{ t('agents.thinkingLevels.low') }}</option>
<option value="medium">{{ t('agents.thinkingLevels.medium') }}</option>
<option value="high">{{ t('agents.thinkingLevels.high') }}</option>
<option value="max">{{ t('agents.thinkingLevels.max') }}</option>
</select>
</div>
<div class="form-group full-width">
<label class="form-label">{{ t('agents.fields.description') }}</label>
<input v-model="form.description" class="form-input" :placeholder="t('agents.placeholders.description')" />
@ -297,7 +308,7 @@ const filterTabs = [
{ key: 'agents.tabs.disabled', value: 'disabled' },
]
const defaultForm = (): Partial<Agent> & { name: string } => ({
const defaultForm = (): Partial<Agent> & { name: string; defaultThinkingLevel: string | null } => ({
name: '',
description: '',
agentType: 'react',
@ -306,6 +317,7 @@ const defaultForm = (): Partial<Agent> & { name: string } => ({
icon: '🤖',
tags: '',
enabled: true,
defaultThinkingLevel: null,
})
const form = ref(defaultForm())
@ -402,6 +414,7 @@ async function openEditModal(agent: Agent) {
icon: agent.icon || '🤖',
tags: agent.tags || '',
enabled: agent.enabled,
defaultThinkingLevel: (agent as any).defaultThinkingLevel || null,
}
modalTab.value = 'basic'
showModal.value = true

View File

@ -233,6 +233,8 @@
@approve="handleApprove"
@deny="handleDeny"
:enable-talk-mode="!!selectedAgentId"
:thinking-enabled="thinkingEnabled"
@toggle-thinking="thinkingEnabled = !thinkingEnabled"
@talk="showTalkMode = true"
/>
</div>
@ -345,6 +347,11 @@ const activeModels = ref<ActiveModelsInfo | null>(null)
const pendingAttachments = ref<ChatAttachment[]>([])
const uploadingAttachment = ref(false)
//
const thinkingEnabled = ref(localStorage.getItem('mateclaw_thinking') !== 'off')
const thinkingLevel = computed(() => thinkingEnabled.value ? 'high' : 'off')
watch(thinkingEnabled, (v) => localStorage.setItem('mateclaw_thinking', v ? 'on' : 'off'))
// Dropdowns & menus
const agentDropdownOpen = ref(false)
@ -550,6 +557,7 @@ const {
resetForNewConversation,
} = useChat({
baseUrl: '',
thinkingLevel,
onStreamEnd: async (meta) => {
// lastActiveTime /
await loadConversations()
@ -1040,6 +1048,7 @@ async function handleSendMessage(content: string) {
conversationId: currentConversationId.value,
agentId: selectedAgentId.value,
contentParts,
thinkingLevel: thinkingLevel.value,
attachments: outgoingAttachments.map(a => ({
type: 'file' as const,
fileUrl: a.url,
@ -1220,7 +1229,10 @@ function normalizeMessage(raw: Message): Message {
if (msg.contentParts.length === 0 && msg.content) {
if (msg.role === 'assistant') {
const parsed = parseThinkingContent(msg.content)
if (parsed.thinking) msg.contentParts.push({ type: 'thinking', text: parsed.thinking })
// thinkingLevel=off thinking <think>
if (parsed.thinking && thinkingLevel.value !== 'off') {
msg.contentParts.push({ type: 'thinking', text: parsed.thinking })
}
if (parsed.content) msg.contentParts.push({ type: 'text', text: parsed.content })
msg.content = parsed.content
} else {