mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-09 16:23:27 +08:00
✨ feat(ai-chat): 思考强度移入聊天面板并按次请求下发
- 面板底部新增思考强度选择,按服务商 profile 动态选项 - ChatSendOptions 支持 thinkingIntensity,仅本次发送生效 - 从供应商表单移除思考强度配置,避免与面板控制重复 - 补齐多语言文案与相关前后端测试
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
74
frontend/src/components/ai/AIChatThinkingIntensitySelect.tsx
Normal file
74
frontend/src/components/ai/AIChatThinkingIntensitySelect.tsx
Normal 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;
|
||||
@@ -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')}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user