mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-06-08 15:39:51 +08:00
🐛 fix(ai/provider/chat-ui): 修复AI供应商兼容性并优化聊天提示交互
- 修复通义千问百炼 Anthropic 兼容鉴权头与健康检查请求 - 拆分通义千问百炼通用与 Coding Plan 双入口,调整预设回填与模型策略 - 修复火山 Coding Plan 模型列表过滤逻辑,避免混入无关模型 - 统一 OpenAI 兼容供应商路径与模型列表处理,补充相关服务层测试 - 优化 AI 设置供应商卡片布局,统一高度并收紧文本展示 - 将聊天区模型校验提示改为输入框上方的内联提示卡,补充前端回归测试
This commit is contained in:
@@ -6,7 +6,6 @@ import { DBGetDatabases, DBGetTables } from '../../wailsjs/go/app/App';
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
import { AIChatMessage, AIToolCall } from '../types';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
import { message as antdMessage } from 'antd';
|
||||
import './AIChatPanel.css';
|
||||
|
||||
import { AIChatHeader } from './ai/AIChatHeader';
|
||||
@@ -14,6 +13,12 @@ import { AIChatWelcome } from './ai/AIChatWelcome';
|
||||
import { AIMessageBubble } from './ai/AIMessageBubble';
|
||||
import { AIChatInput } from './ai/AIChatInput';
|
||||
import { AIHistoryDrawer } from './ai/AIHistoryDrawer';
|
||||
import type { AIComposerNotice } from '../utils/aiComposerNotice';
|
||||
import {
|
||||
buildMissingModelNotice,
|
||||
buildMissingProviderNotice,
|
||||
buildModelFetchFailedNotice,
|
||||
} from '../utils/aiComposerNotice';
|
||||
|
||||
interface AIChatPanelProps {
|
||||
width?: number;
|
||||
@@ -211,6 +216,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const [dynamicModels, setDynamicModels] = useState<string[]>([]);
|
||||
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
||||
const [loadingModels, setLoadingModels] = useState(false);
|
||||
const [composerNotice, setComposerNotice] = useState<AIComposerNotice | null>(null);
|
||||
const [panelWidth, setPanelWidth] = useState(width);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
@@ -224,9 +230,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const panelRef = useRef<HTMLDivElement>(null); // 面板 DOM ref,用于拖拽时直接操作宽度
|
||||
const dragWidthRef = useRef(0); // 拖拽过程中的实时宽度(不触发 React 重渲染)
|
||||
|
||||
// 面板内部 toast 通知(不在屏幕顶部,而在面板容器内显示)
|
||||
const [messageApi, messageContextHolder] = antdMessage.useMessage({ getContainer: () => panelRef.current || document.body });
|
||||
|
||||
const aiChatHistory = useStore(state => state.aiChatHistory);
|
||||
const aiActiveSessionId = useStore(state => state.aiActiveSessionId);
|
||||
const createNewAISession = useStore(state => state.createNewAISession);
|
||||
@@ -336,6 +339,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setDynamicModels([]);
|
||||
setComposerNotice(null);
|
||||
activeProviderIdRef.current = null;
|
||||
loadActiveProvider();
|
||||
};
|
||||
@@ -350,6 +354,7 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
const payload = { ...activeProvider, model: val };
|
||||
await Service?.AISaveProvider?.(payload);
|
||||
setActiveProvider(payload);
|
||||
setComposerNotice(null);
|
||||
} catch (e) { console.warn('Failed to update provider model', e); }
|
||||
};
|
||||
|
||||
@@ -358,33 +363,45 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
useEffect(() => {
|
||||
if (activeProvider?.id && activeProvider.id !== activeProviderIdRef.current) {
|
||||
setDynamicModels([]);
|
||||
setComposerNotice(null);
|
||||
activeProviderIdRef.current = activeProvider.id;
|
||||
}
|
||||
// 供应商被删除后 activeProvider 变为 null,此时也必须清空残留模型
|
||||
if (!activeProvider) {
|
||||
setDynamicModels([]);
|
||||
setComposerNotice(null);
|
||||
activeProviderIdRef.current = null;
|
||||
}
|
||||
}, [activeProvider?.id, activeProvider]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeProvider?.model && String(activeProvider.model).trim()) {
|
||||
setComposerNotice(null);
|
||||
}
|
||||
}, [activeProvider?.model]);
|
||||
|
||||
|
||||
// dynamicModels 仅在内存中使用,不再写回供应商配置,避免污染静态 models 列表
|
||||
|
||||
const fetchDynamicModels = useCallback(async () => {
|
||||
try {
|
||||
setLoadingModels(true);
|
||||
setComposerNotice(null);
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
if (!Service) return;
|
||||
const result = await Service.AIListModels?.();
|
||||
if (result?.success && Array.isArray(result.models) && result.models.length > 0) {
|
||||
const sortedModels = [...result.models].sort((a, b) => a.localeCompare(b));
|
||||
setDynamicModels(sortedModels);
|
||||
setComposerNotice(null);
|
||||
} else if (result && !result.success) {
|
||||
messageApi.warning(result.error || '获取模型列表失败,可手动输入模型名称');
|
||||
setDynamicModels([]);
|
||||
setComposerNotice(buildModelFetchFailedNotice(result.error));
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.warn('Failed to fetch models', e);
|
||||
messageApi.warning('获取模型列表失败: ' + (e?.message || '未知错误'));
|
||||
setDynamicModels([]);
|
||||
setComposerNotice(buildModelFetchFailedNotice('获取模型列表失败:' + (e?.message || '未知错误')));
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
}
|
||||
@@ -1030,13 +1047,14 @@ SELECT * FROM users WHERE status = 1;
|
||||
|
||||
// 前置校验:必须配置供应商且选择模型后才能发送
|
||||
if (!activeProvider) {
|
||||
messageApi.warning('请先在 AI 设置中配置供应商');
|
||||
setComposerNotice(buildMissingProviderNotice());
|
||||
return;
|
||||
}
|
||||
if (!activeProvider.model || !activeProvider.model.trim()) {
|
||||
messageApi.warning('请先选择模型 ID(点击工具栏的模型下拉框选择)');
|
||||
setComposerNotice(buildMissingModelNotice());
|
||||
return;
|
||||
}
|
||||
setComposerNotice(null);
|
||||
|
||||
toolCallRoundRef.current = 0; // 重置工具调用轮次计数
|
||||
nudgeCountRef.current = 0; // 重置催促计数
|
||||
@@ -1258,7 +1276,6 @@ SELECT * FROM users WHERE status = 1;
|
||||
|
||||
return (
|
||||
<div ref={panelRef} className="ai-chat-panel" style={{ width: panelWidth, background: bgColor || 'transparent', color: textColor, borderLeft: overlayTheme.shellBorder, position: 'relative' }}>
|
||||
{messageContextHolder}
|
||||
<div className={`ai-resize-handle${isResizing ? ' active' : ''}`} onMouseDown={handleResizeStart} />
|
||||
|
||||
{isResizing && panelRect.current && createPortal(
|
||||
@@ -1366,6 +1383,7 @@ SELECT * FROM users WHERE status = 1;
|
||||
activeProvider={activeProvider}
|
||||
dynamicModels={dynamicModels}
|
||||
loadingModels={loadingModels}
|
||||
composerNotice={composerNotice}
|
||||
onModelChange={handleModelChange}
|
||||
onFetchModels={fetchDynamicModels}
|
||||
textareaRef={textareaRef}
|
||||
|
||||
@@ -2,6 +2,23 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Modal, Button, Input, Select, Form, message as antdMessage, Tooltip, Tabs, Space, Popconfirm, Slider } from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, EditOutlined, CheckOutlined, ApiOutlined, SafetyCertificateOutlined, RobotOutlined, ThunderboltOutlined, CloudOutlined, ExperimentOutlined, KeyOutlined, LinkOutlined, AppstoreOutlined, ToolOutlined } from '@ant-design/icons';
|
||||
import type { AIProviderConfig, AIProviderType, AISafetyLevel, AIContextLevel } from '../types';
|
||||
import {
|
||||
getProviderFingerprint,
|
||||
getProviderHostname,
|
||||
matchQwenPresetKey,
|
||||
QWEN_BAILIAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_ANTHROPIC_BASE_URL,
|
||||
QWEN_CODING_PLAN_MODELS,
|
||||
resolvePresetBaseURL,
|
||||
resolvePresetModelSelection,
|
||||
} from '../utils/aiProviderPresets';
|
||||
import {
|
||||
PROVIDER_PRESET_CARD_BASE_STYLE,
|
||||
PROVIDER_PRESET_CARD_CONTENT_STYLE,
|
||||
PROVIDER_PRESET_CARD_DESCRIPTION_STYLE,
|
||||
PROVIDER_PRESET_GRID_STYLE,
|
||||
PROVIDER_PRESET_CARD_TITLE_STYLE,
|
||||
} from '../utils/aiSettingsPresetLayout';
|
||||
|
||||
import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme';
|
||||
|
||||
@@ -28,7 +45,8 @@ interface ProviderPreset {
|
||||
const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
{ key: 'openai', label: 'OpenAI', icon: <ApiOutlined />, desc: 'GPT-5.4 / 5.3 系列', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] },
|
||||
{ key: 'deepseek', label: 'DeepSeek', icon: <ThunderboltOutlined />, desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] },
|
||||
{ key: 'qwen', label: '通义千问', icon: <CloudOutlined />, desc: 'Qwen3.5 / Qwen3 系列', color: '#6366f1', backendType: 'openai', defaultBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1', defaultModel: 'qwen-max', models: [] },
|
||||
{ key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: <CloudOutlined />, desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] },
|
||||
{ key: 'qwen-coding-plan', label: '通义千问(Coding Plan)', icon: <CloudOutlined />, desc: 'Coding Plan 专属入口 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'anthropic', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS },
|
||||
{ key: 'zhipu', label: '智谱 GLM', icon: <ExperimentOutlined />, desc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] },
|
||||
{ key: 'moonshot', label: 'Kimi', icon: <ExperimentOutlined />, desc: 'Kimi K2.5 (Anthropic 兼容)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] },
|
||||
{ key: 'anthropic', label: 'Claude', icon: <ExperimentOutlined />, desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] },
|
||||
@@ -42,27 +60,11 @@ const PROVIDER_PRESETS: ProviderPreset[] = [
|
||||
|
||||
const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1];
|
||||
|
||||
const getProviderHostname = (raw?: string): string => {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
return new URL(raw).hostname.toLowerCase();
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const getProviderFingerprint = (raw?: string): string => {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
const normalizedPath = url.pathname.replace(/\/+$/, '').toLowerCase();
|
||||
return `${url.hostname.toLowerCase()}${normalizedPath}`;
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
const matchProviderPreset = (provider: Pick<AIProviderConfig, 'type' | 'baseUrl'>): ProviderPreset => {
|
||||
const qwenPresetKey = matchQwenPresetKey(provider);
|
||||
if (qwenPresetKey) {
|
||||
return findPreset(qwenPresetKey);
|
||||
}
|
||||
const fingerprint = getProviderFingerprint(provider.baseUrl);
|
||||
const exactPreset = PROVIDER_PRESETS.find(pr =>
|
||||
pr.backendType === provider.type
|
||||
@@ -201,15 +203,23 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
|
||||
// 构建 payload,处理 model/models 逻辑
|
||||
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
|
||||
const preset = findPreset(values.presetKey);
|
||||
const resolvedModels = isCustomLike ? (values.models || []) : preset.models;
|
||||
const fallbackModel = resolvedModels.length > 0 ? resolvedModels[0] : '';
|
||||
const finalModel = isCustomLike ? fallbackModel : (values.model || fallbackModel);
|
||||
const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama';
|
||||
const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({
|
||||
presetKey: values.presetKey,
|
||||
presetDefaultModel: preset.defaultModel,
|
||||
presetModels: preset.models,
|
||||
valuesModel: values.model,
|
||||
customModels: values.models,
|
||||
});
|
||||
// 内置供应商自动使用 preset label 作为名称
|
||||
const finalName = isCustomLike ? (values.name || preset.label) : preset.label;
|
||||
|
||||
const finalBaseUrl = values.baseUrl || preset.defaultBaseUrl;
|
||||
const finalBaseUrl = resolvePresetBaseURL({
|
||||
presetKey: values.presetKey,
|
||||
presetDefaultBaseUrl: preset.defaultBaseUrl,
|
||||
valuesBaseUrl: values.baseUrl,
|
||||
});
|
||||
|
||||
const payload = {
|
||||
...editingProvider,
|
||||
@@ -262,7 +272,11 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
setTestStatus('idle');
|
||||
const Service = (window as any).go?.aiservice?.Service;
|
||||
const preset = findPreset(values.presetKey || 'openai');
|
||||
const finalBaseUrl = values.baseUrl || preset.defaultBaseUrl;
|
||||
const finalBaseUrl = resolvePresetBaseURL({
|
||||
presetKey: values.presetKey || 'openai',
|
||||
presetDefaultBaseUrl: preset.defaultBaseUrl,
|
||||
valuesBaseUrl: values.baseUrl,
|
||||
});
|
||||
const res = await Service?.AITestProvider?.({ ...values, baseUrl: finalBaseUrl, maxTokens: Number(values.maxTokens) || 4096, temperature: Number(values.temperature) ?? 0.7 });
|
||||
if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); }
|
||||
else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); }
|
||||
@@ -329,7 +343,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, display: 'flex', alignItems: 'center', gap: 6 }}>
|
||||
<span>{matchedPreset.label}</span>
|
||||
<span style={{ opacity: 0.4 }}>·</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{p.model}</span>
|
||||
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{p.model || '未选择模型'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Space size={2}>
|
||||
@@ -375,25 +389,24 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
<AppstoreOutlined style={{ fontSize: 14 }} /> 服务类型
|
||||
</div>
|
||||
<Form.Item name="presetKey" noStyle>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: 6 }}>
|
||||
<div style={PROVIDER_PRESET_GRID_STYLE}>
|
||||
{PROVIDER_PRESETS.map(pt => (
|
||||
<div key={pt.key} onClick={() => { form.setFieldValue('presetKey', pt.key); handlePresetChange(pt.key); }}
|
||||
style={{
|
||||
padding: '12px 14px', borderRadius: 12, cursor: 'pointer', transition: 'all 0.2s ease',
|
||||
...PROVIDER_PRESET_CARD_BASE_STYLE,
|
||||
border: `1.5px solid ${presetKeyFromForm === pt.key ? overlayTheme.selectedText : 'transparent'}`,
|
||||
background: presetKeyFromForm === pt.key ? overlayTheme.selectedBg : (darkMode ? 'rgba(255,255,255,0.02)' : 'rgba(255,255,255,0.72)'),
|
||||
boxShadow: presetKeyFromForm === pt.key ? 'none' : (darkMode ? 'inset 0 0 0 1px rgba(255,255,255,0.028)' : 'inset 0 0 0 1px rgba(16,24,40,0.03)'),
|
||||
display: 'flex', alignItems: 'flex-start', gap: 10,
|
||||
}}>
|
||||
<div style={{
|
||||
color: presetKeyFromForm === pt.key ? overlayTheme.iconColor : overlayTheme.mutedText,
|
||||
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease',
|
||||
fontSize: 18, marginTop: 2, transition: 'all 0.2s ease', flexShrink: 0,
|
||||
}}>
|
||||
{pt.icon}
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.3 }}>{pt.label}</div>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, marginTop: 4, lineHeight: 1.4 }}>{pt.desc}</div>
|
||||
<div style={PROVIDER_PRESET_CARD_CONTENT_STYLE}>
|
||||
<div style={{ ...PROVIDER_PRESET_CARD_TITLE_STYLE, fontSize: 13, fontWeight: 700, color: overlayTheme.titleText, lineHeight: 1.3 }}>{pt.label}</div>
|
||||
<div style={{ ...PROVIDER_PRESET_CARD_DESCRIPTION_STYLE, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.4 }}>{pt.desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
61
frontend/src/components/ai/AIChatInput.notice.test.tsx
Normal file
61
frontend/src/components/ai/AIChatInput.notice.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import React from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
useStore: (selector: (state: any) => any) => selector({
|
||||
aiContexts: {},
|
||||
addAIContext: vi.fn(),
|
||||
removeAIContext: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../../wailsjs/go/app/App', () => ({
|
||||
DBGetTables: vi.fn(),
|
||||
DBShowCreateTable: vi.fn(),
|
||||
DBGetDatabases: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('AIChatInput notice layout', () => {
|
||||
it('renders the composer notice above the input editor', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIChatInput
|
||||
input=""
|
||||
setInput={() => {}}
|
||||
draftImages={[]}
|
||||
setDraftImages={() => {}}
|
||||
sending={false}
|
||||
onSend={() => {}}
|
||||
onStop={() => {}}
|
||||
handleKeyDown={() => {}}
|
||||
activeConnName=""
|
||||
activeContext={null}
|
||||
activeProvider={{ model: '', models: [] }}
|
||||
dynamicModels={[]}
|
||||
loadingModels={false}
|
||||
composerNotice={{
|
||||
tone: 'error',
|
||||
title: '模型列表加载失败',
|
||||
description: '请检查供应商入口和 API Key。',
|
||||
}}
|
||||
onModelChange={() => {}}
|
||||
onFetchModels={() => {}}
|
||||
textareaRef={React.createRef<HTMLTextAreaElement>()}
|
||||
darkMode={false}
|
||||
textColor="#162033"
|
||||
mutedColor="rgba(16,24,40,0.55)"
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
/>
|
||||
);
|
||||
|
||||
const noticeIndex = markup.indexOf('data-ai-chat-composer-notice="true"');
|
||||
const inputIndex = markup.indexOf('data-ai-chat-composer-input="true"');
|
||||
|
||||
expect(noticeIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(inputIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(noticeIndex).toBeLessThan(inputIndex);
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,10 @@
|
||||
import React from 'react';
|
||||
import { Input, Select, AutoComplete, Tooltip, Modal, Checkbox, Spin, message, Button, Tag } from 'antd';
|
||||
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined } from '@ant-design/icons';
|
||||
import { DatabaseOutlined, SendOutlined, TableOutlined, SearchOutlined, PictureOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { useStore } from '../../store';
|
||||
import { DBGetTables, DBShowCreateTable, DBGetDatabases } from '../../../wailsjs/go/app/App';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import type { AIComposerNotice } from '../../utils/aiComposerNotice';
|
||||
|
||||
interface AIChatInputProps {
|
||||
input: string;
|
||||
@@ -19,6 +20,7 @@ interface AIChatInputProps {
|
||||
activeProvider: any;
|
||||
dynamicModels: string[];
|
||||
loadingModels: boolean;
|
||||
composerNotice?: AIComposerNotice | null;
|
||||
onModelChange: (val: string) => void;
|
||||
onFetchModels: () => void;
|
||||
textareaRef: React.RefObject<HTMLTextAreaElement>;
|
||||
@@ -33,6 +35,7 @@ interface AIChatInputProps {
|
||||
export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
input, setInput, draftImages, setDraftImages, sending, onSend, onStop, handleKeyDown,
|
||||
activeConnName, activeContext, activeProvider, dynamicModels, loadingModels,
|
||||
composerNotice,
|
||||
onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme,
|
||||
contextUsageChars, maxContextChars
|
||||
}) => {
|
||||
@@ -67,6 +70,33 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
|
||||
const filteredTables = contextTables.filter(t => t.name.toLowerCase().includes(searchText.toLowerCase()));
|
||||
const [contextExpanded, setContextExpanded] = React.useState(false);
|
||||
const composerNoticePalette = React.useMemo(() => {
|
||||
if (composerNotice?.tone === 'error') {
|
||||
return darkMode
|
||||
? {
|
||||
background: 'rgba(255,120,117,0.12)',
|
||||
borderColor: 'rgba(255,120,117,0.24)',
|
||||
iconColor: '#ff7875',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(255,77,79,0.08)',
|
||||
borderColor: 'rgba(255,77,79,0.16)',
|
||||
iconColor: '#ff4d4f',
|
||||
};
|
||||
}
|
||||
|
||||
return darkMode
|
||||
? {
|
||||
background: 'rgba(250,173,20,0.12)',
|
||||
borderColor: 'rgba(250,173,20,0.22)',
|
||||
iconColor: '#ffd666',
|
||||
}
|
||||
: {
|
||||
background: 'rgba(250,173,20,0.08)',
|
||||
borderColor: 'rgba(250,173,20,0.18)',
|
||||
iconColor: '#d48806',
|
||||
};
|
||||
}, [composerNotice, darkMode]);
|
||||
|
||||
// Slash commands
|
||||
const [showSlashMenu, setShowSlashMenu] = React.useState(false);
|
||||
@@ -258,7 +288,31 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ position: 'relative' }}>
|
||||
{composerNotice && (
|
||||
<div
|
||||
data-ai-chat-composer-notice="true"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '8px 10px',
|
||||
borderRadius: 12,
|
||||
background: composerNoticePalette.background,
|
||||
border: `1px solid ${composerNoticePalette.borderColor}`,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleFilled style={{ color: composerNoticePalette.iconColor, fontSize: 14, marginTop: 1, flexShrink: 0 }} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: textColor, lineHeight: 1.4 }}>
|
||||
{composerNotice.title}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: mutedColor, lineHeight: 1.5, marginTop: 2, wordBreak: 'break-word' }}>
|
||||
{composerNotice.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
|
||||
{showSlashMenu && filteredSlashCmds.length > 0 && (
|
||||
<div style={{
|
||||
position: 'absolute', bottom: '100%', left: 0, right: 0, marginBottom: 4,
|
||||
@@ -356,7 +410,11 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
variant="filled"
|
||||
value={activeProvider.model || undefined}
|
||||
onChange={onModelChange}
|
||||
onDropdownVisibleChange={(open) => { if (open && dynamicModels.length === 0) onFetchModels(); }}
|
||||
onDropdownVisibleChange={(open) => {
|
||||
if (open && dynamicModels.length === 0 && (activeProvider.models || []).length === 0) {
|
||||
onFetchModels();
|
||||
}
|
||||
}}
|
||||
loading={loadingModels}
|
||||
options={(dynamicModels.length > 0 ? dynamicModels : (activeProvider.models || [])).map((m: string) => ({ label: m, value: m }))}
|
||||
style={{ width: 130, fontSize: 11, background: 'transparent' }}
|
||||
|
||||
Reference in New Issue
Block a user