mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-07-03 09:51:22 +08:00
✨ feat(ai): 优化 AI 对话体验与 MCP 接入配置
- AI 请求:增强 OpenAI 兼容接口降级逻辑,文本模型自动省略图片并在 400 场景重试 - MCP 接入:支持自定义 HTTP 服务监听地址、端口和 Authorization Bearer Token - MCP 生命周期:停止服务后保留授权信息,并将主动关闭子进程视为正常停止 - 交互优化:移除 AI 对话导出入口,支持关闭常驻状态提示并收敛设置弹窗 toast 宽度 - UI 调整:优化 AI 输入框边框、聚焦态和 Authorization 运行中只读可查看体验 - 测试覆盖:补充 OpenAI 降级、MCP HTTP、AI Header 和设置面板相关用例
This commit is contained in:
@@ -305,16 +305,17 @@
|
||||
gap: 8px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
border-bottom-color: rgba(128, 128, 128, 0.4);
|
||||
border-color: rgba(128, 128, 128, 0.22) !important;
|
||||
padding: 6px 10px;
|
||||
transition: all 0.2s ease;
|
||||
background: transparent !important;
|
||||
background: rgba(128, 128, 128, 0.03) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper:focus-within {
|
||||
border-color: var(--ant-primary-color, #1677ff) !important;
|
||||
background: rgba(128, 128, 128, 0.05) !important;
|
||||
border-color: rgba(128, 128, 128, 0.28) !important;
|
||||
background: rgba(128, 128, 128, 0.035) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.ai-chat-input-wrapper textarea {
|
||||
@@ -336,6 +337,32 @@
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-wrapper,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-wrapper:focus-within {
|
||||
border: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-surface,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-surface:focus-within {
|
||||
border: 0.5px solid var(--gn-br-2) !important;
|
||||
border-radius: 10px !important;
|
||||
background: var(--gn-bg-input) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-box textarea,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-box textarea.ant-input:focus,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .gn-v2-ai-input-box textarea.ant-input:focus-visible {
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
outline: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.ai-chat-send-btn {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
@@ -493,17 +520,26 @@
|
||||
left: 50% !important;
|
||||
transform: translateX(-50%) !important;
|
||||
right: auto !important;
|
||||
width: min(100%, 720px);
|
||||
max-width: calc(100% - 32px);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.ai-chat-panel .ant-message {
|
||||
width: min(100%, 720px);
|
||||
max-width: calc(100% - 32px);
|
||||
}
|
||||
|
||||
.ai-settings-body .ant-message {
|
||||
width: fit-content;
|
||||
max-width: min(520px, calc(100% - 32px));
|
||||
}
|
||||
|
||||
.ai-settings-body .ant-message .ant-message-notice {
|
||||
width: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.ai-settings-body .ant-message .ant-message-notice-content {
|
||||
width: 100%;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
white-space: normal;
|
||||
word-break: break-word;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
@@ -595,7 +595,6 @@ export const AIChatPanel: React.FC<AIChatPanelProps> = ({
|
||||
}}
|
||||
onSettingsClick={handleOpenSettingsFromPanel}
|
||||
onClose={onClose}
|
||||
messages={messages}
|
||||
sessionTitle={currentSessionTitle}
|
||||
activeMode={effectivePanelMode}
|
||||
onModeChange={(mode) => {
|
||||
|
||||
@@ -83,9 +83,10 @@ describe('AISettingsModal edit password behavior', () => {
|
||||
|
||||
it('keeps long ai settings toast errors wrapped within the modal body', () => {
|
||||
expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message {');
|
||||
expect(aiChatPanelCss).toContain('width: min(100%, 720px);');
|
||||
expect(aiChatPanelCss).toContain('max-width: calc(100% - 32px);');
|
||||
expect(aiChatPanelCss).toContain('width: fit-content;');
|
||||
expect(aiChatPanelCss).toContain('max-width: min(520px, calc(100% - 32px));');
|
||||
expect(aiChatPanelCss).toContain('.ai-settings-body .ant-message .ant-message-notice-content {');
|
||||
expect(aiChatPanelCss).toContain('max-width: 100%;');
|
||||
expect(aiChatPanelCss).toContain('white-space: normal;');
|
||||
expect(aiChatPanelCss).toContain('overflow-wrap: anywhere;');
|
||||
});
|
||||
|
||||
@@ -14,6 +14,7 @@ import { BUILTIN_AI_TOOL_INFO } from '../utils/aiToolRegistry';
|
||||
import { EMPTY_MCP_CLIENT_STATUSES } from '../utils/mcpClientInstallStatus';
|
||||
import AIBuiltinToolsCatalog from './ai/AIBuiltinToolsCatalog';
|
||||
import AISettingsMCPSection from './ai/AISettingsMCPSection';
|
||||
import type { AIMCPHTTPServerDraft } from './ai/AIMCPHTTPServerPanel';
|
||||
import AISettingsSidebar, { type AISettingsSectionKey } from './ai/AISettingsSidebar';
|
||||
import AISettingsSafetySection from './ai/AISettingsSafetySection';
|
||||
import AISettingsContextSection from './ai/AISettingsContextSection';
|
||||
@@ -48,6 +49,33 @@ const DEFAULT_MCP_HTTP_SERVER_STATUS: AIMCPHTTPServerStatus = {
|
||||
message: 'GoNavi MCP HTTP 服务未启动',
|
||||
};
|
||||
|
||||
const DEFAULT_MCP_HTTP_SERVER_DRAFT: AIMCPHTTPServerDraft = {
|
||||
addr: DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
|
||||
path: DEFAULT_MCP_HTTP_SERVER_STATUS.path,
|
||||
authorizationHeader: '',
|
||||
};
|
||||
|
||||
const buildMCPHTTPServerDraftFromStatus = (
|
||||
status: AIMCPHTTPServerStatus,
|
||||
fallback: AIMCPHTTPServerDraft = DEFAULT_MCP_HTTP_SERVER_DRAFT,
|
||||
): AIMCPHTTPServerDraft => ({
|
||||
addr: String(status.addr || fallback.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr).trim(),
|
||||
path: String(status.path || fallback.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path).trim(),
|
||||
authorizationHeader: String(
|
||||
status.authorizationHeader ||
|
||||
(status.token ? `Bearer ${status.token}` : '') ||
|
||||
fallback.authorizationHeader ||
|
||||
'',
|
||||
).trim(),
|
||||
});
|
||||
|
||||
const normalizeMCPHTTPAuthorizationToken = (value: string): string => {
|
||||
const trimmed = String(value || '').trim();
|
||||
if (!trimmed) return '';
|
||||
const withoutHeaderName = trimmed.replace(/^Authorization\s*:\s*/i, '').trim();
|
||||
return withoutHeaderName.replace(/^Bearer\s+/i, '').trim();
|
||||
};
|
||||
|
||||
const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => {
|
||||
const [providers, setProviders] = useState<AIProviderConfig[]>([]);
|
||||
const [activeProviderId, setActiveProviderId] = useState<string>('');
|
||||
@@ -56,6 +84,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
const [mcpServers, setMCPServers] = useState<AIMCPServerConfig[]>([]);
|
||||
const [mcpTools, setMCPTools] = useState<AIMCPToolDescriptor[]>([]);
|
||||
const [mcpHTTPServerStatus, setMCPHTTPServerStatus] = useState<AIMCPHTTPServerStatus>(DEFAULT_MCP_HTTP_SERVER_STATUS);
|
||||
const [mcpHTTPServerDraft, setMCPHTTPServerDraft] = useState<AIMCPHTTPServerDraft>(DEFAULT_MCP_HTTP_SERVER_DRAFT);
|
||||
const [mcpHTTPServerLoading, setMCPHTTPServerLoading] = useState(false);
|
||||
const [skills, setSkills] = useState<AISkillConfig[]>([]);
|
||||
const [editingProvider, setEditingProvider] = useState<AIProviderConfig | null>(null);
|
||||
@@ -182,10 +211,12 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
if (Array.isArray(mcpServersRes)) setMCPServers(mcpServersRes);
|
||||
if (Array.isArray(mcpToolsRes)) setMCPTools(mcpToolsRes);
|
||||
if (mcpHTTPServerStatusRes) {
|
||||
setMCPHTTPServerStatus({
|
||||
const nextStatus = {
|
||||
...DEFAULT_MCP_HTTP_SERVER_STATUS,
|
||||
...mcpHTTPServerStatusRes,
|
||||
});
|
||||
};
|
||||
setMCPHTTPServerStatus(nextStatus);
|
||||
setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(nextStatus, prev));
|
||||
}
|
||||
if (Array.isArray(skillsRes)) setSkills(skillsRes);
|
||||
if (Array.isArray(mcpClientStatusesRes)) {
|
||||
@@ -480,16 +511,19 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
}
|
||||
const nextStatus = checked
|
||||
? await Service.AIStartMCPHTTPServer({
|
||||
addr: mcpHTTPServerStatus.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
|
||||
path: mcpHTTPServerStatus.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path,
|
||||
addr: mcpHTTPServerDraft.addr || DEFAULT_MCP_HTTP_SERVER_STATUS.addr,
|
||||
path: mcpHTTPServerDraft.path || DEFAULT_MCP_HTTP_SERVER_STATUS.path,
|
||||
token: normalizeMCPHTTPAuthorizationToken(mcpHTTPServerDraft.authorizationHeader),
|
||||
schemaOnly: true,
|
||||
})
|
||||
: await Service.AIStopMCPHTTPServer();
|
||||
if (nextStatus) {
|
||||
setMCPHTTPServerStatus({
|
||||
const normalizedStatus = {
|
||||
...DEFAULT_MCP_HTTP_SERVER_STATUS,
|
||||
...nextStatus,
|
||||
});
|
||||
};
|
||||
setMCPHTTPServerStatus(normalizedStatus);
|
||||
setMCPHTTPServerDraft((prev) => buildMCPHTTPServerDraftFromStatus(normalizedStatus, prev));
|
||||
}
|
||||
void messageApi.success(checked ? 'GoNavi MCP HTTP 服务已启动' : 'GoNavi MCP HTTP 服务已停止');
|
||||
} catch (e: any) {
|
||||
@@ -499,6 +533,13 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateMCPHTTPServerDraft = (patch: Partial<AIMCPHTTPServerDraft>) => {
|
||||
setMCPHTTPServerDraft((prev) => ({
|
||||
...prev,
|
||||
...patch,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCopyMCPHTTPServerURL = async () => {
|
||||
const url = String(mcpHTTPServerStatus.url || '').trim();
|
||||
if (!url) {
|
||||
@@ -724,6 +765,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
selectedMCPClientStatus={selectedMCPClientStatus}
|
||||
selectedMCPClientCommandText={selectedMCPClientCommandText}
|
||||
mcpHTTPServerStatus={mcpHTTPServerStatus}
|
||||
mcpHTTPServerDraft={mcpHTTPServerDraft}
|
||||
mcpServers={mcpServers}
|
||||
mcpTools={mcpTools}
|
||||
darkMode={darkMode}
|
||||
@@ -734,6 +776,7 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
loading={loading}
|
||||
mcpClientStatusLoading={mcpClientStatusLoading}
|
||||
mcpHTTPServerLoading={mcpHTTPServerLoading}
|
||||
onUpdateHTTPServerDraft={handleUpdateMCPHTTPServerDraft}
|
||||
onToggleHTTPServer={handleToggleMCPHTTPServer}
|
||||
onCopyHTTPServerURL={() => void handleCopyMCPHTTPServerURL()}
|
||||
onCopyHTTPServerAuthorization={() => void handleCopyMCPHTTPServerAuthorization()}
|
||||
@@ -796,6 +839,3 @@ const AISettingsModal: React.FC<AISettingsModalProps> = ({ open, onClose, darkMo
|
||||
};
|
||||
|
||||
export default AISettingsModal;
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Button } from 'antd';
|
||||
import {
|
||||
CloseOutlined,
|
||||
CheckCircleFilled,
|
||||
ExclamationCircleFilled,
|
||||
LoadingOutlined,
|
||||
@@ -15,6 +16,7 @@ interface AIChatComposerStatusProps {
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
onAction?: (actionKey: AIComposerNoticeAction) => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
const resolvePalette = (
|
||||
@@ -96,6 +98,7 @@ const AIChatComposerStatus: React.FC<AIChatComposerStatusProps> = ({
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
onAction,
|
||||
onDismiss,
|
||||
}) => {
|
||||
const palette = resolvePalette(snapshot.severity, darkMode);
|
||||
const handleAction = () => {
|
||||
@@ -103,6 +106,7 @@ const AIChatComposerStatus: React.FC<AIChatComposerStatusProps> = ({
|
||||
onAction(snapshot.action.key);
|
||||
}
|
||||
};
|
||||
const canDismiss = typeof onDismiss === 'function';
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -153,16 +157,31 @@ const AIChatComposerStatus: React.FC<AIChatComposerStatusProps> = ({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{snapshot.action && typeof onAction === 'function' && (
|
||||
<Button
|
||||
size="small"
|
||||
type="default"
|
||||
onClick={handleAction}
|
||||
style={{ borderRadius: 8, flexShrink: 0 }}
|
||||
>
|
||||
{snapshot.action.label}
|
||||
</Button>
|
||||
)}
|
||||
{(snapshot.action && typeof onAction === 'function') || canDismiss ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||
{snapshot.action && typeof onAction === 'function' && (
|
||||
<Button
|
||||
size="small"
|
||||
type="default"
|
||||
onClick={handleAction}
|
||||
style={{ borderRadius: 8 }}
|
||||
>
|
||||
{snapshot.action.label}
|
||||
</Button>
|
||||
)}
|
||||
{canDismiss && (
|
||||
<Button
|
||||
aria-label="关闭 AI 状态提示"
|
||||
title="关闭"
|
||||
size="small"
|
||||
type="text"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onDismiss}
|
||||
style={{ borderRadius: 8 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
14
frontend/src/components/ai/AIChatHeader.test.tsx
Normal file
14
frontend/src/components/ai/AIChatHeader.test.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
const headerSource = readFileSync(new URL('./AIChatHeader.tsx', import.meta.url), 'utf8');
|
||||
const v2ThemeCss = readFileSync(new URL('../../v2-theme.css', import.meta.url), 'utf8');
|
||||
|
||||
describe('AIChatHeader export affordance', () => {
|
||||
it('does not expose chat export UI or markdown export implementation', () => {
|
||||
expect(headerSource).not.toContain('exportToMarkdown');
|
||||
expect(headerSource).not.toContain('导出为 Markdown');
|
||||
expect(headerSource).not.toContain('gn-v2-ai-export-button');
|
||||
expect(v2ThemeCss).not.toContain('gn-v2-ai-export-button');
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import type { AIChatMessage } from '../../types';
|
||||
|
||||
interface AIChatHeaderProps {
|
||||
darkMode: boolean;
|
||||
@@ -14,39 +13,16 @@ interface AIChatHeaderProps {
|
||||
onClear: () => void;
|
||||
onSettingsClick: () => void;
|
||||
onClose: () => void;
|
||||
messages?: AIChatMessage[];
|
||||
sessionTitle?: string;
|
||||
activeMode?: 'chat' | 'insights' | 'history';
|
||||
onModeChange?: (mode: 'chat' | 'insights' | 'history') => void;
|
||||
}
|
||||
|
||||
const exportToMarkdown = (messages: AIChatMessage[], title: string) => {
|
||||
const lines: string[] = [`# ${title}`, '', `> 导出时间:${new Date().toLocaleString()}`, ''];
|
||||
messages.forEach(msg => {
|
||||
const role = msg.role === 'user' ? '👤 You' : '🤖 GoNavi AI';
|
||||
lines.push(`## ${role}`);
|
||||
lines.push('');
|
||||
lines.push(msg.content);
|
||||
lines.push('');
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
});
|
||||
const blob = new Blob([lines.join('\n')], { type: 'text/markdown;charset=utf-8' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${title.replace(/[/\\?%*:|"<>]/g, '-')}.md`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
|
||||
darkMode, mutedColor, textColor, overlayTheme,
|
||||
isV2Ui = false,
|
||||
onHistoryClick, onClear, onSettingsClick, onClose,
|
||||
messages = [], sessionTitle = '新对话',
|
||||
sessionTitle = '新对话',
|
||||
activeMode = 'chat',
|
||||
onModeChange,
|
||||
}) => {
|
||||
@@ -63,11 +39,6 @@ export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
|
||||
<span className="ai-title" style={{ color: textColor, fontSize: 13, fontWeight: 600 }}>GoNavi AI</span>
|
||||
</div>
|
||||
<div className="ai-chat-header-right">
|
||||
{messages.length > 0 && (
|
||||
<Tooltip title="导出为 Markdown">
|
||||
<Button type="text" size="small" icon={<ExportOutlined />} onClick={() => exportToMarkdown(messages, sessionTitle)} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
<Tooltip title="新对话 (清空当前)">
|
||||
<Button type="text" size="small" icon={<ClearOutlined />} onClick={onClear} style={{ color: mutedColor }} />
|
||||
</Tooltip>
|
||||
@@ -137,15 +108,6 @@ export const AIChatHeader: React.FC<AIChatHeaderProps> = ({
|
||||
<span>历史</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{messages.length > 0 && (
|
||||
<div className="gn-v2-ai-session-row">
|
||||
<button type="button" className="gn-v2-ai-export-button" onClick={() => exportToMarkdown(messages, sessionTitle)}>
|
||||
<ExportOutlined />
|
||||
<span>导出</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -3,6 +3,8 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AIChatInput } from './AIChatInput';
|
||||
import AIChatComposerStatus from './AIChatComposerStatus';
|
||||
import { buildAIChatReadinessSnapshot } from './aiChatReadiness';
|
||||
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
vi.mock('../../store', () => ({
|
||||
@@ -33,7 +35,7 @@ const baseProvider = {
|
||||
temperature: 0.2,
|
||||
};
|
||||
|
||||
const renderAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChatInput>> = {}) => renderToStaticMarkup(
|
||||
const buildAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChatInput>> = {}) => (
|
||||
<AIChatInput
|
||||
input=""
|
||||
setInput={() => {}}
|
||||
@@ -63,6 +65,9 @@ const renderAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChat
|
||||
/>
|
||||
);
|
||||
|
||||
const renderAIChatInput = (overrides: Partial<React.ComponentProps<typeof AIChatInput>> = {}) =>
|
||||
renderToStaticMarkup(buildAIChatInput(overrides));
|
||||
|
||||
describe('AIChatInput notice layout', () => {
|
||||
it('renders the composer notice above the input editor', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
@@ -251,4 +256,26 @@ describe('AIChatInput notice layout', () => {
|
||||
expect(markup).toContain('自建代理 还缺少 密钥、接口地址');
|
||||
expect(markup).toContain('修复供应商配置');
|
||||
});
|
||||
|
||||
it('renders a dismiss affordance for the non-blocking ready composer status', () => {
|
||||
const snapshot = buildAIChatReadinessSnapshot({
|
||||
activeProvider: {
|
||||
...baseProvider,
|
||||
model: 'MiniMax-M2.7-highspeed',
|
||||
models: ['MiniMax-M2.7-highspeed'],
|
||||
},
|
||||
});
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIChatComposerStatus
|
||||
snapshot={snapshot}
|
||||
darkMode={false}
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
onDismiss={() => {}}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(markup).toContain('data-ai-chat-composer-status="true"');
|
||||
expect(markup).toContain('aria-label="关闭 AI 状态提示"');
|
||||
expect(markup).toContain('title="关闭"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,6 +71,33 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
activeContext,
|
||||
activeContextItems,
|
||||
}), [activeProvider, dynamicModels, loadingModels, activeContext, activeContextItems]);
|
||||
const composerStatusKey = React.useMemo(() => [
|
||||
composerReadiness.status,
|
||||
composerReadiness.activeProvider?.id || '',
|
||||
composerReadiness.activeProvider?.model || '',
|
||||
activeContext?.connectionId || '',
|
||||
activeContext?.dbName || '',
|
||||
composerReadiness.contextAttachedCount,
|
||||
composerReadiness.selectableModelCount,
|
||||
].join('|'), [
|
||||
composerReadiness.status,
|
||||
composerReadiness.activeProvider?.id,
|
||||
composerReadiness.activeProvider?.model,
|
||||
activeContext?.connectionId,
|
||||
activeContext?.dbName,
|
||||
composerReadiness.contextAttachedCount,
|
||||
composerReadiness.selectableModelCount,
|
||||
]);
|
||||
const [dismissedComposerStatusKey, setDismissedComposerStatusKey] = React.useState<string | null>(null);
|
||||
React.useEffect(() => {
|
||||
setDismissedComposerStatusKey(null);
|
||||
}, [composerStatusKey]);
|
||||
const isComposerStatusDismissed = composerReadiness.ready && dismissedComposerStatusKey === composerStatusKey;
|
||||
const handleDismissComposerStatus = React.useCallback(() => {
|
||||
if (composerReadiness.ready) {
|
||||
setDismissedComposerStatusKey(composerStatusKey);
|
||||
}
|
||||
}, [composerReadiness.ready, composerStatusKey]);
|
||||
const {
|
||||
appendingContext,
|
||||
contextExpanded,
|
||||
@@ -164,12 +191,13 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
mutedColor={mutedColor}
|
||||
onComposerNoticeAction={composerNoticeActionHandler}
|
||||
/>
|
||||
{!composerNotice && (
|
||||
{!composerNotice && !isComposerStatusDismissed && (
|
||||
<AIChatComposerStatus
|
||||
snapshot={composerReadiness}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
onAction={composerActionHandler}
|
||||
onDismiss={composerReadiness.ready ? handleDismissComposerStatus : undefined}
|
||||
/>
|
||||
)}
|
||||
<div data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
|
||||
@@ -317,12 +345,13 @@ export const AIChatInput: React.FC<AIChatInputProps> = ({
|
||||
mutedColor={mutedColor}
|
||||
onComposerNoticeAction={composerNoticeActionHandler}
|
||||
/>
|
||||
{!composerNotice && (
|
||||
{!composerNotice && !isComposerStatusDismissed && (
|
||||
<AIChatComposerStatus
|
||||
snapshot={composerReadiness}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
onAction={composerActionHandler}
|
||||
onDismiss={composerReadiness.ready ? handleDismissComposerStatus : undefined}
|
||||
/>
|
||||
)}
|
||||
<div className="gn-v2-ai-input-box" data-ai-chat-composer-input="true" style={{ position: 'relative' }}>
|
||||
|
||||
@@ -5,34 +5,76 @@ import { describe, expect, it } from 'vitest';
|
||||
import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import AIMCPHTTPServerPanel from './AIMCPHTTPServerPanel';
|
||||
|
||||
const findElement = (node: any, predicate: (element: any) => boolean): any => {
|
||||
if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') {
|
||||
return null;
|
||||
}
|
||||
if (Array.isArray(node)) {
|
||||
for (const item of node) {
|
||||
const match = findElement(item, predicate);
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (predicate(node)) {
|
||||
return node;
|
||||
}
|
||||
return findElement(node.props?.children, predicate);
|
||||
};
|
||||
|
||||
const buildPanelProps = () => ({
|
||||
status: {
|
||||
running: true,
|
||||
addr: '127.0.0.1:8765',
|
||||
path: '/mcp',
|
||||
url: 'http://127.0.0.1:8765/mcp',
|
||||
schemaOnly: true,
|
||||
authorizationHeader: 'Bearer gnv_test',
|
||||
message: 'GoNavi MCP HTTP 服务已启动',
|
||||
},
|
||||
draft: {
|
||||
addr: '127.0.0.1:8765',
|
||||
path: '/mcp',
|
||||
authorizationHeader: 'Bearer gnv_test',
|
||||
},
|
||||
loading: false,
|
||||
cardBg: '#fff',
|
||||
cardBorder: 'rgba(0,0,0,0.08)',
|
||||
darkMode: false,
|
||||
overlayTheme: buildOverlayWorkbenchTheme(false),
|
||||
onDraftChange: () => {},
|
||||
onToggle: () => {},
|
||||
onCopyURL: () => {},
|
||||
onCopyAuthorization: () => {},
|
||||
});
|
||||
|
||||
describe('AIMCPHTTPServerPanel', () => {
|
||||
it('renders the in-app MCP HTTP switch and remote connection details', () => {
|
||||
const markup = renderToStaticMarkup(
|
||||
<AIMCPHTTPServerPanel
|
||||
status={{
|
||||
running: true,
|
||||
addr: '127.0.0.1:8765',
|
||||
path: '/mcp',
|
||||
url: 'http://127.0.0.1:8765/mcp',
|
||||
schemaOnly: true,
|
||||
authorizationHeader: 'Bearer gnv_test',
|
||||
message: 'GoNavi MCP HTTP 服务已启动',
|
||||
}}
|
||||
loading={false}
|
||||
cardBg="#fff"
|
||||
cardBorder="rgba(0,0,0,0.08)"
|
||||
darkMode={false}
|
||||
overlayTheme={buildOverlayWorkbenchTheme(false)}
|
||||
onToggle={() => {}}
|
||||
onCopyURL={() => {}}
|
||||
onCopyAuthorization={() => {}}
|
||||
/>,
|
||||
<AIMCPHTTPServerPanel {...buildPanelProps()} />,
|
||||
);
|
||||
|
||||
expect(markup).toContain('GoNavi MCP HTTP 服务');
|
||||
expect(markup).toContain('已启动');
|
||||
expect(markup).toContain('schema-only');
|
||||
expect(markup).toContain('监听地址 / 端口');
|
||||
expect(markup).toContain('Authorization');
|
||||
expect(markup).toContain('127.0.0.1:8765');
|
||||
expect(markup).toContain('http://127.0.0.1:8765/mcp');
|
||||
expect(markup).toContain('复制 Authorization');
|
||||
});
|
||||
|
||||
it('keeps Authorization read-only but revealable while running', () => {
|
||||
const tree = AIMCPHTTPServerPanel(buildPanelProps());
|
||||
const passwordInput = findElement(
|
||||
tree,
|
||||
(node) => node.props?.placeholder === 'Bearer gnv_xxx(留空自动生成)',
|
||||
);
|
||||
|
||||
expect(passwordInput).toBeTruthy();
|
||||
expect(passwordInput.props.disabled).toBe(false);
|
||||
expect(passwordInput.props.readOnly).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import React from 'react';
|
||||
import { Button, Switch, Tag } from 'antd';
|
||||
import { Button, Input, Switch, Tag } from 'antd';
|
||||
import { CopyOutlined } from '@ant-design/icons';
|
||||
|
||||
import type { AIMCPHTTPServerStatus } from '../../types';
|
||||
import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
|
||||
export interface AIMCPHTTPServerDraft {
|
||||
addr: string;
|
||||
path: string;
|
||||
authorizationHeader: string;
|
||||
}
|
||||
|
||||
export interface AIMCPHTTPServerPanelProps {
|
||||
status: AIMCPHTTPServerStatus;
|
||||
draft: AIMCPHTTPServerDraft;
|
||||
loading: boolean;
|
||||
cardBg: string;
|
||||
cardBorder: string;
|
||||
darkMode: boolean;
|
||||
overlayTheme: OverlayWorkbenchTheme;
|
||||
onDraftChange: (patch: Partial<AIMCPHTTPServerDraft>) => void;
|
||||
onToggle: (checked: boolean) => void;
|
||||
onCopyURL: () => void;
|
||||
onCopyAuthorization: () => void;
|
||||
@@ -19,11 +27,13 @@ export interface AIMCPHTTPServerPanelProps {
|
||||
|
||||
const AIMCPHTTPServerPanel: React.FC<AIMCPHTTPServerPanelProps> = ({
|
||||
status,
|
||||
draft,
|
||||
loading,
|
||||
cardBg,
|
||||
cardBorder,
|
||||
darkMode,
|
||||
overlayTheme,
|
||||
onDraftChange,
|
||||
onToggle,
|
||||
onCopyURL,
|
||||
onCopyAuthorization,
|
||||
@@ -31,6 +41,10 @@ const AIMCPHTTPServerPanel: React.FC<AIMCPHTTPServerPanelProps> = ({
|
||||
const running = status?.running === true;
|
||||
const url = String(status?.url || '').trim();
|
||||
const authorizationHeader = String(status?.authorizationHeader || '').trim();
|
||||
const inputStyle: React.CSSProperties = {
|
||||
borderRadius: 10,
|
||||
background: darkMode ? 'rgba(15,23,42,0.82)' : '#fff',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -56,7 +70,7 @@ const AIMCPHTTPServerPanel: React.FC<AIMCPHTTPServerPanelProps> = ({
|
||||
</Tag>
|
||||
</div>
|
||||
<div style={{ marginTop: 6, fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
给 OpenClaw、Hermans 等远程 Agent 使用。打开后默认监听本机地址,自动生成 Bearer Token,只开放连接、库表、字段和 DDL 等结构读取工具。
|
||||
给 OpenClaw、Hermans 等远程 Agent 使用。打开后监听本机地址,只开放连接、库表、字段和 DDL 等结构读取工具。
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -78,10 +92,36 @@ const AIMCPHTTPServerPanel: React.FC<AIMCPHTTPServerPanelProps> = ({
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(220px, 1fr))', gap: 8 }}>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: overlayTheme.mutedText }}>监听地址 / 端口</span>
|
||||
<Input
|
||||
size="small"
|
||||
value={draft.addr}
|
||||
disabled={running || loading}
|
||||
placeholder="127.0.0.1:8765"
|
||||
onChange={(event) => onDraftChange({ addr: event.target.value })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<span style={{ fontSize: 11, fontWeight: 700, color: overlayTheme.mutedText }}>Authorization</span>
|
||||
<Input.Password
|
||||
size="small"
|
||||
value={draft.authorizationHeader}
|
||||
disabled={loading}
|
||||
readOnly={running || loading}
|
||||
placeholder="Bearer gnv_xxx(留空自动生成)"
|
||||
autoComplete="off"
|
||||
onChange={(event) => onDraftChange({ authorizationHeader: event.target.value })}
|
||||
style={inputStyle}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
|
||||
{running
|
||||
? status.message || '服务运行中,可把 URL 和 Authorization Header 配置到远程 MCP 客户端。'
|
||||
: '不用再手动执行 GoNavi.exe mcp-server http 命令;在这里打开开关即可启动本机 HTTP MCP。'}
|
||||
: '可自定义本机监听端口和 Bearer Token;留空 Authorization 时启动会自动生成随机 Token。'}
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, alignItems: 'center' }}>
|
||||
<code
|
||||
|
||||
@@ -66,6 +66,11 @@ const buildMCPSectionProps = (patch: Partial<AISettingsMCPSectionProps> = {}): A
|
||||
schemaOnly: true,
|
||||
message: 'GoNavi MCP HTTP 服务未启动',
|
||||
},
|
||||
mcpHTTPServerDraft: {
|
||||
addr: '127.0.0.1:8765',
|
||||
path: '/mcp',
|
||||
authorizationHeader: 'Bearer gnv_test',
|
||||
},
|
||||
mcpServers: [],
|
||||
mcpTools: [],
|
||||
darkMode: false,
|
||||
@@ -76,6 +81,7 @@ const buildMCPSectionProps = (patch: Partial<AISettingsMCPSectionProps> = {}): A
|
||||
loading: false,
|
||||
mcpClientStatusLoading: false,
|
||||
mcpHTTPServerLoading: false,
|
||||
onUpdateHTTPServerDraft: () => {},
|
||||
onToggleHTTPServer: () => {},
|
||||
onCopyHTTPServerURL: () => {},
|
||||
onCopyHTTPServerAuthorization: () => {},
|
||||
@@ -99,7 +105,7 @@ describe('AISettingsMCPSection', () => {
|
||||
);
|
||||
|
||||
expect(markup).toContain('GoNavi MCP HTTP 服务');
|
||||
expect(markup).toContain('不用再手动执行 GoNavi.exe mcp-server http 命令');
|
||||
expect(markup).toContain('可自定义本机监听端口和 Bearer Token');
|
||||
expect(markup).toContain('http://127.0.0.1:8765/mcp');
|
||||
expect(markup).toContain('复制 Authorization');
|
||||
expect(markup).toContain('接入外部客户端');
|
||||
@@ -208,4 +214,20 @@ describe('AISettingsMCPSection', () => {
|
||||
|
||||
expect(onToggleHTTPServer).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('passes MCP HTTP draft updates through the switch panel', () => {
|
||||
const onUpdateHTTPServerDraft = vi.fn();
|
||||
const tree = AISettingsMCPSection(buildMCPSectionProps({
|
||||
onUpdateHTTPServerDraft,
|
||||
}));
|
||||
|
||||
const httpPanel = findElement(
|
||||
tree,
|
||||
(node) => node.props?.onDraftChange === onUpdateHTTPServerDraft,
|
||||
);
|
||||
expect(httpPanel).toBeTruthy();
|
||||
httpPanel.props.onDraftChange({ addr: '127.0.0.1:9123' });
|
||||
|
||||
expect(onUpdateHTTPServerDraft).toHaveBeenCalledWith({ addr: '127.0.0.1:9123' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme';
|
||||
import AIMCPClientInstallPanel from './AIMCPClientInstallPanel';
|
||||
import AIMCPFieldGuideCard from './AIMCPFieldGuideCard';
|
||||
import AIMCPHTTPServerPanel from './AIMCPHTTPServerPanel';
|
||||
import type { AIMCPHTTPServerDraft } from './AIMCPHTTPServerPanel';
|
||||
import AIMCPQuickAddServerPanel from './AIMCPQuickAddServerPanel';
|
||||
import AIMCPServerCard from './AIMCPServerCard';
|
||||
|
||||
@@ -20,6 +21,7 @@ export interface AISettingsMCPSectionProps {
|
||||
selectedMCPClientStatus?: AIMCPClientInstallStatus;
|
||||
selectedMCPClientCommandText: string;
|
||||
mcpHTTPServerStatus: AIMCPHTTPServerStatus;
|
||||
mcpHTTPServerDraft: AIMCPHTTPServerDraft;
|
||||
mcpServers: AIMCPServerConfig[];
|
||||
mcpTools: AIMCPToolDescriptor[];
|
||||
darkMode: boolean;
|
||||
@@ -30,6 +32,7 @@ export interface AISettingsMCPSectionProps {
|
||||
loading: boolean;
|
||||
mcpClientStatusLoading: boolean;
|
||||
mcpHTTPServerLoading: boolean;
|
||||
onUpdateHTTPServerDraft: (patch: Partial<AIMCPHTTPServerDraft>) => void;
|
||||
onToggleHTTPServer: (checked: boolean) => void;
|
||||
onCopyHTTPServerURL: () => void;
|
||||
onCopyHTTPServerAuthorization: () => void;
|
||||
@@ -51,6 +54,7 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
|
||||
selectedMCPClientStatus,
|
||||
selectedMCPClientCommandText,
|
||||
mcpHTTPServerStatus,
|
||||
mcpHTTPServerDraft,
|
||||
mcpServers,
|
||||
mcpTools,
|
||||
darkMode,
|
||||
@@ -61,6 +65,7 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
|
||||
loading,
|
||||
mcpClientStatusLoading,
|
||||
mcpHTTPServerLoading,
|
||||
onUpdateHTTPServerDraft,
|
||||
onToggleHTTPServer,
|
||||
onCopyHTTPServerURL,
|
||||
onCopyHTTPServerAuthorization,
|
||||
@@ -78,11 +83,13 @@ const AISettingsMCPSection: React.FC<AISettingsMCPSectionProps> = ({
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<AIMCPHTTPServerPanel
|
||||
status={mcpHTTPServerStatus}
|
||||
draft={mcpHTTPServerDraft}
|
||||
loading={mcpHTTPServerLoading}
|
||||
cardBg={cardBg}
|
||||
cardBorder={cardBorder}
|
||||
darkMode={darkMode}
|
||||
overlayTheme={overlayTheme}
|
||||
onDraftChange={onUpdateHTTPServerDraft}
|
||||
onToggle={onToggleHTTPServer}
|
||||
onCopyURL={onCopyHTTPServerURL}
|
||||
onCopyAuthorization={onCopyHTTPServerAuthorization}
|
||||
|
||||
@@ -382,14 +382,15 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window
|
||||
AIStartMCPHTTPServer: async (input: any) => {
|
||||
const addr = String(input?.addr || '127.0.0.1:8765');
|
||||
const path = String(input?.path || '/mcp').startsWith('/') ? String(input?.path || '/mcp') : `/${String(input?.path || '/mcp')}`;
|
||||
const token = String(input?.token || 'gnv_browser_mock_token').trim() || 'gnv_browser_mock_token';
|
||||
mockMCPHTTPServerStatus = {
|
||||
running: true,
|
||||
addr,
|
||||
path,
|
||||
url: `http://${addr}${path}`,
|
||||
schemaOnly: true,
|
||||
token: 'gnv_browser_mock_token',
|
||||
authorizationHeader: 'Bearer gnv_browser_mock_token',
|
||||
token,
|
||||
authorizationHeader: `Bearer ${token}`,
|
||||
startedAt: Date.now(),
|
||||
message: 'GoNavi MCP HTTP 服务已启动',
|
||||
};
|
||||
@@ -399,8 +400,6 @@ if (typeof window !== 'undefined' && (!(window as any).go?.app?.App || !(window
|
||||
mockMCPHTTPServerStatus = {
|
||||
...mockMCPHTTPServerStatus,
|
||||
running: false,
|
||||
token: '',
|
||||
authorizationHeader: '',
|
||||
message: 'GoNavi MCP HTTP 服务已停止',
|
||||
};
|
||||
return cloneBrowserMockValue(mockMCPHTTPServerStatus);
|
||||
|
||||
@@ -5696,29 +5696,6 @@ body[data-ui-version="v2"] .gn-v2-ai-mode-tabs button.is-active {
|
||||
color: var(--gn-fg-1);
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-session-row {
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
min-height: 22px;
|
||||
padding: 0 10px 7px;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-export-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 22px;
|
||||
padding: 0 7px;
|
||||
border: 0.5px solid var(--gn-br-1);
|
||||
border-radius: 6px;
|
||||
background: var(--gn-bg-panel);
|
||||
color: var(--gn-fg-4);
|
||||
font-size: 10.5px;
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-messages {
|
||||
padding: 8px 14px 12px;
|
||||
gap: 10px;
|
||||
@@ -5995,6 +5972,7 @@ body[data-ui-version="v2"] .gn-v2-ai-panel .ai-chat-input-wrapper {
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
gap: 4px !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-context-row {
|
||||
@@ -6157,25 +6135,34 @@ body[data-ui-version="v2"] .gn-v2-ai-attachment-file button {
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-input-box {
|
||||
min-height: 72px;
|
||||
min-height: 68px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-input-surface {
|
||||
min-height: 68px;
|
||||
padding: 6px 6px 6px 10px;
|
||||
border: 0.5px solid var(--gn-br-2);
|
||||
border-radius: 10px;
|
||||
background: var(--gn-bg-input);
|
||||
box-shadow: var(--gn-shadow-sm);
|
||||
border: 0.5px solid var(--gn-br-2) !important;
|
||||
border-radius: 10px !important;
|
||||
background: var(--gn-bg-input) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-input-box textarea {
|
||||
border: 0 !important;
|
||||
border-radius: 0 !important;
|
||||
background: transparent !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 12.5px !important;
|
||||
line-height: 1.55 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-input-box textarea.ant-input:focus,
|
||||
body[data-ui-version="v2"] .gn-v2-ai-input-box textarea.ant-input:focus-visible {
|
||||
border-color: transparent !important;
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
body[data-ui-version="v2"] .gn-v2-ai-slash-menu {
|
||||
@@ -6209,7 +6196,7 @@ body[data-ui-version="v2"] .gn-v2-ai-model-bar {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
padding-top: 6px;
|
||||
padding-top: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user