feat(ai): 完善远程 MCP 结构模式与面板稳定性

- MCP HTTP 支持 schema-only 模式,远程配置默认不暴露 execute_sql

- OpenClaw/Hermans 向导补充安全边界与结构模式命令

- 拆分 AI 面板错误边界和 Linux CJK 字体提示组件
This commit is contained in:
Syngnat
2026-06-11 09:26:54 +08:00
parent 4a944ad23f
commit 450d1d66b4
20 changed files with 293 additions and 119 deletions

View File

@@ -6,11 +6,16 @@ const appSource = readFileSync(
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
'utf8',
);
const aiPanelBoundarySource = readFileSync(
fileURLToPath(new globalThis.URL('./components/ai/AIPanelErrorBoundary.tsx', import.meta.url)),
'utf8',
);
describe('AI panel lazy-load guard', () => {
it('keeps AI panel failures scoped to the panel area with retry support', () => {
expect(appSource).toContain("import AIChatPanel from './components/AIChatPanel';");
expect(appSource).toContain('class AIPanelErrorBoundary extends React.Component');
expect(appSource).toContain("import AIPanelErrorBoundary from './components/ai/AIPanelErrorBoundary';");
expect(aiPanelBoundarySource).toContain('class AIPanelErrorBoundary extends React.Component');
expect(appSource).toContain('<AIPanelErrorBoundary');
expect(appSource).toContain('key={aiPanelRenderNonce}');
expect(appSource).toContain('AI 面板加载失败');

View File

@@ -6,6 +6,10 @@ const appSource = readFileSync(
fileURLToPath(new globalThis.URL('./App.tsx', import.meta.url)),
'utf8',
);
const linuxCJKFontBannerSource = readFileSync(
fileURLToPath(new globalThis.URL('./components/LinuxCJKFontBanner.tsx', import.meta.url)),
'utf8',
);
const getGlobalShortcutCaseBlock = (action: string) => {
const caseToken = `case '${action}':`;
@@ -250,8 +254,10 @@ describe('global appearance tokens', () => {
expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies)');
expect(appSource).toContain('ListInstalledFontFamilies()');
expect(appSource).toContain('const [installedFontFamilies, setInstalledFontFamilies] = useState<InstalledFontFamily[]>(EMPTY_INSTALLED_FONT_FAMILIES);');
expect(appSource).toContain('data-gonavi-linux-cjk-font-banner="true"');
expect(appSource).toContain('Linux CJK fonts missing / Ubuntu 中文字体缺失');
expect(appSource).toContain("import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';");
expect(appSource).toContain('<LinuxCJKFontBanner');
expect(linuxCJKFontBannerSource).toContain('data-gonavi-linux-cjk-font-banner="true"');
expect(linuxCJKFontBannerSource).toContain('Linux CJK fonts missing / Ubuntu 中文字体缺失');
expect(appSource).toContain('setIsLinuxCJKFontBannerDismissed(true)');
expect(appSource).toContain('matchFontFamilyOption');
expect(appSource).toContain('showSearch');

View File

@@ -10,9 +10,11 @@ import SnippetSettingsModal from './components/SnippetSettingsModal';
import ConnectionPackagePasswordModal from './components/ConnectionPackagePasswordModal';
import DataSyncModal from './components/DataSyncModal';
import DriverManagerModal from './components/DriverManagerModal';
import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';
import LogPanel from './components/LogPanel';
import AISettingsModal from './components/AISettingsModal';
import AIChatPanel from './components/AIChatPanel';
import AIPanelErrorBoundary from './components/ai/AIPanelErrorBoundary';
import SecurityUpdateBanner from './components/SecurityUpdateBanner';
import SecurityUpdateIntroModal from './components/SecurityUpdateIntroModal';
import SecurityUpdateProgressModal from './components/SecurityUpdateProgressModal';
@@ -201,43 +203,6 @@ const createClosedConnectionPackageDialogState = (): ConnectionPackageDialogStat
confirmLoading: false,
});
interface AIPanelErrorBoundaryProps {
children: React.ReactNode;
fallback: (error: Error | null) => React.ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface AIPanelErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class AIPanelErrorBoundary extends React.Component<
AIPanelErrorBoundaryProps,
AIPanelErrorBoundaryState
> {
constructor(props: AIPanelErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): AIPanelErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
function App() {
const [isModalOpen, setIsModalOpen] = useState(false);
const [isConnectionModalMounted, setIsConnectionModalMounted] = useState(false);
@@ -3416,51 +3381,15 @@ function App() {
</div>
{showLinuxCJKFontBanner && (
<div
data-gonavi-linux-cjk-font-banner="true"
style={{
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 14px',
borderBottom: darkMode ? '1px solid rgba(250,204,21,0.20)' : '1px solid rgba(217,119,6,0.18)',
background: darkMode ? 'rgba(250,204,21,0.10)' : 'rgba(255,247,237,0.92)',
color: darkMode ? 'rgba(254,249,195,0.96)' : '#7c2d12',
fontSize: 12,
lineHeight: 1.55,
}}
>
<InfoCircleOutlined style={{ flexShrink: 0, color: darkMode ? '#facc15' : '#d97706' }} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 700 }}>
Linux CJK fonts missing / Ubuntu
</div>
<div>
Chinese text may render as . Install fonts, then restart GoNavi:
<code style={{ marginLeft: 6, fontFamily: 'var(--gn-font-mono)', wordBreak: 'break-all' }}>
{linuxCJKFontInstallHint}
</code>
</div>
</div>
<Button
size="small"
onClick={() => {
<LinuxCJKFontBanner
darkMode={darkMode}
installHint={linuxCJKFontInstallHint || ''}
onOpenFontSettings={() => {
setThemeModalSection('appearance');
setIsThemeModalOpen(true);
}}
>
Font Settings
</Button>
<Button
size="small"
type="text"
onClick={() => setIsLinuxCJKFontBannerDismissed(true)}
style={{ color: 'inherit' }}
>
Close
</Button>
</div>
}}
onDismiss={() => setIsLinuxCJKFontBannerDismissed(true)}
/>
)}
<Layout style={{ flex: 1, minHeight: 0, minWidth: 0 }}>

View File

@@ -0,0 +1,62 @@
import React from 'react';
import { Button } from 'antd';
import { InfoCircleOutlined } from '@ant-design/icons';
interface LinuxCJKFontBannerProps {
darkMode: boolean;
installHint: string;
onOpenFontSettings: () => void;
onDismiss: () => void;
}
const LinuxCJKFontBanner: React.FC<LinuxCJKFontBannerProps> = ({
darkMode,
installHint,
onOpenFontSettings,
onDismiss,
}) => (
<div
data-gonavi-linux-cjk-font-banner="true"
style={{
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: 12,
padding: '8px 14px',
borderBottom: darkMode ? '1px solid rgba(250,204,21,0.20)' : '1px solid rgba(217,119,6,0.18)',
background: darkMode ? 'rgba(250,204,21,0.10)' : 'rgba(255,247,237,0.92)',
color: darkMode ? 'rgba(254,249,195,0.96)' : '#7c2d12',
fontSize: 12,
lineHeight: 1.55,
}}
>
<InfoCircleOutlined style={{ flexShrink: 0, color: darkMode ? '#facc15' : '#d97706' }} />
<div style={{ minWidth: 0, flex: 1 }}>
<div style={{ fontWeight: 700 }}>
Linux CJK fonts missing / Ubuntu
</div>
<div>
Chinese text may render as . Install fonts, then restart GoNavi:
<code style={{ marginLeft: 6, fontFamily: 'var(--gn-font-mono)', wordBreak: 'break-all' }}>
{installHint}
</code>
</div>
</div>
<Button
size="small"
onClick={onOpenFontSettings}
>
Font Settings
</Button>
<Button
size="small"
type="text"
onClick={onDismiss}
style={{ color: 'inherit' }}
>
Close
</Button>
</div>
);
export default LinuxCJKFontBanner;

View File

@@ -191,7 +191,8 @@ describe('AIMCPClientInstallPanel', () => {
expect(markup).toContain('远程桥接');
expect(markup).toContain('当前已选中,将复制远程接入说明');
expect(markup).toContain('远程接入边界');
expect(markup).toContain('云端 Agent 只通过 MCP 工具读取连接摘要、库表和 DDL');
expect(markup).toContain('云端 Agent 默认通过 schema-only MCP 工具读取连接摘要、库表和 DDL');
expect(markup).toContain('不注册 execute_sql');
expect(markup).toContain('OpenClaw 远程 MCP 快速配置');
expect(markup).toContain('公网/隧道 URL');
expect(markup).toContain('云端 Agent 能访问到的 Streamable HTTP MCP 地址');
@@ -206,13 +207,14 @@ describe('AIMCPClientInstallPanel', () => {
expect(markup).toContain('&quot;type&quot;: &quot;streamable-http&quot;');
expect(markup).toContain('&quot;url&quot;: &quot;https://&lt;你的域名或隧道地址&gt;/mcp&quot;');
expect(markup).toContain('&quot;Authorization&quot;: &quot;Bearer &lt;随机token&gt;&quot;');
expect(markup).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://&lt;你的域名或隧道地址&gt;/mcp --token &lt;随机token&gt;');
expect(markup).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://&lt;你的域名或隧道地址&gt;/mcp --token &lt;随机token&gt; --schema-only');
expect(markup).toContain('Windows 启动 GoNavi MCP HTTP');
expect(markup).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token &lt;随机token&gt;');
expect(markup).toContain('独立二进制gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token &lt;随机token&gt;');
expect(markup).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token &lt;随机token&gt; --schema-only');
expect(markup).toContain('独立二进制gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token &lt;随机token&gt; --schema-only');
expect(markup).toContain('验证顺序');
expect(markup).toContain('安全边界');
expect(markup).toContain('数据库账号和密码仍保存在 Windows GoNavi');
expect(markup).toContain('默认 --schema-only 不注册 execute_sql');
expect(markup).toContain('CLI 检测:远程 Agent 不需要检测本机 openclaw 命令');
expect(markup).toContain('复制 OpenClaw 远程接入说明');
});

View File

@@ -292,7 +292,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
lineHeight: 1.7,
}}
>
Windows GoNavi Agent MCP DDL使 GoNavi Streamable HTTP token
Windows GoNavi Agent schema-only MCP DDL execute_sql使 GoNavi Streamable HTTP token
</div>
)}
{remoteQuickStart && (
@@ -311,7 +311,7 @@ const AIMCPClientInstallPanel: React.FC<AIMCPClientInstallPanelProps> = ({
{remoteQuickStart.displayName} MCP
</div>
<div style={{ fontSize: 12, color: overlayTheme.mutedText, lineHeight: 1.7 }}>
Agent GUI/CLI Windows GoNavi 使 MCP URL Bearer Token
Agent GUI/CLI Windows GoNavi 使 MCP URL Bearer Token schema-only
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 10 }}>
{REMOTE_MCP_PARAMETER_GUIDES.map((item) => (

View File

@@ -0,0 +1,40 @@
import React from 'react';
interface AIPanelErrorBoundaryProps {
children: React.ReactNode;
fallback: (error: Error | null) => React.ReactNode;
onError?: (error: Error, errorInfo: React.ErrorInfo) => void;
}
interface AIPanelErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class AIPanelErrorBoundary extends React.Component<
AIPanelErrorBoundaryProps,
AIPanelErrorBoundaryState
> {
constructor(props: AIPanelErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): AIPanelErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
this.props.onError?.(error, errorInfo);
}
render() {
if (this.state.hasError) {
return this.props.fallback(this.state.error);
}
return this.props.children;
}
}
export default AIPanelErrorBoundary;

View File

@@ -78,6 +78,7 @@ describe('mcpClientInstallPanelState', () => {
expect(getMCPClientStatusTone(status, false).label).toBe('远程桥接');
expect(getMCPClientInstallStateLabel(status)).toBe('外部工具接入状态:需配置远程 MCP 桥接');
expect(getMCPClientOptionSummary(status)).toContain('默认 schema-only');
expect(getMCPClientOptionSummary(status)).toContain('不复制数据库密码');
expect(getMCPClientDetectionSummary(status)).toContain('本机无需检测 openclaw 命令');
expect(getSelectedMCPClientStateLine(status)).toContain('数据库密码仍留在 GoNavi 本机');

View File

@@ -101,7 +101,7 @@ export const getMCPClientOptionSummary = (status: AIMCPClientInstallStatus | und
return '接入状态读取异常,建议先刷新再处理。';
}
if (isRemoteMCPClientStatus(status)) {
return '适合云端 Agent通过远程 MCP 桥接读取 GoNavi 表结构,不复制数据库密码。';
return '适合云端 Agent默认 schema-only 读取 GoNavi 表结构,不复制数据库密码,不暴露 SQL 执行。';
}
return '尚未把当前 GoNavi MCP 接入到这里。';
};

View File

@@ -133,12 +133,13 @@ describe('mcpClientInstallStatus helpers', () => {
const guide = buildRemoteMCPClientGuide(openClaw);
expect(guide).toContain('GoNavi MCP 远程接入说明 - OpenClaw');
expect(guide).toContain('云端 Agent 不需要保存数据库密码');
expect(guide).toContain('默认使用 schema-only 模式,不注册 execute_sql');
expect(guide).toContain('不能直接使用 Windows 本地 stdio 命令');
expect(guide).toContain('allowMutating=true');
expect(guide).toContain('"type": "streamable-http"');
expect(guide).toContain('"Authorization": "Bearer <随机token>"');
expect(guide).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<你的域名或隧道地址>/mcp --token <随机token>');
expect(guide).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>');
expect(guide).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<你的域名或隧道地址>/mcp --token <随机token> --schema-only');
expect(guide).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only');
});
it('builds remote quick-start snippets for cloud agents without database secrets', () => {
@@ -152,10 +153,11 @@ describe('mcpClientInstallStatus helpers', () => {
expect(quickStart.configJson).toContain('"url": "https://<你的域名或隧道地址>/mcp"');
expect(quickStart.configJson).toContain('"Authorization": "Bearer <随机token>"');
expect(quickStart.configJson).not.toContain('password');
expect(quickStart.configCommand).toBe('GoNavi.exe mcp-server remote-config --client hermans --url https://<你的域名或隧道地址>/mcp --token <随机token>');
expect(quickStart.launchCommand).toBe('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>');
expect(quickStart.standaloneCommand).toBe('gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token>');
expect(quickStart.configCommand).toBe('GoNavi.exe mcp-server remote-config --client hermans --url https://<你的域名或隧道地址>/mcp --token <随机token> --schema-only');
expect(quickStart.launchCommand).toBe('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only');
expect(quickStart.standaloneCommand).toBe('gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only');
expect(quickStart.verificationSteps.join('\n')).toContain('get_connections');
expect(quickStart.securityNotes.join('\n')).toContain('默认 --schema-only 不注册 execute_sql');
expect(quickStart.securityNotes.join('\n')).toContain('allowMutating=true');
});
});

View File

@@ -232,7 +232,7 @@ export const buildRemoteMCPClientGuide = (
'目标:',
'- 数据库连接、账号和密码继续保存在 Windows 上的 GoNavi。云端 Agent 不需要保存数据库密码。',
'- 云端 Agent 只通过 MCP tools 读取 get_connections/get_databases/get_tables/get_columns/get_table_ddl 等结果。',
'- execute_sql 仍受 GoNavi AI 安全控制约束;写操作必须显式传 allowMutating=true。',
'- 远程接入默认使用 schema-only 模式,不注册 execute_sql适合只给 OpenClaw/Hermans 读取库表结构。',
'',
'当前边界:',
'- GoNavi 内置 MCP 本机入口是 stdio适合 Claude Code / Codex 这类和 GoNavi 在同一台机器上的客户端。',
@@ -253,6 +253,7 @@ export const buildRemoteMCPClientGuide = (
'CLI / 服务启动命令:',
quickStart.launchCommand,
`或设置环境变量GONAVI_MCP_HTTP_TOKEN=<随机token> 后运行 ${quickStart.standaloneCommand.replace(' --token <随机token>', '')}`,
'如果明确需要远程执行 SQL可去掉 --schema-only此时 execute_sql 仍受 GoNavi AI 安全控制约束,写操作必须显式传 allowMutating=true。',
'',
status?.message ? `当前提示:${status.message}` : '',
].filter((line, index, lines) => line || index < lines.length - 1).join('\n');
@@ -263,9 +264,9 @@ export const buildRemoteMCPClientQuickStart = (
): RemoteMCPClientQuickStart => {
const displayName = String(status?.displayName || '远程 Agent').trim();
const client = isMCPClientKey(String(status?.client || '')) ? String(status?.client || '').trim() : 'openclaw';
const launchCommand = `GoNavi.exe mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token>`;
const standaloneCommand = `gonavi-mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token>`;
const configCommand = `GoNavi.exe mcp-server remote-config --client ${client} --url ${DEFAULT_REMOTE_MCP_PUBLIC_URL} --token <随机token>`;
const launchCommand = `GoNavi.exe mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token> --schema-only`;
const standaloneCommand = `gonavi-mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token> --schema-only`;
const configCommand = `GoNavi.exe mcp-server remote-config --client ${client} --url ${DEFAULT_REMOTE_MCP_PUBLIC_URL} --token <随机token> --schema-only`;
const configJson = JSON.stringify({
mcpServers: {
gonavi: {
@@ -291,8 +292,9 @@ export const buildRemoteMCPClientQuickStart = (
],
securityNotes: [
'数据库账号和密码仍保存在 Windows GoNavi本段配置不要写数据库密码。',
'默认 --schema-only 不注册 execute_sql远程 Agent 只能走库表结构类工具。',
'HTTP MCP 必须使用随机 Bearer Token并放在 HTTPS、私有网络或受控隧道后面。',
'execute_sql 仍受 GoNavi AI 安全控制约束,写操作仍必须显式传 allowMutating=true。',
'如去掉 --schema-only 开放 execute_sql仍受 GoNavi AI 安全控制约束,写操作仍必须显式传 allowMutating=true。',
],
};
};