From 558966a1295645f0420633ba76ec428b0638b216 Mon Sep 17 00:00:00 2001 From: tianqijiuyun-latiao <69459608+tianqijiuyun-latiao@users.noreply.github.com> Date: Mon, 15 Jun 2026 14:35:08 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(i18n):=20=E6=8E=A8=E8=BF=9B?= =?UTF-8?q?=E5=85=AD=E8=AF=AD=E8=A8=80=E5=A4=9A=E8=AF=AD=E8=A8=80=E4=BD=93?= =?UTF-8?q?=E7=B3=BB=E4=B8=8E=E6=89=AB=E6=8F=8F=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增共享六语言词典、前端 i18n 运行时与语言设置入口 - 推进连接、驱动、数据网格、查询、AI、Redis、表设计等模块文案本地化 - 补充 raw 边界、SQL/驱动/更新场景测试与 i18n 扫描工具 --- frontend/package.json | 3 +- frontend/package.json.md5 | 2 +- frontend/src/App.tool-center.test.ts | 20 +- frontend/src/App.tsx | 653 +-- .../AIChatPanel.message-boundary.test.tsx | 1017 +++- frontend/src/components/AIChatPanel.tsx | 401 +- frontend/src/components/AISettingsModal.tsx | 184 +- .../ConnectionModal.edit-password.test.tsx | 20 + .../components/ConnectionModal.i18n.test.tsx | 1126 +++++ frontend/src/components/ConnectionModal.tsx | 1644 +++++-- .../ConnectionPackagePasswordModal.tsx | 26 +- frontend/src/components/DataGrid.ddl.test.tsx | 290 +- .../src/components/DataGrid.layout.test.tsx | 1747 ++++++- frontend/src/components/DataGrid.tsx | 429 +- .../DataGridColumnInfoPopoverContent.tsx | 33 +- .../components/DataGridColumnQuickFind.tsx | 9 +- .../components/DataGridColumnTitle.test.tsx | 46 +- .../src/components/DataGridColumnTitle.tsx | 13 +- .../DataGridLegacyCellContextMenu.tsx | 45 +- frontend/src/components/DataGridModals.tsx | 52 +- frontend/src/components/DataGridPageFind.tsx | 134 +- .../src/components/DataGridPaginationBar.tsx | 21 +- .../src/components/DataGridPreviewPanel.tsx | 13 +- .../src/components/DataGridRecordViews.tsx | 25 +- .../components/DataGridResultViewSwitcher.tsx | 11 +- .../components/DataGridSecondaryActions.tsx | 29 +- .../src/components/DataGridToolbarFrame.tsx | 101 +- .../src/components/DataGridV2DdlWorkspace.tsx | 86 +- .../components/DataGridV2MetadataViews.tsx | 29 +- .../src/components/DataSyncModal.i18n.test.ts | 39 + frontend/src/components/DataSyncModal.tsx | 315 +- .../DriverManagerModal.i18n.test.tsx | 805 +++ .../src/components/DriverManagerModal.tsx | 742 ++- .../components/LanguageSettingsPanel.test.tsx | 45 + .../src/components/LanguageSettingsPanel.tsx | 39 + .../QueryEditor.external-sql-save.test.tsx | 2232 ++++++++- frontend/src/components/QueryEditor.tsx | 611 ++- .../src/components/RedisViewer.i18n.test.ts | 41 + frontend/src/components/RedisViewer.tsx | 410 +- .../Sidebar.locate-toolbar.test.tsx | 1176 ++++- frontend/src/components/Sidebar.tsx | 937 ++-- .../components/Sidebar.v2-metadata.test.tsx | 45 +- .../src/components/TabManager.hover.test.tsx | 133 +- frontend/src/components/TabManager.tsx | 124 +- .../src/components/TableDesigner.i18n.test.ts | 41 + frontend/src/components/TableDesigner.tsx | 490 +- .../TableDesignerSqlPreview.test.tsx | 120 + .../src/components/TableOverview.i18n.test.ts | 88 + frontend/src/components/TableOverview.tsx | 197 +- .../src/components/V2TableContextMenu.tsx | 263 +- .../components/ai/AIChatHeader.i18n.test.tsx | 276 ++ frontend/src/components/ai/AIChatHeader.tsx | 57 +- .../components/ai/AIChatWelcome.i18n.test.tsx | 273 ++ frontend/src/components/ai/AIChatWelcome.tsx | 64 +- .../ai/AIHistoryDrawer.i18n.test.tsx | 220 + .../src/components/ai/AIHistoryDrawer.tsx | 26 +- .../src/components/dataGridCopyInsert.test.ts | 46 +- frontend/src/components/dataGridCopyInsert.ts | 31 +- .../src/components/dataSyncRequest.test.ts | 8 +- frontend/src/components/dataSyncRequest.ts | 16 +- .../src/components/useDataGridFilters.tsx | 41 +- frontend/src/i18n/catalog.test.ts | 2288 +++++++++ frontend/src/i18n/catalog.ts | 37 + frontend/src/i18n/frameworkLocale.ts | 24 + frontend/src/i18n/i18n.test.ts | 255 + frontend/src/i18n/index.ts | 198 + frontend/src/i18n/provider.test.tsx | 71 + frontend/src/i18n/provider.tsx | 60 + frontend/src/i18n/resolveLanguage.test.ts | 29 + frontend/src/i18n/resolveLanguage.ts | 47 + frontend/src/i18n/runtime.test.ts | 255 + frontend/src/i18n/runtime.ts | 70 + frontend/src/i18n/types.ts | 3 + frontend/src/main.browserMock.test.ts | 259 +- frontend/src/main.tsx | 75 +- frontend/src/store.test.ts | 112 + frontend/src/store.ts | 57 +- frontend/src/utils/aiComposerNotice.test.ts | 65 +- frontend/src/utils/aiComposerNotice.ts | 59 +- .../src/utils/browserMockConnections.test.ts | 3 +- frontend/src/utils/browserMockConnections.ts | 6 +- frontend/src/utils/connectionExport.test.ts | 11 +- frontend/src/utils/connectionExport.ts | 23 +- .../utils/connectionModalPresentation.test.ts | 94 +- .../src/utils/connectionModalPresentation.ts | 118 +- frontend/src/utils/customConnectionDsn.ts | 6 +- frontend/src/utils/dataGridPagination.test.ts | 96 +- frontend/src/utils/dataGridPagination.ts | 33 +- .../src/utils/driverImportGuidance.test.ts | 154 +- frontend/src/utils/driverImportGuidance.ts | 17 +- frontend/src/utils/externalSqlTree.test.ts | 50 +- frontend/src/utils/externalSqlTree.ts | 34 +- frontend/src/utils/shortcuts.test.ts | 33 +- frontend/src/utils/shortcuts.ts | 173 +- frontend/src/utils/sqlDialect.test.ts | 54 + frontend/src/utils/sqlDialect.ts | 394 +- frontend/wailsjs/go/aiservice/Service.d.ts | 2 + frontend/wailsjs/go/aiservice/Service.js | 4 + frontend/wailsjs/go/app/App.d.ts | 2 + frontend/wailsjs/go/app/App.js | 4 + internal/ai/service/config_store.go | 40 +- internal/ai/service/provider_secret.go | 29 +- internal/ai/service/provider_secret_test.go | 138 + internal/ai/service/service.go | 220 +- internal/ai/service/service_qwen_test.go | 28 + internal/app/app.go | 51 + internal/app/methods_driver.go | 384 +- .../app/methods_driver_local_import_test.go | 38 + internal/app/methods_driver_version_test.go | 97 +- internal/app/methods_update.go | 133 +- internal/app/methods_update_test.go | 13 + shared/i18n/catalog.go | 164 + shared/i18n/catalog_test.go | 85 + shared/i18n/de-DE.json | 4362 +++++++++++++++++ shared/i18n/en-US.json | 4362 +++++++++++++++++ shared/i18n/ja-JP.json | 4362 +++++++++++++++++ shared/i18n/locales.ts | 14 + shared/i18n/messages.ts | 1747 +++++++ shared/i18n/ru-RU.json | 4362 +++++++++++++++++ shared/i18n/translate.ts | 24 + shared/i18n/zh-CN.json | 4362 +++++++++++++++++ shared/i18n/zh-TW.json | 4362 +++++++++++++++++ tools/i18n-scan/main.go | 368 ++ tools/i18n-scan/main_test.go | 415 ++ 124 files changed, 49584 insertions(+), 4011 deletions(-) create mode 100644 frontend/src/components/ConnectionModal.i18n.test.tsx create mode 100644 frontend/src/components/DataSyncModal.i18n.test.ts create mode 100644 frontend/src/components/DriverManagerModal.i18n.test.tsx create mode 100644 frontend/src/components/LanguageSettingsPanel.test.tsx create mode 100644 frontend/src/components/LanguageSettingsPanel.tsx create mode 100644 frontend/src/components/RedisViewer.i18n.test.ts create mode 100644 frontend/src/components/TableDesigner.i18n.test.ts create mode 100644 frontend/src/components/TableOverview.i18n.test.ts create mode 100644 frontend/src/components/ai/AIChatHeader.i18n.test.tsx create mode 100644 frontend/src/components/ai/AIChatWelcome.i18n.test.tsx create mode 100644 frontend/src/components/ai/AIHistoryDrawer.i18n.test.tsx create mode 100644 frontend/src/i18n/catalog.test.ts create mode 100644 frontend/src/i18n/catalog.ts create mode 100644 frontend/src/i18n/frameworkLocale.ts create mode 100644 frontend/src/i18n/i18n.test.ts create mode 100644 frontend/src/i18n/index.ts create mode 100644 frontend/src/i18n/provider.test.tsx create mode 100644 frontend/src/i18n/provider.tsx create mode 100644 frontend/src/i18n/resolveLanguage.test.ts create mode 100644 frontend/src/i18n/resolveLanguage.ts create mode 100644 frontend/src/i18n/runtime.test.ts create mode 100644 frontend/src/i18n/runtime.ts create mode 100644 frontend/src/i18n/types.ts create mode 100644 shared/i18n/catalog.go create mode 100644 shared/i18n/catalog_test.go create mode 100644 shared/i18n/de-DE.json create mode 100644 shared/i18n/en-US.json create mode 100644 shared/i18n/ja-JP.json create mode 100644 shared/i18n/locales.ts create mode 100644 shared/i18n/messages.ts create mode 100644 shared/i18n/ru-RU.json create mode 100644 shared/i18n/translate.ts create mode 100644 shared/i18n/zh-CN.json create mode 100644 shared/i18n/zh-TW.json create mode 100644 tools/i18n-scan/main.go create mode 100644 tools/i18n-scan/main_test.go diff --git a/frontend/package.json b/frontend/package.json index 4181217a..45be8746 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,7 +7,8 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", - "test": "vitest run" + "test": "vitest run", + "i18n:scan": "go run ../tools/i18n-scan --root .." }, "dependencies": { "@ant-design/icons": "^5.2.6", diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 7396e243..0a899ccd 100755 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -d0464f9da25e9356e61652e638c99ffe \ No newline at end of file +9cd8f4622f2dcccbe605710ea36885d4 \ No newline at end of file diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 5ad5a57f..85628d07 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -24,7 +24,8 @@ const getGlobalShortcutCaseBlock = (action: string) => { describe('tool center menu entries', () => { it('exposes snippet management next to shortcut management', () => { expect(appSource).toContain("key: 'snippet-settings'"); - expect(appSource).toContain("title: '代码片段管理'"); + expect(appSource).toContain("title: t('app.tools.entry.snippets.title')"); + expect(appSource).toContain("description: t('app.tools.entry.snippets.description')"); expect(appSource).toContain('setIsSnippetModalOpen(true)'); const snippetIndex = appSource.indexOf("key: 'snippet-settings'"); @@ -66,7 +67,10 @@ describe('tool center menu entries', () => { it('lets the v2 Sidebar own the entire left layout instead of stacking legacy controls above it', () => { const siderIndex = appSource.indexOf("className={isV2Ui ? 'gn-v2-app-sider' : undefined}"); const legacyGuardIndex = appSource.indexOf('{!isV2Ui && (', siderIndex); - const legacyCreateIndex = appSource.indexOf('新建连接', legacyGuardIndex); + const legacyCreateIndex = appSource.indexOf(' - @@ -3297,7 +3361,7 @@ function App() { }} role="separator" aria-orientation="vertical" - title="拖动调整宽度" + title={t('app.sidebar.resize_width')} style={{ position: 'absolute', right: 0, @@ -3348,7 +3412,7 @@ function App() { pointerEvents: 'auto' }} > - SQL 执行日志 + {t('app.sidebar.sql_execution_log')} )} @@ -3394,7 +3458,7 @@ function App() { - + + @@ -3514,7 +3578,7 @@ function App() { )} {isToolsModalOpen && ( , '工具中心', '集中处理连接配置、同步、驱动和快捷键相关操作。')} + title={renderUtilityModalTitle(, t('app.tools.title'), t('app.tools.description'))} open={isToolsModalOpen} onCancel={() => setIsToolsModalOpen(false)} footer={null} @@ -3526,8 +3590,8 @@ function App() { { key: 'import', icon: , - title: '导入连接配置', - description: '从本地文件恢复连接列表。', + title: t('app.tools.entry.import.title'), + description: t('app.tools.entry.import.description'), onClick: () => { setIsToolsModalOpen(false); void handleImportConnections(); @@ -3536,8 +3600,8 @@ function App() { { key: 'export', icon: , - title: '导出连接配置', - description: '导出当前连接与可见配置字段。', + title: t('app.tools.entry.export.title'), + description: t('app.tools.entry.export.description'), onClick: () => { setIsToolsModalOpen(false); void handleExportConnections(); @@ -3546,8 +3610,8 @@ function App() { { key: 'sync', icon: , - title: '数据同步', - description: '进入跨源同步工作流。', + title: t('app.tools.entry.sync.title'), + description: t('app.tools.entry.sync.description'), onClick: () => { setIsToolsModalOpen(false); setIsSyncModalOpen(true); @@ -3556,8 +3620,8 @@ function App() { { key: 'drivers', icon: , - title: '驱动管理', - description: '安装、更新或移除数据库驱动。', + title: t('app.tools.entry.drivers.title'), + description: t('app.tools.entry.drivers.description'), onClick: () => { setIsToolsModalOpen(false); setIsDriverModalOpen(true); @@ -3566,8 +3630,8 @@ function App() { { key: 'data-root', icon: , - title: '数据目录', - description: '查看、切换或迁移本地数据存储位置。', + title: t('app.tools.entry.data_root.title'), + description: t('app.tools.entry.data_root.description'), onClick: () => { setIsToolsModalOpen(false); setIsDataRootModalOpen(true); @@ -3576,8 +3640,8 @@ function App() { { key: 'snippet-settings', icon: , - title: '代码片段管理', - description: '管理 SQL 代码片段和前缀补全。', + title: t('app.tools.entry.snippets.title'), + description: t('app.tools.entry.snippets.description'), onClick: () => { setIsToolsModalOpen(false); setIsSnippetModalOpen(true); @@ -3586,8 +3650,8 @@ function App() { { key: 'shortcut-settings', icon: , - title: '快捷键管理', - description: '查看并调整全局快捷键绑定。', + title: t('app.tools.entry.shortcuts.title'), + description: t('app.tools.entry.shortcuts.description'), onClick: () => { setIsToolsModalOpen(false); setIsShortcutModalOpen(true); @@ -3596,10 +3660,10 @@ function App() { { key: 'security-update', icon: , - title: '安全更新', + title: t('app.tools.entry.security_update.title'), description: securityUpdateEntryVisibility.showDetailEntry || securityUpdateHasLegacySensitiveItems - ? `当前状态:${securityUpdateStatusMeta.label}` - : '查看已保存配置的安全更新状态。', + ? t('app.tools.entry.security_update.status_description', { status: securityUpdateStatusMeta.label }) + : t('app.tools.entry.security_update.description'), onClick: () => { setIsToolsModalOpen(false); setIsSecurityUpdateSettingsOpen(true); @@ -3621,7 +3685,7 @@ function App() { )} {isSettingsModalOpen && ( , '设置中心', '集中处理代理、主题、AI 与关于等通用配置入口。')} + title={renderUtilityModalTitle(, t('app.settings.title'), t('app.settings.description'))} open={isSettingsModalOpen} onCancel={() => setIsSettingsModalOpen(false)} footer={null} @@ -3630,11 +3694,21 @@ function App() { >
{[ + { + key: 'language', + icon: , + title: t('settings.language.title'), + description: t('settings.language.description'), + onClick: () => { + setIsSettingsModalOpen(false); + setIsLanguageModalOpen(true); + }, + }, { key: 'theme', icon: , - title: '主题与外观', - description: '切换亮暗主题并调整界面观感。', + title: t('app.settings.entry.theme.title'), + description: t('app.settings.entry.theme.description'), onClick: () => { setIsSettingsModalOpen(false); setThemeModalSection('theme'); @@ -3644,8 +3718,8 @@ function App() { { key: 'proxy', icon: , - title: '全局代理', - description: '统一配置更新检查、驱动管理和公共网络出口。', + title: t('app.settings.entry.proxy.title'), + description: t('app.settings.entry.proxy.description'), onClick: () => { setIsSettingsModalOpen(false); setSecurityUpdateRepairSource(null); @@ -3655,8 +3729,8 @@ function App() { { key: 'ai', icon: , - title: 'AI 设置', - description: '管理模型供应商、密钥和默认行为。', + title: t('app.settings.entry.ai.title'), + description: t('app.settings.entry.ai.description'), onClick: () => { setIsSettingsModalOpen(false); handleOpenAISettings(); @@ -3665,8 +3739,8 @@ function App() { { key: 'about', icon: , - title: '关于 GoNavi', - description: '查看版本信息、仓库地址和更新状态。', + title: t('app.settings.entry.about.title'), + description: t('app.settings.entry.about.description'), onClick: () => { setIsSettingsModalOpen(false); setIsAboutOpen(true); @@ -3686,9 +3760,21 @@ function App() {
)} + {isLanguageModalOpen && ( + , t('settings.language.title'), t('settings.language.description'))} + open={isLanguageModalOpen} + onCancel={() => setIsLanguageModalOpen(false)} + footer={null} + width={520} + styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10 } }} + > + + + )} {isDataRootModalOpen && ( , '数据存储位置', '统一管理连接、代理、AI 配置与驱动等文件型数据的根目录。')} + title={renderUtilityModalTitle(, t('app.data_root.title'), t('app.data_root.description'))} open={isDataRootModalOpen} onCancel={() => setIsDataRootModalOpen(false)} footer={null} @@ -3702,54 +3788,54 @@ function App() { ) : (
-
当前目录
+
{t('app.data_root.current_directory')}
-
默认目录
+
{t('app.data_root.default_directory')}
{dataRootInfo?.defaultPath || '-'}
-
驱动目录
+
{t('app.data_root.driver_directory')}
{dataRootInfo?.driverPath || '-'}
-
切换目标
+
{t('app.data_root.switch_target')}
-
应用方式
+
{t('app.data_root.apply_method')}
- 切换后建议重启应用,以确保 AI 与其他长生命周期模块完全切换到新目录。敏感密码仍保存在系统 secret store,不会随文件目录迁移。 + {t('app.data_root.restart_hint')}
@@ -3810,14 +3896,18 @@ function App() { )} { setConnectionPackageDialog((current) => ({ ...current, @@ -3848,25 +3938,25 @@ function App() { onCancel={closeConnectionPackageDialog} /> , '关于 GoNavi', '查看版本信息、仓库地址、更新状态与下载入口。')} + title={renderUtilityModalTitle(, t('app.about.title'), t('app.about.description'))} open={isAboutOpen} onCancel={() => setIsAboutOpen(false)} styles={{ content: utilityModalShellStyle, header: { background: 'transparent', borderBottom: 'none', paddingBottom: 8 }, body: { paddingTop: 8 }, footer: { background: 'transparent', borderTop: 'none', paddingTop: 10, display: 'flex', flexWrap: 'wrap', gap: 10, justifyContent: 'flex-end' } }} footer={[ isBackgroundProgressForLatestUpdate && !isLatestUpdateDownloaded ? ( - + ) : null, lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? ( - + ) : null, - , - , + , + , lastUpdateInfo?.hasUpdate && !isLatestUpdateDownloaded && !isBackgroundProgressForLatestUpdate ? ( - + ) : null, isLatestUpdateDownloaded ? ( ) : null, ].filter(Boolean)} @@ -3880,45 +3970,45 @@ function App() {
-
版本
+
{t('app.about.field.version')}
{aboutDisplayVersion}
-
作者
-
{aboutInfo?.author || '未知'}
+
{t('app.about.field.author')}
+
{aboutInfo?.author || t('common.unknown')}
-
更新状态
-
{aboutUpdateStatus || '未检查'}
+
{t('app.about.field.update_status')}
+
{aboutUpdateStatus || t('app.about.update_status.not_checked')}
{aboutInfo?.communityUrl ? ( ) : null}
@@ -3930,10 +4020,10 @@ function App() { : , - themeModalSection === 'theme' ? '主题设置' : '外观设置', + themeModalSection === 'theme' ? t('app.theme.theme_settings_title') : t('app.theme.appearance_settings_title'), themeModalSection === 'theme' - ? '切换亮暗主题,保持整体视觉风格统一。' - : '统一调整缩放、字体、透明度与模糊效果。' + ? t('app.theme.theme_settings_description') + : t('app.theme.appearance_settings_description') )} open={isThemeModalOpen} onCancel={() => { setIsThemeModalOpen(false); setThemeModalSection('theme'); }} @@ -3943,11 +4033,11 @@ function App() { >
-
设置导航
+
{t('app.theme.navigation_title')}
{[ - { key: 'theme', title: '主题模式', description: '亮色与暗色切换', icon: }, - { key: 'appearance', title: '外观参数', description: '缩放、字体与透明度', icon: }, + { key: 'theme', title: t('app.theme.nav.theme.title'), description: t('app.theme.nav.theme.description'), icon: }, + { key: 'appearance', title: t('app.theme.nav.appearance.title'), description: t('app.theme.nav.appearance.description'), icon: }, ].map((item) => { const active = themeModalSection === item.key; return ( @@ -3986,7 +4076,7 @@ function App() {
- 界面版本 + {t('app.theme.ui_version.title')} - NEW + {t('app.theme.ui_version.badge.new')}
- 在保留全部功能的前提下切换整体外观,新版采用更紧凑的信息层级与更现代的视觉语言。 + {t('app.theme.ui_version.description')}
{[ - { key: 'legacy', label: '旧版 UI', description: '当前稳定界面,所有功能完整可用。', badge: '默认' }, - { key: 'v2', label: '新版 UI', description: '重新设计的紧凑界面,强化 AI 入口与表概览。', badge: 'Beta' }, + { key: 'legacy', label: t('app.theme.ui_version.legacy.label'), description: t('app.theme.ui_version.legacy.description'), badge: t('app.theme.ui_version.legacy.badge') }, + { key: 'v2', label: t('app.theme.ui_version.v2.label'), description: t('app.theme.ui_version.v2.description'), badge: t('app.theme.ui_version.v2.badge') }, ].map((item) => { const active = (appearance.uiVersion ?? 'legacy') === item.key; return ( @@ -4059,7 +4149,7 @@ function App() { })}
- Windows、macOS 与 Linux 均可切换;切换后立即生效,部分弹窗会在下次打开时使用新样式。 + {t('app.theme.ui_version.platform_hint')}
{appearance.uiVersion === 'v2' && (
- 新版 UI 仍在 Beta,部分屏幕样式可能与旧版有差异,遇到问题可随时切回。 + {t('app.theme.ui_version.beta_warning')}
)}
-
主题模式
+
{t('app.theme.mode_title')}
{[ - { key: 'light', label: '亮色主题', description: '适合明亮环境,层次更轻。' }, - { key: 'dark', label: '暗色主题', description: '适合低光环境,视觉更沉稳。' }, + { key: 'light', label: t('app.theme.mode.light.label'), description: t('app.theme.mode.light.description') }, + { key: 'dark', label: t('app.theme.mode.dark.label'), description: t('app.theme.mode.dark.description') }, ].map((item) => { const active = themeMode === item.key; return ( @@ -4119,7 +4209,7 @@ function App() { ) : (
-
界面缩放 (UI Scale)
+
{t('app.theme.appearance.ui_scale_title')}
{Math.round(effectiveUiScale * 100)}%
- * 建议小屏设备设置为 85%-95% + {t('app.theme.appearance.ui_scale_hint')}
-
基础字体大小 (Font Size)
+
{t('app.theme.appearance.font_size_title')}
-
字体族
+
{t('app.theme.font_family.title')}
-
界面字体 (UI Font Family)
+
{t('app.theme.font_family.ui_title')}
{fontFamiliesLoadError - ? '当前已回退常见代码字体预置。作用于 SQL 编辑器、AI 代码块、日志、DDL 与数据表等宽内容。' - : '优先展示当前系统已安装字体,名称接近 Mono/Code/Console 的字体会靠前。作用于 SQL 编辑器、AI 代码块、日志、DDL 与数据表等宽内容。'} + ? t('app.theme.font_family.mono_fallback_hint') + : t('app.theme.font_family.mono_hint')}
-
透明与模糊效果
+
{t('app.theme.appearance.transparency_blur_title')}
-
启用透明与模糊
-
关闭后保留当前阈值,重新开启时直接恢复之前的设置。
+
{t('app.theme.appearance.enable_transparency_blur')}
+
{t('app.theme.appearance.enable_transparency_blur_hint')}
setAppearance({ enabled: checked })} />
-
背景不透明度 (Opacity)
+
{t('app.theme.appearance.opacity_title')}
-
高斯模糊 (Blur)
+
{t('app.theme.appearance.blur_title')}
{isWindowsPlatform() ? (
- Windows 使用系统 Acrylic 效果,模糊程度由系统控制 + {t('app.theme.appearance.windows_acrylic_hint')}
) : ( <> @@ -4263,7 +4353,7 @@ function App() { {appearance.blur}px
- * 仅控制应用内覆盖层的模糊效果 + {t('app.theme.appearance.blur_hint')}
)} @@ -4271,12 +4361,12 @@ function App() {
-
数据表显示
+
{t('app.theme.data_table.title')}
-
显示数据表竖向分隔线
-
仅作用于数据表页面 DataGrid,不影响其他表格组件。
+
{t('app.theme.data_table.vertical_borders')}
+
{t('app.theme.data_table.vertical_borders_hint')}
-
表格密度
+
{t('app.theme.data_table.density')}
({ + ...option, + label: t(`app.theme.data_table.density.${option.value}`), + }))} value={appearance.dataTableDensity} onChange={(value) => setAppearance({ dataTableDensity: sanitizeDataTableDensity(value) })} />
- 控制行高、列宽和内边距。舒适适合大屏细看;紧凑适合最大化信息密度。已手动拖拽的列宽优先保留。 + {t('app.theme.data_table.density_hint')}
-
数据表字体大小
+
{t('app.theme.data_table.font_size')}
@@ -4329,7 +4422,7 @@ function App() {
-
左侧库表字体大小
+
{t('app.theme.data_table.sidebar_tree_font_size')}
@@ -4363,11 +4456,11 @@ function App() {
{isMacRuntime ? (
-
macOS 窗口控制
+
{t('app.theme.mac_window.title')}
-
使用 macOS 原生窗口控制
-
启用后显示左上角红黄绿按钮,并优先使用 macOS 原生全屏行为。
+
{t('app.theme.mac_window.use_native_controls')}
+
{t('app.theme.mac_window.use_native_controls_hint')}
- * 已同步隐藏右上角自定义按钮;如系统窗口样式未立即刷新,可重启应用后再确认 + {t('app.theme.mac_window.restart_hint')}
) : null}
-
启动窗口
+
{t('app.theme.startup_window.title')}
- {isWindowsRuntime ? '启动时全屏(Windows 按最大化处理)' : '启动时全屏'} + {isWindowsRuntime ? t('app.theme.startup_window.fullscreen_windows') : t('app.theme.startup_window.fullscreen')} setStartupFullscreen(checked)} />
- {isWindowsRuntime ? '* Windows 下该选项按“启动时最大化”处理,修改后下次启动生效' : '* 修改后下次启动生效'} + {isWindowsRuntime ? t('app.theme.startup_window.windows_hint') : t('app.theme.startup_window.hint')}
@@ -4397,7 +4490,7 @@ function App() { setAppearance({ ...DEFAULT_APPEARANCE }); }} > - 恢复默认 + {t('app.theme.action.restore_defaults')}
@@ -4409,7 +4502,7 @@ function App() { {isShortcutModalOpen && ( , '快捷键管理', '统一查看、录制与启停常用快捷键,保持操作习惯一致。')} + title={renderUtilityModalTitle(, t('app.shortcuts.title'), t('app.shortcuts.description'))} open={isShortcutModalOpen} onCancel={() => { setIsShortcutModalOpen(false); @@ -4433,13 +4526,13 @@ function App() { , + resetShortcutOptions(); + setCapturingShortcutAction(null); + void message.success(t('app.shortcuts.message.restored_defaults')); + }} + > + {t('app.shortcuts.action.restore_defaults')} + , , ]} >
- 点击“录制”后按下快捷键。按 Esc 可取消录制。全局快捷键建议包含修饰键;AI 聊天发送仅支持 Enter 相关组合,Shift+Enter 保留换行。 + {t('app.shortcuts.capture_hint')}
{SHORTCUT_ACTION_ORDER.map((action) => { @@ -4485,25 +4578,25 @@ function App() { {conflictInfo && (
{conflictInfo.hasMonaco && ( - <>⚠ 已覆盖编辑器「{conflictInfo.monacoLabels}」默认快捷键 - )} - {conflictInfo.hasOther && ( - <>⚠ 与{conflictInfo.otherContexts}「{conflictInfo.otherLabels}」冲突,可能失效 - )} + <>⚠ {t('app.shortcuts.message.reserved_conflict_info', { labels: conflictInfo.monacoLabels })} + )} + {conflictInfo.hasOther && ( + <>⚠ {t('app.shortcuts.message.reserved_conflict_warning', { contexts: conflictInfo.otherContexts, labels: conflictInfo.otherLabels })} + )}
)}
, '全局代理设置', '统一配置更新检查、驱动管理与未单独指定代理的连接网络出口。')} + title={renderUtilityModalTitle(, t('app.proxy.title'), t('app.proxy.description'))} open={isProxyModalOpen} onCancel={handleCloseGlobalProxySettings} footer={null} @@ -4535,14 +4628,14 @@ function App() { >
-
全局代理
+
{t('app.proxy.section_title')}
- 启用全局代理 + {t('app.proxy.enable')} setGlobalProxy({ enabled: checked })} />
-
代理类型
+
{t('app.proxy.type')}
setGlobalProxy({ host: e.target.value })} />
-
用户名(可选)
+
{t('app.proxy.username_optional')}
-
密码(可选)
+
{t('app.proxy.password_optional')}
- * 作用于更新检查、驱动管理网络请求,以及未单独配置代理的数据库连接 + {t('app.proxy.scope_hint')}
@@ -4603,7 +4696,9 @@ function App() { )} - 隐藏到后台 + {t('app.about.action.hide_to_background')} ] : (updateDownloadProgress.status === 'done' ? [ - , + , ] : (updateDownloadProgress.status === 'error' ? [ - + ] : null))} >
diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index 52c792b5..1d6eaccf 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -1,15 +1,1030 @@ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; +import { catalogs } from '../i18n/catalog'; +import { SUPPORTED_LANGUAGES } from '../i18n/resolveLanguage'; + const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8'); +const renderErrorKeys = [ + 'ai_chat.panel.render_error.title', + 'ai_chat.panel.render_error.description', + 'ai_chat.panel.render_error.unknown', + 'ai_chat.panel.render_error.retry', + 'ai_chat.panel.render_error.delete', +] as const; + +const panelFallbackKeys = [ + 'ai_chat.panel.history.empty', + 'ai_chat.panel.session.default_title', +] as const; + +const composerNoticeKeys = [ + 'ai_chat.composer_notice.missing_provider.title', + 'ai_chat.composer_notice.missing_provider.description', + 'ai_chat.composer_notice.missing_model.title', + 'ai_chat.composer_notice.missing_model.description', + 'ai_chat.composer_notice.model_fetch_failed.title', + 'ai_chat.composer_notice.model_fetch_failed.default_description', + 'ai_chat.composer_notice.model_fetch_failed.detail_description', +] as const; + +const sendLifecycleKeys = [ + 'ai_chat.panel.status.model_connecting', + 'ai_chat.panel.status.waking_engine', + 'ai_chat.panel.status.waiting_response', + 'ai_chat.panel.message.service_not_ready', + 'ai_chat.panel.message.send_failed', +] as const; + +const toolTransitionAndErrorKeys = [ + 'ai_chat.panel.message.error', + 'ai_chat.panel.message.empty_response', + 'ai_chat.panel.message.request_interrupted', + 'ai_chat.panel.status.summarizing_probe', + 'ai_chat.panel.status.returning_runtime_data', + 'ai_chat.panel.status.deep_reasoning', + 'ai_chat.panel.status.waiting_instruction', + 'ai_chat.panel.status.analyzing_chain', +] as const; + +const modelControlKeys = [ + 'ai_chat.panel.model_control.force_tool_call', + 'ai_chat.panel.model_control.continue_after_summary', +] as const; + +const memoryAndSanitizedErrorKeys = [ + 'ai_chat.panel.status.memory_compressing', + 'ai_chat.panel.status.memory_compress_failed', + 'ai_chat.panel.status.memory_summary', + 'ai_chat.panel.status.memory_probe_summary', + 'ai_chat.panel.error.unknown', + 'ai_chat.panel.error.http_server', + 'ai_chat.panel.error.html_response', + 'ai_chat.panel.error.truncated_suffix', +] as const; + +const memorySummaryPromptKey = 'ai_chat.panel.prompt.memory_summary' as const; + +const jvmDiagnosticPromptKey = 'ai_chat.panel.prompt.jvm_diagnostic' as const; + +const jvmRuntimePromptKey = 'ai_chat.panel.prompt.jvm_runtime' as const; + +const sqlPromptKeys = [ + 'ai_chat.panel.prompt.sql.context_tables', + 'ai_chat.panel.prompt.sql.current_database', + 'ai_chat.panel.prompt.sql.no_context', + 'ai_chat.panel.prompt.sql.no_connections', +] as const; + +const jvmDiagnosticPolicyKeys = [ + 'ai_chat.panel.jvm_diagnostic.policy.read_only', + 'ai_chat.panel.jvm_diagnostic.policy.plan_first', + 'ai_chat.panel.jvm_diagnostic.permission.allowed', + 'ai_chat.panel.jvm_diagnostic.permission.forbidden', +] as const; + +const jvmRuntimePolicyKeys = [ + 'ai_chat.panel.jvm_runtime.policy.read_only', + 'ai_chat.panel.jvm_runtime.policy.preview_required', + 'ai_chat.panel.jvm_runtime.resource_path.current', + 'ai_chat.panel.jvm_runtime.resource_path.missing', +] as const; + +const executeLocalToolWrapperKeys = [ + 'ai_chat.panel.tool_result.columns_exact_fields', + 'ai_chat.panel.tool_error.connection_not_found', + 'ai_chat.panel.tool_error.unknown_function', + 'ai_chat.panel.tool_error.fetch_databases_failed', + 'ai_chat.panel.tool_error.fetch_tables_failed', + 'ai_chat.panel.tool_error.fetch_columns_failed', + 'ai_chat.panel.tool_error.fetch_table_ddl_failed', + 'ai_chat.panel.tool_error.sql_blocked', + 'ai_chat.panel.tool_error.sql_execute_failed', + 'ai_chat.panel.tool_error.sql_execute_exception', + 'ai_chat.panel.probe.max_rounds', + 'ai_chat.panel.probe.consecutive_failed', +] as const; + +const localToolSchemaKeys = [ + 'ai_chat.panel.local_tool.get_connections.description', + 'ai_chat.panel.local_tool.get_databases.description', + 'ai_chat.panel.local_tool.get_tables.description', + 'ai_chat.panel.local_tool.get_columns.description', + 'ai_chat.panel.local_tool.get_table_ddl.description', + 'ai_chat.panel.local_tool.execute_sql.description', + 'ai_chat.panel.local_tool.param.connection_id', + 'ai_chat.panel.local_tool.param.connection_id_from_get_connections', + 'ai_chat.panel.local_tool.param.db_name', + 'ai_chat.panel.local_tool.param.table_name', + 'ai_chat.panel.local_tool.param.sql', +] as const; + +const localToolFunctionNames = [ + 'get_connections', + 'get_databases', + 'get_tables', + 'get_columns', + 'get_table_ddl', + 'execute_sql', +] as const; + +const localToolSchemaRawSnippets = [ + "type: 'function'", + "type: 'object'", + "type: 'string'", + "connectionId: { type: 'string'", + "dbName: { type: 'string'", + "tableName: { type: 'string'", + "sql: { type: 'string'", + "required: ['connectionId']", + "required: ['connectionId', 'dbName']", + "required: ['connectionId', 'dbName', 'tableName']", + "required: ['connectionId', 'dbName', 'sql']", +] as const; + +const fixedChineseLocalToolSchemaSnippets = [ + '当需要查询、操作数据库但用户没有选择任何连接上下文时', + '获取指定连接(connectionId)下的所有数据库', + '当已经确定了目标连接和数据库名后', + '获取指定表的字段列表', + '获取指定表的完整建表语句', + '在指定连接和数据库上执行 SQL 查询并返回结果', + '连接ID', + '数据库名', + '表名', + '要执行的 SQL 语句', +] as const; + +const aiInsightKeys = [ + 'ai_chat.panel.insight.context.linked_title', + 'ai_chat.panel.insight.context.empty_title', + 'ai_chat.panel.insight.context.linked_body', + 'ai_chat.panel.insight.context.empty_body', + 'ai_chat.panel.insight.context.table_separator', + 'ai_chat.panel.insight.context.more_tables_suffix', + 'ai_chat.panel.insight.query.slowest_title', + 'ai_chat.panel.insight.query.empty_title', + 'ai_chat.panel.insight.query.empty_body', + 'ai_chat.panel.insight.status.failed_title', + 'ai_chat.panel.insight.status.ok_title', + 'ai_chat.panel.insight.status.recent_body', + 'ai_chat.panel.insight.status.empty_body', + 'ai_chat.panel.insight.write.detected_title', + 'ai_chat.panel.insight.write.readonly_title', + 'ai_chat.panel.insight.write.detected_body', + 'ai_chat.panel.insight.write.readonly_body', +] as const; + +const getPlaceholders = (value: string): string[] => + Array.from(value.matchAll(/\{\{([A-Za-z0-9_]+)\}\}/g), (match) => match[1]).sort(); + +const countOccurrences = (value: string, needle: string): number => value.split(needle).length - 1; + +const getStreamSubscriptionEffect = (): string => { + const marker = 'EventsOn(eventName, handler);'; + const markerIndex = source.indexOf(marker); + expect(markerIndex).toBeGreaterThan(-1); + + const effectStart = source.lastIndexOf('useEffect(() => {', markerIndex); + const depsEnd = source.indexOf(']);', markerIndex); + expect(effectStart).toBeGreaterThan(-1); + expect(depsEnd).toBeGreaterThan(-1); + + return source.slice(effectStart, depsEnd + 3); +}; + +const getStreamSubscriptionDeps = (): string[] => { + const effect = getStreamSubscriptionEffect(); + const match = effect.match(/\}, \[([\s\S]*?)\]\);$/); + expect(match).not.toBeNull(); + + return (match?.[1] || '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +}; + +const getExecuteLocalToolsCallback = (): string => { + const marker = 'const executeLocalTools = useCallback(async'; + const callbackStart = source.indexOf(marker); + expect(callbackStart).toBeGreaterThan(-1); + + const rest = source.slice(callbackStart); + const callbackEnd = rest.match(/^ \}, \[([^\]]*)\]\);/m); + expect(callbackEnd).not.toBeNull(); + expect(callbackEnd?.index).toBeDefined(); + + return rest.slice(0, (callbackEnd?.index || 0) + (callbackEnd?.[0].length || 0)); +}; + +const getExecuteLocalToolsDeps = (): string[] => { + const callback = getExecuteLocalToolsCallback(); + const match = callback.match(/\}, \[([^\]]*)\]\);$/); + expect(match).not.toBeNull(); + + return (match?.[1] || '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +}; + +const getLocalToolsBuilder = (): string => { + const marker = 'const buildLocalTools = (translateLocalToolSchema: AIChatTranslator) => ['; + const builderStart = source.indexOf(marker); + expect(builderStart).toBeGreaterThan(-1); + + const builderEnd = source.indexOf('export const AIChatPanel', builderStart); + expect(builderEnd).toBeGreaterThan(builderStart); + + return source.slice(builderStart, builderEnd); +}; + +const getFetchDynamicModelsCallback = (): string => { + const marker = 'const fetchDynamicModels = useCallback(async () => {'; + const callbackStart = source.indexOf(marker); + expect(callbackStart).toBeGreaterThan(-1); + + const rest = source.slice(callbackStart); + const callbackEnd = rest.match(/^ \}, \[([^\]]*)\]\);/m); + expect(callbackEnd).not.toBeNull(); + expect(callbackEnd?.index).toBeDefined(); + + return rest.slice(0, (callbackEnd?.index || 0) + (callbackEnd?.[0].length || 0)); +}; + +const getFetchDynamicModelsDeps = (): string[] => { + const callback = getFetchDynamicModelsCallback(); + const match = callback.match(/\}, \[([^\]]*)\]\);$/); + expect(match).not.toBeNull(); + + return (match?.[1] || '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +}; + +const getAiInsightsMemo = (): string => { + const marker = 'const aiInsights = useMemo(() => {'; + const memoStart = source.indexOf(marker); + expect(memoStart).toBeGreaterThan(-1); + + const rest = source.slice(memoStart); + const memoEnd = rest.match(/^ \}, \[([^\]]*)\]\);/m); + expect(memoEnd).not.toBeNull(); + expect(memoEnd?.index).toBeDefined(); + + return rest.slice(0, (memoEnd?.index || 0) + (memoEnd?.[0].length || 0)); +}; + +const getAiInsightsDeps = (): string[] => { + const memo = getAiInsightsMemo(); + const match = memo.match(/\}, \[([^\]]*)\]\);$/); + expect(match).not.toBeNull(); + + return (match?.[1] || '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +}; + +const getBuildSystemContextMessagesCallback = (): string => { + const marker = 'const buildSystemContextMessages = useCallback(async'; + const callbackStart = source.indexOf(marker); + expect(callbackStart).toBeGreaterThan(-1); + + const rest = source.slice(callbackStart); + const callbackEnd = rest.match(/^ \}, \[\]\);/m); + expect(callbackEnd).not.toBeNull(); + expect(callbackEnd?.index).toBeDefined(); + + return rest.slice(0, (callbackEnd?.index || 0) + (callbackEnd?.[0].length || 0)); +}; + +const getBuildSystemContextMessagesDeps = (): string[] => { + const callback = getBuildSystemContextMessagesCallback(); + const match = callback.match(/\}, \[([^\]]*)\]\);$/); + expect(match).not.toBeNull(); + + return (match?.[1] || '') + .split(',') + .map((part) => part.trim()) + .filter(Boolean); +}; + describe('AIChatPanel message render isolation', () => { it('keeps per-message render failures scoped to the broken bubble', () => { expect(source).toContain('class AIMessageRenderBoundary extends React.Component'); expect(source).toContain('[AI Message Render Error]'); - expect(source).toContain('这条 AI 消息渲染失败,已自动隔离'); expect(source).toContain('__gonaviLastAIMessageRenderError'); expect(source).toContain(' { + for (const key of renderErrorKeys) { + expect(source).toContain(key); + } + + expect(source).toContain('translateRenderError={t}'); + expect(source).toContain("this.state.error?.message || translateRenderError('ai_chat.panel.render_error.unknown')"); + + expect(source).not.toContain('这条 AI 消息渲染失败,已自动隔离'); + expect(source).not.toContain('其余对话仍可继续使用。你可以先删除这条异常消息,再继续操作。'); + expect(source).not.toContain('未知渲染错误'); + expect(source).not.toContain('重试渲染'); + expect(source).not.toContain('删除这条消息'); + }); + + it('keeps render-boundary catalog keys present and placeholder-free in every language', () => { + for (const language of SUPPORTED_LANGUAGES) { + for (const key of renderErrorKeys) { + expect(catalogs[language]).toHaveProperty(key); + expect(catalogs[language][key]).toBeTruthy(); + expect(getPlaceholders(catalogs[language][key])).toEqual([]); + } + } + }); + + it('uses i18n fallback text for V2 history and default session titles', () => { + expect(source).toContain("t('ai_chat.panel.history.empty')"); + expect(source).toContain("session.title || t('ai_chat.panel.session.default_title')"); + expect(source).toContain("?.title || t('ai_chat.panel.session.default_title')"); + expect(countOccurrences(source, "t('ai_chat.panel.history.empty')")).toBe(1); + expect(countOccurrences(source, "t('ai_chat.panel.session.default_title')")).toBe(2); + + expect(source).not.toContain('暂无历史会话'); + expect(source).not.toContain("session.title || '新对话'"); + expect(source).not.toContain("?.title || '新对话'"); + }); + + it('keeps panel fallback catalog keys present and placeholder-free in every language', () => { + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + for (const key of panelFallbackKeys) { + expect(catalog).toHaveProperty(key); + expect(catalog[key]).toBeTruthy(); + expect(getPlaceholders(catalog[key])).toEqual([]); + } + } + }); + + it('keeps raw session titles untranslated while localizing only empty-title fallback', () => { + expect(source).toContain("session.title || t('ai_chat.panel.session.default_title')"); + expect(source).not.toContain('t(session.title'); + expect(source).not.toContain('t(currentSession'); + }); + + it('uses i18n keys for send lifecycle chrome while preserving raw send details', () => { + for (const key of sendLifecycleKeys) { + expect(source).toContain(key); + } + + expect(source).toContain("t('ai_chat.panel.message.send_failed', { detail: cleanE })"); + expect(source).toContain("t('ai_chat.panel.message.send_failed', { detail: cleanE2 })"); + expect(source).not.toContain('t(cleanE'); + expect(source).not.toContain('t(cleanE2'); + expect(source).not.toContain('t(rawE'); + expect(source).not.toContain('t(rawE2'); + + expect(source).not.toContain('等待模型响应'); + expect(source).not.toContain('❌ 发送失败:'); + }); + + it('keeps send-failed wrapper catalog placeholder limited to detail in every language', () => { + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + expect(catalog).toHaveProperty('ai_chat.panel.message.send_failed'); + expect(catalog['ai_chat.panel.message.send_failed']).toBeTruthy(); + expect(getPlaceholders(catalog['ai_chat.panel.message.send_failed'])).toEqual(['detail']); + } + }); + + it('uses i18n keys for tool transition and generic error chrome while preserving raw details', () => { + for (const key of toolTransitionAndErrorKeys) { + expect(source).toContain(key); + } + + expect(source).toContain("t('ai_chat.panel.message.error', { detail: cleanErr })"); + expect(source).not.toContain('t(cleanErr'); + expect(source).not.toContain('t(rawErr'); + + expect(source).not.toContain('❌ 错误: ${cleanErr}'); + expect(source).not.toContain('❌ 模型未能成功响应任何内容,可能遭遇频控、上下文超载或理解拒绝。'); + expect(source).not.toContain('❌ 请求中断:未收到任何具体回复。'); + expect(source).not.toContain('汇总探针执行结果中'); + expect(source).not.toContain('向模型回传运行时数据'); + expect(source).not.toContain('模型大脑深度推理中'); + expect(source).not.toContain('等待下发操作指令'); + expect(source).not.toContain('正在深度思考链路与逻辑'); + }); + + it('keeps generic error wrapper catalog placeholder limited to detail in every language', () => { + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + expect(catalog).toHaveProperty('ai_chat.panel.message.error'); + expect(catalog['ai_chat.panel.message.error']).toBeTruthy(); + expect(getPlaceholders(catalog['ai_chat.panel.message.error'])).toEqual(['detail']); + } + }); + + it('uses i18n keys for model-control prompt wrappers', () => { + const streamSubscriptionEffect = getStreamSubscriptionEffect(); + const executeLocalToolsCallback = getExecuteLocalToolsCallback(); + + expect(streamSubscriptionEffect).toContain("content: tRef.current('ai_chat.panel.model_control.force_tool_call')"); + expect(executeLocalToolsCallback).toContain( + "content: translateToolChrome('ai_chat.panel.model_control.continue_after_summary')", + ); + }); + + it('does not keep fixed Chinese model-control prompt wrappers in code', () => { + expect(source).not.toContain('请直接使用 function call 调用工具执行操作,不要只用文字描述计划。'); + expect(source).not.toContain('请根据上述最新状态与探索结果,继续完成你先前未竟的分析或执行下一步。'); + }); + + it('keeps model-control catalog keys present and placeholder-free in every language', () => { + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + for (const key of modelControlKeys) { + expect(catalog).toHaveProperty(key); + expect(catalog[key]).toBeTruthy(); + expect(getPlaceholders(catalog[key])).toEqual([]); + } + } + }); + + it('uses i18n keys for executeLocalTools fixed wrappers while preserving raw tool details', () => { + for (const key of executeLocalToolWrapperKeys) { + expect(source).toContain(key); + } + + expect(source).toContain("translateToolChrome('ai_chat.panel.probe.max_rounds', { count: MAX_TOOL_CALL_ROUNDS })"); + expect(source).toContain("translateToolChrome('ai_chat.panel.probe.consecutive_failed')"); + expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.connection_not_found')"); + expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.unknown_function', { functionName: tc.function.name })"); + expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.fetch_databases_failed', { detail: String(e?.message || e) })"); + expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.fetch_tables_failed', { detail: String(e?.message || e) })"); + expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.fetch_columns_failed', { detail: String(e?.message || e) })"); + expect(source).toContain("translateToolChrome('ai_chat.panel.tool_result.columns_exact_fields', { tableName: safeTable, fieldNames, detailJson: JSON.stringify(cols) })"); + expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.fetch_table_ddl_failed', { detail: String(e?.message || e) })"); + expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.sql_blocked', { operationType: check.operationType })"); + expect(source).toContain("qRes?.message || translateToolChrome('ai_chat.panel.tool_error.sql_execute_failed')"); + expect(source).toContain("translateToolChrome('ai_chat.panel.tool_error.sql_execute_exception', { detail: String(e?.message || e) })"); + + expect(source).not.toContain('t(qRes?.message'); + expect(source).not.toContain('t(safeSql'); + expect(source).not.toContain('t(safeDbName'); + expect(source).not.toContain('t(safeTable'); + expect(source).not.toContain('t(toolResult.content'); + expect(source).not.toContain('t(fieldNames'); + expect(source).not.toContain('t(JSON.stringify(cols)'); + + expect(source).not.toContain('以下为 ${safeTable} 表的真实字段列表'); + expect(source).not.toContain('可用字段:${fieldNames}'); + expect(source).not.toContain('详细信息:${JSON.stringify(cols)}'); + expect(source).not.toContain('`⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`'); + expect(source).not.toContain('`获取数据库列表失败: ${e?.message || e}`'); + expect(source).not.toContain('`获取表列表失败: ${e?.message || e}`'); + expect(source).not.toContain('`获取字段列表失败: ${e?.message || e}`'); + expect(source).not.toContain('`获取建表语句失败: ${e?.message || e}`'); + expect(source).not.toContain('`安全策略拦截:当前安全级别不允许执行 ${check.operationType} 类型的 SQL。请将 SQL 展示给用户,让用户手动执行。`'); + expect(source).not.toContain("'SQL 执行失败'"); + expect(source).not.toContain('`SQL 执行异常: ${e?.message || e}`'); + expect(source).not.toContain("'⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。'"); + expect(source).not.toContain("'Connection not found'"); + expect(source).not.toContain('`Unknown function: ${tc.function.name}`'); + }); + + it('keeps executeLocalTools wrapper catalog placeholders exact in every language', () => { + const placeholderExpectations: Record<(typeof executeLocalToolWrapperKeys)[number], string[]> = { + 'ai_chat.panel.tool_result.columns_exact_fields': ['detailJson', 'fieldNames', 'tableName'], + 'ai_chat.panel.tool_error.connection_not_found': [], + 'ai_chat.panel.tool_error.unknown_function': ['functionName'], + 'ai_chat.panel.tool_error.fetch_databases_failed': ['detail'], + 'ai_chat.panel.tool_error.fetch_tables_failed': ['detail'], + 'ai_chat.panel.tool_error.fetch_columns_failed': ['detail'], + 'ai_chat.panel.tool_error.fetch_table_ddl_failed': ['detail'], + 'ai_chat.panel.tool_error.sql_blocked': ['operationType'], + 'ai_chat.panel.tool_error.sql_execute_failed': [], + 'ai_chat.panel.tool_error.sql_execute_exception': ['detail'], + 'ai_chat.panel.probe.max_rounds': ['count'], + 'ai_chat.panel.probe.consecutive_failed': [], + }; + + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + for (const key of executeLocalToolWrapperKeys) { + expect(catalog).toHaveProperty(key); + expect(catalog[key]).toBeTruthy(); + expect(getPlaceholders(catalog[key])).toEqual(placeholderExpectations[key]); + } + } + }); + + it('uses the current translator inside executeLocalTools without depending on stale t closures', () => { + const callback = getExecuteLocalToolsCallback(); + const deps = getExecuteLocalToolsDeps(); + + expect(callback).toContain('const translateToolChrome: AIChatTranslator = (key, params) => tRef.current(key, params);'); + expect(callback).not.toContain('const translateToolChrome = tRef.current;'); + expect(deps).not.toContain('t'); + expect(callback).not.toMatch(/\bt\(/); + + for (const key of executeLocalToolWrapperKeys) { + expect(callback).toContain(`translateToolChrome('${key}'`); + } + }); + + it('builds LOCAL_TOOLS schema descriptions from the current translator while preserving raw schema identifiers', () => { + const builder = getLocalToolsBuilder(); + + expect(source).toContain('const getLocalTools = useCallback(() => buildLocalTools(tRef.current), []);'); + expect(source).toContain('AIChatStream(sid, allMsg, getLocalTools())'); + expect(source).toContain('AIChatStream(sid, allMessages, getLocalTools())'); + expect(source).toContain('AIChatSend(allMessages, getLocalTools())'); + expect(source).toContain('const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : getLocalTools();'); + expect(source).not.toContain('LOCAL_TOOLS'); + + for (const key of localToolSchemaKeys) { + expect(builder).toContain(`translateLocalToolSchema('${key}')`); + } + + for (const functionName of localToolFunctionNames) { + expect(builder).toContain(`name: '${functionName}'`); + } + + for (const rawSnippet of localToolSchemaRawSnippets) { + expect(builder).toContain(rawSnippet); + } + + for (const fixedChineseSnippet of fixedChineseLocalToolSchemaSnippets) { + expect(builder).not.toContain(fixedChineseSnippet); + } + }); + + it('keeps local tool schema catalog entries placeholder-free in every language', () => { + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + for (const key of localToolSchemaKeys) { + expect(catalog).toHaveProperty(key); + expect(catalog[key]).toBeTruthy(); + expect(getPlaceholders(catalog[key])).toEqual([]); + } + } + }); + + it('uses i18n composer notices while preserving raw model-fetch failure detail', () => { + const callback = getFetchDynamicModelsCallback(); + const deps = getFetchDynamicModelsDeps(); + + expect(source).toContain('AIComposerNoticeDescriptor'); + expect(source).toContain( + 'const [composerNoticeState, setComposerNoticeState] = useState(null);', + ); + expect(source).toContain( + 'const composerNotice = useMemo(() => buildAIComposerNotice(t, composerNoticeState), [composerNoticeState, t]);', + ); + expect(source).toContain("setComposerNoticeState({ kind: 'missing_provider' });"); + expect(source).toContain("setComposerNoticeState({ kind: 'missing_model' });"); + expect(callback).toContain("setComposerNoticeState({ kind: 'model_fetch_failed', detail: result.error });"); + expect(callback).toContain("const detail = e?.message || String(e || '');"); + expect(callback).toContain("setComposerNoticeState({ kind: 'model_fetch_failed', detail });"); + expect(deps).not.toContain('t'); + + expect(source).not.toContain('setComposerNotice(buildMissingProviderNotice(t));'); + expect(source).not.toContain('setComposerNotice(buildMissingModelNotice(t));'); + expect(callback).not.toContain('buildModelFetchFailedNotice(t'); + expect(callback).not.toMatch(/\bt\(/); + expect(source).not.toContain('setComposerNotice(buildMissingProviderNotice());'); + expect(source).not.toContain('setComposerNotice(buildMissingModelNotice());'); + expect(source).not.toContain("buildModelFetchFailedNotice('获取模型列表失败:'"); + }); + + it('keeps composer notice catalog placeholders exact in every language', () => { + const placeholderExpectations: Record<(typeof composerNoticeKeys)[number], string[]> = { + 'ai_chat.composer_notice.missing_provider.title': [], + 'ai_chat.composer_notice.missing_provider.description': [], + 'ai_chat.composer_notice.missing_model.title': [], + 'ai_chat.composer_notice.missing_model.description': [], + 'ai_chat.composer_notice.model_fetch_failed.title': [], + 'ai_chat.composer_notice.model_fetch_failed.default_description': [], + 'ai_chat.composer_notice.model_fetch_failed.detail_description': ['detail'], + }; + + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + for (const key of composerNoticeKeys) { + expect(catalog).toHaveProperty(key); + expect(catalog[key]).toBeTruthy(); + expect(getPlaceholders(catalog[key])).toEqual(placeholderExpectations[key]); + } + } + }); + + it('uses i18n keys for AI insight chrome while preserving raw insight data', () => { + const memo = getAiInsightsMemo(); + const deps = getAiInsightsDeps(); + + for (const key of aiInsightKeys) { + expect(memo).toContain(key); + } + + expect(memo).toContain('slowest.sql.slice(0, 140)'); + expect(memo).toContain('errors[0]?.message ||'); + expect(deps).toContain('t'); + + expect(source).not.toContain('已关联 ${contextCount} 张表'); + expect(source).not.toContain('尚未关联表结构'); + expect(source).not.toContain('当前对话会带上 '); + expect(source).not.toContain(' 的结构上下文。'); + expect(source).not.toContain('在表页打开 AI 后会自动关联当前表,也可以在输入框上方手动添加上下文。'); + expect(source).not.toContain('最近最慢查询 ${Math.round(slowest.duration).toLocaleString()}ms'); + expect(source).not.toContain('暂无查询耗时样本'); + expect(source).not.toContain('执行查询后这里会显示可用于优化分析的 SQL 线索。'); + expect(source).not.toContain('${errors.length} 条最近查询失败'); + expect(source).not.toContain('最近查询状态正常'); + expect(source).not.toContain('已记录 ${recentLogs.length} 条最近 SQL,可直接让 AI 解释或优化。'); + expect(source).not.toContain('暂无 SQL 日志。'); + expect(source).not.toContain('检测到 ${writeCount} 条写操作'); + expect(source).not.toContain('当前以只读分析为主'); + expect(source).not.toContain('涉及写入的 SQL 建议先生成预览与回滚语句,再执行提交。'); + expect(source).not.toContain('AI 默认优先解释、生成 SELECT、分析 Schema 与优化索引。'); + }); + + it('keeps AI insight catalog placeholders exact in every language', () => { + const placeholderExpectations: Record<(typeof aiInsightKeys)[number], string[]> = { + 'ai_chat.panel.insight.context.linked_title': ['count'], + 'ai_chat.panel.insight.context.empty_title': [], + 'ai_chat.panel.insight.context.linked_body': ['tables'], + 'ai_chat.panel.insight.context.empty_body': [], + 'ai_chat.panel.insight.context.table_separator': [], + 'ai_chat.panel.insight.context.more_tables_suffix': [], + 'ai_chat.panel.insight.query.slowest_title': ['duration'], + 'ai_chat.panel.insight.query.empty_title': [], + 'ai_chat.panel.insight.query.empty_body': [], + 'ai_chat.panel.insight.status.failed_title': ['count'], + 'ai_chat.panel.insight.status.ok_title': [], + 'ai_chat.panel.insight.status.recent_body': ['count'], + 'ai_chat.panel.insight.status.empty_body': [], + 'ai_chat.panel.insight.write.detected_title': ['count'], + 'ai_chat.panel.insight.write.readonly_title': [], + 'ai_chat.panel.insight.write.detected_body': [], + 'ai_chat.panel.insight.write.readonly_body': [], + }; + + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + for (const key of aiInsightKeys) { + expect(catalog).toHaveProperty(key); + expect(catalog[key]).toBeTruthy(); + expect(getPlaceholders(catalog[key])).toEqual(placeholderExpectations[key]); + } + } + }); + + it('keeps stream subscription stable when the UI translator changes', () => { + const effect = getStreamSubscriptionEffect(); + const effectWithoutTranslatorRefCalls = effect.replace(/tRef\.current\(/g, ''); + const deps = getStreamSubscriptionDeps(); + + expect(deps).toContain('addAIChatMessage'); + expect(deps).toContain('updateAIChatMessage'); + expect(deps).toContain('sid'); + expect(deps).not.toContain('t'); + + for (const key of [ + 'ai_chat.panel.message.error', + 'ai_chat.panel.message.empty_response', + 'ai_chat.panel.message.request_interrupted', + ]) { + expect(effect).toContain(`tRef.current('${key}'`); + expect(effectWithoutTranslatorRefCalls).not.toContain(`t('${key}'`); + } + }); + + it('uses i18n keys for memory compression chrome while preserving generated summaries as raw detail', () => { + for (const key of memoryAndSanitizedErrorKeys) { + expect(source).toContain(key); + } + + expect(source).toContain("content: t('ai_chat.panel.status.memory_compressing')"); + expect(source).toContain("content: t('ai_chat.panel.status.memory_compress_failed')"); + expect(source).toContain("content: t('ai_chat.panel.status.memory_summary', { summary })"); + expect(source).toContain("content: translateToolChrome('ai_chat.panel.status.memory_probe_summary', { summary })"); + expect(source).not.toContain('t(summary'); + + expect(source).not.toContain('对话已超载,正在启动记忆压缩'); + expect(source).not.toContain('记忆压缩失败,将尝试原样接续'); + expect(source).not.toContain('【自动记忆重塑】已将超长历史压缩为摘要'); + expect(source).not.toContain('【自动记忆重塑】已将超长历史探针数据和对话压缩为摘要'); + }); + + it('uses the memory-summary prompt catalog key instead of a fixed source prompt', () => { + expect(source).toContain(`t('${memorySummaryPromptKey}')`); + + expect(source).not.toContain('这是一段超长对话的历史记录。为了释放上下文空间同时保留你的记忆核心'); + expect(source).not.toContain('技术事实、已探索出的数据结构状态、用户的中心诉求、当前进展'); + expect(source).not.toContain('剔除无效执行过程、客套话、JSON返回值本身。'); + expect(source).not.toContain('请控制在 1000-2000 字左右,输出纯干货 Markdown。'); + }); + + it('keeps the memory-summary prompt catalog entry placeholder-free in every language', () => { + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + expect(catalog).toHaveProperty(memorySummaryPromptKey); + expect(catalog[memorySummaryPromptKey]).toBeTruthy(); + expect(getPlaceholders(catalog[memorySummaryPromptKey])).toEqual([]); + } + }); + + it('uses the JVM diagnostic prompt catalog key while preserving raw diagnostic context values', () => { + const callback = getBuildSystemContextMessagesCallback(); + const callbackWithoutTranslatorRefCalls = callback.replace(/tRef\.current\(/g, ''); + const deps = getBuildSystemContextMessagesDeps(); + + expect(source).toContain(`const jvmDiagnosticPromptKey = '${jvmDiagnosticPromptKey}' as const;`); + expect(callback).toContain('content: tRef.current(jvmDiagnosticPromptKey, {'); + expect(callback).toContain('connectionName: activeConnection.name'); + expect(callback).toContain("host: activeConnection.config.host || '-'"); + expect(callback).toContain('transport: diagnosticTransport'); + expect(callback).toContain('environment'); + expect(callback).toContain( + "readOnlyPolicy: tRef.current(readOnly ? 'ai_chat.panel.jvm_diagnostic.policy.read_only' : 'ai_chat.panel.jvm_diagnostic.policy.plan_first')", + ); + expect(callback).toContain( + "observePolicy: tRef.current(diagnostic?.allowObserveCommands !== false ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden')", + ); + expect(callback).toContain( + "tracePolicy: tRef.current(diagnostic?.allowTraceCommands === true ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden')", + ); + expect(callback).toContain( + "mutatingPolicy: tRef.current(diagnostic?.allowMutatingCommands === true ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden')", + ); + expect(deps).not.toContain('t'); + expect(callbackWithoutTranslatorRefCalls).not.toMatch(/content:\s*t\(jvmDiagnosticPromptKey,\s*\{/); + expect(callbackWithoutTranslatorRefCalls).not.toMatch(/\bt\(/); + + expect(source).not.toContain('你是 GoNavi 的 JVM 诊断助手'); + expect(source).not.toContain('当前页签是 Arthas 兼容诊断工作台'); + expect(source).not.toContain('回答规则:\n1. 可以先给一小段分析'); + expect(source).not.toContain('命令权限:observe='); + expect(source).not.toContain('JSON 字段严格限定为 intent、transport、command、riskLevel、reason、expectedSignals'); + expect(source).not.toContain('t(activeConnection.name'); + expect(source).not.toContain('t(activeConnection.config.host'); + expect(source).not.toContain('t(diagnosticTransport'); + expect(source).not.toContain('t(environment'); + }); + + it('keeps the JVM diagnostic prompt placeholders exact and preserves raw plan identifiers in every language', () => { + const expectedPromptPlaceholders = [ + 'connectionName', + 'environment', + 'host', + 'mutatingPolicy', + 'observePolicy', + 'readOnlyPolicy', + 'tracePolicy', + 'transport', + ]; + const rawPromptIdentifiers = [ + 'GoNavi', + 'JVM', + 'Arthas', + 'JSON', + 'intent', + 'transport', + 'command', + 'riskLevel', + 'reason', + 'expectedSignals', + 'low', + 'medium', + 'high', + 'observe', + 'trace', + 'mutating', + ]; + + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + expect(catalog).toHaveProperty(jvmDiagnosticPromptKey); + expect(catalog[jvmDiagnosticPromptKey]).toBeTruthy(); + expect(getPlaceholders(catalog[jvmDiagnosticPromptKey])).toEqual(expectedPromptPlaceholders); + + for (const rawIdentifier of rawPromptIdentifiers) { + expect(catalog[jvmDiagnosticPromptKey]).toContain(rawIdentifier); + } + + for (const key of jvmDiagnosticPolicyKeys) { + expect(catalog).toHaveProperty(key); + expect(catalog[key]).toBeTruthy(); + expect(getPlaceholders(catalog[key])).toEqual([]); + } + } + }); + + it('uses the JVM runtime prompt catalog key while preserving raw runtime context values', () => { + const callback = getBuildSystemContextMessagesCallback(); + const callbackWithoutTranslatorRefCalls = callback.replace(/tRef\.current\(/g, ''); + const deps = getBuildSystemContextMessagesDeps(); + + expect(source).toContain(`const jvmRuntimePromptKey = '${jvmRuntimePromptKey}' as const;`); + expect(callback).toContain('content: tRef.current(jvmRuntimePromptKey, {'); + expect(callback).toContain('connectionName: activeConnection.name'); + expect(callback).toContain("host: activeConnection.config.host || '-'"); + expect(callback).toContain('providerMode'); + expect(callback).toContain('environment'); + expect(callback).toContain( + "connectionPolicy: tRef.current(readOnly ? 'ai_chat.panel.jvm_runtime.policy.read_only' : 'ai_chat.panel.jvm_runtime.policy.preview_required')", + ); + expect(callback).toContain( + "resourcePathStatus: tRef.current(resourcePath ? 'ai_chat.panel.jvm_runtime.resource_path.current' : 'ai_chat.panel.jvm_runtime.resource_path.missing', { resourcePath })", + ); + expect(deps).not.toContain('t'); + expect(callbackWithoutTranslatorRefCalls).not.toMatch(/content:\s*t\(jvmRuntimePromptKey,\s*\{/); + expect(callbackWithoutTranslatorRefCalls).not.toMatch(/\bt\(/); + + expect(source).not.toContain('你是 GoNavi 的 JVM 运行时分析助手'); + expect(source).not.toContain('当前上下文不是 SQL,而是 JVM 资源工作台'); + expect(source).not.toContain('JSON 字段严格限定为 targetType、selector、action、payload、reason'); + expect(source).not.toContain('selector.resourcePath 优先使用当前资源路径'); + expect(source).not.toContain('不要输出脚本、命令或“已经执行成功”之类的表述'); + expect(callbackWithoutTranslatorRefCalls).not.toContain('t(activeConnection.name'); + expect(callbackWithoutTranslatorRefCalls).not.toContain('t(activeConnection.config.host'); + expect(callbackWithoutTranslatorRefCalls).not.toContain('t(providerMode'); + expect(callbackWithoutTranslatorRefCalls).not.toContain('t(environment'); + expect(callbackWithoutTranslatorRefCalls).not.toContain('t(resourcePath'); + }); + + it('keeps the JVM runtime prompt placeholders exact and preserves raw plan identifiers in every language', () => { + const expectedPromptPlaceholders = [ + 'connectionName', + 'connectionPolicy', + 'environment', + 'host', + 'providerMode', + 'resourcePathStatus', + ]; + const policyPlaceholderExpectations: Record<(typeof jvmRuntimePolicyKeys)[number], string[]> = { + 'ai_chat.panel.jvm_runtime.policy.read_only': [], + 'ai_chat.panel.jvm_runtime.policy.preview_required': [], + 'ai_chat.panel.jvm_runtime.resource_path.current': ['resourcePath'], + 'ai_chat.panel.jvm_runtime.resource_path.missing': [], + }; + const rawPromptIdentifiers = [ + 'GoNavi', + 'JVM', + 'SQL', + 'JSON', + 'targetType', + 'selector', + 'action', + 'payload', + 'reason', + 'supportedActions', + 'selector.resourcePath', + 'resourcePath', + 'format', + 'json', + 'text', + ]; + + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + expect(catalog).toHaveProperty(jvmRuntimePromptKey); + expect(catalog[jvmRuntimePromptKey]).toBeTruthy(); + expect(getPlaceholders(catalog[jvmRuntimePromptKey])).toEqual(expectedPromptPlaceholders); + + for (const rawIdentifier of rawPromptIdentifiers) { + expect(catalog[jvmRuntimePromptKey]).toContain(rawIdentifier); + } + + for (const key of jvmRuntimePolicyKeys) { + expect(catalog).toHaveProperty(key); + expect(catalog[key]).toBeTruthy(); + expect(getPlaceholders(catalog[key])).toEqual(policyPlaceholderExpectations[key]); + } + } + }); + + it('uses SQL system prompt catalog keys while preserving raw SQL context parameters', () => { + const callback = getBuildSystemContextMessagesCallback(); + const callbackWithoutTranslatorRefCalls = callback.replace(/tRef\.current\(/g, ''); + const deps = getBuildSystemContextMessagesDeps(); + + for (const key of sqlPromptKeys) { + expect(callback).toContain(key); + } + + expect(callback).toContain('const sqlPromptKey = activeContextItems.length > 0'); + expect(callback).toContain("targetConnId && targetDbName"); + expect(callback).toContain("conns.length > 0"); + expect(callback).toContain('content: tRef.current(sqlPromptKey, sqlPromptParams),'); + expect(callback).toContain('dbDisplayType,'); + expect(callback).toContain('ddlChunks,'); + expect(callback).toContain('targetDbName,'); + expect(callback).toContain('connList,'); + expect(deps).not.toContain('t'); + expect(callbackWithoutTranslatorRefCalls).not.toMatch(/\bt\(/); + + expect(callbackWithoutTranslatorRefCalls).not.toContain('t(dbDisplayType'); + expect(callbackWithoutTranslatorRefCalls).not.toContain('t(ddlChunks'); + expect(callbackWithoutTranslatorRefCalls).not.toContain('t(targetDbName'); + expect(callbackWithoutTranslatorRefCalls).not.toContain('t(connList'); + + expect(source).not.toContain('你是一个专业的数据库助手。当前连接的数据库类型是'); + expect(source).not.toContain('用户目前在界面上没有选中任何具体的数据库或数据表'); + expect(source).not.toContain('当前存在的连接:'); + }); + + it('keeps SQL system prompt placeholders exact and raw workflow identifiers intact in every language', () => { + const placeholderExpectations: Record<(typeof sqlPromptKeys)[number], string[]> = { + 'ai_chat.panel.prompt.sql.context_tables': ['dbDisplayType', 'ddlChunks'], + 'ai_chat.panel.prompt.sql.current_database': ['dbDisplayType', 'targetDbName'], + 'ai_chat.panel.prompt.sql.no_context': ['connList'], + 'ai_chat.panel.prompt.sql.no_connections': [], + }; + const rawPromptIdentifiers = [ + 'SQL', + 'DDL', + 'get_connections', + 'get_databases', + 'get_tables', + 'get_columns', + 'host', + 'localhost', + '127.0.0.1', + 'name', + 'dev', + 'local', + 'connectionId', + 'dbName', + 'database.table', + '@context', + ]; + + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + for (const key of sqlPromptKeys) { + expect(catalog).toHaveProperty(key); + expect(catalog[key]).toBeTruthy(); + expect(getPlaceholders(catalog[key])).toEqual(placeholderExpectations[key]); + } + + const combinedPrompt = sqlPromptKeys.map((key) => catalog[key]).join('\n'); + for (const rawIdentifier of rawPromptIdentifiers) { + expect(combinedPrompt).toContain(rawIdentifier); + } + } + }); + + it('passes the active translator into sanitized error fallbacks without translating raw details', () => { + expect(source).toContain('const sanitizeErrorMsg = (raw: string, t: AIChatTranslator): string =>'); + expect(source).toContain('sanitizeErrorMsg(data.error, tRef.current)'); + expect(source).toContain('sanitizeErrorMsg(errRaw, t)'); + expect(source).toContain('sanitizeErrorMsg(errR, translateToolChrome)'); + expect(source).toContain('sanitizeErrorMsg(errR2, t)'); + expect(source).toContain('sanitizeErrorMsg(rawE, t)'); + expect(source).toContain('sanitizeErrorMsg(rawE2, t)'); + + expect(source).toContain("return t('ai_chat.panel.error.unknown')"); + expect(source).toContain("return t('ai_chat.panel.error.http_server', { code })"); + expect(source).toContain("return t('ai_chat.panel.error.html_response')"); + expect(source).toContain("+ t('ai_chat.panel.error.truncated_suffix')"); + expect(source).not.toContain('t(title'); + expect(source).not.toContain('t(raw'); + }); + + it('uses the generic i18n error wrapper for non-stream AIChatSend fallbacks', () => { + expect(source).toContain("content: result?.success ? result.content : t('ai_chat.panel.message.error', { detail: errClean })"); + expect(source).toContain("content: result?.success ? result.content : translateToolChrome('ai_chat.panel.message.error', { detail: errC })"); + expect(source).toContain("content: result?.success ? result.content : t('ai_chat.panel.message.error', { detail: errC2 })"); + + expect(source).not.toContain('`❌ ${errClean}`'); + expect(source).not.toContain('`❌ ${errC}`'); + expect(source).not.toContain('`❌ ${errC2}`'); + }); + + it('keeps memory and sanitized-error catalog placeholders consistent in every language', () => { + const placeholderExpectations: Record<(typeof memoryAndSanitizedErrorKeys)[number], string[]> = { + 'ai_chat.panel.status.memory_compressing': [], + 'ai_chat.panel.status.memory_compress_failed': [], + 'ai_chat.panel.status.memory_summary': ['summary'], + 'ai_chat.panel.status.memory_probe_summary': ['summary'], + 'ai_chat.panel.error.unknown': [], + 'ai_chat.panel.error.http_server': ['code'], + 'ai_chat.panel.error.html_response': [], + 'ai_chat.panel.error.truncated_suffix': [], + }; + + for (const language of SUPPORTED_LANGUAGES) { + const catalog = catalogs[language] as Record; + for (const key of memoryAndSanitizedErrorKeys) { + expect(catalog).toHaveProperty(key); + expect(catalog[key]).toBeTruthy(); + expect(getPlaceholders(catalog[key])).toEqual(placeholderExpectations[key]); + } + } + }); }); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index 5f06522a..f0172f14 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -18,19 +18,16 @@ 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 type { AIComposerNoticeDescriptor } from '../utils/aiComposerNotice'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; -import { - buildMissingModelNotice, - buildMissingProviderNotice, - buildModelFetchFailedNotice, -} from '../utils/aiComposerNotice'; +import { buildAIComposerNotice } from '../utils/aiComposerNotice'; import { buildAIReadonlyPreviewSQL } from '../utils/aiSqlLimit'; import { resolveAITableSchemaToolResult } from '../utils/aiTableSchemaTool'; import { consumeAIChatSendShortcutOnKeyDown } from '../utils/aiChatSendShortcut'; import { toAIRequestMessage } from '../utils/aiMessagePayload'; import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts'; import { isMacLikePlatform } from '../utils/appearance'; +import { useI18n } from '../i18n/provider'; interface AIChatPanelProps { width?: number; @@ -44,6 +41,11 @@ interface AIChatPanelProps { const genId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`; +type AIChatTranslator = (key: string, params?: Record) => string; + +const jvmDiagnosticPromptKey = 'ai_chat.panel.prompt.jvm_diagnostic' as const; +const jvmRuntimePromptKey = 'ai_chat.panel.prompt.jvm_runtime' as const; + interface AIMessageRenderBoundaryProps { children: React.ReactNode; msg: AIChatMessage; @@ -51,6 +53,7 @@ interface AIMessageRenderBoundaryProps { overlayTheme: OverlayWorkbenchTheme; onDeleteMessage: (id: string) => void; onError?: (error: Error, errorInfo: React.ErrorInfo, msg: AIChatMessage) => void; + translateRenderError: (key: string) => string; } interface AIMessageRenderBoundaryState { @@ -81,7 +84,7 @@ class AIMessageRenderBoundary extends React.Component< render() { if (this.state.hasError) { - const { msg, darkMode, overlayTheme, onDeleteMessage } = this.props; + const { msg, darkMode, overlayTheme, onDeleteMessage, translateRenderError } = this.props; return (
- 这条 AI 消息渲染失败,已自动隔离 + {translateRenderError('ai_chat.panel.render_error.title')}
- 其余对话仍可继续使用。你可以先删除这条异常消息,再继续操作。 + {translateRenderError('ai_chat.panel.render_error.description')}
- {this.state.error?.message || '未知渲染错误'} + {this.state.error?.message || translateRenderError('ai_chat.panel.render_error.unknown')}
@@ -178,7 +181,7 @@ export const getDynamicMaxContextChars = (modelName?: string) => { }; // 当超出指定字符上限时触发上下文自建压缩 -const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number) => { +const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number, t: AIChatTranslator) => { try { const chars = messagesPayload.reduce((sum, m) => sum + (m.content?.length || 0) + (m.reasoning_content?.length || 0) + JSON.stringify(m.tool_calls || []).length, 0); if (chars < maxLimit) return null; @@ -188,15 +191,10 @@ const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxL const connectingMsgId = genId(); useStore.getState().addAIChatMessage(sid, { - id: connectingMsgId, role: 'assistant', phase: 'connecting', content: '⚙️ 对话已超载,正在启动记忆压缩...', timestamp: Date.now(), loading: true + id: connectingMsgId, role: 'assistant', phase: 'connecting', content: t('ai_chat.panel.status.memory_compressing'), timestamp: Date.now(), loading: true }); - const summaryPrompt = `这是一段超长对话的历史记录。为了释放上下文空间同时保留你的记忆核心,请你仔细阅读并以“技术事实、已探索出的数据结构状态、用户的中心诉求、当前进展”为准则,进行高度浓缩的结构化总结。 -注意: -1. 客观准确,不能遗漏关键业务逻辑或探索出的表名/字段。 -2. 剔除无效执行过程、客套话、JSON返回值本身。 -3. 请控制在 1000-2000 字左右,输出纯干货 Markdown。 -4. 开头直接输出总结,不要带寒暄。`; + const summaryPrompt = t('ai_chat.panel.prompt.memory_summary'); const sysMsg = { role: 'system', content: summaryPrompt }; const result = await Service.AIChatSend([sysMsg, ...messagesPayload]); @@ -205,7 +203,7 @@ const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxL useStore.getState().deleteAIChatMessage(sid, connectingMsgId); return result.content; } else { - useStore.getState().updateAIChatMessage(sid, connectingMsgId, { loading: false, phase: 'idle', content: '❌ 记忆压缩失败,将尝试原样接续...' }); + useStore.getState().updateAIChatMessage(sid, connectingMsgId, { loading: false, phase: 'idle', content: t('ai_chat.panel.status.memory_compress_failed') }); } } catch (e) { console.error("Compression exception:", e); @@ -214,8 +212,8 @@ const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxL }; // 清洗错误信息:去除 HTML 标签、提取关键错误描述、截断过长文本 -const sanitizeErrorMsg = (raw: string): string => { - if (!raw || typeof raw !== 'string') return '未知错误'; +const sanitizeErrorMsg = (raw: string, t: AIChatTranslator): string => { + if (!raw || typeof raw !== 'string') return t('ai_chat.panel.error.unknown'); // 检测 HTML 内容 if (raw.includes(' 内容 @@ -225,20 +223,20 @@ const sanitizeErrorMsg = (raw: string): string => { const title = titleMatch?.[1]?.trim(); const code = codeMatch?.[1]; if (title) return code ? `HTTP ${code}: ${title}` : title; - if (code) return `HTTP ${code} 服务端错误`; - return '服务端返回了异常 HTML 响应(可能是网关超时或服务不可用)'; + if (code) return t('ai_chat.panel.error.http_server', { code }); + return t('ai_chat.panel.error.html_response'); } // 截断过长的纯文本错误 - if (raw.length > 300) return raw.substring(0, 280) + '...(已截断)'; + if (raw.length > 300) return raw.substring(0, 280) + t('ai_chat.panel.error.truncated_suffix'); return raw; }; -const LOCAL_TOOLS = [ +const buildLocalTools = (translateLocalToolSchema: AIChatTranslator) => [ { type: 'function', function: { name: 'get_connections', - description: '当需要查询、操作数据库但用户没有选择任何连接上下文时,获取当前软件中可用的所有数据库连接信息。返回的数据包含连接ID(id)和名称(name)。', + description: translateLocalToolSchema('ai_chat.panel.local_tool.get_connections.description'), parameters: { type: 'object', properties: {} } } }, @@ -246,11 +244,11 @@ const LOCAL_TOOLS = [ type: 'function', function: { name: 'get_databases', - description: '获取指定连接(connectionId)下的所有数据库(Database/Schema)名。', + description: translateLocalToolSchema('ai_chat.panel.local_tool.get_databases.description'), parameters: { type: 'object', properties: { - connectionId: { type: 'string', description: '连接ID (从 get_connections 获取)' } + connectionId: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.connection_id_from_get_connections') } }, required: ['connectionId'] } @@ -260,12 +258,12 @@ const LOCAL_TOOLS = [ type: 'function', function: { name: 'get_tables', - description: '当已经确定了目标连接和数据库名后,如果用户询问或隐式提到了表但你不知道确切表名,调用此工具获取该数据库下的所有表名列表(只含表名,帮助你推断目标表)。', + description: translateLocalToolSchema('ai_chat.panel.local_tool.get_tables.description'), parameters: { type: 'object', properties: { - connectionId: { type: 'string', description: '连接ID' }, - dbName: { type: 'string', description: '数据库名' }, + connectionId: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.connection_id') }, + dbName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.db_name') }, }, required: ['connectionId', 'dbName'] } @@ -275,13 +273,13 @@ const LOCAL_TOOLS = [ type: 'function', function: { name: 'get_columns', - description: '获取指定表的字段列表(字段名、类型、是否可空、默认值、注释等)。在生成 SQL 之前必须先调用此工具确认真实字段名,禁止猜测字段名。', + description: translateLocalToolSchema('ai_chat.panel.local_tool.get_columns.description'), parameters: { type: 'object', properties: { - connectionId: { type: 'string', description: '连接ID' }, - dbName: { type: 'string', description: '数据库名' }, - tableName: { type: 'string', description: '表名' }, + connectionId: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.connection_id') }, + dbName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.db_name') }, + tableName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.table_name') }, }, required: ['connectionId', 'dbName', 'tableName'] } @@ -291,13 +289,13 @@ const LOCAL_TOOLS = [ type: 'function', function: { name: 'get_table_ddl', - description: '获取指定表的完整建表语句(CREATE TABLE DDL),包含字段、索引、约束等完整结构信息。', + description: translateLocalToolSchema('ai_chat.panel.local_tool.get_table_ddl.description'), parameters: { type: 'object', properties: { - connectionId: { type: 'string', description: '连接ID' }, - dbName: { type: 'string', description: '数据库名' }, - tableName: { type: 'string', description: '表名' }, + connectionId: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.connection_id') }, + dbName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.db_name') }, + tableName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.table_name') }, }, required: ['connectionId', 'dbName', 'tableName'] } @@ -307,13 +305,13 @@ const LOCAL_TOOLS = [ type: 'function', function: { name: 'execute_sql', - description: '在指定连接和数据库上执行 SQL 查询并返回结果。受安全级别控制,只读模式下只能执行 SELECT/SHOW/DESCRIBE 等查询操作。结果最多返回 50 行。', + description: translateLocalToolSchema('ai_chat.panel.local_tool.execute_sql.description'), parameters: { type: 'object', properties: { - connectionId: { type: 'string', description: '连接ID' }, - dbName: { type: 'string', description: '数据库名' }, - sql: { type: 'string', description: '要执行的 SQL 语句' }, + connectionId: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.connection_id') }, + dbName: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.db_name') }, + sql: { type: 'string', description: translateLocalToolSchema('ai_chat.panel.local_tool.param.sql') }, }, required: ['connectionId', 'dbName', 'sql'] } @@ -324,6 +322,10 @@ const LOCAL_TOOLS = [ export const AIChatPanel: React.FC = ({ width = 380, darkMode, bgColor, onClose, onOpenSettings, onWidthChange, overlayTheme }) => { + const { t } = useI18n(); + const tRef = useRef(t); + tRef.current = t; + const getLocalTools = useCallback(() => buildLocalTools(tRef.current), []); const [input, setInput] = useState(''); const [draftImages, setDraftImages] = useState([]); const [sending, setSending] = useState(false); @@ -331,11 +333,12 @@ export const AIChatPanel: React.FC = ({ const [dynamicModels, setDynamicModels] = useState([]); const [showScrollBottom, setShowScrollBottom] = useState(false); const [loadingModels, setLoadingModels] = useState(false); - const [composerNotice, setComposerNotice] = useState(null); + const [composerNoticeState, setComposerNoticeState] = useState(null); const [panelWidth, setPanelWidth] = useState(width); const [isResizing, setIsResizing] = useState(false); const [historyOpen, setHistoryOpen] = useState(false); const [activePanelMode, setActivePanelMode] = useState<'chat' | 'insights' | 'history'>('chat'); + const composerNotice = useMemo(() => buildAIComposerNotice(t, composerNoticeState), [composerNoticeState, t]); const messagesEndRef = useRef(null); const textareaRef = useRef(null); @@ -523,7 +526,7 @@ export const AIChatPanel: React.FC = ({ useEffect(() => { const handler = () => { setDynamicModels([]); - setComposerNotice(null); + setComposerNoticeState(null); activeProviderIdRef.current = null; loadActiveProvider(); }; @@ -543,7 +546,7 @@ export const AIChatPanel: React.FC = ({ }; await Service?.AISaveProvider?.(payload); setActiveProvider(payload); - setComposerNotice(null); + setComposerNoticeState(null); } catch (e) { console.warn('Failed to update provider model', e); } }; @@ -552,20 +555,20 @@ export const AIChatPanel: React.FC = ({ useEffect(() => { if (activeProvider?.id && activeProvider.id !== activeProviderIdRef.current) { setDynamicModels([]); - setComposerNotice(null); + setComposerNoticeState(null); activeProviderIdRef.current = activeProvider.id; } // 供应商被删除后 activeProvider 变为 null,此时也必须清空残留模型 if (!activeProvider) { setDynamicModels([]); - setComposerNotice(null); + setComposerNoticeState(null); activeProviderIdRef.current = null; } }, [activeProvider?.id, activeProvider]); useEffect(() => { if (activeProvider?.model && String(activeProvider.model).trim()) { - setComposerNotice(null); + setComposerNoticeState(null); } }, [activeProvider?.model]); @@ -575,22 +578,23 @@ export const AIChatPanel: React.FC = ({ const fetchDynamicModels = useCallback(async () => { try { setLoadingModels(true); - setComposerNotice(null); + setComposerNoticeState(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); + setComposerNoticeState(null); } else if (result && !result.success) { setDynamicModels([]); - setComposerNotice(buildModelFetchFailedNotice(result.error)); + setComposerNoticeState({ kind: 'model_fetch_failed', detail: result.error }); } } catch (e: any) { console.warn('Failed to fetch models', e); setDynamicModels([]); - setComposerNotice(buildModelFetchFailedNotice('获取模型列表失败:' + (e?.message || '未知错误'))); + const detail = e?.message || String(e || ''); + setComposerNoticeState({ kind: 'model_fetch_failed', detail }); } finally { setLoadingModels(false); } @@ -675,16 +679,16 @@ export const AIChatPanel: React.FC = ({ } if (data.error) { - const cleanErr = sanitizeErrorMsg(data.error); + const cleanErr = sanitizeErrorMsg(data.error, tRef.current); const rawErr = cleanErr !== data.error ? data.error : undefined; if (assistantMsgId) { - updateAIChatMessage(sid, assistantMsgId, { content: `❌ 错误: ${cleanErr}`, phase: 'idle', loading: false, rawError: rawErr }); + updateAIChatMessage(sid, assistantMsgId, { content: tRef.current('ai_chat.panel.message.error', { detail: cleanErr }), phase: 'idle', loading: false, rawError: rawErr }); } else { addAIChatMessage(sid, { id: genId(), role: 'assistant', phase: 'idle', - content: `❌ 错误: ${cleanErr}`, + content: tRef.current('ai_chat.panel.message.error', { detail: cleanErr }), rawError: rawErr, timestamp: Date.now(), jvmPlanContext: pendingJVMPlanContextRef.current, @@ -814,10 +818,10 @@ export const AIChatPanel: React.FC = ({ existing.jvmDiagnosticPlanContext, ); // 追加催促消息 - messagesPayload.push({ role: 'user', content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。' }); + messagesPayload.push({ role: 'user', content: tRef.current('ai_chat.panel.model_control.force_tool_call') }); const allMsg = [...sysMessages, ...messagesPayload]; const Service = (window as any).go?.aiservice?.Service; - if (Service?.AIChatStream) await Service.AIChatStream(sid, allMsg, LOCAL_TOOLS); + if (Service?.AIChatStream) await Service.AIChatStream(sid, allMsg, getLocalTools()); } catch (e) { console.error('Nudge failed', e); setSending(false); @@ -834,12 +838,12 @@ export const AIChatPanel: React.FC = ({ const hasTools = !!(existing?.tool_calls?.length); if (!hasContent && !hasThinking && !hasTools) { - updateAIChatMessage(sid, doneAssistantId, { content: '❌ 模型未能成功响应任何内容,可能遭遇频控、上下文超载或理解拒绝。', loading: false, phase: 'idle' }); + updateAIChatMessage(sid, doneAssistantId, { content: tRef.current('ai_chat.panel.message.empty_response'), loading: false, phase: 'idle' }); } else { updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' }); } } else { - addAIChatMessage(sid, { id: genId(), role: 'assistant', content: '❌ 请求中断:未收到任何具体回复。', timestamp: Date.now(), loading: false }); + addAIChatMessage(sid, { id: genId(), role: 'assistant', content: tRef.current('ai_chat.panel.message.request_interrupted'), timestamp: Date.now(), loading: false }); } setSending(false); }, 50); @@ -848,7 +852,7 @@ export const AIChatPanel: React.FC = ({ EventsOn(eventName, handler); return () => { EventsOff(eventName); }; - }, [addAIChatMessage, updateAIChatMessage, sid]); + }, [addAIChatMessage, updateAIChatMessage, sid, getLocalTools]); const generateTitleForSession = async (currentSid: string) => { try { @@ -942,14 +946,14 @@ export const AIChatPanel: React.FC = ({ const Service = (window as any).go?.aiservice?.Service; if (Service?.AIChatStream) { - await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS); + await Service.AIChatStream(sid, allMessages, getLocalTools()); } else if (Service?.AIChatSend) { - const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS); - const errRaw = result?.error || '未知错误'; - const errClean = sanitizeErrorMsg(errRaw); + const result = await Service.AIChatSend(allMessages, getLocalTools()); + const errRaw = result?.error || t('ai_chat.panel.error.unknown'); + const errClean = sanitizeErrorMsg(errRaw, t); addAIChatMessage(sid, { id: genId(), role: 'assistant', - content: result?.success ? result.content : `❌ ${errClean}`, + content: result?.success ? result.content : t('ai_chat.panel.message.error', { detail: errClean }), thinking: result?.success ? result.reasoning_content : undefined, reasoning_content: result?.success ? result.reasoning_content : undefined, rawError: (!result?.success && errClean !== errRaw) ? errRaw : undefined, @@ -963,11 +967,11 @@ export const AIChatPanel: React.FC = ({ } } catch(e: any) { const rawE = e?.message || String(e); - const cleanE = sanitizeErrorMsg(rawE); + const cleanE = sanitizeErrorMsg(rawE, t); addAIChatMessage(sid, { id: genId(), role: 'assistant', - content: `❌ 发送失败: ${cleanE}`, + content: t('ai_chat.panel.message.send_failed', { detail: cleanE }), rawError: cleanE !== rawE ? rawE : undefined, timestamp: Date.now(), jvmPlanContext: retryJVMPlanContext, @@ -982,6 +986,8 @@ export const AIChatPanel: React.FC = ({ addAIChatMessage, getCurrentJVMPlanContext, getCurrentJVMDiagnosticPlanContext, + getLocalTools, + t, ]); const buildSystemContextMessages = useCallback(async ( @@ -1037,23 +1043,16 @@ export const AIChatPanel: React.FC = ({ const environment = activeConnection.config.jvm?.environment || 'unknown'; systemMessages.push({ role: 'system', - content: `你是 GoNavi 的 JVM 诊断助手。当前页签是 Arthas 兼容诊断工作台,目标是输出可回填到诊断控制台的结构化诊断计划。 - -当前连接:${activeConnection.name} -目标主机:${activeConnection.config.host || '-'} -诊断 transport:${diagnosticTransport} -运行环境:${environment} -连接策略:${readOnly ? '默认按只读诊断思路回答,只生成观察、trace、排障命令,不要假设已经执行。' : '允许生成诊断命令,但仍然必须先给计划,再由用户决定是否执行。'} -命令权限:observe=${diagnostic?.allowObserveCommands !== false ? '允许' : '禁止'},trace=${diagnostic?.allowTraceCommands === true ? '允许' : '禁止'},mutating=${diagnostic?.allowMutatingCommands === true ? '允许' : '禁止'} - -回答规则: -1. 可以先给一小段分析,但必须包含且只包含一个 \`\`\`json 代码块。 -2. JSON 字段严格限定为 intent、transport、command、riskLevel、reason、expectedSignals。 -3. transport 必须填写当前值 ${diagnosticTransport},不要编造其他 transport。 -4. command 必须是单条诊断命令,不要带 shell 提示符、换行拼接、多条命令或代码围栏。 -5. riskLevel 只能是 low、medium、high。 -6. expectedSignals 必须是字符串数组,描述执行后需要重点观察的信号。 -7. 如果命令权限不允许某类操作,就不要输出该类命令;无法满足时直接说明限制。`, + content: tRef.current(jvmDiagnosticPromptKey, { + connectionName: activeConnection.name, + host: activeConnection.config.host || '-', + transport: diagnosticTransport, + environment, + readOnlyPolicy: tRef.current(readOnly ? 'ai_chat.panel.jvm_diagnostic.policy.read_only' : 'ai_chat.panel.jvm_diagnostic.policy.plan_first'), + observePolicy: tRef.current(diagnostic?.allowObserveCommands !== false ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden'), + tracePolicy: tRef.current(diagnostic?.allowTraceCommands === true ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden'), + mutatingPolicy: tRef.current(diagnostic?.allowMutatingCommands === true ? 'ai_chat.panel.jvm_diagnostic.permission.allowed' : 'ai_chat.panel.jvm_diagnostic.permission.forbidden'), + }), }); return systemMessages; } @@ -1069,22 +1068,14 @@ export const AIChatPanel: React.FC = ({ const environment = activeConnection.config.jvm?.environment || 'unknown'; systemMessages.push({ role: 'system', - content: `你是 GoNavi 的 JVM 运行时分析助手。当前上下文不是 SQL,而是 JVM 资源工作台。 - -当前连接:${activeConnection.name} -目标主机:${activeConnection.config.host || '-'} -Provider 模式:${providerMode} -运行环境:${environment} -连接策略:${readOnly ? '只读连接,只能分析和生成变更计划,绝不能假设已执行写入。' : '可写连接,但任何修改都必须先生成预览并等待人工确认。'} -${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体资源路径。'} - -回答规则: -1. 你可以解释资源结构、风险、修改建议和回滚建议。 -2. 如果用户要求生成 JVM 修改方案,必须输出一个唯一的 \`\`\`json 代码块,并且 JSON 字段严格限定为 targetType、selector、action、payload、reason。 -3. action 优先使用当前资源快照或元数据里已经声明的 supportedActions;如果当前资源没有声明,再基于快照内容谨慎推断。 -4. selector.resourcePath 优先使用当前资源路径;如果当前路径未知,就明确说明无法精确定位,不要编造路径。 -5. payload 只能使用 {"format":"json","value":{...}} 或 {"format":"text","value":"..."} 这两种包装形式,不要输出脚本、命令或裸值。 -6. 不要输出脚本、命令或“已经执行成功”之类的表述。` + content: tRef.current(jvmRuntimePromptKey, { + connectionName: activeConnection.name, + host: activeConnection.config.host || '-', + providerMode, + environment, + connectionPolicy: tRef.current(readOnly ? 'ai_chat.panel.jvm_runtime.policy.read_only' : 'ai_chat.panel.jvm_runtime.policy.preview_required'), + resourcePathStatus: tRef.current(resourcePath ? 'ai_chat.panel.jvm_runtime.resource_path.current' : 'ai_chat.panel.jvm_runtime.resource_path.missing', { resourcePath }), + }), }); return systemMessages; } @@ -1098,57 +1089,37 @@ ${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体 } } - if (activeContextItems.length > 0) { - const conn = conns.find(c => c.id === targetConnId); - const dbType = conn?.config?.type || 'unknown'; - const dbDisplayType = dbType === 'diros' ? 'Doris' : dbType.charAt(0).toUpperCase() + dbType.slice(1); - const ddlChunks = activeContextItems.map(c => `-- Table: ${c.dbName}.${c.tableName}\n${c.ddl}`).join('\n\n'); - systemMessages.push({ - role: 'system', - content: `你是一个专业的数据库助手。当前连接的数据库类型是 ${dbDisplayType}。请使用 ${dbDisplayType} 方言生成 SQL。以下是用户关联的表结构信息,请在回答时优先参考:\n\n${ddlChunks}` - }); - } - else if (targetConnId && targetDbName) { - const conn = conns.find(c => c.id === targetConnId); - const dbType = conn?.config?.type || 'unknown'; - const dbDisplayType = dbType === 'diros' ? 'Doris' : dbType.charAt(0).toUpperCase() + dbType.slice(1); - systemMessages.push({ - role: 'system', - content: `你是一个专业的数据库助手。当前连接的数据库类型是 ${dbDisplayType},当前数据库名为 ${targetDbName}。如果用户需要查询特定的表或者有关当前库的信息,你可以调用提供的 get_tables 工具来主动获取数据表信息。` - }); - } - else { - const connList = conns.map(c => `{id: "${c.id}", name: "${c.name}", type: "${c.config?.type || 'unknown'}"}`).join(', '); - systemMessages.push({ - role: 'system', - content: `你是一个专业的数据库助手。用户目前在界面上没有选中任何具体的数据库或数据表用于充当上下文。 - -重要规则: -1. 如果你需要帮用户寻找目标表,千万不要凭空猜测表名!必须调用工具去获取真实数据。 -2. 完整工作流程:get_connections → get_databases → get_tables → get_columns → 生成 SQL。每一步都不可跳过。 -3. 【连接优先级 - 极重要】获取连接列表后,必须按以下优先级依次检索: - - 第一优先:host 为 localhost、127.0.0.1、或包含"本地"的连接 - - 第二优先:name 或 host 包含"开发"、"dev"、"local" 的连接,或 host 为 10.x、192.168.x、172.16-31.x 等内网 IP 的连接 - - 第三优先:其他连接(如"测试"、"生产"等) - 如果在高优先级连接中已找到目标表,直接使用该连接,不再查找低优先级连接。 -4. 如果在当前数据库中未找到目标表,必须继续查询其他数据库,不要放弃。 -5. 只有当所有可能的数据库都已检查完毕,或者已经明确找到目标表时,才可以停止。 -6. 如果是常规问答(不涉及数据库查询)则正常作答即可。 - -SQL 生成规则(极重要,必须严格遵守): -7. 【字段精确性 - 绝对红线】生成 SQL 之前,必须先调用 get_columns 获取目标表的真实字段列表。SQL 中的每一个字段名必须与 get_columns 返回的 field 字段完全一致(区分大小写)。不得自行拼凑、缩写或联想字段名(例如字段是 channel 就必须写 channel,不得写成 pay_channel)。 -8. 生成 SQL 时禁止使用 "database.table" 格式的限定前缀,只写表名本身。 -9. 报告结果时,连接名/ID 和数据库名必须严格来自同一个 get_tables 调用的实际参数。禁止将 A 连接的 connectionId 与 B 连接的 dbName 混搭。 -10. 如果有多个名称相似的数据库,请明确告诉用户目标表具体位于哪个数据库。 -11. 【关键】每个 SQL 代码块的第一行必须添加上下文声明注释,格式严格为:-- @context connectionId=<连接ID> dbName=<数据库名>。connectionId 和 dbName 必须来自同一个成功的 get_tables 调用(即你在该调用中传入的实际参数值)。示例: -\`\`\`sql --- @context connectionId=1770778676549 dbName=mkefu_test -SELECT * FROM users WHERE status = 1; -\`\`\` - -当前存在的连接:[${connList || '无连接'}]` - }); - } + const conn = conns.find(c => c.id === targetConnId); + const dbType = conn?.config?.type || 'unknown'; + const dbDisplayType = dbType === 'diros' ? 'Doris' : dbType.charAt(0).toUpperCase() + dbType.slice(1); + const ddlChunks = activeContextItems.map(c => `-- Table: ${c.dbName}.${c.tableName}\n${c.ddl}`).join('\n\n'); + const connList = conns.map(c => `{id: "${c.id}", name: "${c.name}", type: "${c.config?.type || 'unknown'}"}`).join(', '); + const sqlPromptKey = activeContextItems.length > 0 + ? 'ai_chat.panel.prompt.sql.context_tables' + : targetConnId && targetDbName + ? 'ai_chat.panel.prompt.sql.current_database' + : conns.length > 0 + ? 'ai_chat.panel.prompt.sql.no_context' + : 'ai_chat.panel.prompt.sql.no_connections'; + const sqlPromptParams = activeContextItems.length > 0 + ? { + dbDisplayType, + ddlChunks, + } + : targetConnId && targetDbName + ? { + dbDisplayType, + targetDbName, + } + : conns.length > 0 + ? { + connList, + } + : {}; + systemMessages.push({ + role: 'system', + content: tRef.current(sqlPromptKey, sqlPromptParams), + }); return systemMessages; }, []); // 零依赖:函数内部通过 useStore.getState() 实时读取 @@ -1156,6 +1127,7 @@ SELECT * FROM users WHERE status = 1; const toolContextMapRef = useRef>(new Map()); const executeLocalTools = useCallback(async (toolCalls: AIToolCall[], currentAsstMsgId: string) => { + const translateToolChrome: AIChatTranslator = (key, params) => tRef.current(key, params); const currentAsstMsg = (useStore.getState().aiChatHistory[sid] || []).find(m => m.id === currentAsstMsgId); const inheritedJVMPlanContext = currentAsstMsg?.jvmPlanContext || pendingJVMPlanContextRef.current; const inheritedJVMDiagnosticPlanContext = @@ -1170,7 +1142,7 @@ SELECT * FROM users WHERE status = 1; updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' }); useStore.getState().addAIChatMessage(sid, { id: genId(), role: 'assistant', - content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`, + content: translateToolChrome('ai_chat.panel.probe.max_rounds', { count: MAX_TOOL_CALL_ROUNDS }), timestamp: Date.now(), jvmPlanContext: inheritedJVMPlanContext, jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, @@ -1211,9 +1183,9 @@ SELECT * FROM users WHERE status = 1; resStr = dbRes?.message || 'Failed to fetch DBs'; } } catch (e: any) { - resStr = `获取数据库列表失败: ${e?.message || e}`; + resStr = translateToolChrome('ai_chat.panel.tool_error.fetch_databases_failed', { detail: String(e?.message || e) }); } - } else { resStr = 'Connection not found'; } + } else { resStr = translateToolChrome('ai_chat.panel.tool_error.connection_not_found'); } break; } case 'get_tables': { @@ -1236,9 +1208,9 @@ SELECT * FROM users WHERE status = 1; }); } else { resStr = tbRes?.message || 'Failed to fetch Tables'; } } catch (e: any) { - resStr = `获取表列表失败: ${e?.message || e}`; + resStr = translateToolChrome('ai_chat.panel.tool_error.fetch_tables_failed', { detail: String(e?.message || e) }); } - } else { resStr = 'Connection not found'; } + } else { resStr = translateToolChrome('ai_chat.panel.tool_error.connection_not_found'); } break; } case 'get_columns': { @@ -1263,13 +1235,13 @@ SELECT * FROM users WHERE status = 1; }); // ⚠️ 在工具返回结果中直接注入强制警告,确保模型使用精确字段名 const fieldNames = cols.map((c: any) => c.field).join(', '); - resStr = `⚠️ 以下为 ${safeTable} 表的真实字段列表。生成 SQL 时只能使用这些 field 值作为列名,必须原样使用,禁止修改、缩写或自行拼凑字段名。\n可用字段:${fieldNames}\n详细信息:${JSON.stringify(cols)}`; + resStr = translateToolChrome('ai_chat.panel.tool_result.columns_exact_fields', { tableName: safeTable, fieldNames, detailJson: JSON.stringify(cols) }); success = true; } else { resStr = colRes?.message || 'Failed to fetch columns'; } } catch (e: any) { - resStr = `获取字段列表失败: ${e?.message || e}`; + resStr = translateToolChrome('ai_chat.panel.tool_error.fetch_columns_failed', { detail: String(e?.message || e) }); } - } else { resStr = 'Connection not found'; } + } else { resStr = translateToolChrome('ai_chat.panel.tool_error.connection_not_found'); } break; } case 'get_table_ddl': { @@ -1288,9 +1260,9 @@ SELECT * FROM users WHERE status = 1; resStr = toolResult.content; success = toolResult.success; } catch (e: any) { - resStr = `获取建表语句失败: ${e?.message || e}`; + resStr = translateToolChrome('ai_chat.panel.tool_error.fetch_table_ddl_failed', { detail: String(e?.message || e) }); } - } else { resStr = 'Connection not found'; } + } else { resStr = translateToolChrome('ai_chat.panel.tool_error.connection_not_found'); } break; } case 'execute_sql': { @@ -1304,7 +1276,7 @@ SELECT * FROM users WHERE status = 1; if (Service?.AICheckSQL) { const check = await Service.AICheckSQL(safeSql); if (!check.allowed) { - resStr = `安全策略拦截:当前安全级别不允许执行 ${check.operationType} 类型的 SQL。请将 SQL 展示给用户,让用户手动执行。`; + resStr = translateToolChrome('ai_chat.panel.tool_error.sql_blocked', { operationType: check.operationType }); break; } } @@ -1316,15 +1288,15 @@ SELECT * FROM users WHERE status = 1; const limitedRows = rows.slice(0, 50); resStr = JSON.stringify({ rowCount: rows.length, data: limitedRows }); success = true; - } else { resStr = qRes?.message || 'SQL 执行失败'; } + } else { resStr = qRes?.message || translateToolChrome('ai_chat.panel.tool_error.sql_execute_failed'); } } catch (e: any) { - resStr = `SQL 执行异常: ${e?.message || e}`; + resStr = translateToolChrome('ai_chat.panel.tool_error.sql_execute_exception', { detail: String(e?.message || e) }); } - } else { resStr = 'Connection not found'; } + } else { resStr = translateToolChrome('ai_chat.panel.tool_error.connection_not_found'); } break; } default: - resStr = `Unknown function: ${tc.function.name}`; + resStr = translateToolChrome('ai_chat.panel.tool_error.unknown_function', { functionName: tc.function.name }); } } catch (e: any) { resStr = e.message; @@ -1357,7 +1329,7 @@ SELECT * FROM users WHERE status = 1; if (toolCallRoundRef.current >= 3) { useStore.getState().addAIChatMessage(sid, { id: genId(), role: 'assistant', - content: '⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。', + content: translateToolChrome('ai_chat.panel.probe.consecutive_failed'), timestamp: Date.now(), jvmPlanContext: inheritedJVMPlanContext, jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, @@ -1373,7 +1345,7 @@ SELECT * FROM users WHERE status = 1; // 插入过渡气泡 const chainConnectingMsg: AIChatMessage = { id: genId(), role: 'assistant', phase: 'connecting', - content: '汇总探针执行结果中', + content: translateToolChrome('ai_chat.panel.status.summarizing_probe'), timestamp: Date.now(), loading: true, jvmPlanContext: inheritedJVMPlanContext, jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, @@ -1389,10 +1361,10 @@ SELECT * FROM users WHERE status = 1; } }; - setTimeout(() => safeUpdateTransition('向模型回传运行时数据'), 200); - setTimeout(() => safeUpdateTransition('模型大脑深度推理中'), 500); - setTimeout(() => safeUpdateTransition('等待下发操作指令'), 1200); - setTimeout(() => safeUpdateTransition('正在深度思考链路与逻辑'), 3000); + setTimeout(() => safeUpdateTransition(translateToolChrome('ai_chat.panel.status.returning_runtime_data')), 200); + setTimeout(() => safeUpdateTransition(translateToolChrome('ai_chat.panel.status.deep_reasoning')), 500); + setTimeout(() => safeUpdateTransition(translateToolChrome('ai_chat.panel.status.waiting_instruction')), 1200); + setTimeout(() => safeUpdateTransition(translateToolChrome('ai_chat.panel.status.analyzing_chain')), 3000); setSending(true); const currentHistory = useStore.getState().aiChatHistory[sid] || []; @@ -1406,13 +1378,13 @@ SELECT * FROM users WHERE status = 1; let finalMessagesPayload = messagesPayload; // 在这里加入长度检查和自动摘要(带上动态限额) const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model); - const summary = await compressContextIfNeeded(sid, messagesPayload, dynamicMaxLimit); + const summary = await compressContextIfNeeded(sid, messagesPayload, dynamicMaxLimit, translateToolChrome); if (summary) { const compressedMsg: AIChatMessage = { - id: genId(), role: 'assistant', content: `【自动记忆重塑】已将超长历史探针数据和对话压缩为摘要:\n\n${summary}`, timestamp: Date.now() - 1000 + id: genId(), role: 'assistant', content: translateToolChrome('ai_chat.panel.status.memory_probe_summary', { summary }), timestamp: Date.now() - 1000 }; const continueMsg: AIChatMessage = { - id: genId(), role: 'user', content: '请根据上述最新状态与探索结果,继续完成你先前未竟的分析或执行下一步。', timestamp: Date.now() - 500 + id: genId(), role: 'user', content: translateToolChrome('ai_chat.panel.model_control.continue_after_summary'), timestamp: Date.now() - 500 }; useStore.getState().replaceAIChatHistory(sid, [compressedMsg, continueMsg, chainConnectingMsg]); finalMessagesPayload = [ @@ -1425,18 +1397,18 @@ SELECT * FROM users WHERE status = 1; // 【软收敛】超过 10 轮工具调用后,不再传递 tools 参数,从物理层面强制模型只能用文本回答 const SOFT_LIMIT_ROUNDS = 10; - const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : LOCAL_TOOLS; + const chainTools = totalToolRoundRef.current >= SOFT_LIMIT_ROUNDS ? [] : getLocalTools(); const Service = (window as any).go?.aiservice?.Service; if (Service?.AIChatStream) { await Service.AIChatStream(sid, allMessages, chainTools); } else if (Service?.AIChatSend) { const result = await Service.AIChatSend(allMessages, chainTools); - const errR = result?.error || '未知错误'; - const errC = sanitizeErrorMsg(errR); + const errR = result?.error || translateToolChrome('ai_chat.panel.error.unknown'); + const errC = sanitizeErrorMsg(errR, translateToolChrome); useStore.getState().addAIChatMessage(sid, { id: genId(), role: 'assistant', - content: result?.success ? result.content : `❌ ${errC}`, + content: result?.success ? result.content : translateToolChrome('ai_chat.panel.message.error', { detail: errC }), thinking: result?.success ? result.reasoning_content : undefined, reasoning_content: result?.success ? result.reasoning_content : undefined, rawError: (!result?.success && errC !== errR) ? errR : undefined, @@ -1450,7 +1422,7 @@ SELECT * FROM users WHERE status = 1; console.error('Failed to chain tool call', e); setSending(false); } - }, [sid, buildSystemContextMessages]); + }, [sid, buildSystemContextMessages, getLocalTools]); const handleSend = useCallback(async () => { const text = input.trim(); @@ -1458,14 +1430,14 @@ SELECT * FROM users WHERE status = 1; // 前置校验:必须配置供应商且选择模型后才能发送 if (!activeProvider) { - setComposerNotice(buildMissingProviderNotice()); + setComposerNoticeState({ kind: 'missing_provider' }); return; } if (!activeProvider.model || !activeProvider.model.trim()) { - setComposerNotice(buildMissingModelNotice()); + setComposerNoticeState({ kind: 'missing_model' }); return; } - setComposerNotice(null); + setComposerNoticeState(null); toolCallRoundRef.current = 0; // 重置工具调用轮次计数 totalToolRoundRef.current = 0; // 重置总轮次计数 @@ -1504,17 +1476,17 @@ SELECT * FROM users WHERE status = 1; ); // 【过渡状态 2】上下文已组装完成,即将接入模型 - updateAIChatMessage(sid, connectingMsg.id, { content: '模型接入中' }); + updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.model_connecting') }); const chatMessages = [...messages, userMsg].map(toAIRequestMessage); let finalMessagesPayload = chatMessages; const dynamicMaxLimit = getDynamicMaxContextChars(activeProvider?.model); - const summary = await compressContextIfNeeded(sid, chatMessages, dynamicMaxLimit); + const summary = await compressContextIfNeeded(sid, chatMessages, dynamicMaxLimit, t); if (summary) { // 清理原有历史,保留系统生成的总结记录和当前的 userMsg 以及 connectingMsg const compressedMsg: AIChatMessage = { - id: genId(), role: 'assistant', content: `【自动记忆重塑】已将超长历史压缩为摘要:\n\n${summary}`, timestamp: Date.now() - 1000 + id: genId(), role: 'assistant', content: t('ai_chat.panel.status.memory_summary', { summary }), timestamp: Date.now() - 1000 }; useStore.getState().replaceAIChatHistory(sid, [compressedMsg, userMsg, connectingMsg]); finalMessagesPayload = [ @@ -1526,22 +1498,22 @@ SELECT * FROM users WHERE status = 1; const allMessages = [...systemMessages, ...finalMessagesPayload]; // 【过渡状态 3】大脑唤醒 - updateAIChatMessage(sid, connectingMsg.id, { content: '唤醒推理引擎中' }); + updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.waking_engine') }); // 【过渡状态 4】最后一步,等待第一字节返回 - updateAIChatMessage(sid, connectingMsg.id, { content: '等待模型响应' }); + updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.waiting_response') }); try { const Service = (window as any).go?.aiservice?.Service; if (Service?.AIChatStream) { - await Service.AIChatStream(sid, allMessages, LOCAL_TOOLS); + await Service.AIChatStream(sid, allMessages, getLocalTools()); } else if (Service?.AIChatSend) { - const result = await Service.AIChatSend(allMessages, LOCAL_TOOLS); - const errR2 = result?.error || '未知错误'; - const errC2 = sanitizeErrorMsg(errR2); + const result = await Service.AIChatSend(allMessages, getLocalTools()); + const errR2 = result?.error || t('ai_chat.panel.error.unknown'); + const errC2 = sanitizeErrorMsg(errR2, t); const assistantMsg: AIChatMessage = { id: genId(), role: 'assistant', - content: result?.success ? result.content : `❌ ${errC2}`, + content: result?.success ? result.content : t('ai_chat.panel.message.error', { detail: errC2 }), thinking: result?.success ? result.reasoning_content : undefined, reasoning_content: result?.success ? result.reasoning_content : undefined, rawError: (!result?.success && errC2 !== errR2) ? errR2 : undefined, @@ -1560,7 +1532,7 @@ SELECT * FROM users WHERE status = 1; addAIChatMessage(sid, { id: genId(), role: 'assistant', - content: '❌ AI Service 未就绪', + content: t('ai_chat.panel.message.service_not_ready'), timestamp: Date.now(), jvmPlanContext: currentJVMPlanContext, jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, @@ -1569,11 +1541,11 @@ SELECT * FROM users WHERE status = 1; } } catch (e: any) { const rawE2 = e?.message || String(e); - const cleanE2 = sanitizeErrorMsg(rawE2); + const cleanE2 = sanitizeErrorMsg(rawE2, t); addAIChatMessage(sid, { id: genId(), role: 'assistant', - content: `❌ 发送失败: ${cleanE2}`, + content: t('ai_chat.panel.message.send_failed', { detail: cleanE2 }), rawError: cleanE2 !== rawE2 ? rawE2 : undefined, timestamp: Date.now(), jvmPlanContext: currentJVMPlanContext, @@ -1592,6 +1564,8 @@ SELECT * FROM users WHERE status = 1; buildSystemContextMessages, getCurrentJVMPlanContext, getCurrentJVMDiagnosticPlanContext, + getLocalTools, + t, ]); const handleKeyDown = useCallback((e: React.KeyboardEvent) => { @@ -1740,36 +1714,38 @@ SELECT * FROM users WHERE status = 1; const errors = recentLogs.filter((log) => log.status === 'error'); const writeCount = recentLogs.filter((log) => /\b(INSERT|UPDATE|DELETE|ALTER|DROP|CREATE)\b/i.test(log.sql)).length; const contextCount = contextTableNames.length; + const tableSeparator = t('ai_chat.panel.insight.context.table_separator'); + const tablePreview = `${contextTableNames.slice(0, 3).join(tableSeparator)}${contextCount > 3 ? t('ai_chat.panel.insight.context.more_tables_suffix') : ''}`; return [ { tone: 'info', - title: contextCount > 0 ? `已关联 ${contextCount} 张表` : '尚未关联表结构', + title: contextCount > 0 ? t('ai_chat.panel.insight.context.linked_title', { count: contextCount }) : t('ai_chat.panel.insight.context.empty_title'), body: contextCount > 0 - ? `当前对话会带上 ${contextTableNames.slice(0, 3).join('、')}${contextCount > 3 ? ' 等表' : ''} 的结构上下文。` - : '在表页打开 AI 后会自动关联当前表,也可以在输入框上方手动添加上下文。', + ? t('ai_chat.panel.insight.context.linked_body', { tables: tablePreview }) + : t('ai_chat.panel.insight.context.empty_body'), }, { tone: slowest && slowest.duration > 1000 ? 'warn' : 'accent', - title: slowest ? `最近最慢查询 ${Math.round(slowest.duration).toLocaleString()}ms` : '暂无查询耗时样本', - body: slowest ? slowest.sql.slice(0, 140) : '执行查询后这里会显示可用于优化分析的 SQL 线索。', + title: slowest ? t('ai_chat.panel.insight.query.slowest_title', { duration: Math.round(slowest.duration).toLocaleString() }) : t('ai_chat.panel.insight.query.empty_title'), + body: slowest ? slowest.sql.slice(0, 140) : t('ai_chat.panel.insight.query.empty_body'), }, { tone: errors.length > 0 ? 'warn' : 'info', - title: errors.length > 0 ? `${errors.length} 条最近查询失败` : '最近查询状态正常', - body: errors[0]?.message || (recentLogs.length > 0 ? `已记录 ${recentLogs.length} 条最近 SQL,可直接让 AI 解释或优化。` : '暂无 SQL 日志。'), + title: errors.length > 0 ? t('ai_chat.panel.insight.status.failed_title', { count: errors.length }) : t('ai_chat.panel.insight.status.ok_title'), + body: errors[0]?.message || (recentLogs.length > 0 ? t('ai_chat.panel.insight.status.recent_body', { count: recentLogs.length }) : t('ai_chat.panel.insight.status.empty_body')), }, { tone: writeCount > 0 ? 'warn' : 'accent', - title: writeCount > 0 ? `检测到 ${writeCount} 条写操作` : '当前以只读分析为主', - body: writeCount > 0 ? '涉及写入的 SQL 建议先生成预览与回滚语句,再执行提交。' : 'AI 默认优先解释、生成 SELECT、分析 Schema 与优化索引。', + title: writeCount > 0 ? t('ai_chat.panel.insight.write.detected_title', { count: writeCount }) : t('ai_chat.panel.insight.write.readonly_title'), + body: writeCount > 0 ? t('ai_chat.panel.insight.write.detected_body') : t('ai_chat.panel.insight.write.readonly_body'), }, ]; - }, [contextTableNames, sqlLogs]); + }, [contextTableNames, sqlLogs, t]); const renderPanelHistoryList = () => { const sessions = aiChatSessions.slice(0, 8); if (sessions.length === 0) { - return
暂无历史会话
; + return
{t('ai_chat.panel.history.empty')}
; } return sessions.map((session) => ( @@ -1832,7 +1808,7 @@ SELECT * FROM users WHERE status = 1; onSettingsClick={() => { onOpenSettings?.(); setTimeout(loadActiveProvider, 500); }} onClose={onClose} messages={messages} - sessionTitle={useStore.getState().aiChatSessions.find(s => s.id === sid)?.title || '新对话'} + sessionTitle={useStore.getState().aiChatSessions.find(s => s.id === sid)?.title || t('ai_chat.panel.session.default_title')} activeMode={effectivePanelMode} onModeChange={(mode) => { if (!isV2Ui) return; @@ -1876,6 +1852,7 @@ SELECT * FROM users WHERE status = 1; overlayTheme={overlayTheme} onDeleteMessage={handleDeleteMessage} onError={handleMessageRenderError} + translateRenderError={t} > void; @@ -32,9 +33,11 @@ interface AISettingsModalProps { // 预设配置:每个预设映射到后端 type(openai/anthropic/gemini/custom)并附带默认 URL 和 Model interface ProviderPreset { key: string; - label: string; + labelKey: string; + fallbackLabel: string; icon: React.ReactNode; - desc: string; + descKey: string; + fallbackDesc: string; color: string; backendType: AIProviderType; fixedApiFormat?: string; @@ -44,19 +47,19 @@ interface ProviderPreset { } const PROVIDER_PRESETS: ProviderPreset[] = [ - { key: 'openai', label: 'OpenAI', icon: , 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: , desc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] }, - { key: 'qwen-bailian', label: '通义千问(百炼通用)', icon: , desc: '百炼 Anthropic 兼容 / 模型从远端拉取', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] }, - { key: 'qwen-coding-plan', label: '通义千问(Coding Plan)', icon: , desc: 'Claude Code CLI 代理链路 / 使用官方支持模型清单', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS }, - { key: 'zhipu', label: '智谱 GLM', icon: , 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: , 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: , desc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] }, - { key: 'gemini', label: 'Gemini', icon: , desc: 'Gemini 3.1 / 2.5 系列', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] }, - { key: 'volcengine-ark', label: '火山方舟', icon: , desc: 'Ark 通用推理 / 豆包模型', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] }, - { key: 'volcengine-coding', label: '火山 Coding Plan', icon: , desc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] }, - { key: 'minimax', label: 'MiniMax', icon: , desc: 'M2.7 / M2.5 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'] }, - { key: 'ollama', label: 'Ollama', icon: , desc: '本地部署开源模型', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] }, - { key: 'custom', label: '自定义', icon: , desc: '自定义 API 端点', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] }, + { key: 'openai', labelKey: 'ai_settings.provider_preset.openai.label', fallbackLabel: 'OpenAI', icon: , descKey: 'ai_settings.provider_preset.openai.desc', fallbackDesc: 'GPT-5.4 / 5.3 series', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] }, + { key: 'deepseek', labelKey: 'ai_settings.provider_preset.deepseek.label', fallbackLabel: 'DeepSeek', icon: , descKey: 'ai_settings.provider_preset.deepseek.desc', fallbackDesc: 'DeepSeek-V4 / R1', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] }, + { key: 'qwen-bailian', labelKey: 'ai_settings.provider_preset.qwen_bailian.label', fallbackLabel: 'Qwen (Bailian General)', icon: , descKey: 'ai_settings.provider_preset.qwen_bailian.desc', fallbackDesc: 'Bailian Anthropic-compatible endpoint / remote model list', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] }, + { key: 'qwen-coding-plan', labelKey: 'ai_settings.provider_preset.qwen_coding_plan.label', fallbackLabel: 'Qwen (Coding Plan)', icon: , descKey: 'ai_settings.provider_preset.qwen_coding_plan.desc', fallbackDesc: 'Claude Code CLI proxy chain / official supported model list', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS }, + { key: 'zhipu', labelKey: 'ai_settings.provider_preset.zhipu.label', fallbackLabel: 'Zhipu GLM', icon: , descKey: 'ai_settings.provider_preset.zhipu.desc', fallbackDesc: 'GLM-5 / GLM-5-Turbo', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] }, + { key: 'moonshot', labelKey: 'ai_settings.provider_preset.moonshot.label', fallbackLabel: 'Kimi', icon: , descKey: 'ai_settings.provider_preset.moonshot.desc', fallbackDesc: 'Kimi K2.5 (Anthropic-compatible)', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] }, + { key: 'anthropic', labelKey: 'ai_settings.provider_preset.anthropic.label', fallbackLabel: 'Claude', icon: , descKey: 'ai_settings.provider_preset.anthropic.desc', fallbackDesc: 'Claude Opus/Sonnet', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] }, + { key: 'gemini', labelKey: 'ai_settings.provider_preset.gemini.label', fallbackLabel: 'Gemini', icon: , descKey: 'ai_settings.provider_preset.gemini.desc', fallbackDesc: 'Gemini 3.1 / 2.5 series', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] }, + { key: 'volcengine-ark', labelKey: 'ai_settings.provider_preset.volcengine_ark.label', fallbackLabel: 'Volcengine Ark', icon: , descKey: 'ai_settings.provider_preset.volcengine_ark.desc', fallbackDesc: 'Ark general inference / Doubao models', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] }, + { key: 'volcengine-coding', labelKey: 'ai_settings.provider_preset.volcengine_coding.label', fallbackLabel: 'Volcengine Coding Plan', icon: , descKey: 'ai_settings.provider_preset.volcengine_coding.desc', fallbackDesc: 'Ark Code / Coding Plan', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] }, + { key: 'minimax', labelKey: 'ai_settings.provider_preset.minimax.label', fallbackLabel: 'MiniMax', icon: , descKey: 'ai_settings.provider_preset.minimax.desc', fallbackDesc: 'M2.7 / M2.5 series (Anthropic-compatible)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M2.7', models: ['MiniMax-M2.7', 'MiniMax-M2.7-highspeed', 'MiniMax-M2.5', 'MiniMax-M2.5-highspeed', 'MiniMax-M2.1', 'MiniMax-M2.1-highspeed', 'MiniMax-M2'] }, + { key: 'ollama', labelKey: 'ai_settings.provider_preset.ollama.label', fallbackLabel: 'Ollama', icon: , descKey: 'ai_settings.provider_preset.ollama.desc', fallbackDesc: 'Locally deployed open-source models', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] }, + { key: 'custom', labelKey: 'ai_settings.provider_preset.custom.label', fallbackLabel: 'Custom', icon: , descKey: 'ai_settings.provider_preset.custom.desc', fallbackDesc: 'Custom API endpoint', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] }, ]; const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find(p => p.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1]; @@ -66,19 +69,20 @@ const matchProviderPreset = (provider: Pick = ({ open, onClose, darkMode, overlayTheme, focusProviderId }) => { + const { t } = useI18n(); const [providers, setProviders] = useState([]); const [activeProviderId, setActiveProviderId] = useState(''); const [safetyLevel, setSafetyLevel] = useState('readonly'); @@ -205,7 +209,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo }, })); } catch (e: any) { - void messageApi.error(e?.message || '读取供应商配置失败'); + void messageApi.error(e?.message || t('ai_settings.message.load_provider_failed')); } }; @@ -219,16 +223,16 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo if (wasActive) { const newProviders: any[] = await Service?.AIGetProviders?.() || []; if (newProviders.length > 0) { - const newActiveName = newProviders[0]?.name || '下一个供应商'; - void messageApi.success(`已删除,自动切换到「${newActiveName}」`); + const newActiveName = newProviders[0]?.name || t('ai_settings.provider.next_provider'); + void messageApi.success(t('ai_settings.message.deleted_and_switched', { name: newActiveName })); } else { - void messageApi.success('已删除'); + void messageApi.success(t('ai_settings.message.deleted')); } } else { - void messageApi.success('已删除'); + void messageApi.success(t('ai_settings.message.deleted')); } window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed')); - } catch (e: any) { void messageApi.error(e?.message || '删除失败'); } + } catch (e: any) { void messageApi.error(e?.message || t('ai_settings.message.delete_failed')); } }; const handleSaveProvider = async () => { @@ -248,7 +252,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo customModels: values.models, }); // 内置供应商自动使用 preset label 作为名称 - const finalName = isCustomLike ? (values.name || preset.label) : preset.label; + const finalName = isCustomLike ? (values.name || preset.fallbackLabel) : preset.fallbackLabel; const finalBaseUrl = resolvePresetBaseURL({ presetKey: values.presetKey, @@ -277,11 +281,11 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo }; // 后端 AISaveProvider 统一处理新增和更新,返回 void,失败抛异常 await Service?.AISaveProvider?.(payload); - void messageApi.success('已保存'); resetProviderEditorSession(); void loadConfig(); + void messageApi.success(t('ai_settings.message.saved')); resetProviderEditorSession(); void loadConfig(); window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed')); } catch (e: any) { if (e?.errorFields) { /* antd form validation error, ignore */ } - else void messageApi.error(e?.message || '保存失败'); + else void messageApi.error(e?.message || t('ai_settings.message.save_failed')); } finally { setLoading(false); } }; @@ -289,9 +293,9 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo try { const Service = (window as any).go?.aiservice?.Service; await Service?.AISetActiveProvider?.(id); - setActiveProviderId(id); void messageApi.success('已切换'); + setActiveProviderId(id); void messageApi.success(t('ai_settings.message.switched')); window.dispatchEvent(new CustomEvent('gonavi:ai:provider-changed')); - } catch (e: any) { void messageApi.error(e?.message || '切换失败'); } + } catch (e: any) { void messageApi.error(e?.message || t('ai_settings.message.switch_failed')); } }; const handleSafetyChange = async (level: AISafetyLevel) => { @@ -338,7 +342,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo apiKeyInput: values.apiKey, }); if (secretDraft.mode === 'clear') { - throw new Error('测试连接前请填写 API Key'); + throw new Error(t('ai_settings.message.test_requires_new_api_key')); } const res = await Service?.AITestProvider?.({ ...editingProvider, @@ -353,9 +357,9 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo temperature: Number(values.temperature) ?? 0.7, apiFormat: resolvedTransport.apiFormat, }); - if (res?.success) { setTestStatus('success'); void messageApi.success('连接成功'); } - else { setTestStatus('error'); void messageApi.error(`测试失败: ${res?.message || '未知错误'}`); } - } catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || '测试失败'); } + if (res?.success) { setTestStatus('success'); void messageApi.success(t('ai_settings.message.test_success')); } + else { setTestStatus('error'); void messageApi.error(res?.message || t('ai_settings.message.test_failed')); } + } catch (e: any) { setTestStatus('error'); void messageApi.error(e?.message || t('ai_settings.message.test_failed')); } finally { setLoading(false); } }; @@ -384,6 +388,8 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo fontSize: 13, fontWeight: 700, textTransform: 'uppercase' as const, letterSpacing: '0.08em', color: sectionLabelColor, marginBottom: 10, display: 'flex', alignItems: 'center', gap: 6, }; + const presetLabel = (preset: ProviderPreset): string => t(preset.labelKey) || preset.fallbackLabel; + const presetDesc = (preset: ProviderPreset): string => t(preset.descKey) || preset.fallbackDesc; // ===== Provider 列表 ===== const renderProviderList = () => ( @@ -394,8 +400,8 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo border: `1px dashed ${cardBorder}`, borderRadius: 14, background: cardBg, }}> - 暂未配置模型供应商
- 添加一个以开始使用 AI 助手 + {t('ai_settings.provider.empty.title')}
+ {t('ai_settings.provider.empty.description')}
)} {providers.map(p => { @@ -422,19 +428,19 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo {isActive && }
- {matchedPreset.label} + {presetLabel(matchedPreset)} · - {p.model || '未选择模型'} + {p.model || t('ai_settings.provider.no_model')}
- +
); @@ -457,9 +463,9 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo {/* 顶部返回 */}
+ style={{ borderRadius: 8 }}>← {t('ai_settings.action.back')} - {editingProvider?.id ? '编辑模型供应商' : '添加模型供应商'} + {editingProvider?.id ? t('ai_settings.provider.editor.edit_title') : t('ai_settings.provider.editor.add_title')}
@@ -467,7 +473,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo {/* Provider 类型选择 - 卡片式 */}
- 服务类型 + {t('ai_settings.form.section.service_type')}
@@ -486,8 +492,8 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo {pt.icon}
-
{pt.label}
-
{pt.desc}
+
{presetLabel(pt)}
+
{presetDesc(pt)}
))} @@ -500,17 +506,17 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && (
- 基本信息 + {t('ai_settings.form.section.basic')}
- 供应商名称} name="name" rules={[{ required: true, message: '请输入名称' }]} style={{ marginBottom: 16 }}> - {t('ai_settings.form.provider_name')}} name="name" rules={[{ required: true, message: t('ai_settings.form.provider_name_required') }]} style={{ marginBottom: 16 }}> + {presetKeyFromForm === 'custom' && ( - API 格式} name="apiFormat" style={{ marginBottom: 16 }}> + {t('ai_settings.form.api_format')}} name="apiFormat" style={{ marginBottom: 16 }}>
= ({ open, onClose, darkMo )} - 可用模型列表(可选配置)} name="models" style={{ marginBottom: 0 }}> -
)} @@ -545,10 +551,10 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo {/* 认证信息 */}
- 认证 & 连接 + {t('ai_settings.form.section.auth_connection')}
- API Key} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || editingProvider?.id) { return Promise.resolve(); } return Promise.reject(new Error('请输入 API Key')); } }]} style={{ marginBottom: 16 }}> - {t('ai_settings.form.api_key')}} name="apiKey" rules={[{ validator: (_, value) => { const apiKey = String(value || '').trim(); if (apiKey || editingProvider?.id) { return Promise.resolve(); } return Promise.reject(new Error(t('ai_settings.form.api_key_required'))); } }]} style={{ marginBottom: 16 }}> + = ({ open, onClose, darkMo {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( - API Endpoint (URL)} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}> + {t('ai_settings.form.api_endpoint')}} name="baseUrl" rules={[{ required: true, message: t('ai_settings.form.api_endpoint_required') }]} style={{ marginBottom: 0 }}> } @@ -576,11 +582,11 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo }}>
@@ -592,7 +598,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const renderSafetySettings = () => (
- 控制 AI 可执行的 SQL 操作类型,保护数据安全 + {t('ai_settings.safety.description')}
{SAFETY_OPTIONS.map(opt => { const active = safetyLevel === opt.value; @@ -613,10 +619,10 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo
- {opt.label} + {t(opt.labelKey)} {active && }
-
{opt.desc}
+
{t(opt.descKey)}
); @@ -628,7 +634,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const renderContextSettings = () => (
- 控制发送给 AI 的数据库上下文信息量 + {t('ai_settings.context.description')}
{CONTEXT_OPTIONS.map(opt => { const active = contextLevel === opt.value; @@ -649,10 +655,10 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo
- {opt.label} + {t(opt.labelKey)} {active && }
-
{opt.desc}
+
{t(opt.descKey)}
); @@ -663,7 +669,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const renderBuiltinPrompts = () => (
- 以下为当前版本 GoNavi 预设的底层 AI 提示词(只读)。它们会被动态注入到对应场景的请求上下文中。 + {t('ai_settings.prompts.description')}
{Object.entries(builtinPrompts).map(([title, promptText]) => (
= ({ open, onClose, darkMo ); const BUILTIN_TOOLS_INFO = [ - { name: 'get_connections', icon: '🔗', desc: '获取所有可用的数据库连接', detail: '返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。', params: '无参数' }, - { name: 'get_databases', icon: '🗄️', desc: '获取指定连接下的所有数据库', detail: '传入 connectionId,返回该连接下的数据库/Schema 名称列表。', params: 'connectionId: 连接 ID' }, - { name: 'get_tables', icon: '📋', desc: '获取指定数据库下的所有表名', detail: '传入 connectionId 和 dbName,返回表名列表。AI 用它来定位用户提到的目标表。', params: 'connectionId, dbName' }, - { name: 'get_columns', icon: '🔍', desc: '获取指定表的字段结构', detail: '传入 connectionId、dbName 和 tableName,返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。', params: 'connectionId, dbName, tableName' }, - { name: 'get_table_ddl', icon: '📝', desc: '获取表的建表语句 (DDL)', detail: '传入 connectionId、dbName 和 tableName,返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。', params: 'connectionId, dbName, tableName' }, - { name: 'execute_sql', icon: '▶️', desc: '执行 SQL 查询并返回结果', detail: '传入 connectionId、dbName 和 sql,在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE。', params: 'connectionId, dbName, sql' }, + { name: 'get_connections', icon: '🔗', descKey: 'ai_settings.tools.get_connections.desc', detailKey: 'ai_settings.tools.get_connections.detail', params: t('ai_settings.tools.params.none') }, + { name: 'get_databases', icon: '🗄️', descKey: 'ai_settings.tools.get_databases.desc', detailKey: 'ai_settings.tools.get_databases.detail', params: 'connectionId' }, + { name: 'get_tables', icon: '📋', descKey: 'ai_settings.tools.get_tables.desc', detailKey: 'ai_settings.tools.get_tables.detail', params: 'connectionId, dbName' }, + { name: 'get_columns', icon: '🔍', descKey: 'ai_settings.tools.get_columns.desc', detailKey: 'ai_settings.tools.get_columns.detail', params: 'connectionId, dbName, tableName' }, + { name: 'get_table_ddl', icon: '📝', descKey: 'ai_settings.tools.get_table_ddl.desc', detailKey: 'ai_settings.tools.get_table_ddl.detail', params: 'connectionId, dbName, tableName' }, + { name: 'execute_sql', icon: '▶️', descKey: 'ai_settings.tools.execute_sql.desc', detailKey: 'ai_settings.tools.execute_sql.detail', params: 'connectionId, dbName, sql' }, ]; const renderBuiltinTools = () => (
- AI 助手在处理数据库相关问题时,可以自动调用以下内置工具获取真实数据,全程无需人工干预。 + {t('ai_settings.tools.description')}
- 💡 工作流程:get_connections → get_databases → get_tables → get_columns → 生成 SQL + {t('ai_settings.tools.workflow')}
{BUILTIN_TOOLS_INFO.map(tool => (
= ({ open, onClose, darkMo
{tool.name}
-
{tool.desc}
+
{t(tool.descKey)}
- {tool.detail} + {t(tool.detailKey)}
- 参数: + {t('ai_settings.tools.params_label')} {tool.params} @@ -750,9 +756,9 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo
-
AI 设置
+
{t('ai_settings.title')}
- 配置 AI 模型、安全级别和上下文选项 + {t('ai_settings.subtitle')}
@@ -770,14 +776,14 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo
{messageContextHolder}
-
设置导航
+
{t('ai_settings.nav.title')}
{[ - { key: 'providers', title: '模型供应商', description: '配置大模型接口与秘钥', icon: }, - { key: 'safety', title: '安全控制', description: '限制 AI 操作风险级别', icon: }, - { key: 'context', title: '上下文', description: '配置携带的数据架构信息', icon: }, - { key: 'tools', title: '内置工具', description: '查看 AI 可调用的数据探针', icon: }, - { key: 'prompts', title: '内置提示词', description: '查看系统预设的底层要求', icon: }, + { key: 'providers', title: t('ai_settings.nav.providers.title'), description: t('ai_settings.nav.providers.description'), icon: }, + { key: 'safety', title: t('ai_settings.nav.safety.title'), description: t('ai_settings.nav.safety.description'), icon: }, + { key: 'context', title: t('ai_settings.nav.context.title'), description: t('ai_settings.nav.context.description'), icon: }, + { key: 'tools', title: t('ai_settings.nav.tools.title'), description: t('ai_settings.nav.tools.description'), icon: }, + { key: 'prompts', title: t('ai_settings.nav.prompts.title'), description: t('ai_settings.nav.prompts.description'), icon: }, ].map((item) => { const active = activeSection === item.key; return ( diff --git a/frontend/src/components/ConnectionModal.edit-password.test.tsx b/frontend/src/components/ConnectionModal.edit-password.test.tsx index 55b50d84..615d6788 100644 --- a/frontend/src/components/ConnectionModal.edit-password.test.tsx +++ b/frontend/src/components/ConnectionModal.edit-password.test.tsx @@ -15,4 +15,24 @@ describe('ConnectionModal edit password behavior', () => { expect(source).not.toContain('description:\n "当前已保存 Redis 密码。留空表示继续沿用,输入新值表示替换。"'); expect(source).toContain('String(config.password || "") === ""'); }); + + it('reuses the shared backend-cancel helper for file and certificate pickers', () => { + expect(source).not.toContain('res?.message !== "已取消"'); + expect(source.match(/isBackendCancelledResult\(res\)/g) ?? []).toHaveLength(3); + }); + + it('uses localized SSL mode labels instead of hardcoded English strings', () => { + expect(source).not.toContain('label: "Preferred"'); + expect(source).not.toContain('label: "Required"'); + expect(source).not.toContain('label: "Skip Verify"'); + expect(source).toMatch( + /label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.preferred",\s*\)/, + ); + expect(source).toMatch( + /label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.required",\s*\)/, + ); + expect(source).toMatch( + /label:\s*t\(\s*"connection\.modal\.network\.ssl_mode\.skip_verify",\s*\)/, + ); + }); }); diff --git a/frontend/src/components/ConnectionModal.i18n.test.tsx b/frontend/src/components/ConnectionModal.i18n.test.tsx new file mode 100644 index 00000000..0946948d --- /dev/null +++ b/frontend/src/components/ConnectionModal.i18n.test.tsx @@ -0,0 +1,1126 @@ +import React from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { act, create, type ReactTestRenderer } from "react-test-renderer"; +import { readFileSync } from "node:fs"; + +import { setCurrentLanguage } from "../i18n"; + +const storeState = { + addConnection: vi.fn(), + updateConnection: vi.fn(), + theme: "light", + languagePreference: "zh-CN", + setLanguagePreference: vi.fn((languagePreference: "zh-CN" | "en-US") => { + storeState.languagePreference = languagePreference; + setCurrentLanguage(languagePreference); + notifyStoreSubscribers(); + }), + appearance: { uiVersion: "legacy", opacity: 1 }, +}; + +const storeSubscribers = new Set<() => void>(); +const notifyStoreSubscribers = () => { + storeSubscribers.forEach((subscriber) => subscriber()); +}; + +let mockFormValues: Record = {}; + +const antdMessage = vi.hoisted(() => ({ + error: vi.fn(), + warning: vi.fn(), + success: vi.fn(), + destroy: vi.fn(), +})); + +const backendApp = { + DBGetDatabases: vi.fn(), + GetDriverStatusList: vi.fn(), + MongoDiscoverMembers: vi.fn(), + TestConnection: vi.fn(), + RedisConnect: vi.fn(), + SelectDatabaseFile: vi.fn(), + SelectCertificateFile: vi.fn(), + SelectSSHKeyFile: vi.fn(), + TestJVMConnection: vi.fn(), +}; + +const textContent = (node: any): string => { + if (node === null || node === undefined) return ""; + if (typeof node === "string") return node; + if (Array.isArray(node)) { + return node.map((item) => textContent(item)).join(""); + } + return [node.props?.placeholder, textContent(node.children || [])] + .filter(Boolean) + .join(""); +}; + +const findButton = (renderer: ReactTestRenderer, text: string) => + renderer.root.findAll((node) => node.type === "button" && textContent(node).includes(text))[0]; + +const findButtonByAnyText = (renderer: ReactTestRenderer, texts: string[]) => + renderer.root.findAll( + (node) => node.type === "button" && texts.some((text) => textContent(node).includes(text)), + )[0]; + +const findClickableByAnyText = (renderer: ReactTestRenderer, texts: string[]) => + renderer.root.findAll( + (node) => typeof node.props?.onClick === "function" && texts.some((text) => textContent(node).includes(text)), + )[0]; + +const findClickableCard = (renderer: ReactTestRenderer, text: string) => + renderer.root.findAll((node) => node.props?.role === "button" && textContent(node).includes(text))[0]; + +const flushConnectionTestTick = async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + await Promise.resolve(); +}; + +const source = readFileSync(new URL("./ConnectionModal.tsx", import.meta.url), "utf8"); + +const initialConnection = (type: string, config: Record = {}) => + ({ + id: `${type}-conn`, + name: `${type} connection`, + type, + config: { + type, + host: "localhost", + port: 3306, + user: "user", + ...config, + }, + }) as any; + +vi.mock("../store", () => ({ + useStore: (selector: (state: typeof storeState) => unknown) => + React.useSyncExternalStore( + (subscriber) => { + storeSubscribers.add(subscriber); + return () => { + storeSubscribers.delete(subscriber); + }; + }, + () => selector(storeState), + () => selector(storeState), + ), +})); + +vi.mock("../../wailsjs/go/app/App", () => backendApp); + +vi.mock("../utils/overlayWorkbenchTheme", () => ({ + buildOverlayWorkbenchTheme: () => ({ + shellBg: "#fff", + shellBorder: "1px solid #eee", + shellShadow: "none", + shellBackdropFilter: "none", + sectionBorder: "1px solid #eee", + sectionBg: "#fff", + mutedText: "#666", + titleText: "#111", + iconBg: "#f5f5f5", + iconColor: "#111", + }), +})); + +vi.mock("./DatabaseIcons", () => ({ + getDbIcon: (type: string) => {type}, + getDbDefaultColor: () => "#1677ff", + getDbIconLabel: (type: string) => type, + DB_ICON_TYPES: ["mysql", "postgres"], + PRESET_ICON_COLORS: ["#1677ff", "#52c41a"], +})); + +vi.mock("@ant-design/icons", () => { + const Icon = () => ; + return { + DatabaseOutlined: Icon, + FileTextOutlined: Icon, + CloudOutlined: Icon, + CheckCircleFilled: Icon, + CloseCircleFilled: Icon, + LinkOutlined: Icon, + EditOutlined: Icon, + AppstoreOutlined: Icon, + BgColorsOutlined: Icon, + ApiOutlined: Icon, + ClusterOutlined: Icon, + CodeOutlined: Icon, + GatewayOutlined: Icon, + SafetyCertificateOutlined: Icon, + ThunderboltOutlined: Icon, + }; +}); + +vi.mock("antd", () => { + const Button: any = ({ children, disabled, loading, onClick, ...rest }: any) => ( + + ); + Button.Group = ({ children }: any) =>
{children}
; + + const Input: any = ({ children, value, onChange, placeholder, ...rest }: any) => ( + + {children} + + ); + Input.Password = ({ value, onChange, placeholder, ...rest }: any) => ( + + ); + Input.TextArea = ({ value, onChange, placeholder, ...rest }: any) => ( +