feat(ai-chat): 思考强度移入聊天面板并按次请求下发

- 面板底部新增思考强度选择,按服务商 profile 动态选项
- ChatSendOptions 支持 thinkingIntensity,仅本次发送生效
- 从供应商表单移除思考强度配置,避免与面板控制重复
- 补齐多语言文案与相关前后端测试
This commit is contained in:
Syngnat
2026-07-09 12:17:40 +08:00
parent 4fcd7ff61f
commit bc3f76dd73
19 changed files with 314 additions and 83 deletions

View File

@@ -95,6 +95,8 @@ const renderAIChatInput = (
onComposerAction={() => {}}
onModelChange={() => {}}
onFetchModels={() => {}}
thinkingIntensity="medium"
onThinkingIntensityChange={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
@@ -128,6 +130,8 @@ const renderAIChatInputWithoutProvider = (
onComposerAction={() => {}}
onModelChange={() => {}}
onFetchModels={() => {}}
thinkingIntensity="medium"
onThinkingIntensityChange={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"

View File

@@ -70,6 +70,8 @@ const buildAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChatI
onComposerAction={() => {}}
onModelChange={() => {}}
onFetchModels={() => {}}
thinkingIntensity="medium"
onThinkingIntensityChange={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
@@ -113,6 +115,8 @@ describe('AIChatInput notice layout', () => {
onComposerAction={() => {}}
onModelChange={() => {}}
onFetchModels={() => {}}
thinkingIntensity="medium"
onThinkingIntensityChange={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
@@ -152,6 +156,8 @@ describe('AIChatInput notice layout', () => {
onComposerAction={() => {}}
onModelChange={() => {}}
onFetchModels={() => {}}
thinkingIntensity="medium"
onThinkingIntensityChange={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
@@ -185,6 +191,8 @@ describe('AIChatInput notice layout', () => {
onComposerAction={() => {}}
onModelChange={() => {}}
onFetchModels={() => {}}
thinkingIntensity="medium"
onThinkingIntensityChange={() => {}}
textareaRef={React.createRef<HTMLTextAreaElement>()}
darkMode={false}
textColor="#162033"
@@ -195,6 +203,8 @@ describe('AIChatInput notice layout', () => {
);
expect(markup).toContain('gn-v2-ai-model-select');
expect(markup).toContain('gn-v2-ai-thinking-select');
expect(markup).toContain('gn-v2-ai-model-bar');
expect(markup).not.toContain('ant-select-filled');
expect(markup).not.toContain('ant-select-show-search');
});

View File

@@ -15,7 +15,9 @@ import AIChatComposerStatus from './AIChatComposerStatus';
import AIChatComposerActions from './AIChatComposerActions';
import AIChatAttachmentStrip from './AIChatAttachmentStrip';
import AIChatContextPreview from './AIChatContextPreview';
import { noAutoCapInputProps } from '../../utils/inputAutoCap';
import AIChatProviderModelSelect from './AIChatProviderModelSelect';
import AIChatThinkingIntensitySelect from './AIChatThinkingIntensitySelect';
import { buildAIChatReadinessSnapshot } from './aiChatReadiness';
import { useAIChatContextBinding } from './useAIChatContextBinding';
import { useAIChatDraftAttachments } from './useAIChatDraftAttachments';
@@ -42,6 +44,8 @@ interface AIChatInputProps {
onComposerAction?: (actionKey: AIComposerNoticeAction) => void;
onModelChange: (val: string) => void;
onFetchModels: () => void;
thinkingIntensity: string;
onThinkingIntensityChange: (val: string) => void;
textareaRef: React.RefObject<HTMLTextAreaElement>;
darkMode: boolean;
textColor: string;
@@ -56,7 +60,8 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
input, setInput, draftAttachments, setDraftAttachments, sending, onSend, onStop, handleKeyDown,
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
sendShortcutBinding, shortcutPlatform = 'windows', composerNotice, onComposerAction,
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
onModelChange, onFetchModels, thinkingIntensity, onThinkingIntensityChange,
textareaRef, darkMode, textColor, mutedColor, overlayTheme,
contextUsageChars, maxContextChars, isV2Ui = false
}) => {
const i18n = useOptionalI18n();
@@ -248,7 +253,9 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
onKeyDown={handleKeyDown as any}
placeholder={t('ai_chat.input.placeholder', { shortcut: sendShortcutLabel })}
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
autoSize={{ minRows: 3, maxRows: 8 }}
autoComplete="off"
{...noAutoCapInputProps}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
/>
</div>
@@ -260,10 +267,11 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
display: 'flex', alignItems: 'center', gap: 4,
fontSize: 11, padding: '2px 8px', borderRadius: 12,
background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
color: overlayTheme.mutedText, cursor: 'default'
color: overlayTheme.mutedText, cursor: 'default',
minWidth: 96, maxWidth: 200, overflow: 'hidden', flex: '1 1 auto',
}}>
<DatabaseOutlined style={{ fontSize: 10 }} />
<span style={{ maxWidth: 240, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
<DatabaseOutlined style={{ fontSize: 10, flex: '0 0 auto' }} />
<span style={{ minWidth: 0, flex: '1 1 auto', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
</span>
</div>
@@ -279,6 +287,13 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
onFetchModels={onFetchModels}
/>
<AIChatThinkingIntensitySelect
activeProvider={activeProvider}
value={thinkingIntensity}
variant="legacy"
onChange={onThinkingIntensityChange}
/>
{contextUsageChars !== undefined && maxContextChars !== undefined && (
<Tooltip title={memoryTooltipLabel}>
<div style={{
@@ -401,7 +416,9 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
onKeyDown={handleKeyDown as any}
placeholder={t('ai_chat.input.placeholder_compact', { shortcut: sendShortcutLabel })}
variant="borderless"
autoSize={{ minRows: 1, maxRows: 8 }}
autoSize={{ minRows: 3, maxRows: 8 }}
autoComplete="off"
{...noAutoCapInputProps}
style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }}
/>
<AIChatComposerActions
@@ -423,16 +440,18 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
</div>
</div>
<div className="gn-v2-ai-model-bar">
{/* 单行紧凑:连接 | 模型 | 思考 | 用量;模型固定宽,不撑满 */}
{activeConnName && (
<Tooltip title={connectionTooltipLabel}>
<div className="gn-v2-ai-context-chip">
<span className="gn-v2-ai-context-live-dot" />
<DatabaseOutlined />
<span>{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}</span>
<span className="gn-v2-ai-context-chip-text">
{activeConnName}{activeContext?.dbName ? ` / ${activeContext.dbName}` : ''}
</span>
</div>
</Tooltip>
)}
<AIChatProviderModelSelect
activeProvider={activeProvider}
dynamicModels={dynamicModels}
@@ -441,16 +460,21 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
onModelChange={onModelChange}
onFetchModels={onFetchModels}
/>
<div className="gn-v2-ai-model-spacer" />
<AIChatThinkingIntensitySelect
activeProvider={activeProvider}
value={thinkingIntensity}
variant="v2"
onChange={onThinkingIntensityChange}
/>
{contextUsageChars !== undefined && maxContextChars !== undefined && (
<Tooltip title={memoryTooltipLabel}>
<div className={`gn-v2-ai-token-meter${contextUsageChars > maxContextChars * 0.8 ? ' is-warn' : ''}`}>
<span className="gn-v2-ai-token-bar" aria-hidden="true">
<span style={{ width: `${Math.min(100, (contextUsageChars / Math.max(1, maxContextChars)) * 100)}%` }} />
</span>
<span>{(contextUsageChars / 1000).toFixed(1)}k/{(maxContextChars / 1000).toFixed(0)}k</span>
<span className="gn-v2-ai-token-meter-text">
{(contextUsageChars / 1000).toFixed(1)}k/{(maxContextChars / 1000).toFixed(0)}k
</span>
</div>
</Tooltip>
)}

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Select } from 'antd';
import { DownOutlined } from '@ant-design/icons';
import { t as catalogTranslate } from '../../i18n/catalog';
import { useOptionalI18n } from '../../i18n/provider';
import type { AIProviderConfig } from '../../types';
import {
resolveThinkingIntensityOptions,
resolveThinkingIntensityProfile,
} from '../../utils/aiThinkingIntensity';
interface AIChatThinkingIntensitySelectProps {
activeProvider?: AIProviderConfig | null;
value: string;
variant: 'legacy' | 'v2';
onChange: (value: string) => void;
}
const AIChatThinkingIntensitySelect: React.FC<AIChatThinkingIntensitySelectProps> = ({
activeProvider,
value,
variant,
onChange,
}) => {
const i18n = useOptionalI18n();
const t = i18n?.t ?? ((key: string, params?: Record<string, string | number | boolean | null | undefined>) =>
catalogTranslate('en-US', key, params));
if (!activeProvider) {
return null;
}
const profile = resolveThinkingIntensityProfile({
type: activeProvider.type,
apiFormat: activeProvider.apiFormat,
baseUrl: activeProvider.baseUrl,
model: activeProvider.model,
});
const options = resolveThinkingIntensityOptions(profile).map((item) => ({
value: item.value,
label: t(item.labelKey),
}));
if (variant === 'legacy') {
return (
<Select
size="small"
variant="filled"
value={value || undefined}
onChange={onChange}
options={options}
style={{ width: 110, fontSize: 11, background: 'transparent' }}
styles={{ popup: { root: { minWidth: 160 } } }}
placeholder={t('ai_chat.input.thinking_intensity.placeholder')}
/>
);
}
return (
<Select
size="small"
value={value || undefined}
onChange={onChange}
options={options}
styles={{ popup: { root: { minWidth: 160 } } }}
placeholder={t('ai_chat.input.thinking_intensity.placeholder')}
className="gn-v2-ai-thinking-select"
suffixIcon={<DownOutlined />}
/>
);
};
export default AIChatThinkingIntensitySelect;

View File

@@ -14,12 +14,6 @@ import {
PROVIDER_PRESET_GRID_STYLE,
PROVIDER_PRESET_CARD_TITLE_STYLE,
} from '../../utils/aiSettingsPresetLayout';
import {
coerceThinkingIntensityForProfile,
resolveThinkingIntensityHintKey,
resolveThinkingIntensityOptions,
resolveThinkingIntensityProfile,
} from '../../utils/aiThinkingIntensity';
export interface AISettingsProviderPresetOption {
key: string;
@@ -126,44 +120,8 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
const currentFieldLabelStyle = fieldLabelStyle(sectionLabelColor);
const watchedModel = Form.useWatch('model', form);
const watchedModels = Form.useWatch('models', form);
const watchedBaseUrl = Form.useWatch('baseUrl', form);
const watchedType = Form.useWatch('type', form);
const watchedThinkingIntensity = Form.useWatch('thinkingIntensity', form);
const watchedInlineCompletionModel = Form.useWatch('inlineCompletionModel', form);
const presetFromForm = providerPresets.find((preset) => preset.key === presetKeyFromForm);
const thinkingProfile = React.useMemo(() => resolveThinkingIntensityProfile({
type: watchedType || editingProvider?.type,
apiFormat: watchedApiFormat || editingProvider?.apiFormat,
baseUrl: watchedBaseUrl || editingProvider?.baseUrl || presetFromForm?.defaultBaseUrl,
model: watchedModel || editingProvider?.model || presetFromForm?.defaultModel,
}), [
editingProvider?.apiFormat,
editingProvider?.baseUrl,
editingProvider?.model,
editingProvider?.type,
presetFromForm?.defaultBaseUrl,
presetFromForm?.defaultModel,
watchedApiFormat,
watchedBaseUrl,
watchedModel,
watchedType,
]);
const thinkingIntensityOptions = React.useMemo(
() => resolveThinkingIntensityOptions(thinkingProfile).map((item) => ({
value: item.value,
label: copy(item.labelKey),
})),
// copy 依赖 i18n 语言label 随语言刷新即可
// eslint-disable-next-line react-hooks/exhaustive-deps
[copy, thinkingProfile, i18n?.language],
);
React.useEffect(() => {
if (!isEditing) return;
const next = coerceThinkingIntensityForProfile(watchedThinkingIntensity, thinkingProfile);
if (next && next !== String(watchedThinkingIntensity || '').trim()) {
form.setFieldsValue({ thinkingIntensity: next });
}
}, [form, isEditing, thinkingProfile, watchedThinkingIntensity]);
const inlineCompletionModelOptions = React.useMemo(() => {
const values = [
watchedModel,
@@ -412,24 +370,6 @@ const AISettingsProvidersSection: React.FC<AISettingsProvidersSectionProps> = ({
<Form.Item name="model" hidden><Input /></Form.Item>
<Form.Item name="name" hidden><Input /></Form.Item>
<div style={{ ...currentFieldGroupStyle, marginTop: 16 }}>
<div style={currentFieldLabelStyle}>
<RobotOutlined style={{ fontSize: 14 }} /> {copy('ai_settings.form.section.thinking')}
</div>
<Form.Item
label={<span style={{ fontWeight: 500, color: overlayTheme.titleText }}>{copy('ai_settings.form.thinking_intensity')}</span>}
name="thinkingIntensity"
extra={copy(resolveThinkingIntensityHintKey(thinkingProfile))}
style={{ marginBottom: 0 }}
>
<Select
size="middle"
style={{ width: '100%' }}
options={thinkingIntensityOptions}
/>
</Form.Item>
</div>
<div style={{ ...currentFieldGroupStyle, marginTop: 16 }}>
<div style={currentFieldLabelStyle}>
<RobotOutlined style={{ fontSize: 14 }} /> {copy('ai_settings.form.section.inline_completion')}

View File

@@ -7,8 +7,26 @@ import type { AIChatToolDefinition } from '../../utils/aiToolRegistry';
import { sanitizeErrorMsg } from '../../utils/aiChatRuntime';
import { t as translateCatalog, type I18nParams } from '../../i18n';
export interface AIChatSendRequestOptions {
model?: string;
thinkingIntensity?: string;
temperature?: number;
maxTokens?: number;
}
interface AIChatService {
AIChatStreamWithOptions?: (
sid: string,
messages: any[],
tools: AIChatToolDefinition[],
options: AIChatSendRequestOptions,
) => Promise<any>;
AIChatStream?: (sid: string, messages: any[], tools: AIChatToolDefinition[]) => Promise<any>;
AIChatSendWithOptions?: (
messages: any[],
tools: AIChatToolDefinition[],
options: AIChatSendRequestOptions,
) => Promise<any>;
AIChatSendInSession?: (sid: string, messages: any[], tools: AIChatToolDefinition[]) => Promise<any>;
AIChatSend?: (messages: any[], tools: AIChatToolDefinition[]) => Promise<any>;
}
@@ -17,6 +35,7 @@ interface DispatchAIChatPayloadOptions {
sid: string;
messages: any[];
tools: AIChatToolDefinition[];
sendOptions?: AIChatSendRequestOptions;
addAIChatMessage: (sid: string, message: AIChatMessage) => void;
updateAIChatMessage?: (
sid: string,
@@ -78,6 +97,7 @@ export const dispatchAIChatPayload = async ({
sid,
messages,
tools,
sendOptions,
addAIChatMessage,
updateAIChatMessage,
setSending,
@@ -91,11 +111,48 @@ export const dispatchAIChatPayload = async ({
}: DispatchAIChatPayloadOptions): Promise<'stream' | 'send' | 'unavailable' | 'error'> => {
try {
const service = getAIChatService();
const requestOptions: AIChatSendRequestOptions = {
model: String(sendOptions?.model || '').trim() || undefined,
thinkingIntensity: String(sendOptions?.thinkingIntensity || '').trim() || undefined,
temperature: sendOptions?.temperature,
maxTokens: sendOptions?.maxTokens,
};
if (service?.AIChatStreamWithOptions) {
await service.AIChatStreamWithOptions(sid, messages, tools, requestOptions);
return 'stream';
}
if (service?.AIChatStream) {
await service.AIChatStream(sid, messages, tools);
return 'stream';
}
if (service?.AIChatSendWithOptions) {
const result = await service.AIChatSendWithOptions(messages, tools, requestOptions);
const rawError = result?.error || translate('common.unknown');
const cleanError = sanitizeErrorMsg(rawError, translate);
settleAssistantMessage({
sid,
addAIChatMessage,
updateAIChatMessage,
nextMessageId,
pendingAssistantMessageId,
patch: {
content: result?.success ? result.content : `${cleanError}`,
thinking: result?.success ? result.reasoning_content : undefined,
reasoning_content: result?.success ? result.reasoning_content : undefined,
rawError: !result?.success && cleanError !== rawError ? rawError : undefined,
jvmPlanContext,
jvmDiagnosticPlanContext,
},
});
if (result?.success) {
onNonStreamSuccess?.();
}
setSending(false);
return 'send';
}
if (service?.AIChatSendInSession || service?.AIChatSend) {
const result = service?.AIChatSendInSession
? await service.AIChatSendInSession(sid, messages, tools)