diff --git a/frontend/src/App.tool-center.test.ts b/frontend/src/App.tool-center.test.ts index 78137f1..3e3aa0b 100644 --- a/frontend/src/App.tool-center.test.ts +++ b/frontend/src/App.tool-center.test.ts @@ -273,8 +273,8 @@ describe('global appearance tokens', () => { expect(appSource).toContain('fontFamilyCode: resolvedMonoFontFamily'); expect(appSource).toContain("t('app.theme.data_table.font_size')"); expect(appSource).toContain("t('app.theme.data_table.sidebar_tree_font_size')"); - expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies)'); - expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies)'); + expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'ui\', installedFontFamilies, t)'); + expect(appSource).toContain('buildFontFamilyOptions(runtimePlatform, \'mono\', installedFontFamilies, t)'); expect(appSource).toContain('ListInstalledFontFamilies()'); expect(appSource).toContain('const [installedFontFamilies, setInstalledFontFamilies] = useState(EMPTY_INSTALLED_FONT_FAMILIES);'); expect(appSource).toContain("import LinuxCJKFontBanner from './components/LinuxCJKFontBanner';"); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ae04004..dd0fffb 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -366,12 +366,12 @@ function App() { const [fontFamiliesLoadError, setFontFamiliesLoadError] = useState(null); const hasLoadedInstalledFontsRef = useRef(false); const uiFontOptions = useMemo( - () => buildFontFamilyOptions(runtimePlatform, 'ui', installedFontFamilies), - [installedFontFamilies, runtimePlatform], + () => buildFontFamilyOptions(runtimePlatform, 'ui', installedFontFamilies, t), + [installedFontFamilies, runtimePlatform, t], ); const monoFontOptions = useMemo( - () => buildFontFamilyOptions(runtimePlatform, 'mono', installedFontFamilies), - [installedFontFamilies, runtimePlatform], + () => buildFontFamilyOptions(runtimePlatform, 'mono', installedFontFamilies, t), + [installedFontFamilies, runtimePlatform, t], ); const linuxCJKFontInstallHint = getLinuxCJKFontInstallHint(runtimePlatform, installedFontFamilies); const [isStoreHydrated, setIsStoreHydrated] = useState(() => useStore.persist.hasHydrated()); @@ -1524,7 +1524,7 @@ function App() { const isAboutOpenRef = React.useRef(false); const [aboutLoading, setAboutLoading] = useState(false); const [aboutInfo, setAboutInfo] = useState<{ version: string; author: string; buildTime?: string; repoUrl?: string; issueUrl?: string; releaseUrl?: string; communityUrl?: string } | null>(null); - const aboutDisplayVersion = resolveAboutDisplayVersion(runtimeBuildType, aboutInfo?.version); + const aboutDisplayVersion = resolveAboutDisplayVersion(runtimeBuildType, aboutInfo?.version, t('common.unknown')); const [aboutUpdateStatus, setAboutUpdateStatus] = useState(''); const [lastUpdateInfo, setLastUpdateInfo] = useState(null); const [updateDownloadProgress, setUpdateDownloadProgress] = useState<{ diff --git a/frontend/src/components/AIChatPanel.message-boundary.test.tsx b/frontend/src/components/AIChatPanel.message-boundary.test.tsx index 9412480..86d4483 100644 --- a/frontend/src/components/AIChatPanel.message-boundary.test.tsx +++ b/frontend/src/components/AIChatPanel.message-boundary.test.tsx @@ -1,10 +1,21 @@ import { describe, expect, it } from 'vitest'; import { readFileSync } from 'node:fs'; +import { t as catalogTranslate } from '../i18n/catalog'; + const source = readFileSync(new URL('./AIChatPanel.tsx', import.meta.url), 'utf8'); const testSource = readFileSync(new URL('./AIChatPanel.message-boundary.test.tsx', import.meta.url), 'utf8'); const boundarySource = readFileSync(new URL('./ai/AIMessageRenderBoundary.tsx', import.meta.url), 'utf8'); const conversationViewSource = readFileSync(new URL('./ai/AIChatPanelConversationView.tsx', import.meta.url), 'utf8'); +const derivedStateSource = readFileSync(new URL('./ai/aiChatPanelDerivedState.ts', import.meta.url), 'utf8'); + +const REQUIRED_RENDER_BOUNDARY_KEYS = [ + 'ai_chat.message.render_error.title', + 'ai_chat.message.render_error.body', + 'ai_chat.message.render_error.unknown', + 'ai_chat.message.render_error.retry', + 'ai_chat.message.render_error.delete', +] as const; describe('AIChatPanel merge resolution', () => { it('clears conflict markers from the merged files', () => { @@ -26,6 +37,26 @@ describe('AIChatPanel merge resolution', () => { expect(source).toContain('[AI Message Render Error]'); }); + it('keeps render-boundary recovery chrome translated through catalog keys', () => { + expect(boundarySource).toContain('useOptionalI18n()'); + expect(boundarySource).toContain("catalogTranslate('en-US'"); + for (const key of REQUIRED_RENDER_BOUNDARY_KEYS) { + expect(catalogTranslate('en-US', key)).not.toBe(key); + expect(catalogTranslate('zh-CN', key)).not.toBe(key); + expect(boundarySource).toContain(key); + } + + for (const oldCopy of [ + '这条 AI 消息渲染失败,已自动隔离', + '其余对话仍可继续使用。你可以先删除这条异常消息,再继续操作。', + '未知渲染错误', + '重试渲染', + '删除这条消息', + ]) { + expect(boundarySource).not.toContain(oldCopy); + } + }); + it('restores panel-level i18n orchestration for composer notices and send lifecycle text', () => { expect(source).toContain("import { useI18n } from '../i18n/provider';"); expect(source).toContain("import type { AIComposerNoticeDescriptor } from '../utils/aiComposerNotice';"); @@ -36,6 +67,8 @@ describe('AIChatPanel merge resolution', () => { expect(source).toContain("setComposerNoticeState({ kind: 'missing_provider' });"); expect(source).toContain("setComposerNoticeState({ kind: 'provider_incomplete', issues: readiness.issues });"); expect(source).toContain("setComposerNoticeState({ kind: 'missing_model' });"); + expect(source).toContain('const chatMessages = [...messages, userMsg].map((message) => toAIRequestMessage(message, t));'); + expect(source).toContain('toAIRequestMessage(userMsg, t)'); for (const key of [ 'ai_chat.panel.status.model_connecting', @@ -55,12 +88,13 @@ describe('AIChatPanel merge resolution', () => { it('keeps translated session and insight chrome in the panel layer instead of falling back to hardcoded copy', () => { expect(source).toContain("() => orderedAISessions.find((session) => session.id === sid)?.title || t('ai_chat.panel.session.default_title')"); expect(source).toContain("title: session.title || t('ai_chat.panel.session.default_title')"); - expect(source).toContain("t('ai_chat.panel.insight.context.linked_title', { count: contextCount })"); - expect(source).toContain("t('ai_chat.panel.insight.context.linked_body', { tables: tablePreview })"); - expect(source).toContain("t('ai_chat.panel.insight.query.slowest_title', { duration: Math.round(slowest.duration).toLocaleString() })"); - expect(source).toContain("t('ai_chat.panel.insight.status.recent_body', { count: recentLogs.length })"); - expect(source).toContain("t('ai_chat.panel.insight.write.detected_title', { count: writeCount })"); - expect(source).not.toContain('buildAIChatInsights({'); + expect(source).toContain('buildAIChatInsights({'); + expect(source).toContain('translate: t,'); + expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.context.linked_title', { count: contextCount })"); + expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.context.linked_body', { tables: tablePreview })"); + expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.query.slowest_title', { duration: Math.round(slowest.duration).toLocaleString() })"); + expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.status.recent_body', { count: recentLogs.length })"); + expect(derivedStateSource).toContain("translate('ai_chat.panel.insight.write.detected_title', { count: writeCount })"); expect(source).not.toContain("|| '新对话'"); }); }); diff --git a/frontend/src/components/AIChatPanel.tsx b/frontend/src/components/AIChatPanel.tsx index af57f99..248ccdc 100644 --- a/frontend/src/components/AIChatPanel.tsx +++ b/frontend/src/components/AIChatPanel.tsx @@ -25,6 +25,7 @@ import { getShortcutPlatform, resolveShortcutBinding } from '../utils/shortcuts' import { isMacLikePlatform } from '../utils/appearance'; import { buildAvailableAIChatTools } from '../utils/aiToolRegistry'; import { + buildAIChatInsights, buildAIChatInlineHistorySessions, calculateAIContextUsageChars, collectAIChatContextTableNames, @@ -233,8 +234,9 @@ export const AIChatPanel: React.FC = ({ userPromptSettings, overrideJVMPlanContext, overrideJVMDiagnosticPlanContext, + translate: t, }); - }, [availableTools, skills, userPromptSettings]); + }, [availableTools, skills, t, userPromptSettings]); const { executeLocalTools, @@ -252,6 +254,7 @@ export const AIChatPanel: React.FC = ({ pendingJVMDiagnosticPlanContextRef, setSending, skills, + translate: t, updateAIChatMessage, userPromptSettings, }); @@ -296,7 +299,7 @@ export const AIChatPanel: React.FC = ({ addAIChatMessage(sid, connectingMsg); const truncatedHistory = historyLocal.slice(0, lastUserMsgIndex + 1); - const messagesPayload = truncatedHistory.map(toAIRequestMessage); + const messagesPayload = truncatedHistory.map((message) => toAIRequestMessage(message, t)); try { const sysMessages = await buildSystemContextMessages( @@ -315,6 +318,7 @@ export const AIChatPanel: React.FC = ({ pendingAssistantMessageId: connectingMsg.id, jvmPlanContext: retryJVMPlanContext, jvmDiagnosticPlanContext: retryJVMDiagnosticPlanContext, + translate: t, }); } catch { setSending(false); @@ -329,6 +333,7 @@ export const AIChatPanel: React.FC = ({ getCurrentJVMPlanContext, getCurrentJVMDiagnosticPlanContext, resetToolCallState, + t, updateAIChatMessage, ]); @@ -346,6 +351,7 @@ export const AIChatPanel: React.FC = ({ nudgeCountRef, pendingJVMPlanContextRef, pendingJVMDiagnosticPlanContextRef, + translate: t, }); const handleSend = useCallback(async () => { @@ -422,11 +428,11 @@ export const AIChatPanel: React.FC = ({ updateAIChatMessage(sid, connectingMsg.id, { content: t('ai_chat.panel.status.model_connecting') }); - const chatMessages = [...messages, userMsg].map(toAIRequestMessage); + const chatMessages = [...messages, userMsg].map((message) => toAIRequestMessage(message, t)); 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) { const compressedMsg: AIChatMessage = { id: genId(), @@ -437,7 +443,7 @@ export const AIChatPanel: React.FC = ({ useStore.getState().replaceAIChatHistory(sid, [compressedMsg, userMsg, connectingMsg]); finalMessagesPayload = [ { role: 'assistant', content: compressedMsg.content }, - toAIRequestMessage(userMsg), + toAIRequestMessage(userMsg, t), ]; } @@ -458,6 +464,7 @@ export const AIChatPanel: React.FC = ({ jvmPlanContext: currentJVMPlanContext, jvmDiagnosticPlanContext: currentJVMDiagnosticPlanContext, unavailableContent: t('ai_chat.panel.message.service_not_ready'), + translate: t, onNonStreamSuccess: messages.length === 0 ? () => generateTitleForSession(sid) : undefined, @@ -549,54 +556,11 @@ export const AIChatPanel: React.FC = ({ [activeContext?.connectionId, activeContext?.dbName, aiContexts], ); const aiInsights = useMemo(() => { - const recentLogs = sqlLogs.slice(0, 24); - const slowest = recentLogs - .filter((log) => log.status === 'success') - .sort((left, right) => right.duration - left.duration)[0]; - 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' as const, - title: contextCount > 0 - ? t('ai_chat.panel.insight.context.linked_title', { count: contextCount }) - : t('ai_chat.panel.insight.context.empty_title'), - body: contextCount > 0 - ? t('ai_chat.panel.insight.context.linked_body', { tables: tablePreview }) - : t('ai_chat.panel.insight.context.empty_body'), - }, - { - tone: slowest && slowest.duration > 1000 ? 'warn' as const : 'accent' as const, - 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' as const : 'info' as const, - 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' as const : 'accent' as const, - 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'), - }, - ]; + return buildAIChatInsights({ + contextTableNames, + sqlLogs, + translate: t, + }); }, [contextTableNames, sqlLogs, t]); const panelHistorySessions = useMemo( () => buildAIChatInlineHistorySessions( diff --git a/frontend/src/components/AISettingsModal.tsx b/frontend/src/components/AISettingsModal.tsx index fa35078..47c945a 100644 --- a/frontend/src/components/AISettingsModal.tsx +++ b/frontend/src/components/AISettingsModal.tsx @@ -29,8 +29,9 @@ import { EMPTY_SKILL, PROVIDER_PRESETS, findPreset, + localizeProviderPreset, + localizeProviderPresets, matchProviderPreset, - type ProviderPreset, waitForAIService, } from './ai/aiSettingsModalConfig'; interface AISettingsModalProps { @@ -117,6 +118,19 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo const watchedType = Form.useWatch('type', form); const watchedPresetKey = Form.useWatch('presetKey', form); const watchedApiFormat = Form.useWatch('apiFormat', form) || 'openai'; + const localizedProviderPresets = useMemo( + () => localizeProviderPresets(PROVIDER_PRESETS, t), + [t], + ); + const findLocalizedPreset = useCallback( + (key: string) => localizeProviderPreset(findPreset(key), t), + [t], + ); + const matchLocalizedProviderPreset = useCallback( + (provider: Pick) => + localizeProviderPreset(matchProviderPreset(provider), t), + [t], + ); const skillRequiredToolOptions = useMemo(() => ([ ...BUILTIN_AI_TOOL_INFO.map((tool) => ({ label: `${tool.name} · ${t('ai_settings.tools.builtin_tool_label')}`, @@ -169,6 +183,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo onBeforeInstall: () => setLoading(true), onAfterInstall: () => setLoading(false), onConfigChanged: () => window.dispatchEvent(new CustomEvent('gonavi:ai:config-changed')), + translate: t, }); const loadConfig = useCallback(async () => { @@ -345,6 +360,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo // 构建 payload,处理 model/models 逻辑 const preset = findPreset(values.presetKey); + const localizedPreset = localizeProviderPreset(preset, t); const isCustomLike = values.presetKey === 'custom' || values.presetKey === 'ollama'; const { model: finalModel, models: resolvedModels } = resolvePresetModelSelection({ presetKey: values.presetKey, @@ -354,7 +370,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 || localizedPreset.label) : localizedPreset.label; const finalBaseUrl = resolvePresetBaseURL({ presetKey: values.presetKey, @@ -720,7 +736,7 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo editingProvider={editingProvider} isEditing={isEditing} form={form} - providerPresets={PROVIDER_PRESETS} + providerPresets={localizedProviderPresets} watchedPresetKey={watchedPresetKey} watchedApiFormat={watchedApiFormat} loading={loading} @@ -732,8 +748,8 @@ const AISettingsModal: React.FC = ({ open, onClose, darkMo cardBorder={cardBorder} inputBg={inputBg} onPrimaryPasswordVisibleChange={setPrimaryPasswordVisible} - resolveProviderPreset={matchProviderPreset} - resolvePresetByKey={findPreset} + resolveProviderPreset={matchLocalizedProviderPreset} + resolvePresetByKey={findLocalizedPreset} onAddProvider={handleAddProvider} onEditProvider={handleEditProvider} onDeleteProvider={handleDeleteProvider} diff --git a/frontend/src/components/ConnectionModal.edit-password.test.tsx b/frontend/src/components/ConnectionModal.edit-password.test.tsx index 68688b5..d38477a 100644 --- a/frontend/src/components/ConnectionModal.edit-password.test.tsx +++ b/frontend/src/components/ConnectionModal.edit-password.test.tsx @@ -51,7 +51,10 @@ describe('ConnectionModal data source registry', () => { expect(source).toContain("name: 'Elasticsearch'"); expect(source).toContain('icon: getDbIcon(item.key, undefined, 36)'); expect(source).toContain('type === "elasticsearch"'); - expect(source).toContain("return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';"); + expect(source).toContain("'connection_modal.step1.hint.elasticsearch'"); + expect(source).toContain( + "'Index browsing, Mapping inspection, JSON DSL, and query_string queries'", + ); expect(source).toContain('const PRIMARY_USERNAME_OPTIONAL_TYPES = new Set(['); expect(source).toContain('"mqtt",'); expect(source).toContain( @@ -75,7 +78,10 @@ describe('ConnectionModal data source registry', () => { expect(source).toContain("key: 'chroma'"); expect(source).toContain("name: 'Chroma'"); expect(source).toContain('type === "chroma"'); - expect(source).toContain("return 'Collection 浏览、向量检索和元数据过滤';"); + expect(source).toContain("'connection_modal.step1.hint.chroma'"); + expect(source).toContain( + "'Collection browsing, vector retrieval, and metadata filtering'", + ); expect(source).toContain('return "http://127.0.0.1:8000/default_database?tenant=default_tenant";'); expect(source).toContain('return "tenant=default_tenant&apiKey=...";'); }); @@ -87,7 +93,10 @@ describe('ConnectionModal data source registry', () => { expect(source).toContain("key: 'qdrant'"); expect(source).toContain("name: 'Qdrant'"); expect(source).toContain('type === "qdrant"'); - expect(source).toContain("return 'Collection 浏览、向量搜索和 Payload 过滤';"); + expect(source).toContain("'connection_modal.step1.hint.qdrant'"); + expect(source).toContain( + "'Collection browsing, vector search, and Payload filtering'", + ); expect(source).toContain('return "http://127.0.0.1:6333";'); expect(source).toContain('return "apiKey=...";'); }); @@ -178,7 +187,8 @@ describe('ConnectionModal data source registry', () => { expect(source).toContain("key: 'goldendb'"); expect(source).toContain("name: 'GoldenDB'"); expect(source).toContain('type === "goldendb"'); - expect(source).toContain("return 'MySQL 兼容 / 分布式事务';"); + expect(source).toContain("'connection_modal.step1.hint.goldendb'"); + expect(source).toContain("'MySQL compatible / distributed transactions'"); expect(source).toContain('dbType === "goldendb" ? "goldendb" : "mysql"'); expect(source).toContain('type === "goldendb" ? "goldendb" : "mysql"'); expect(source).toContain('? "goldendb"'); @@ -186,7 +196,9 @@ describe('ConnectionModal data source registry', () => { it('keeps OceanBase Oracle service name optional for OBClient/MySQL-wire connections', () => { expect(source).toContain('connection.modal.field.oceanBaseServiceName.label'); - expect(source).toContain('isOceanBaseOracle\n ? []'); + expect(source).toMatch( + /isOceanBaseOracle\s*\?\s*\[\]\s*:\s*\[\s*createUriAwareRequiredRule\(\s*t\("connection\.modal\.field\.serviceName\.required"/, + ); expect(source).toContain('connection.modal.field.oceanBaseServiceName.help'); expect(source).toContain('connection.modal.field.serviceName.help'); expect(source).toContain('connection.modal.field.serviceName.required'); diff --git a/frontend/src/components/ConnectionModal.i18n.test.tsx b/frontend/src/components/ConnectionModal.i18n.test.tsx index 0946948..9ada8cb 100644 --- a/frontend/src/components/ConnectionModal.i18n.test.tsx +++ b/frontend/src/components/ConnectionModal.i18n.test.tsx @@ -855,6 +855,17 @@ describe("ConnectionModal i18n", () => { expect(antdMessage.error).toHaveBeenCalledWith("Member discovery failed"); }); + it("localizes the Redis URI example separator while preserving URI examples as raw text", () => { + expect(source).not.toContain(`topology=cluster ${"\u6216"} redis://`); + expect(source).toContain('t("connection.modal.example.or"'); + expect(source).toContain( + '"redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster"', + ); + expect(source).toContain( + '"redis://:pass@10.0.0.1:26379,10.0.0.2:26379/0?topology=sentinel&master=mymaster"', + ); + }); + it("removes the remaining Chinese user-facing tail strings from ConnectionModal source", () => { [ 'label: "自动"', diff --git a/frontend/src/components/ConnectionModal.tsx b/frontend/src/components/ConnectionModal.tsx index ce38764..0f4c15c 100644 --- a/frontend/src/components/ConnectionModal.tsx +++ b/frontend/src/components/ConnectionModal.tsx @@ -71,7 +71,7 @@ import { resolveRedisConfigDraft, } from "../utils/redisConnectionUri"; import { - CONNECTION_TYPE_GROUPS, + buildConnectionTypeGroups, getAllConnectionTypeCatalogItems, getConnectionTypeDefaultPort as getDefaultPortByType, getConnectionTypeHint, @@ -1228,7 +1228,7 @@ const ConnectionModal: React.FC<{ const normalizedParamsText = normalizeConnectionParamsText(rawParams); const protocolFromParams = resolveOceanBaseProtocolQueryText(normalizedParamsText); if (protocolFromParams.unsupportedValue) { - throw new Error(describeUnsupportedOceanBaseProtocol(protocolFromParams.unsupportedValue)); + throw new Error(describeUnsupportedOceanBaseProtocol(protocolFromParams.unsupportedValue, t)); } const params = new URLSearchParams(normalizedParamsText); for (const key of OCEANBASE_PROTOCOL_PARAM_KEYS) { @@ -2155,7 +2155,12 @@ const ConnectionModal: React.FC<{ return "rabbitmq://guest:guest@127.0.0.1:15672/%2F?defaultQueue=orders.queue&exchange=events.topic&timeout=30"; } if (dbType === "redis") { - return "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster 或 redis://:pass@10.0.0.1:26379,10.0.0.2:26379/0?topology=sentinel&master=mymaster"; + return t("connection.modal.example.or", { + first: + "redis://:pass@127.0.0.1:6379,127.0.0.2:6379/0?topology=cluster", + second: + "redis://:pass@10.0.0.1:26379,10.0.0.2:26379/0?topology=sentinel&master=mymaster", + }); } if (dbType === "oracle") { return "oracle://user:pass@127.0.0.1:1521/ORCLPDB1"; @@ -4368,7 +4373,7 @@ const ConnectionModal: React.FC<{ const dbTypeGroups = useMemo( () => - CONNECTION_TYPE_GROUPS.map((group) => ({ + buildConnectionTypeGroups(t).map((group) => ({ ...group, items: group.items.map((item) => ({ ...item, @@ -4537,7 +4542,7 @@ const ConnectionModal: React.FC<{ {item.name} - {getConnectionTypeHint(item.key)} + {getConnectionTypeHint(item.key, t)} @@ -7874,7 +7879,7 @@ const ConnectionModal: React.FC<{ {error && hasDefinition && (
- +
)}
diff --git a/frontend/src/components/DriverManagerModal.i18n.test.tsx b/frontend/src/components/DriverManagerModal.i18n.test.tsx index faf6d3f..a1b6541 100644 --- a/frontend/src/components/DriverManagerModal.i18n.test.tsx +++ b/frontend/src/components/DriverManagerModal.i18n.test.tsx @@ -345,6 +345,39 @@ describe('DriverManagerModal i18n', () => { expect(source).not.toContain("await installDriverFromLocalPath(row, directoryPath, '目录', { silentToast: true, skipRefresh: true });"); }); + it('localizes install watchdog and version switch chrome without translating raw driver values', () => { + const source = readFileSync(new URL('./DriverManagerModal.tsx', import.meta.url), 'utf8'); + + [ + 'driver_manager.message.install_watchdog_timeout', + 'driver_manager.message.install_failed_fallback', + 'driver_manager.version.switch_pending', + 'driver_manager.version.current_fallback', + 'driver_manager.version.target_fallback', + 'driver_manager.version.installed_with_version', + 'driver_manager.version.installed', + 'driver_manager.version.needs_reinstall_suffix', + 'driver_manager.action.switch_version', + ].forEach((key) => { + expect(source).toContain(key); + }); + + [ + '仍未完成。后台任务可能仍在下载或构建', + '安装 ${row.name} 失败', + '当前已安装', + '当前版本', + '目标版本', + '已选择', + '点击“切换版本”生效', + '已安装', + '需重装', + '切换版本', + ].forEach((legacyCopy) => { + expect(source).not.toContain(legacyCopy); + }); + }); + it.each([ ['legacy', 'zh-CN', '驱动管理', '安装所有驱动', '搜索驱动名称/类型(如 DuckDB、clickhouse)', '驱动日志 - ClickHouse', '安装目录:', '驱动可执行文件:', '当前驱动暂无操作日志。'], ['v2', 'en-US', 'Driver Manager', 'Install all drivers', 'Search driver name/type (for example DuckDB, clickhouse)', 'Driver Logs - ClickHouse', 'Install directory:', 'Driver executable:', 'This driver has no operation logs yet.'], diff --git a/frontend/src/components/DriverManagerModal.test.tsx b/frontend/src/components/DriverManagerModal.test.tsx index affbb65..660becf 100644 --- a/frontend/src/components/DriverManagerModal.test.tsx +++ b/frontend/src/components/DriverManagerModal.test.tsx @@ -4,6 +4,7 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import DriverManagerModal from './DriverManagerModal'; +import { t } from '../i18n'; const connectionModalSource = readFileSync(new URL('./ConnectionModal.tsx', import.meta.url), 'utf8'); const driverManagerModalSource = readFileSync(new URL('./DriverManagerModal.tsx', import.meta.url), 'utf8'); @@ -64,14 +65,12 @@ vi.mock('@ant-design/icons', () => { describe('driver-agent update prompt placement', () => { it('keeps revision mismatch prompts inside driver manager only', () => { - expect(driverManagerModalSource).toContain('需要重装'); + expect(driverManagerModalSource).toContain('driver_manager.version.needs_reinstall_suffix'); expect(driverManagerModalSource).toContain('row.needsUpdate'); expect(connectionModalSource).not.toContain('当前数据源驱动代理建议重装'); expect(connectionModalSource).not.toContain('去驱动管理重装'); - expect(sidebarSource).not.toContain('warnIfConnectionDriverAgentNeedsUpdate'); - expect(sidebarSource).not.toContain('driver-agent-update-'); expect(sidebarSource).not.toContain('驱动代理需要重装:'); }); }); @@ -223,10 +222,10 @@ describe('DriverManagerModal toolbar actions', () => { }); await flushPromises(); - const installButton = findButton(renderer!, '安装启用'); - const openDirButtonBefore = findButton(renderer!, '打开驱动目录'); - const importDirButtonBefore = findButton(renderer!, '导入驱动目录'); - const installAllButtonBefore = findButton(renderer!, '安装所有驱动'); + const installButton = findButton(renderer!, t('driver.modal.card.action.install')); + const openDirButtonBefore = findButton(renderer!, t('driver.modal.toolbar.openDirectory')); + const importDirButtonBefore = findButton(renderer!, t('driver.modal.toolbar.importDirectory')); + const installAllButtonBefore = findButton(renderer!, t('driver.modal.toolbar.installAll')); expect(openDirButtonBefore.props.disabled).toBeFalsy(); expect(importDirButtonBefore.props.disabled).toBeFalsy(); @@ -237,9 +236,9 @@ describe('DriverManagerModal toolbar actions', () => { await Promise.resolve(); }); - const openDirButtonAfter = findButton(renderer!, '打开驱动目录'); - const importDirButtonAfter = findButton(renderer!, '导入驱动目录'); - const installAllButtonAfter = findButton(renderer!, '安装所有驱动'); + const openDirButtonAfter = findButton(renderer!, t('driver.modal.toolbar.openDirectory')); + const importDirButtonAfter = findButton(renderer!, t('driver.modal.toolbar.importDirectory')); + const installAllButtonAfter = findButton(renderer!, t('driver.modal.toolbar.installAll')); expect(openDirButtonAfter.props.disabled).toBeFalsy(); expect(importDirButtonAfter.props.disabled).toBeFalsy(); @@ -255,21 +254,23 @@ describe('DriverManagerModal toolbar actions', () => { }); await flushPromises(); - const installButton = findButton(renderer!, '安装启用'); + const installButton = findButton(renderer!, t('driver.modal.card.action.install')); await act(async () => { installButton.props.onClick(); await Promise.resolve(); }); - expect(findButton(renderer!, '安装启用').props.disabled).toBe(true); + expect(findButton(renderer!, t('driver.modal.card.action.install')).props.disabled).toBe(true); await act(async () => { vi.advanceTimersByTime(12 * 60 * 1000); await Promise.resolve(); }); - expect(findButton(renderer!, '安装启用').props.disabled).toBeFalsy(); - expect(messageApi.error).toHaveBeenCalledWith(expect.stringContaining('超过 12 分钟仍未完成')); + expect(findButton(renderer!, t('driver.modal.card.action.install')).props.disabled).toBeFalsy(); + expect(messageApi.error).toHaveBeenCalledWith( + t('driver_manager.message.install_watchdog_timeout', { name: 'DuckDB', minutes: 12 }), + ); } finally { vi.useRealTimers(); } @@ -317,7 +318,7 @@ describe('DriverManagerModal toolbar actions', () => { }); await flushPromises(); - const reinstallButton = findButton(renderer!, '重装驱动'); + const reinstallButton = findButton(renderer!, t('driver.modal.card.action.reinstall')); await act(async () => { await reinstallButton.props.onClick(); }); @@ -371,7 +372,7 @@ describe('DriverManagerModal toolbar actions', () => { }); await flushPromises(); - const refreshButton = findButton(renderer!, '刷新'); + const refreshButton = findButton(renderer!, t('driver.modal.footer.refresh')); await act(async () => { await refreshButton.props.onClick(); }); @@ -390,7 +391,7 @@ describe('DriverManagerModal toolbar actions', () => { }); await flushPromises(); - const switchButtons = renderer!.root.findAll((node) => node.type === 'button' && textContent(node).includes('切换版本')); + const switchButtons = renderer!.root.findAll((node) => node.type === 'button' && textContent(node).includes(t('driver_manager.action.switch_version'))); expect(switchButtons).toHaveLength(1); const switchButton = switchButtons[0]; await act(async () => { diff --git a/frontend/src/components/DriverManagerModal.tsx b/frontend/src/components/DriverManagerModal.tsx index 670b253..ca9e59c 100644 --- a/frontend/src/components/DriverManagerModal.tsx +++ b/frontend/src/components/DriverManagerModal.tsx @@ -1024,7 +1024,10 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG const installWatchdog = new Promise((_, reject) => { watchdogId = setTimeout(() => { - reject(new Error(`安装 ${row.name} 超过 ${Math.round(DRIVER_INSTALL_WATCHDOG_MS / 60000)} 分钟仍未完成。后台任务可能仍在下载或构建,请稍后刷新状态;如多次出现,请检查代理或改用本地驱动包导入。`)); + reject(new Error(t('driver_manager.message.install_watchdog_timeout', { + name: row.name, + minutes: Math.round(DRIVER_INSTALL_WATCHDOG_MS / 60000), + }))); }, DRIVER_INSTALL_WATCHDOG_MS); }); const result = await Promise.race([ @@ -1062,7 +1065,9 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG } return true; } catch (error) { - const errText = error instanceof Error ? error.message : String(error || `安装 ${row.name} 失败`); + const errText = error instanceof Error + ? error.message + : String(error || t('driver_manager.message.install_failed_fallback', { name: row.name })); appendOperationLog(row.type, `[ERROR] ${errText}`); updateDriverProgress(row.type, { status: 'error', @@ -1403,8 +1408,18 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG {(row.packageInstalled || row.connectable) ? ( {versionSwitchPending - ? `当前已安装 ${installedVersion || '当前版本'},已选择 ${selectedOption?.version || '目标版本'},点击“切换版本”生效` - : `${installedVersion ? `${installedVersion}(已安装` : '已安装'}${row.needsUpdate ? ',需重装' : ''}${installedVersion ? ')' : ''}`} + ? t('driver_manager.version.switch_pending', { + installedVersion: installedVersion || t('driver_manager.version.current_fallback'), + targetVersion: selectedOption?.version || t('driver_manager.version.target_fallback'), + }) + : installedVersion + ? t('driver_manager.version.installed_with_version', { + version: installedVersion, + suffix: row.needsUpdate ? t('driver_manager.version.needs_reinstall_suffix') : '', + }) + : t('driver_manager.version.installed', { + suffix: row.needsUpdate ? t('driver_manager.version.needs_reinstall_suffix') : '', + })} ) : null} {mongoHint ? {mongoHint} : null} @@ -1434,7 +1449,7 @@ const DriverManagerModal: React.FC<{ open: boolean; onClose: () => void; onOpenG ) : versionSwitchPending ? ( ) : row.connectable ? (
- +
- +
- +
@@ -9698,13 +9701,13 @@ const Sidebar: React.FC<{ setIsCreateDbModalOpen(false)} >
- + {/* Charset option could be added here */} @@ -9729,7 +9732,9 @@ const Sidebar: React.FC<{ { @@ -9739,7 +9744,7 @@ const Sidebar: React.FC<{ }} > - + @@ -9763,7 +9768,9 @@ const Sidebar: React.FC<{ { @@ -9773,14 +9780,16 @@ const Sidebar: React.FC<{ }} >
- +
{ @@ -9790,7 +9799,7 @@ const Sidebar: React.FC<{ }} >
- + @@ -9859,7 +9868,7 @@ const Sidebar: React.FC<{
, "批量操作表", "按对象批量导出结构、数据或完整备份。")} + title={renderSidebarModalTitle(, t('sidebar.modal.batch_tables.title'), t('sidebar.modal.batch_tables.description'))} open={isBatchModalOpen} onCancel={() => setIsBatchModalOpen(false)} width={720} @@ -9868,7 +9877,7 @@ const Sidebar: React.FC<{ footer={
@@ -9911,12 +9920,12 @@ const Sidebar: React.FC<{ >
- +
- +
-
先选择连接与数据库,再决定导出范围和目标对象。
+
{t('sidebar.modal.batch_tables.selection_hint')}
{batchTables.length > 0 && ( @@ -9951,7 +9960,7 @@ const Sidebar: React.FC<{ allowClear value={batchFilterKeyword} onChange={(e) => setBatchFilterKeyword(e.target.value)} - placeholder="筛选表/视图名称" + placeholder={t('sidebar.placeholder.filter_table_view')} prefix={} style={{ width: 260 }} /> @@ -9960,9 +9969,9 @@ const Sidebar: React.FC<{ onChange={(value) => setBatchFilterType(value as BatchObjectFilterType)} style={{ width: 140 }} options={[ - { label: '全部对象', value: 'all' }, - { label: '仅表', value: 'table' }, - { label: '仅视图', value: 'view' }, + { label: t('sidebar.filter.all_objects'), value: 'all' }, + { label: t('sidebar.filter.tables_only'), value: 'table' }, + { label: t('sidebar.filter.views_only'), value: 'view' }, ]} /> {connections.filter(c => c.config.type !== 'redis').map(conn => ( @@ -10104,7 +10113,7 @@ const Sidebar: React.FC<{ ))} -
连接选定后会加载当前连接下可批量导出的数据库列表。
+
{t('sidebar.modal.batch_databases.selection_hint')}
{batchDatabases.length > 0 && ( @@ -10115,22 +10124,22 @@ const Sidebar: React.FC<{ size="small" onClick={() => handleCheckAllDb(true)} > - 全选 + {t('sidebar.action.select_all')} - 已选择 {checkedDbKeys.length} / {batchDatabases.length} 个库 + {t('sidebar.batch.selected_databases', { selected: checkedDbKeys.length, total: batchDatabases.length })} diff --git a/frontend/src/components/SidebarAIPrompt.i18n.test.ts b/frontend/src/components/SidebarAIPrompt.i18n.test.ts new file mode 100644 index 0000000..90cdf57 --- /dev/null +++ b/frontend/src/components/SidebarAIPrompt.i18n.test.ts @@ -0,0 +1,48 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; +const requiredKeys = [ + 'sidebar.message.ai_table_context_missing', + 'sidebar.ai_prompt.explain.intro', + 'sidebar.ai_prompt.explain.detail', + 'sidebar.ai_prompt.query.intro', + 'sidebar.ai_prompt.query.detail', + 'sidebar.command_search.action.ask_ai.title', +]; + +describe('Sidebar AI prompt i18n', () => { + it('localizes AI table prompt shells without translating raw table or DDL values', () => { + [ + '当前表缺少连接上下文,无法发送给 AI', + '请解释数据表 ${conn.dbName}.${tableName} 的结构和业务含义。', + '重点说明字段含义、主键/索引、潜在关联关系、典型查询场景和风险点。', + '请基于数据表 ${conn.dbName}.${tableName} 生成 3 条常用查询 SQL。', + '要求包含:数据预览查询、按关键字段过滤查询、一个聚合或统计查询。', + "title: '让 AI 回答'", + ].forEach((legacyCopy) => { + expect(source).not.toContain(legacyCopy); + }); + + requiredKeys.forEach((key) => { + expect(source).toContain(`t('${key}'`); + }); + + expect(source).toContain('DBShowCreateTable'); + expect(source).toContain('conn.dbName'); + expect(source).toContain('tableName'); + expect(source).toContain('ddl ? `\\n\\`\\`\\`sql'); + expect(source).toContain('${ddl}'); + expect(source).toContain('v2CommandSearchQuery.aiPrompt'); + }); + + it('keeps AI prompt keys available in every locale', () => { + locales.forEach((locale) => { + const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record; + requiredKeys.forEach((key) => { + expect(catalog[key], `${locale}:${key}`).toBeTruthy(); + }); + }); + }); +}); diff --git a/frontend/src/components/SidebarBatchActions.i18n.test.ts b/frontend/src/components/SidebarBatchActions.i18n.test.ts new file mode 100644 index 0000000..0883e97 --- /dev/null +++ b/frontend/src/components/SidebarBatchActions.i18n.test.ts @@ -0,0 +1,90 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const sidebarSource = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; + +const requiredKeys = [ + 'sidebar.action.batch_tables', + 'sidebar.action.batch_databases', + 'sidebar.action.clear_tables', + 'sidebar.action.export_schema', + 'sidebar.action.export_data_only', + 'sidebar.action.backup_schema_data', + 'sidebar.action.select_all', + 'sidebar.action.clear_selection', + 'sidebar.action.invert_selection', + 'sidebar.action.export_database_schema_count', + 'sidebar.action.backup_database_count', + 'sidebar.field.select_connection', + 'sidebar.field.select_database', + 'sidebar.placeholder.select_connection', + 'sidebar.placeholder.select_connection_first', + 'sidebar.placeholder.filter_table_view', + 'sidebar.filter.all_objects', + 'sidebar.filter.tables_only', + 'sidebar.filter.views_only', + 'sidebar.filter.scope_filtered', + 'sidebar.filter.scope_all', + 'sidebar.modal.batch_tables.title', + 'sidebar.modal.batch_tables.description', + 'sidebar.modal.batch_tables.selection_hint', + 'sidebar.modal.batch_databases.title', + 'sidebar.modal.batch_databases.description', + 'sidebar.modal.batch_databases.selection_hint', + 'sidebar.batch.filtered_count', + 'sidebar.batch.selected_objects', + 'sidebar.batch.selected_databases', + 'sidebar.batch.group.tables', + 'sidebar.batch.group.views', + 'sidebar.batch.no_matching_objects', +] as const; + +describe('Sidebar batch actions i18n', () => { + it('localizes batch table and database action copy', () => { + [ + '批量操作表', + '批量操作库', + '按对象批量导出结构、数据或完整备份。', + '按数据库批量导出结构,或生成结构加数据的备份。', + '清空表', + '导出结构', + '仅数据(INSERT)', + '备份(结构+数据)', + '选择连接:', + '选择数据库:', + '请选择连接', + '请先选择连接', + '先选择连接与数据库,再决定导出范围和目标对象。', + '筛选表/视图名称', + "label: '全部对象'", + "label: '仅表'", + "label: '仅视图'", + '勾选作用于:当前筛选结果', + '勾选作用于:全部对象', + '当前筛选命中', + '取消全选', + '反选', + '个对象', + '无匹配对象', + '备份库', + '连接选定后会加载当前连接下可批量导出的数据库列表。', + '个库', + ].forEach((rawSnippet) => { + expect(sidebarSource).not.toContain(rawSnippet); + }); + + requiredKeys.forEach((key) => { + expect(sidebarSource, key).toContain(`t('${key}'`); + }); + }); + + it('keeps batch action catalog entries available in every locale', () => { + locales.forEach((locale) => { + const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record; + requiredKeys.forEach((key) => { + expect(catalog[key], `${locale}:${key}`).toBeTruthy(); + }); + }); + }); +}); diff --git a/frontend/src/components/SidebarCommandSearch.i18n.test.ts b/frontend/src/components/SidebarCommandSearch.i18n.test.ts new file mode 100644 index 0000000..e895d8e --- /dev/null +++ b/frontend/src/components/SidebarCommandSearch.i18n.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; +const requiredKeys = [ + 'sidebar.command_search.recent_sql_fallback', + 'sidebar.command_search.action.new_query.meta', + 'sidebar.command_search.action.new_connection.title', + 'sidebar.command_search.action.new_connection.meta', + 'sidebar.command_search.action.open_ai.title', + 'sidebar.command_search.action.open_ai.meta', + 'sidebar.command_search.action.open_sql_log.title', + 'sidebar.command_search.action.open_sql_log.meta', + 'sidebar.command_search.empty.ai', + 'sidebar.command_search.empty.object', + 'sidebar.command_search.empty.default', + 'sidebar.command_search.section.goto', + 'sidebar.command_search.section.ai', + 'sidebar.command_search.section.actions', + 'sidebar.command_search.section.recent', + 'sidebar.command_search.footer.navigate', + 'sidebar.command_search.footer.select', + 'sidebar.command_search.footer.object_only', + 'sidebar.command_search.footer.ask_ai', + 'sidebar.tab.recent_query', +]; + +describe('Sidebar command search i18n', () => { + it('localizes v2 command search chrome without translating raw SQL or object values', () => { + [ + "'SQL 记录'", + "title: '最近查询'", + "meta: '打开一个新的 SQL 编辑页'", + "title: '新建数据源'", + "meta: '创建数据库、运行时或其他数据源连接'", + "title: '打开 AI 数据洞察'", + "meta: '让 AI 分析当前数据库上下文'", + "title: '查看 SQL 执行日志'", + "meta: '打开最近执行记录面板'", + '输入「?」后加问题,按 Enter 发送到 AI 面板。', + '未找到匹配的表、视图或物化视图。', + '未找到匹配项。可输入 @表名 只搜表对象,或输入 ?问题 让 AI 回答。', + "'跳转 · GO TO'", + "'AI · ASK'", + "'动作 · ACTIONS'", + "'近期查询 · RECENT'", + '导航', + '选择', + '只搜表对象', + '发送给 AI', + ].forEach((legacyCopy) => { + expect(source).not.toContain(legacyCopy); + }); + + requiredKeys.forEach((key) => { + expect(source).toContain(`t('${key}'`); + }); + + expect(source).toContain('log.sql.replace'); + expect(source).toContain('item.sql'); + expect(source).toContain('dataRef.tableName || dataRef.viewName'); + }); + + it('keeps command search keys available in every locale', () => { + locales.forEach((locale) => { + const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record; + requiredKeys.forEach((key) => { + expect(catalog[key], `${locale}:${key}`).toBeTruthy(); + }); + }); + }); +}); + diff --git a/frontend/src/components/SidebarManagementModals.i18n.test.ts b/frontend/src/components/SidebarManagementModals.i18n.test.ts new file mode 100644 index 0000000..65d29de --- /dev/null +++ b/frontend/src/components/SidebarManagementModals.i18n.test.ts @@ -0,0 +1,63 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const sidebarSource = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; + +const requiredKeys = [ + 'sidebar.action.new_group', + 'sidebar.action.locate_current_tab', + 'sidebar.message.locate_current_tab_unavailable', + 'app.sidebar.sql_execution_log', + 'sidebar.modal.create_database.title', + 'sidebar.field.database_name', + 'sidebar.validation.name_required', + 'sidebar.modal.rename_schema.title', + 'sidebar.field.schema_name', + 'sidebar.validation.schema_name_required', + 'sidebar.modal.rename_table.title', + 'sidebar.field.new_table_name', + 'sidebar.validation.new_table_name_required', + 'sidebar.modal.rename_view.title', + 'sidebar.field.new_view_name', + 'sidebar.validation.new_view_name_required', +] as const; + +describe('Sidebar management modals i18n', () => { + it('localizes legacy toolbar and management modal copy', () => { + [ + 'title="新建数据库"', + 'label="数据库名称"', + "message: '请输入名称'", + 'title="新建组"', + 'aria-label="新建组"', + '定位当前标签页', + '当前标签页没有可定位的内容', + 'SQL 执行日志', + '编辑模式${', + 'label="模式名称"', + "message: '请输入模式名称'", + '重命名表${', + 'label="新表名"', + "message: '请输入新表名'", + '重命名视图${', + 'label="新视图名"', + "message: '请输入新视图名'", + ].forEach((rawSnippet) => { + expect(sidebarSource).not.toContain(rawSnippet); + }); + + requiredKeys.forEach((key) => { + expect(sidebarSource, key).toContain(`t('${key}'`); + }); + }); + + it('keeps management modal catalog entries available in every locale', () => { + locales.forEach((locale) => { + const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record; + requiredKeys.forEach((key) => { + expect(catalog[key], `${locale}:${key}`).toBeTruthy(); + }); + }); + }); +}); diff --git a/frontend/src/components/SidebarObjectActions.i18n.test.ts b/frontend/src/components/SidebarObjectActions.i18n.test.ts new file mode 100644 index 0000000..3d11012 --- /dev/null +++ b/frontend/src/components/SidebarObjectActions.i18n.test.ts @@ -0,0 +1,168 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const sidebarSource = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const tableDataDangerActionsSource = readFileSync(new URL('./tableDataDangerActions.ts', import.meta.url), 'utf8'); +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; + +const requiredKeys = [ + 'sidebar.message.schema_edit_unsupported', + 'sidebar.message.schema_target_edit_missing', + 'sidebar.message.schema_name_unchanged', + 'sidebar.message.schema_renamed', + 'sidebar.message.schema_target_delete_missing', + 'sidebar.message.schema_deleted', + 'sidebar.modal.confirm_delete_schema.title', + 'sidebar.modal.confirm_delete_schema.content', + 'sidebar.menu.edit_schema', + 'sidebar.menu.export_current_schema_sql', + 'sidebar.menu.backup_current_schema_sql', + 'sidebar.menu.delete_schema', + 'sidebar.menu.copy_object_name', + 'sidebar.menu.table_structure', + 'sidebar.menu.copy_table_name', + 'sidebar.menu.copy_table_structure', + 'sidebar.menu.backup_table_sql', + 'sidebar.menu.rename_table', + 'sidebar.menu.truncate_table', + 'sidebar.menu.clear_table', + 'sidebar.menu.delete_table', + 'sidebar.menu.export_table_data', + 'sidebar.menu.export_csv', + 'sidebar.menu.export_xlsx', + 'sidebar.menu.export_json', + 'sidebar.menu.export_markdown', + 'sidebar.menu.export_html', + 'sidebar.message.table_name_required', + 'sidebar.message.table_name_unchanged', + 'sidebar.message.table_renamed', + 'sidebar.message.table_deleted', + 'sidebar.message.view_name_required', + 'sidebar.message.view_name_unchanged', + 'sidebar.message.view_renamed', + 'sidebar.message.view_deleted', + 'sidebar.message.rename_failed', + 'sidebar.message.delete_failed', + 'sidebar.message.table_data_action_loading', + 'sidebar.message.table_data_action_success', + 'sidebar.message.table_data_action_failed', + 'sidebar.table_action.truncate.label', + 'sidebar.table_action.truncate.progress', + 'sidebar.table_action.clear.label', + 'sidebar.table_action.clear.progress', +] as const; + +describe('Sidebar object actions i18n', () => { + it('localizes schema, table, and view object action copy', () => { + [ + '当前节点不支持通过此入口编辑模式', + '未找到目标模式,无法编辑', + '新旧模式名称相同,无需修改', + '模式重命名成功', + '编辑失败: ', + '未找到目标模式,无法删除', + '确认删除模式', + '确定删除模式', + '模式删除成功', + '删除失败: ', + '表名不能为空', + '表重命名成功', + '确认删除表', + '确定删除表', + '表删除成功', + '确认${label}', + '${label}会永久删除表', + '正在${progressLabel}', + '${progressLabel}成功', + '${progressLabel}失败', + '视图名称不能为空', + '视图重命名成功', + '确认删除视图', + '确定删除视图', + '视图删除成功', + "label: '编辑模式'", + "label: '导出当前模式表结构 (SQL)'", + "label: '备份当前模式全部表 (结构+数据 SQL)'", + "label: '删除模式'", + "label: '复制名称'", + "label: '测试发送消息'", + "label: '表结构'", + "label: '设计表'", + "label: '复制表名'", + "label: '复制表结构'", + "label: '备份表 (SQL)'", + "label: '重命名表'", + "label: '截断表'", + "label: '清空表'", + "label: '删除表'", + "label: '导出表数据'", + "label: '导出 CSV'", + ].forEach((rawSnippet) => { + expect(sidebarSource).not.toContain(rawSnippet); + }); + + [ + "t('sidebar.message.schema_edit_unsupported')", + "t('sidebar.message.schema_target_edit_missing')", + "t('sidebar.message.schema_name_unchanged')", + "t('sidebar.message.schema_renamed')", + "t('sidebar.message.schema_target_delete_missing')", + "t('sidebar.message.schema_deleted')", + "t('sidebar.modal.confirm_delete_schema.title')", + "t('sidebar.modal.confirm_delete_schema.content'", + "t('sidebar.menu.edit_schema')", + "t('sidebar.menu.export_current_schema_sql')", + "t('sidebar.menu.backup_current_schema_sql')", + "t('sidebar.menu.delete_schema')", + "t('sidebar.menu.copy_object_name')", + "t('message_publish_modal.title')", + "t('sidebar.menu.table_structure')", + "t('sidebar.menu.copy_table_name')", + "t('sidebar.menu.copy_table_structure')", + "t('sidebar.menu.backup_table_sql')", + "t('sidebar.menu.rename_table')", + "t('sidebar.menu.truncate_table')", + "t('sidebar.menu.clear_table')", + "t('sidebar.menu.delete_table')", + "t('sidebar.menu.export_table_data')", + "t('sidebar.menu.export_csv')", + "t('sidebar.menu.export_xlsx')", + "t('sidebar.menu.export_json')", + "t('sidebar.menu.export_markdown')", + "t('sidebar.menu.export_html')", + "t('sidebar.message.table_name_required')", + "t('sidebar.message.table_name_unchanged')", + "t('sidebar.message.table_renamed')", + "t('sidebar.message.table_deleted')", + "t('sidebar.message.view_name_required')", + "t('sidebar.message.view_name_unchanged')", + "t('sidebar.message.view_renamed')", + "t('sidebar.message.view_deleted')", + "t('sidebar.message.rename_failed'", + "t('sidebar.message.delete_failed'", + "t('sidebar.message.table_data_action_loading'", + "t('sidebar.message.table_data_action_success'", + "t('sidebar.message.table_data_action_failed'", + ].forEach((lookup) => { + expect(sidebarSource).toContain(lookup); + }); + }); + + it('localizes table data danger action metadata', () => { + expect(tableDataDangerActionsSource).not.toContain("return { label: '截断表', progressLabel: '截断' };"); + expect(tableDataDangerActionsSource).not.toContain("return { label: '清空表', progressLabel: '清空' };"); + expect(tableDataDangerActionsSource).toContain("'sidebar.table_action.truncate.label'"); + expect(tableDataDangerActionsSource).toContain("'sidebar.table_action.truncate.progress'"); + expect(tableDataDangerActionsSource).toContain("'sidebar.table_action.clear.label'"); + expect(tableDataDangerActionsSource).toContain("'sidebar.table_action.clear.progress'"); + }); + + it('keeps object action catalog entries available in every locale', () => { + locales.forEach((locale) => { + const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record; + requiredKeys.forEach((key) => { + expect(catalog[key], `${locale}:${key}`).toBeTruthy(); + }); + }); + }); +}); diff --git a/frontend/src/components/SidebarResidualActions.i18n.test.ts b/frontend/src/components/SidebarResidualActions.i18n.test.ts new file mode 100644 index 0000000..72b3ae3 --- /dev/null +++ b/frontend/src/components/SidebarResidualActions.i18n.test.ts @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; + +const requiredKeys = [ + 'sidebar.message.saved_query_rename_failed', + 'sidebar.message.saved_query_rebind_success', + 'sidebar.message.saved_query_rebind_failed', + 'sidebar.message.message_publish_unsupported', + 'sidebar.message.message_publish_success', + 'sidebar.message.message_publish_success_with_count', + 'sidebar.message.message_publish_target_fallback', + 'sidebar.message.connection_release_failed', + 'sidebar.message.connection_release_failed_from_sidebar', + 'sidebar.menu.new_table', + 'sidebar.menu.create_event', + 'sidebar.tab.new_event', + 'sidebar.modal.confirm_delete_tag.content', + 'sidebar.menu.bind_to_connection', + 'sidebar.message.saved_query_delete_failed', + 'sidebar.message.database_created', + 'sidebar.message.operation_create_failed', + 'sidebar.aria.switch_connection', +]; + +describe('Sidebar residual actions i18n', () => { + it('localizes saved-query, message publish, tag, and residual group-menu copy', () => { + [ + '重命名查询失败: ', + '查询已绑定到 ', + '绑定查询失败: ', + '当前对象不支持测试发送消息', + '(已提交 ', + '测试消息已发送到 ', + "destination || '目标'", + "'释放连接失败'", + '连接已从侧边栏断开,但后端连接释放失败', + "label: '刷新'", + "label: '新建表'", + "label: '按名称排序'", + "label: '按使用频率排序'", + "label: '新建事件'", + "title: '新建事件'", + "label: '编辑标签'", + "label: '删除标签'", + "title: '确认删除'", + '确定要删除标签', + "label: '绑定到连接'", + '删除查询失败: ', + '数据库创建成功', + '创建失败: ', + 'aria-label={`切换到连接 ${conn.name}`}', + ].forEach((legacyCopy) => { + expect(source).not.toContain(legacyCopy); + }); + + requiredKeys.forEach((key) => { + expect(source).toContain(`t('${key}'`); + }); + + expect(source).toContain("label: conn.name || conn.id"); + expect(source).toContain('node.title'); + }); + + it('keeps residual Sidebar keys available in every locale', () => { + locales.forEach((locale) => { + const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record; + requiredKeys.forEach((key) => { + expect(catalog[key], `${locale}:${key}`).toBeTruthy(); + }); + }); + }); +}); diff --git a/frontend/src/components/SidebarStarRocksRollup.i18n.test.ts b/frontend/src/components/SidebarStarRocksRollup.i18n.test.ts new file mode 100644 index 0000000..e5306bc --- /dev/null +++ b/frontend/src/components/SidebarStarRocksRollup.i18n.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const source = readFileSync(new URL('./Sidebar.tsx', import.meta.url), 'utf8'); +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; +const requiredKeys = [ + 'sidebar.v2_table_menu.new_rollup', +] as const; + +const catalogs = Object.fromEntries(locales.map(locale => [ + locale, + JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record, +])) as Record>; + +const placeholdersOf = (value: string): string[] => ( + Array.from(value.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g), match => match[1]).sort() +); + +describe('Sidebar StarRocks Rollup i18n', () => { + it('localizes Rollup tab title and menu label while keeping SQL raw', () => { + expect(source).not.toContain("title: '新增 Rollup'"); + expect(source).not.toContain("label: '新增 Rollup'"); + expect(source).toContain("t('sidebar.v2_table_menu.new_rollup'"); + expect(source).toContain("keyword: 'Rollup'"); + expect(source).toContain('ADD ROLLUP rollup_name (column1, column2);'); + }); + + it('keeps Rollup label key available in every locale with matching placeholders', () => { + const zhCnCatalog = catalogs['zh-CN']; + requiredKeys.forEach(key => { + expect(zhCnCatalog, `zh-CN:${key}`).toHaveProperty(key); + const expectedPlaceholders = placeholdersOf(zhCnCatalog[key]); + locales.forEach(locale => { + expect(catalogs[locale], `${locale}:${key}`).toHaveProperty(key); + expect(placeholdersOf(catalogs[locale][key]), `${locale}:${key}`).toEqual(expectedPlaceholders); + }); + }); + }); +}); diff --git a/frontend/src/components/SnippetSettingsModal.i18n.test.tsx b/frontend/src/components/SnippetSettingsModal.i18n.test.tsx new file mode 100644 index 0000000..8a7b1f6 --- /dev/null +++ b/frontend/src/components/SnippetSettingsModal.i18n.test.tsx @@ -0,0 +1,367 @@ +import { readFileSync } from 'node:fs'; +import React from 'react'; +import { act, create } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '../i18n/provider'; +import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; +import SnippetSettingsModal from './SnippetSettingsModal'; + +const source = readFileSync(new URL('./SnippetSettingsModal.tsx', import.meta.url), 'utf8'); + +const storeState = vi.hoisted(() => ({ + sqlSnippets: [] as Array>, + saveSqlSnippet: vi.fn(), + deleteSqlSnippet: vi.fn(), + resetBuiltinSqlSnippet: vi.fn(), +})); + +const messageApi = vi.hoisted(() => ({ + warning: vi.fn(), + success: vi.fn(), +})); + +vi.mock('../store', () => ({ + useStore: (selector: (state: typeof storeState) => unknown) => selector(storeState), +})); + +vi.mock('../i18n/runtime', () => ({ + syncLanguageRuntime: vi.fn(async () => undefined), +})); + +vi.mock('antd', async () => { + const React = await import('react'); + + const Button = ({ + children, + icon, + onClick, + ...props + }: { + children?: React.ReactNode; + icon?: React.ReactNode; + onClick?: () => void; + }) => React.createElement('button', { ...props, onClick }, icon, children); + + const Input = ({ + value, + onChange, + placeholder, + ...props + }: { + value?: string; + onChange?: (event: { target: { value: string } }) => void; + placeholder?: string; + }) => React.createElement('input', { + ...props, + value, + placeholder, + onChange: (event: { target: { value: string } }) => onChange?.(event), + }); + + Input.TextArea = ({ + value, + onChange, + placeholder, + children, + ...props + }: { + value?: string; + onChange?: (event: { target: { value: string } }) => void; + placeholder?: string; + children?: React.ReactNode; + }) => React.createElement('textarea', { + ...props, + value, + placeholder, + onChange: (event: { target: { value: string } }) => onChange?.(event), + }, children); + + const List = ({ + dataSource, + renderItem, + }: { + dataSource: unknown[]; + renderItem: (item: unknown) => React.ReactNode; + }) => React.createElement( + 'div', + null, + dataSource.map((item, index) => React.createElement(React.Fragment, { key: index }, renderItem(item))), + ); + List.Item = ({ + children, + ...props + }: { + children?: React.ReactNode; + }) => React.createElement('div', props, children); + + const Tag = ({ + children, + ...props + }: { + children?: React.ReactNode; + }) => React.createElement('span', props, children); + + const Popconfirm = ({ + title, + description, + children, + }: { + title?: React.ReactNode; + description?: React.ReactNode; + children?: React.ReactNode; + }) => React.createElement('div', null, title, description, children); + + const Collapse = ({ + items, + }: { + items?: Array<{ key: string; label: React.ReactNode; children: React.ReactNode }>; + }) => React.createElement( + 'div', + null, + items?.map((item) => React.createElement('section', { key: item.key }, item.label, item.children)), + ); + + const Typography = { + Text: ({ + children, + ...props + }: { + children?: React.ReactNode; + }) => React.createElement('code', props, children), + }; + + return { + Modal: ({ + open, + title, + children, + }: { + open?: boolean; + title?: React.ReactNode; + children?: React.ReactNode; + }) => (open ? React.createElement('div', null, title, children) : null), + Button, + Input, + List, + Tag, + Popconfirm, + message: messageApi, + Collapse, + Typography, + }; +}); + +vi.mock('@ant-design/icons', async () => { + const React = await import('react'); + return { + PlusOutlined: () => React.createElement('span', null, 'plus'), + DeleteOutlined: () => React.createElement('span', null, 'delete'), + UndoOutlined: () => React.createElement('span', null, 'undo'), + SaveOutlined: () => React.createElement('span', null, 'save'), + CodeOutlined: () => React.createElement('span', null, 'code'), + }; +}); + +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; +const requiredKeys = [ + 'snippet_settings.list.title', + 'snippet_settings.action.new', + 'snippet_settings.action.reset', + 'snippet_settings.action.delete', + 'snippet_settings.action.save', + 'snippet_settings.action.close', + 'snippet_settings.tag.builtin', + 'snippet_settings.field.prefix.label', + 'snippet_settings.field.prefix.placeholder', + 'snippet_settings.field.name.label', + 'snippet_settings.field.name.placeholder', + 'snippet_settings.field.description.label', + 'snippet_settings.field.description.placeholder', + 'snippet_settings.field.body.label', + 'snippet_settings.empty_state', + 'snippet_settings.syntax_help.label', + 'snippet_settings.syntax_help.placeholder', + 'snippet_settings.syntax_reference.label', + 'snippet_settings.syntax_reference.first_tabstop', + 'snippet_settings.syntax_reference.second_tabstop', + 'snippet_settings.syntax_reference.final_cursor', + 'snippet_settings.syntax_reference.linked_tabstop', + 'snippet_settings.syntax_reference.builtin_variables', + 'snippet_settings.syntax_reference.current_date', + 'snippet_settings.syntax_reference.current_time', + 'snippet_settings.syntax_reference.unix_seconds', + 'snippet_settings.syntax_reference.uuid', + 'snippet_settings.syntax_reference.random', + 'snippet_settings.syntax_reference.example', + 'snippet_settings.confirm.reset.title', + 'snippet_settings.confirm.reset.description', + 'snippet_settings.confirm.delete.title', + 'snippet_settings.confirm.delete.description', + 'snippet_settings.message.prefix_required', + 'snippet_settings.message.name_required', + 'snippet_settings.message.body_required', + 'snippet_settings.message.prefix_duplicate', + 'snippet_settings.message.saved', + 'snippet_settings.message.deleted', + 'snippet_settings.message.reset_default', +] as const; + +const overlayTheme: OverlayWorkbenchTheme = { + isDark: false, + shellBg: '#fff', + shellBorder: '1px solid #eee', + shellShadow: 'none', + shellBackdropFilter: 'none', + sectionBg: '#fff', + sectionBorder: '1px solid #eee', + mutedText: '#666', + titleText: '#111', + iconBg: '#f5f5f5', + iconColor: '#1677ff', + hoverBg: '#f5f5f5', + selectedBg: '#e6f4ff', + selectedText: '#1677ff', + divider: '#eee', +}; + +const renderModal = async () => { + let renderer: ReturnType; + + await act(async () => { + renderer = create( + undefined} + > + undefined} + darkMode={false} + overlayTheme={overlayTheme} + /> + , + ); + }); + + return renderer!; +}; + +const getText = (node: any): string => ( + (node.children || []) + .map((child: any) => (typeof child === 'string' ? child : getText(child))) + .join('') +); + +const getJsonText = (node: any): string => { + if (!node) return ''; + if (typeof node === 'string') return node; + if (Array.isArray(node)) return node.map((item) => getJsonText(item)).join(''); + return (node.children || []).map((child: any) => getJsonText(child)).join(''); +}; + +describe('SnippetSettingsModal i18n', () => { + beforeEach(() => { + storeState.sqlSnippets = []; + storeState.saveSqlSnippet.mockReset(); + storeState.deleteSqlSnippet.mockReset(); + storeState.resetBuiltinSqlSnippet.mockReset(); + messageApi.warning.mockReset(); + messageApi.success.mockReset(); + }); + + it('localizes shell, action and feedback source strings instead of keeping hard-coded Chinese copy', () => { + expect(source).toContain("const { t } = useI18n();"); + expect(source).toContain("t('app.tools.entry.snippets.title')"); + expect(source).toContain("t('app.tools.entry.snippets.description')"); + expect(source).toContain("t('snippet_settings.list.title')"); + expect(source).toContain("t('snippet_settings.action.new')"); + expect(source).toContain("message.warning(t('snippet_settings.message.prefix_required'))"); + expect(source).toContain("message.warning(t('snippet_settings.message.name_required'))"); + expect(source).toContain("message.warning(t('snippet_settings.message.body_required'))"); + expect(source).toContain("message.success(t('snippet_settings.message.saved'))"); + expect(source).toContain("message.success(t('snippet_settings.message.deleted'))"); + expect(source).toContain("message.success(t('snippet_settings.message.reset_default'))"); + expect(source).toContain("t('snippet_settings.syntax_help.label')"); + expect(source).toContain("t('snippet_settings.syntax_help.placeholder')"); + expect(source).toContain("t('snippet_settings.syntax_reference.label')"); + expect(source).toContain("t('snippet_settings.syntax_reference.first_tabstop')"); + expect(source).toContain("t('snippet_settings.syntax_reference.example')"); + + expect(source).not.toContain("void message.warning('前缀不能为空')"); + expect(source).not.toContain("void message.warning('名称不能为空')"); + expect(source).not.toContain("void message.warning('片段内容不能为空')"); + expect(source).not.toContain("void message.success('片段已保存')"); + expect(source).not.toContain("void message.success('片段已删除')"); + expect(source).not.toContain("void message.success('已重置为默认')"); + expect(source).not.toContain('代码片段管理'); + expect(source).not.toContain('管理 SQL 代码片段,输入前缀后按 Tab 展开'); + 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('关闭'); + expect(source).not.toContain('片段语法说明(可编辑)'); + expect(source).not.toContain('展示在补全详情中的用法说明,例如占位符含义、参数约定或注意事项'); + expect(source).not.toContain('占位符语法参考'); + expect(source).not.toContain('第一个 Tab 位,占位符为提示文字'); + expect(source).not.toContain('第二个 Tab 位,默认值可直接确认'); + expect(source).not.toContain('最终光标位置'); + expect(source).not.toContain('同一数字在多处出现时会同步编辑'); + expect(source).not.toContain('内置变量(展开时自动替换为实际值):'); + expect(source).not.toContain('当前日期'); + expect(source).not.toContain('当前时间'); + expect(source).not.toContain('Unix 时间戳'); + expect(source).not.toContain('随机 UUID'); + expect(source).not.toContain('6 位随机数'); + expect(source).not.toContain('示例:SELECT'); + }); + + it('keeps the shell and feedback keys available in every locale', () => { + locales.forEach((locale) => { + const catalog = JSON.parse(readFileSync(new URL(`../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record; + requiredKeys.forEach((key) => { + expect(catalog[key], `${locale}:${key}`).toBeTruthy(); + }); + }); + }); + + it('renders the modal shell in English and localizes save validation feedback', async () => { + const renderer = await renderModal(); + const initialText = getJsonText(renderer.toJSON()); + expect(initialText).toContain('Snippet Management'); + expect(initialText).toContain('Manage SQL snippets and prefix completion.'); + expect(initialText).toContain('Snippet List'); + expect(initialText).toContain('New Snippet'); + expect(initialText).toContain('Select a snippet on the left to edit, or click "New Snippet"'); + + const newButton = renderer.root.findAll((node: any) => node.type === 'button' && getText(node).includes('New Snippet'))[0]; + + await act(async () => { + newButton.props.onClick(); + }); + + const editorText = getJsonText(renderer.toJSON()); + expect(editorText).toContain('Save'); + expect(editorText).toContain('Close'); + expect(editorText).toContain('Prefix'); + expect(editorText).toContain('Name'); + expect(editorText).toContain('Description (optional)'); + expect(editorText).toContain('Snippet Body'); + expect(editorText).toContain('Snippet syntax notes (editable)'); + expect(editorText).toContain('Placeholder syntax reference'); + expect(editorText).toContain('Built-in variables (auto-replaced when expanded):'); + expect(editorText).toContain('Example: SELECT ${1:column_name} FROM ${2:table_name}'); + + const saveButton = renderer.root.findAll((node: any) => node.type === 'button' && getText(node).includes('Save'))[0]; + + await act(async () => { + saveButton.props.onClick(); + }); + + expect(messageApi.warning).toHaveBeenCalledWith('Prefix is required'); + }); +}); diff --git a/frontend/src/components/SnippetSettingsModal.tsx b/frontend/src/components/SnippetSettingsModal.tsx index 41b3ba3..933e933 100644 --- a/frontend/src/components/SnippetSettingsModal.tsx +++ b/frontend/src/components/SnippetSettingsModal.tsx @@ -10,6 +10,7 @@ import { import { v4 as uuidv4 } from 'uuid'; import type { SqlSnippet } from '../types'; import { useStore } from '../store'; +import { useI18n } from '../i18n/provider'; import { BUILTIN_SNIPPET_MAP } from '../utils/sqlSnippetDefaults'; import type { OverlayWorkbenchTheme } from '../utils/overlayWorkbenchTheme'; interface SnippetSettingsModalProps { @@ -37,6 +38,7 @@ export default function SnippetSettingsModal({ darkMode, overlayTheme, }: SnippetSettingsModalProps) { + const { t } = useI18n(); const sqlSnippets = useStore((s) => s.sqlSnippets); const saveSqlSnippet = useStore((s) => s.saveSqlSnippet); const deleteSqlSnippet = useStore((s) => s.deleteSqlSnippet); @@ -69,6 +71,7 @@ export default function SnippetSettingsModal({ const textColor = darkMode ? 'rgba(255,255,255,0.85)' : 'rgba(16,24,40,0.9)'; const mutedColor = darkMode ? 'rgba(255,255,255,0.5)' : 'rgba(16,24,40,0.55)'; const selectedBg = darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.04)'; + const newSnippetAction = t('snippet_settings.action.new'); const sortedSnippets = useMemo( () => [...sqlSnippets].sort((a, b) => a.prefix.localeCompare(b.prefix)), @@ -98,15 +101,15 @@ export default function SnippetSettingsModal({ const handleSave = useCallback(() => { const prefix = draft.prefix.toLowerCase().replace(/[^a-z0-9_]/g, '').slice(0, 20); if (!prefix) { - void message.warning('前缀不能为空'); + void message.warning(t('snippet_settings.message.prefix_required')); return; } if (!draft.name.trim()) { - void message.warning('名称不能为空'); + void message.warning(t('snippet_settings.message.name_required')); return; } if (!draft.body.trim()) { - void message.warning('片段内容不能为空'); + void message.warning(t('snippet_settings.message.body_required')); return; } @@ -114,7 +117,7 @@ export default function SnippetSettingsModal({ (s) => s.prefix.toLowerCase() === prefix && s.id !== draft.id, ); if (duplicate) { - void message.warning(`前缀 "${prefix}" 已被其他片段使用`); + void message.warning(t('snippet_settings.message.prefix_duplicate', { prefix })); return; } @@ -132,8 +135,8 @@ export default function SnippetSettingsModal({ saveSqlSnippet(toSave); setSelectedId(toSave.id); setIsCreating(false); - void message.success('片段已保存'); - }, [draft, sqlSnippets, saveSqlSnippet]); + void message.success(t('snippet_settings.message.saved')); + }, [draft, sqlSnippets, saveSqlSnippet, t]); const handleDelete = useCallback( (id: string) => { @@ -142,9 +145,9 @@ export default function SnippetSettingsModal({ setSelectedId(null); setDraft(emptyDraft()); } - void message.success('片段已删除'); + void message.success(t('snippet_settings.message.deleted')); }, - [deleteSqlSnippet, selectedId], + [deleteSqlSnippet, selectedId, t], ); const handleReset = useCallback( @@ -154,22 +157,22 @@ export default function SnippetSettingsModal({ if (original && selectedId === id) { setDraft({ ...original, syntaxHelp: original.syntaxHelp || '' }); } - void message.success('已重置为默认'); + void message.success(t('snippet_settings.message.reset_default')); }, - [resetBuiltinSqlSnippet, selectedId], + [resetBuiltinSqlSnippet, selectedId, t], ); const syntaxHelpItems = useMemo( () => [ { key: 'snippet-help', - label: '片段语法说明(可编辑)', + label: t('snippet_settings.syntax_help.label'), children: ( setDraft((d) => ({ ...d, syntaxHelp: e.target.value }))} - placeholder="展示在补全详情中的用法说明,例如占位符含义、参数约定或注意事项" + placeholder={t('snippet_settings.syntax_help.placeholder')} maxLength={1000} autoSize={{ minRows: 4, maxRows: 8 }} style={{ @@ -182,27 +185,27 @@ export default function SnippetSettingsModal({ }, { key: 'syntax', - label: '占位符语法参考', + label: t('snippet_settings.syntax_reference.label'), children: (
-
{'${1:占位符} 第一个 Tab 位,占位符为提示文字'}
-
{'${2:默认值} 第二个 Tab 位,默认值可直接确认'}
-
{'$0 最终光标位置'}
-
{'${1:表名} 同一数字在多处出现时会同步编辑'}
-
{'内置变量(展开时自动替换为实际值):'}
-
{'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} 当前日期'}
-
{'${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND} 当前时间'}
-
{'${CURRENT_SECONDS_UNIX} Unix 时间戳'}
-
{'${UUID} 随机 UUID'}
-
{'${RANDOM} 6 位随机数'}
+
{t('snippet_settings.syntax_reference.first_tabstop')}
+
{t('snippet_settings.syntax_reference.second_tabstop')}
+
{t('snippet_settings.syntax_reference.final_cursor')}
+
{t('snippet_settings.syntax_reference.linked_tabstop')}
+
{t('snippet_settings.syntax_reference.builtin_variables')}
+
{t('snippet_settings.syntax_reference.current_date')}
+
{t('snippet_settings.syntax_reference.current_time')}
+
{t('snippet_settings.syntax_reference.unix_seconds')}
+
{t('snippet_settings.syntax_reference.uuid')}
+
{t('snippet_settings.syntax_reference.random')}
- {'示例:SELECT ${1:列名} FROM ${2:表名} WHERE date >= \'${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}\';$0'} + {t('snippet_settings.syntax_reference.example')}
), }, ], - [draft.syntaxHelp, mutedColor, textColor], + [draft.syntaxHelp, mutedColor, t, textColor], ); const showEditor = isCreating || selectedSnippet; @@ -226,9 +229,9 @@ export default function SnippetSettingsModal({
-
代码片段管理
+
{t('app.tools.entry.snippets.title')}
- 管理 SQL 代码片段,输入前缀后按 Tab 展开 + {t('app.tools.entry.snippets.description')}
@@ -258,7 +261,7 @@ export default function SnippetSettingsModal({ }} >
- 片段列表 + {t('snippet_settings.list.title')}
- 内置 + {t('snippet_settings.tag.builtin')} )}
@@ -317,7 +320,7 @@ export default function SnippetSettingsModal({
@@ -336,23 +339,23 @@ export default function SnippetSettingsModal({ >
-
前缀
+
{t('snippet_settings.field.prefix.label')}
setDraft((d) => ({ ...d, prefix: e.target.value.toLowerCase() })) } - placeholder="如 sel, ins" + placeholder={t('snippet_settings.field.prefix.placeholder')} maxLength={20} size="small" />
-
名称
+
{t('snippet_settings.field.name.label')}
setDraft((d) => ({ ...d, name: e.target.value }))} - placeholder="片段显示名称" + placeholder={t('snippet_settings.field.name.placeholder')} maxLength={60} size="small" /> @@ -360,18 +363,18 @@ export default function SnippetSettingsModal({
-
描述(可选)
+
{t('snippet_settings.field.description.label')}
setDraft((d) => ({ ...d, description: e.target.value }))} - placeholder="补全详情中的描述文字" + placeholder={t('snippet_settings.field.description.placeholder')} maxLength={200} size="small" />
-
片段内容
+
{t('snippet_settings.field.body.label')}
setDraft((d) => ({ ...d, body: e.target.value }))} @@ -404,7 +407,7 @@ export default function SnippetSettingsModal({ fontSize: 13, }} > - 选择左侧片段编辑,或点击「新建片段」 + {t('snippet_settings.empty_state', { action: newSnippetAction })}
)}
@@ -423,33 +426,33 @@ export default function SnippetSettingsModal({ > {showEditor && draft.isBuiltin && draft.createdAt && ( handleReset(draft.id)} > )} {showEditor && !draft.isBuiltin && !isCreating && ( handleDelete(draft.id)} > )} {showEditor && ( )}
diff --git a/frontend/src/components/TabManager.hover.test.tsx b/frontend/src/components/TabManager.hover.test.tsx index 2618101..43e953e 100644 --- a/frontend/src/components/TabManager.hover.test.tsx +++ b/frontend/src/components/TabManager.hover.test.tsx @@ -12,9 +12,37 @@ import { stopTabHoverDragPropagation, } from './TabManager'; import { setCurrentLanguage } from '../i18n'; +import { catalogs } from '../i18n/catalog'; import type { TabData } from '../types'; import { buildTabDisplayModel } from '../utils/tabDisplay'; +const TAB_MANAGER_SQL_FILE_CLOSE_KEYS = [ + 'tab_manager.sql_file_close.read_failed_cancel_close', + 'tab_manager.sql_file_close.dirty_single_label', + 'tab_manager.sql_file_close.dirty_multiple_label', + 'tab_manager.sql_file_close.save_confirm_title', + 'tab_manager.sql_file_close.save_confirm_content', + 'tab_manager.sql_file_close.save_and_close', + 'tab_manager.sql_file_close.discard', + 'tab_manager.sql_file_close.save_failed', + 'tab_manager.sql_file_close.unknown_error', + 'tab_manager.sql_file_close.saved', + 'tab_manager.sql_file_close.missing_single_label', + 'tab_manager.sql_file_close.missing_multiple_label', + 'tab_manager.sql_file_close.missing_confirm_title', + 'tab_manager.sql_file_close.missing_confirm_content', + 'tab_manager.sql_file_close.continue_close', + 'tab_manager.sql_file_close.close_tabs', +] as const; + +const TAB_MANAGER_MENU_KEYS = [ + 'tab_manager.menu.tab_display_settings', + 'tab_manager.menu.close_other', + 'tab_manager.menu.close_left', + 'tab_manager.menu.close_right', + 'tab_manager.menu.close_all', +] as const; + const stripSourceComments = (source: string): string => source .replace(/\/\*[\s\S]*?\*\//g, '') @@ -255,9 +283,9 @@ describe('TabManager hover info', () => { }); it('renders tab labels from appearance tab display settings', () => { - const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); + const source = stripSourceComments(readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8')); - expect(source).toContain('buildTabDisplayModel(tab, connection, appearance.tabDisplay)'); + expect(source).toContain('buildTabDisplayModel(tab, connection, appearance.tabDisplay, t)'); expect(source).toContain('displayModel={displayModel}'); expect(source).toContain('displayModel.primaryParts.map(renderV2TabDisplayPart)'); expect(source).toContain("if (part.key === 'kind')"); @@ -270,7 +298,8 @@ describe('TabManager hover info', () => { expect(source).toContain('gn-v2-tab-label-double'); expect(source).toContain('gn-v2-tab-label-main tab-title-text'); expect(source).toContain("key: 'tab-display-settings'"); - expect(source).toContain('label: \'标签设置\''); + expect(source).toContain("label: t('tab_manager.menu.tab_display_settings')"); + expect(source).not.toContain("label: '标签设置'"); expect(source).toContain('icon: '); expect(source).toContain('onClick: openTabDisplaySettings'); expect(source).toContain("rootClassName={isV2Ui ? 'gn-v2-tab-context-menu-popup' : undefined}"); @@ -304,19 +333,32 @@ describe('TabManager hover info', () => { }); it('guards closing opened SQL file tabs with save confirmation', () => { - const source = readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8'); + const source = stripSourceComments(readFileSync(new URL('./TabManager.tsx', import.meta.url), 'utf8')); expect(source).toContain('ReadSQLFile(filePath)'); expect(source).toContain('isSQLFileMissingReadResult(res)'); expect(source).toContain('isSQLFileMissingErrorMessage(errorMessage)'); - expect(source).toContain("title: '关闭已丢失的 SQL 文件标签?'"); - expect(source).toContain('关闭后将丢弃标签内的本地草稿'); + TAB_MANAGER_SQL_FILE_CLOSE_KEYS.forEach((key) => { + expect(source).toContain(`t('${key}'`); + }); + [ + '读取 SQL 文件失败,已取消关闭', + '保存 SQL 文件修改?', + '有未保存修改,是否保存后再关闭?', + '保存并关闭', + '不保存', + '未知错误', + 'SQL 文件已保存', + '关闭已丢失的 SQL 文件标签?', + '对应的外部 SQL 文件已不存在或已被移动', + '继续关闭', + '关闭标签', + ].forEach((text) => { + expect(source).not.toContain(text); + }); expect(source).toContain('confirmDirtyTabsOrClose();'); expect(source).toContain("getSQLFileTabDraft(tab.id, String(tab.query ?? ''))"); expect(source).toContain('hasSQLFileTabUnsavedChanges({ ...tab, query: draft }, normalizeSQLFileReadContent(res.data))'); - expect(source).toContain("title: '保存 SQL 文件修改?'"); - expect(source).toContain("okText: '保存并关闭'"); - expect(source).toContain('不保存'); expect(source).toContain('WriteSQLFile(filePath, draft)'); expect(source).toContain('clearSQLFileTabDraft(tab.id)'); expect(source).toContain('closeTabsWithSQLFilePrompt([id], () => closeTab(id))'); @@ -325,4 +367,20 @@ describe('TabManager hover info', () => { expect(source).toContain('closeTabsWithSQLFilePrompt(getCloseTabsToRightIds(tabs, tab.id), () => closeTabsToRight(tab.id))'); expect(source).toContain('closeTabsWithSQLFilePrompt(tabs.map((item) => item.id), () => closeAllTabs())'); }); + + it('keeps SQL file close prompt keys in every catalog', () => { + Object.entries(catalogs).forEach(([language, catalog]) => { + TAB_MANAGER_SQL_FILE_CLOSE_KEYS.forEach((key) => { + expect(catalog, `${language}:${key}`).toHaveProperty(key); + }); + }); + }); + + it('keeps tab context menu keys in every catalog', () => { + Object.entries(catalogs).forEach(([language, catalog]) => { + TAB_MANAGER_MENU_KEYS.forEach((key) => { + expect(catalog, `${language}:${key}`).toHaveProperty(key); + }); + }); + }); }); diff --git a/frontend/src/components/TabManager.tsx b/frontend/src/components/TabManager.tsx index 40ac1b4..65d7d85 100644 --- a/frontend/src/components/TabManager.tsx +++ b/frontend/src/components/TabManager.tsx @@ -484,7 +484,7 @@ const TabManager: React.FC = React.memo(() => { missingFileTabs.push({ tab, filePath }); continue; } - message.error(`读取 SQL 文件失败,已取消关闭:${res.message || filePath}`); + message.error(t('tab_manager.sql_file_close.read_failed_cancel_close', { detail: res.message || filePath })); return; } const draft = getSQLFileTabDraft(tab.id, String(tab.query ?? '')); @@ -497,7 +497,7 @@ const TabManager: React.FC = React.memo(() => { missingFileTabs.push({ tab, filePath }); continue; } - message.error('读取 SQL 文件失败,已取消关闭:' + errorMessage); + message.error(t('tab_manager.sql_file_close.read_failed_cancel_close', { detail: errorMessage })); return; } } @@ -511,15 +511,15 @@ const TabManager: React.FC = React.memo(() => { const firstDirtyTab = dirtyTabs[0].tab; const dirtyFilePath = getSQLFileTabPath(firstDirtyTab); const dirtyLabel = dirtyTabs.length === 1 - ? `“${firstDirtyTab.title || dirtyFilePath}”` - : `${dirtyTabs.length} 个 SQL 文件`; + ? t('tab_manager.sql_file_close.dirty_single_label', { title: firstDirtyTab.title || dirtyFilePath }) + : t('tab_manager.sql_file_close.dirty_multiple_label', { count: dirtyTabs.length }); let destroyConfirm: (() => void) | null = null; const confirmRef = Modal.confirm({ - title: '保存 SQL 文件修改?', - content: `${dirtyLabel} 有未保存修改,是否保存后再关闭?`, - okText: '保存并关闭', - cancelText: '取消', + title: t('tab_manager.sql_file_close.save_confirm_title'), + content: t('tab_manager.sql_file_close.save_confirm_content', { label: dirtyLabel }), + okText: t('tab_manager.sql_file_close.save_and_close'), + cancelText: t('common.cancel'), closable: true, maskClosable: true, okButtonProps: { type: 'primary' }, @@ -531,7 +531,7 @@ const TabManager: React.FC = React.memo(() => { closeConfirmedTabsAndClearDrafts(); }} > - 不保存 + {t('tab_manager.sql_file_close.discard')} @@ -544,10 +544,13 @@ const TabManager: React.FC = React.memo(() => { if (!filePath) continue; const res = await WriteSQLFile(filePath, draft); if (!res.success) { - throw new Error(`保存 ${tab.title || filePath} 失败:${res.message || '未知错误'}`); + throw new Error(t('tab_manager.sql_file_close.save_failed', { + title: tab.title || filePath, + detail: res.message || t('tab_manager.sql_file_close.unknown_error'), + })); } } - message.success('SQL 文件已保存'); + message.success(t('tab_manager.sql_file_close.saved')); closeConfirmedTabsAndClearDrafts(); } catch (error) { message.error(error instanceof Error ? error.message : String(error)); @@ -561,13 +564,13 @@ const TabManager: React.FC = React.memo(() => { if (missingFileTabs.length > 0) { const firstMissing = missingFileTabs[0]; const missingLabel = missingFileTabs.length === 1 - ? `“${firstMissing.tab.title || firstMissing.filePath}”` - : `${missingFileTabs.length} 个 SQL 文件标签`; + ? t('tab_manager.sql_file_close.missing_single_label', { title: firstMissing.tab.title || firstMissing.filePath }) + : t('tab_manager.sql_file_close.missing_multiple_label', { count: missingFileTabs.length }); Modal.confirm({ - title: '关闭已丢失的 SQL 文件标签?', - content: `${missingLabel} 对应的外部 SQL 文件已不存在或已被移动,关闭后将丢弃标签内的本地草稿。`, - okText: dirtyTabs.length > 0 ? '继续关闭' : '关闭标签', - cancelText: '取消', + title: t('tab_manager.sql_file_close.missing_confirm_title'), + content: t('tab_manager.sql_file_close.missing_confirm_content', { label: missingLabel }), + okText: dirtyTabs.length > 0 ? t('tab_manager.sql_file_close.continue_close') : t('tab_manager.sql_file_close.close_tabs'), + cancelText: t('common.cancel'), closable: true, maskClosable: true, okButtonProps: { danger: true }, @@ -677,7 +680,7 @@ const TabManager: React.FC = React.memo(() => { const hasDoubleLineTabLabel = useMemo(() => ( tabs.some((tab) => { const connection = connections.find((conn) => conn.id === tab.connectionId); - const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay); + const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t); return displayModel.layout === 'double' && Boolean(displayModel.secondaryText); }) ), [appearance.tabDisplay, connections, tabs]); @@ -690,7 +693,7 @@ const TabManager: React.FC = React.memo(() => { const items = useMemo(() => tabs.map((tab, index) => { const connection = connections.find((conn) => conn.id === tab.connectionId); - const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay); + const displayModel = buildTabDisplayModel(tab, connection, appearance.tabDisplay, t); const displayTitle = displayModel.fullTitle; const hostSummary = resolveConnectionHostSummary(connection?.config); const tabIsActive = tab.id === activeTabId; @@ -699,7 +702,7 @@ const TabManager: React.FC = React.memo(() => { { key: 'tab-display-settings', icon: , - label: '标签设置', + label: t('tab_manager.menu.tab_display_settings'), onClick: openTabDisplaySettings, }, { type: 'divider' }, diff --git a/frontend/src/components/TableDesigner.i18n.test.ts b/frontend/src/components/TableDesigner.i18n.test.ts index ee81a64..7fee5e3 100644 --- a/frontend/src/components/TableDesigner.i18n.test.ts +++ b/frontend/src/components/TableDesigner.i18n.test.ts @@ -43,6 +43,30 @@ describe('TableDesigner i18n', () => { expect(source).toContain('-- Trigger definition unavailable'); }); + it('localizes trigger edit tab title and DuckDB primary key warning while keeping raw names', () => { + [ + '修改触发器:', + 'DuckDB 当前仅支持为无主键表新增主键;已有主键的修改或删除需要通过重建表完成。', + ].forEach((snippet) => { + expect(source).not.toContain(snippet); + }); + + [ + "t('table_designer.tab.edit_trigger_title'", + "t('table_designer.message.duckdb_primary_key_change_unsupported'", + ].forEach((snippet) => { + expect(source).toContain(snippet); + }); + + ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'].forEach((locale) => { + const messages = readLocale(locale); + + expect(messages['table_designer.tab.edit_trigger_title']).toBeTruthy(); + expect(messages['table_designer.tab.edit_trigger_title']).toContain('{{name}}'); + expect(messages['table_designer.message.duckdb_primary_key_change_unsupported']).toBeTruthy(); + }); + }); + it('localizes remaining V2 and StarRocks technical labels without translating raw values', () => { [ 'SCHEMA DESIGNER', diff --git a/frontend/src/components/TableDesigner.tsx b/frontend/src/components/TableDesigner.tsx index 644b250..7d22dac 100644 --- a/frontend/src/components/TableDesigner.tsx +++ b/frontend/src/components/TableDesigner.tsx @@ -1110,7 +1110,7 @@ ${selectedTrigger.statement}`; setActiveContext({ connectionId: tab.connectionId, dbName }); addTab({ id: `query-edit-trigger-${tab.connectionId}-${dbName}-${tab.tableName || ''}-${selectedTrigger.name}-${Date.now()}`, - title: `修改触发器: ${selectedTrigger.name}`, + title: t('table_designer.tab.edit_trigger_title', { name: selectedTrigger.name }, i18nLanguage), type: 'query', connectionId: tab.connectionId, dbName, @@ -1656,6 +1656,7 @@ ${selectedTrigger.statement}`; charset: targetCharset, collation: targetCollation, starRocksOptions: buildStarRocksCreateOptions(), + translate: (key, params) => t(key, params, i18nLanguage), }); }; @@ -1900,6 +1901,7 @@ END;`; columnNames: form.columnNames, kind: form.kind, indexType: form.indexType, + translate: (key, params) => t(key, params, i18nLanguage), }); }; @@ -2287,7 +2289,7 @@ END;`; if (tableInfo.dbType === 'duckdb') { const pkChange = summarizeDuckDbPrimaryKeyChange(originalColumns, columns); if (pkChange.isUnsupportedChange) { - message.warning('DuckDB 当前仅支持为无主键表新增主键;已有主键的修改或删除需要通过重建表完成。'); + message.warning(t('table_designer.message.duckdb_primary_key_change_unsupported', undefined, i18nLanguage)); return; } } @@ -2296,6 +2298,7 @@ END;`; tableName: tableInfo.qualifiedName, originalColumns, columns, + translate: (key, params) => t(key, params, i18nLanguage), }); if (!sql.trim()) { diff --git a/frontend/src/components/TableDesignerSqlPreview.test.tsx b/frontend/src/components/TableDesignerSqlPreview.test.tsx index 3900bb0..0d6e7b0 100644 --- a/frontend/src/components/TableDesignerSqlPreview.test.tsx +++ b/frontend/src/components/TableDesignerSqlPreview.test.tsx @@ -18,6 +18,17 @@ const tableDesignerColumnI18nKeys = [ 'table_designer.tooltip.edit_comment_popup', ] as const; +const tableDesignerSqlPreviewChangeKeys = [ + 'table_designer.sql_preview.change.add', + 'table_designer.sql_preview.change.comment', + 'table_designer.sql_preview.change.constraint', + 'table_designer.sql_preview.change.create', + 'table_designer.sql_preview.change.create_index', + 'table_designer.sql_preview.change.drop', + 'table_designer.sql_preview.change.modify', + 'table_designer.sql_preview.change.rename', +] as const; + const sharedI18nDir = new URL('../../../shared/i18n/', import.meta.url); const sharedI18nLocaleFiles = [ 'de-DE.json', @@ -168,6 +179,30 @@ describe('TableDesignerSqlPreview', () => { expect(badValues).toEqual([]); }); + it('keeps SQL preview change labels in i18n catalogs without shipping Chinese source labels', () => { + const source = readFileSync(new URL('./TableDesignerSqlPreview.tsx', import.meta.url), 'utf8'); + + for (const localeFile of sharedI18nLocaleFiles) { + const catalog = JSON.parse(readFileSync(new URL(localeFile, sharedI18nDir), 'utf8')) as Record; + for (const key of tableDesignerSqlPreviewChangeKeys) { + expect(catalog[key], `${localeFile} ${key}`).toBeTruthy(); + } + } + + for (const literal of [ + '重命名变更', + '新增变更', + '删除变更', + '字段属性变更', + '约束变更', + '备注变更', + '新建索引', + '新建表结构', + ]) { + expect(source).not.toContain(literal); + } + }); + it('keeps generated SQL fallback text independent from UI locale', () => { const source = readFileSync(new URL('./TableDesigner.tsx', import.meta.url), 'utf8'); @@ -230,27 +265,42 @@ describe('TableDesignerSqlPreview', () => { }); it('detects only SQL change operation lines instead of highlighting the whole SQL block', () => { + const translate = (key: string) => `translated:${key}`; const highlights = resolveSqlChangeHighlights([ 'ALTER TABLE "users"', 'ADD COLUMN "age" int NULL;', 'ALTER TABLE "users"', 'RENAME COLUMN "name" TO "display_name";', '-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注', - ].join('\n')); + ].join('\n'), translate); expect(highlights).toEqual([ - expect.objectContaining({ kind: 'add', lineNumber: 2 }), - expect.objectContaining({ kind: 'rename', lineNumber: 4 }), + expect.objectContaining({ + kind: 'add', + lineNumber: 2, + label: 'translated:table_designer.sql_preview.change.add', + }), + expect.objectContaining({ + kind: 'rename', + lineNumber: 4, + label: 'translated:table_designer.sql_preview.change.rename', + }), ]); }); it('detects CREATE INDEX preview lines as create changes', () => { + const translate = (key: string) => `translated:${key}`; const highlights = resolveSqlChangeHighlights( 'CREATE UNIQUE NONCLUSTERED INDEX [IX_Users_Email] ON [dbo].[Users] ([email]);', + translate, ); expect(highlights).toEqual([ - expect.objectContaining({ kind: 'create', lineNumber: 1, label: '新建索引' }), + expect.objectContaining({ + kind: 'create', + lineNumber: 1, + label: 'translated:table_designer.sql_preview.change.create_index', + }), ]); }); diff --git a/frontend/src/components/TableDesignerSqlPreview.tsx b/frontend/src/components/TableDesignerSqlPreview.tsx index 4d1407a..1039b1c 100644 --- a/frontend/src/components/TableDesignerSqlPreview.tsx +++ b/frontend/src/components/TableDesignerSqlPreview.tsx @@ -1,5 +1,7 @@ import { useCallback, useEffect, useMemo, useRef } from 'react'; import Editor, { type BeforeMount, type OnMount } from './MonacoEditor'; +import { t as defaultTranslate } from '../i18n'; +import { useOptionalI18n } from '../i18n/provider'; interface TableDesignerSqlPreviewProps { sql: string; darkMode?: boolean; @@ -22,36 +24,74 @@ export interface SqlChangeHighlight { label: string; } +type SqlPreviewTranslator = (key: string) => string; +type SqlChangeHighlightLabelKey = + | 'table_designer.sql_preview.change.add' + | 'table_designer.sql_preview.change.comment' + | 'table_designer.sql_preview.change.constraint' + | 'table_designer.sql_preview.change.create' + | 'table_designer.sql_preview.change.create_index' + | 'table_designer.sql_preview.change.drop' + | 'table_designer.sql_preview.change.modify' + | 'table_designer.sql_preview.change.rename'; + const SQL_PREVIEW_LIGHT_THEME = 'gonavi-sql-preview-light'; const SQL_PREVIEW_DARK_THEME = 'gonavi-sql-preview-dark'; +const SQL_PREVIEW_CHANGE_LABEL_FALLBACKS: Record = { + 'table_designer.sql_preview.change.add': 'Add change', + 'table_designer.sql_preview.change.comment': 'Comment change', + 'table_designer.sql_preview.change.constraint': 'Constraint change', + 'table_designer.sql_preview.change.create': 'New table structure', + 'table_designer.sql_preview.change.create_index': 'Create index', + 'table_designer.sql_preview.change.drop': 'Drop change', + 'table_designer.sql_preview.change.modify': 'Column property change', + 'table_designer.sql_preview.change.rename': 'Rename change', +}; + const CHANGE_LINE_RULES: Array<{ kind: SqlChangeHighlightKind; - label: string; + labelKey: SqlChangeHighlightLabelKey; pattern: RegExp; }> = [ - { kind: 'rename', label: '重命名变更', pattern: /\b(RENAME\s+COLUMN|CHANGE\s+COLUMN|RENAME\s+TO|SP_RENAME)\b/i }, - { kind: 'add', label: '新增变更', pattern: /\b(ADD\s+COLUMN|ADD\s+PRIMARY\s+KEY)\b/i }, - { kind: 'drop', label: '删除变更', pattern: /\b(DROP\s+COLUMN|DROP\s+PRIMARY\s+KEY)\b/i }, - { kind: 'modify', label: '字段属性变更', pattern: /\b(MODIFY\s+COLUMN|ALTER\s+COLUMN|SET\s+DATA\s+TYPE|SET\s+DEFAULT|DROP\s+DEFAULT|SET\s+NOT\s+NULL|DROP\s+NOT\s+NULL)\b/i }, - { kind: 'constraint', label: '约束变更', pattern: /\b(ADD\s+CONSTRAINT|DROP\s+CONSTRAINT)\b/i }, - { kind: 'comment', label: '备注变更', pattern: /\b(COMMENT\s+ON\s+COLUMN|COMMENT\s+ON\s+TABLE)\b/i }, - { kind: 'create', label: '新建索引', pattern: /\bCREATE\s+(UNIQUE\s+)?((CLUSTERED|NONCLUSTERED)\s+)?INDEX\b/i }, + { kind: 'rename', labelKey: 'table_designer.sql_preview.change.rename', pattern: /\b(RENAME\s+COLUMN|CHANGE\s+COLUMN|RENAME\s+TO|SP_RENAME)\b/i }, + { kind: 'add', labelKey: 'table_designer.sql_preview.change.add', pattern: /\b(ADD\s+COLUMN|ADD\s+PRIMARY\s+KEY)\b/i }, + { kind: 'drop', labelKey: 'table_designer.sql_preview.change.drop', pattern: /\b(DROP\s+COLUMN|DROP\s+PRIMARY\s+KEY)\b/i }, + { kind: 'modify', labelKey: 'table_designer.sql_preview.change.modify', pattern: /\b(MODIFY\s+COLUMN|ALTER\s+COLUMN|SET\s+DATA\s+TYPE|SET\s+DEFAULT|DROP\s+DEFAULT|SET\s+NOT\s+NULL|DROP\s+NOT\s+NULL)\b/i }, + { kind: 'constraint', labelKey: 'table_designer.sql_preview.change.constraint', pattern: /\b(ADD\s+CONSTRAINT|DROP\s+CONSTRAINT)\b/i }, + { kind: 'comment', labelKey: 'table_designer.sql_preview.change.comment', pattern: /\b(COMMENT\s+ON\s+COLUMN|COMMENT\s+ON\s+TABLE)\b/i }, + { kind: 'create', labelKey: 'table_designer.sql_preview.change.create_index', pattern: /\bCREATE\s+(UNIQUE\s+)?((CLUSTERED|NONCLUSTERED)\s+)?INDEX\b/i }, ]; const CREATE_TABLE_PATTERN = /^\s*CREATE\s+TABLE\b/i; -const getCreateTableLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => { +const resolveSqlPreviewChangeLabel = ( + labelKey: SqlChangeHighlightLabelKey, + translate: SqlPreviewTranslator = defaultTranslate, +): string => { + const translated = translate(labelKey); + return translated === labelKey ? SQL_PREVIEW_CHANGE_LABEL_FALLBACKS[labelKey] : translated; +}; + +const getCreateTableLineHighlight = ( + line: string, + lineNumber: number, + translate?: SqlPreviewTranslator, +): SqlChangeHighlight | null => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('--')) return null; return { line, lineNumber, kind: 'create', - label: '新建表结构', + label: resolveSqlPreviewChangeLabel('table_designer.sql_preview.change.create', translate), }; }; -const getAlterLineHighlight = (line: string, lineNumber: number): SqlChangeHighlight | null => { +const getAlterLineHighlight = ( + line: string, + lineNumber: number, + translate?: SqlPreviewTranslator, +): SqlChangeHighlight | null => { const trimmed = line.trim(); if (!trimmed || trimmed.startsWith('--')) return null; @@ -62,19 +102,22 @@ const getAlterLineHighlight = (line: string, lineNumber: number): SqlChangeHighl line, lineNumber, kind: matchedRule.kind, - label: matchedRule.label, + label: resolveSqlPreviewChangeLabel(matchedRule.labelKey, translate), }; }; -export const resolveSqlChangeHighlights = (sql: string): SqlChangeHighlight[] => { +export const resolveSqlChangeHighlights = ( + sql: string, + translate?: SqlPreviewTranslator, +): SqlChangeHighlight[] => { const lines = sql.split(/\r?\n/); const isCreateTableSql = lines.some((line) => CREATE_TABLE_PATTERN.test(line)); return lines .map((line, index) => ( isCreateTableSql - ? getCreateTableLineHighlight(line, index + 1) - : getAlterLineHighlight(line, index + 1) + ? getCreateTableLineHighlight(line, index + 1, translate) + : getAlterLineHighlight(line, index + 1, translate) )) .filter((highlight): highlight is SqlChangeHighlight => Boolean(highlight)); }; @@ -133,7 +176,9 @@ const TableDesignerSqlPreview: React.FC = ({ const decorationIdsRef = useRef([]); const editorRef = useRef(null); const monacoRef = useRef(null); - const changeHighlights = useMemo(() => resolveSqlChangeHighlights(sql), [sql]); + const i18n = useOptionalI18n(); + const translate = i18n?.t ?? defaultTranslate; + const changeHighlights = useMemo(() => resolveSqlChangeHighlights(sql, translate), [sql, translate]); const applyChangeDecorations = useCallback(() => { const editor = editorRef.current; diff --git a/frontend/src/components/TableOverview.context-menu.test.ts b/frontend/src/components/TableOverview.context-menu.test.ts index bec24d0..defa873 100644 --- a/frontend/src/components/TableOverview.context-menu.test.ts +++ b/frontend/src/components/TableOverview.context-menu.test.ts @@ -5,11 +5,11 @@ describe('TableOverview v2 context menu', () => { it('renders card and list table context menus through a measured portal', () => { const source = readFileSync(new URL('./TableOverview.tsx', import.meta.url), 'utf8'); const cardSource = source.slice( - source.indexOf('const renderCardTableContent = (t: TableStatRow) => ('), - source.indexOf('const renderListTable = (t: TableStatRow) => {'), + source.indexOf('const renderCardTableContent = (table: TableStatRow) => ('), + source.indexOf('const renderListTable = (table: TableStatRow) => {'), ); const listSource = source.slice( - source.indexOf('const renderListTable = (t: TableStatRow) => {'), + source.indexOf('const renderListTable = (table: TableStatRow) => {'), source.indexOf('if (loading) {'), ); @@ -20,8 +20,8 @@ describe('TableOverview v2 context menu', () => { expect(source).toContain('gn-v2-table-overview-context-menu-portal'); expect(source).toContain("['--gn-v2-context-menu-max-height' as any]"); expect(source).toContain('renderV2OverviewTableContextMenu(v2ContextMenuTable)'); - expect(cardSource).toContain('onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, t) : undefined}'); - expect(listSource).toContain('onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, t) : undefined}'); + expect(cardSource).toContain('onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, table) : undefined}'); + expect(listSource).toContain('onContextMenu={isV2Ui ? (event) => openV2OverviewContextMenu(event, table) : undefined}'); expect(cardSource).not.toContain('popupRender'); expect(listSource).not.toContain('popupRender'); }); diff --git a/frontend/src/components/TableOverview.i18n.test.ts b/frontend/src/components/TableOverview.i18n.test.ts index ebfdbe0..9d1f3a7 100644 --- a/frontend/src/components/TableOverview.i18n.test.ts +++ b/frontend/src/components/TableOverview.i18n.test.ts @@ -2,6 +2,22 @@ import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; const source = readFileSync(new URL('./TableOverview.tsx', import.meta.url), 'utf8'); +const catalogFiles = [ + 'zh-CN', + 'zh-TW', + 'en-US', + 'ja-JP', + 'de-DE', + 'ru-RU', +] as const; +const catalogs = Object.fromEntries(catalogFiles.map(language => [ + language, + JSON.parse(readFileSync(new URL(`../../../shared/i18n/${language}.json`, import.meta.url), 'utf8')) as Record, +])) as Record>; + +const placeholdersOf = (value: string): string[] => ( + Array.from(value.matchAll(/\{\{\s*([\w.]+)\s*\}\}/g), match => match[1]).sort() +); const cardSource = source.slice( source.indexOf('const renderCardTableContent = (table: TableStatRow) => ('), @@ -31,6 +47,75 @@ const toggleOverviewTablePinnedSource = source.slice( ); const normalizedToggleOverviewTablePinnedSource = toggleOverviewTablePinnedSource.replace(/\s+/g, ' ').trim(); +const tableOperationSource = source.slice( + source.indexOf('const loadData = useCallback(async () => {'), + source.indexOf('const buildMenuItems = useMemo {"), + source.indexOf(' // --- Theme ---'), +); + +const requiredTableOperationKeys = [ + 'table_overview.tab.design_table_title', + 'table_overview.tab.table_structure_title', + 'table_overview.message.load_tables_failed', + 'table_overview.message.unknown_error', + 'table_overview.message.copy_structure_success', + 'table_overview.message.copy_table_name_empty', + 'table_overview.message.copy_table_name_success', + 'table_overview.message.copy_table_name_failed', + 'table_overview.message.exporting_table_format', + 'table_overview.message.export_success', + 'table_overview.message.export_failed', + 'table_overview.message.delete_table_success', + 'table_overview.message.delete_table_failed', + 'table_overview.message.table_data_action_loading', + 'table_overview.message.table_data_action_success', + 'table_overview.message.table_data_action_failed', + 'table_overview.message.rename_table_success', + 'table_overview.message.rename_table_failed', + 'table_overview.modal.delete_table.title', + 'table_overview.modal.delete_table.content', + 'table_overview.modal.table_data_action.title', + 'table_overview.modal.table_data_action.content', + 'table_overview.modal.rename_table.title', + 'table_overview.modal.rename_table.placeholder', + 'table_overview.validation.table_name_required', + 'table_overview.validation.table_name_unchanged', + 'table_overview.menu.new_query', + 'table_overview.menu.design_table', + 'table_overview.menu.table_structure', + 'table_overview.menu.copy_table_name', + 'table_overview.menu.copy_structure', + 'table_overview.menu.backup_table_sql', + 'table_overview.menu.rename_table', + 'table_overview.menu.danger_operations', + 'table_overview.menu.truncate_table', + 'table_overview.menu.clear_table', + 'table_overview.menu.delete_table', + 'table_overview.menu.export_table_data', + 'table_overview.menu.export_csv', + 'table_overview.menu.export_xlsx', + 'table_overview.menu.export_json', + 'table_overview.menu.export_markdown', + 'table_overview.menu.export_html', +] as const; + +const requiredAIPromptKeys = [ + 'sidebar.message.ai_table_context_missing', + 'sidebar.ai_prompt.explain.intro', + 'sidebar.ai_prompt.explain.detail', + 'sidebar.ai_prompt.query.intro', + 'sidebar.ai_prompt.query.detail', +] as const; + +const requiredRollupKeys = [ + 'sidebar.v2_table_menu.new_rollup', +] as const; + describe('TableOverview i18n', () => { it('localizes the selected card and list overview copy with existing table_overview keys', () => { expect(cardSource).not.toContain('title="行数"'); @@ -85,4 +170,147 @@ describe('TableOverview i18n', () => { "message.success(shouldPin ? t('table_overview.message.pinned') : t('table_overview.message.unpinned'));", ); }); + + it('localizes table operation tabs, messages, modals and legacy menu labels', () => { + [ + '获取表信息失败: ', + '未知错误', + '表结构', + '设计表', + '新建查询', + '表结构已复制到剪贴板', + '表名为空,无法复制', + '表名已复制到剪贴板', + '复制表名失败: ', + '正在导出 ', + '导出成功', + '导出失败: ', + '确认删除表', + '确定删除表', + '表删除成功', + '删除失败: ', + '确认${label}', + '操作不可逆', + '继续', + '正在${progressLabel}', + '${progressLabel}成功', + '${progressLabel}失败', + '重命名表', + '输入新表名', + '表名不能为空', + '新旧表名相同', + '表重命名成功', + '重命名失败: ', + '复制表名', + '复制表结构', + '备份表 (SQL)', + '危险操作', + '截断表', + '清空表', + '删除表', + '导出表数据', + '导出 CSV', + '导出 Excel (XLSX)', + '导出 JSON', + '导出 Markdown', + '导出 HTML', + ].forEach(text => { + expect(tableOperationSource).not.toContain(text); + }); + + [ + 'table_overview.tab.design_table_title', + 'table_overview.tab.table_structure_title', + "t('table_overview.message.copy_structure_failed'", + "t('table_overview.message.copy_table_name_empty')", + "t('table_overview.message.copy_table_name_success')", + "t('table_overview.message.copy_table_name_failed'", + "t('table_overview.modal.delete_table.title')", + "t('table_overview.modal.delete_table.content'", + "t('table_overview.modal.table_data_action.title'", + "t('table_overview.modal.table_data_action.content'", + "okText: t('common.continue')", + "cancelText: t('common.cancel')", + "t('table_overview.modal.rename_table.title')", + "t('table_overview.modal.rename_table.placeholder')", + "t('table_overview.validation.table_name_required')", + "t('table_overview.validation.table_name_unchanged')", + "t('table_overview.message.rename_table_success')", + "t('table_overview.message.rename_table_failed'", + "t('table_overview.menu.copy_table_name')", + "t('table_overview.menu.table_structure')", + "t('table_overview.menu.export_xlsx')", + ].forEach(text => { + expect(tableOperationSource).toContain(text); + }); + + expect(tableOperationSource).not.toContain('message.error(res.message);'); + expect(normalizedTableOperationSource).toContain( + "detail: res.message || t('table_overview.message.unknown_error')", + ); + }); + + it('keeps table operation catalog keys in all supported languages with matching placeholders', () => { + const zhCnCatalog = catalogs['zh-CN']; + requiredTableOperationKeys.forEach(key => { + expect(zhCnCatalog, `zh-CN:${key}`).toHaveProperty(key); + const expectedPlaceholders = placeholdersOf(zhCnCatalog[key]); + catalogFiles.forEach(language => { + expect(catalogs[language], `${language}:${key}`).toHaveProperty(key); + expect(placeholdersOf(catalogs[language][key]), `${language}:${key}`).toEqual(expectedPlaceholders); + }); + }); + }); + + it('localizes AI table prompt shells while keeping table references and DDL raw', () => { + [ + '当前表缺少连接上下文,无法发送给 AI', + '请解释数据表 ${dbName}.${tableName} 的结构和业务含义。', + '重点说明字段含义、主键/索引、潜在关联关系、典型查询场景和风险点。', + '请基于数据表 ${dbName}.${tableName} 生成 3 条常用查询 SQL。', + '要求包含:数据预览查询、按关键字段过滤查询、一个聚合或统计查询。', + ].forEach(text => { + expect(aiPromptSource).not.toContain(text); + }); + + requiredAIPromptKeys.forEach(key => { + expect(aiPromptSource).toContain(`t('${key}'`); + }); + + expect(aiPromptSource).toContain('DBShowCreateTable'); + expect(aiPromptSource).toContain('const tableRef = `${dbName}.${tableName}`;'); + expect(aiPromptSource).toContain('ddl ? `\\n\\`\\`\\`sql'); + expect(aiPromptSource).toContain('${ddl}'); + }); + + it('keeps reused AI prompt keys in all supported languages with matching placeholders', () => { + const zhCnCatalog = catalogs['zh-CN']; + requiredAIPromptKeys.forEach(key => { + expect(zhCnCatalog, `zh-CN:${key}`).toHaveProperty(key); + const expectedPlaceholders = placeholdersOf(zhCnCatalog[key]); + catalogFiles.forEach(language => { + expect(catalogs[language], `${language}:${key}`).toHaveProperty(key); + expect(placeholdersOf(catalogs[language][key]), `${language}:${key}`).toEqual(expectedPlaceholders); + }); + }); + }); + + it('localizes StarRocks Rollup entry labels while keeping Rollup SQL raw', () => { + expect(tableOperationSource).not.toContain("title: '新增 Rollup'"); + expect(tableOperationSource).toContain("t('sidebar.v2_table_menu.new_rollup'"); + expect(tableOperationSource).toContain("keyword: 'Rollup'"); + expect(tableOperationSource).toContain('ADD ROLLUP rollup_name (column1, column2);'); + }); + + it('keeps reused StarRocks Rollup key in all supported languages with matching placeholders', () => { + const zhCnCatalog = catalogs['zh-CN']; + requiredRollupKeys.forEach(key => { + expect(zhCnCatalog, `zh-CN:${key}`).toHaveProperty(key); + const expectedPlaceholders = placeholdersOf(zhCnCatalog[key]); + catalogFiles.forEach(language => { + expect(catalogs[language], `${language}:${key}`).toHaveProperty(key); + expect(placeholdersOf(catalogs[language][key]), `${language}:${key}`).toEqual(expectedPlaceholders); + }); + }); + }); }); diff --git a/frontend/src/components/TableOverview.tsx b/frontend/src/components/TableOverview.tsx index 35a7e4f..539e0a0 100644 --- a/frontend/src/components/TableOverview.tsx +++ b/frontend/src/components/TableOverview.tsx @@ -9,7 +9,7 @@ import type { TabData } from '../types'; import { useAutoFetchVisibility } from '../utils/autoFetchVisibility'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { noAutoCapInputProps } from '../utils/inputAutoCap'; -import { getTableDataDangerActionMeta, supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; +import { supportsTableTruncateAction, type TableDataDangerActionKind } from './tableDataDangerActions'; import { buildTableSelectQuery } from '../utils/objectQueryTemplates'; import { TABLE_OVERVIEW_RENDER_BATCH_SIZE, @@ -295,7 +295,9 @@ const TableOverview: React.FC = ({ tab }) => { if (res.success && Array.isArray(res.data)) { setTables(parseTableStats(metadataDialect, res.data)); } else { - message.error('获取表信息失败: ' + (res.message || '未知错误')); + message.error(t('table_overview.message.load_tables_failed', { + detail: res.message || t('table_overview.message.unknown_error'), + })); } return; } @@ -315,7 +317,7 @@ const TableOverview: React.FC = ({ tab }) => { } finally { setLoading(false); } - }, [connection, metadataDialect, schemaName, tab.dbName]); + }, [connection, metadataDialect, schemaName, t, tab.dbName]); useEffect(() => { if (!autoFetchVisible) { @@ -444,7 +446,10 @@ const TableOverview: React.FC = ({ tab }) => { const structureOnly = !supportsDesignWrite; addTab({ id: `design-${connection.id}-${tab.dbName}-${tableName}`, - title: `${structureOnly ? '表结构' : '设计表'} (${tableName})`, + title: t( + structureOnly ? 'table_overview.tab.table_structure_title' : 'table_overview.tab.design_table_title', + { table: tableName }, + ), type: 'design', connectionId: connection.id, dbName: tab.dbName, @@ -452,14 +457,14 @@ const TableOverview: React.FC = ({ tab }) => { initialTab: 'columns', readOnly: structureOnly, }); - }, [connection, tab.dbName, addTab, setActiveContext, supportsDesignWrite]); + }, [connection, tab.dbName, addTab, setActiveContext, supportsDesignWrite, t]); const openTableDdl = useCallback((tableName: string) => { if (!connection) return; setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' }); addTab({ id: `design-${connection.id}-${tab.dbName}-${tableName}`, - title: `表结构 (${tableName})`, + title: t('table_overview.tab.table_structure_title', { table: tableName }), type: 'design', connectionId: connection.id, dbName: tab.dbName, @@ -467,20 +472,20 @@ const TableOverview: React.FC = ({ tab }) => { initialTab: 'ddl', readOnly: true, }); - }, [connection, tab.dbName, addTab, setActiveContext]); + }, [connection, tab.dbName, addTab, setActiveContext, t]); const openQueryForTable = useCallback((tableName: string) => { if (!connection) return; setActiveContext({ connectionId: connection.id, dbName: tab.dbName || '' }); addTab({ id: `query-${Date.now()}`, - title: '新建查询', + title: t('table_overview.menu.new_query'), type: 'query', connectionId: connection.id, dbName: tab.dbName, query: buildTableSelectQuery(metadataDialect, tableName), }); - }, [addTab, connection, metadataDialect, setActiveContext, tab.dbName]); + }, [addTab, connection, metadataDialect, setActiveContext, t, tab.dbName]); const openTableInER = useCallback((tableName: string) => { if (!connection) return; @@ -515,38 +520,45 @@ const TableOverview: React.FC = ({ tab }) => { const res = await DBShowCreateTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName); if (res.success) { navigator.clipboard.writeText(res.data as string); - message.success('表结构已复制到剪贴板'); + message.success(t('table_overview.message.copy_structure_success')); } else { - message.error(res.message); + message.error(t('table_overview.message.copy_structure_failed', { + detail: res.message || t('table_overview.message.unknown_error'), + })); } - }, [buildConfig, tab.dbName]); + }, [buildConfig, t, tab.dbName]); const handleCopyTableName = useCallback(async (tableName: string) => { const name = String(tableName || '').trim(); if (!name) { - message.warning('表名为空,无法复制'); + message.warning(t('table_overview.message.copy_table_name_empty')); return; } try { await navigator.clipboard.writeText(name); - message.success('表名已复制到剪贴板'); + message.success(t('table_overview.message.copy_table_name_success')); } catch (e: any) { - message.error('复制表名失败: ' + (e?.message || String(e))); + message.error(t('table_overview.message.copy_table_name_failed', { + detail: e?.message || String(e), + })); } - }, []); + }, [t]); const handleExport = useCallback(async (tableName: string, format: string) => { const config = buildConfig(); if (!config) return; - const hide = message.loading(`正在导出 ${tableName} 为 ${format.toUpperCase()}...`, 0); + const hide = message.loading(t('table_overview.message.exporting_table_format', { + table: tableName, + format: format.toUpperCase(), + }), 0); const res = await ExportTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, format); hide(); if (res.success) { - message.success('导出成功'); + message.success(t('table_overview.message.export_success')); } else if (res.message !== '已取消') { - message.error('导出失败: ' + res.message); + message.error(t('table_overview.message.export_failed', { detail: res.message })); } - }, [buildConfig, tab.dbName]); + }, [buildConfig, t, tab.dbName]); const handleCopyTableAsInsert = useCallback(async (tableName: string) => { await handleExport(tableName, 'sql'); @@ -556,54 +568,63 @@ const TableOverview: React.FC = ({ tab }) => { const config = buildConfig(); if (!config) return; Modal.confirm({ - title: '确认删除表', - content: `确定删除表 "${tableName}" 吗?该操作不可恢复。`, + title: t('table_overview.modal.delete_table.title'), + content: t('table_overview.modal.delete_table.content', { table: tableName }), okButtonProps: { danger: true }, onOk: async () => { const res = await DropTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName); if (res.success) { - message.success('表删除成功'); + message.success(t('table_overview.message.delete_table_success')); loadData(); } else { - message.error('删除失败: ' + res.message); + message.error(t('table_overview.message.delete_table_failed', { detail: res.message })); } }, }); - }, [buildConfig, tab.dbName, loadData]); + }, [buildConfig, loadData, t, tab.dbName]); const handleTableDataDangerAction = useCallback((tableName: string, action: TableDataDangerActionKind) => { const config = buildConfig(); if (!config) return; - const { label, progressLabel } = getTableDataDangerActionMeta(action); + const actionLabel = t(`table_overview.table_data_action.${action}.label`); Modal.confirm({ - title: `确认${label}`, - content: `${label}会永久删除表 "${tableName}" 中的所有数据,操作不可逆,是否继续?`, - okText: '继续', - cancelText: '取消', + title: t('table_overview.modal.table_data_action.title', { action: actionLabel }), + content: t('table_overview.modal.table_data_action.content', { + action: actionLabel, + table: tableName, + }), + okText: t('common.continue'), + cancelText: t('common.cancel'), okButtonProps: { danger: true }, onOk: async () => { const app = (window as any).go.app.App; const methodName = action === 'truncate' ? 'TruncateTables' : 'ClearTables'; - const hide = message.loading(`正在${progressLabel} ${tableName}...`, 0); + const hide = message.loading(t('table_overview.message.table_data_action_loading', { + action: actionLabel, + table: tableName, + }), 0); try { const res = await app[methodName](buildRpcConnectionConfig(config) as any, tab.dbName || '', [tableName]); hide(); if (res.success) { - message.success(`${progressLabel}成功`); + message.success(t('table_overview.message.table_data_action_success', { action: actionLabel })); loadData(); } else { - message.error(`${progressLabel}失败: ${res.message}`); + message.error(t('table_overview.message.table_data_action_failed', { action: actionLabel, detail: res.message })); return Promise.reject(); } } catch (e: any) { hide(); - message.error(`${progressLabel}失败: ${e?.message || String(e)}`); + message.error(t('table_overview.message.table_data_action_failed', { + action: actionLabel, + detail: e?.message || String(e), + })); return Promise.reject(); } }, }); - }, [buildConfig, tab.dbName, loadData]); + }, [buildConfig, loadData, t, tab.dbName]); const toggleOverviewTablePinned = useCallback((tableName: string, pinned?: boolean) => { if (!connection?.id || !tab.dbName || !tableName) return; @@ -623,38 +644,38 @@ const TableOverview: React.FC = ({ tab }) => { }, })); message.success(shouldPin ? t('table_overview.message.pinned') : t('table_overview.message.unpinned')); - }, [connection?.id, pinnedSidebarTables, schemaName, setSidebarTablePinned, tab.dbName]); + }, [connection?.id, pinnedSidebarTables, schemaName, setSidebarTablePinned, t, tab.dbName]); const handleRenameTable = useCallback((tableName: string) => { const config = buildConfig(); if (!config) return; let newName = tableName; Modal.confirm({ - title: '重命名表', + title: t('table_overview.modal.rename_table.title'), content: ( { newName = e.target.value; }} - placeholder="输入新表名" + placeholder={t('table_overview.modal.rename_table.placeholder')} autoFocus style={{ marginTop: 8 }} /> ), onOk: async () => { const trimmed = newName.trim(); - if (!trimmed) { message.error('表名不能为空'); return Promise.reject(); } - if (trimmed === tableName) { message.warning('新旧表名相同'); return; } + if (!trimmed) { message.error(t('table_overview.validation.table_name_required')); return Promise.reject(); } + if (trimmed === tableName) { message.warning(t('table_overview.validation.table_name_unchanged')); return; } const res = await RenameTable(buildRpcConnectionConfig(config) as any, tab.dbName || '', tableName, trimmed); if (res.success) { - message.success('表重命名成功'); + message.success(t('table_overview.message.rename_table_success')); loadData(); } else { - message.error('重命名失败: ' + res.message); + message.error(t('table_overview.message.rename_table_failed', { detail: res.message })); } }, }); - }, [buildConfig, tab.dbName, loadData]); + }, [buildConfig, loadData, t, tab.dbName]); const openCreateStarRocksRollup = useCallback((tableName: string) => { if (!connection) return; @@ -662,20 +683,21 @@ const TableOverview: React.FC = ({ tab }) => { const quotedTable = safeTable.includes('`') ? safeTable : safeTable.split('.').map(part => `\`${part.replace(/`/g, '``')}\``).join('.'); addTab({ id: `query-create-starrocks-rollup-${Date.now()}`, - title: '新增 Rollup', + title: t('sidebar.v2_table_menu.new_rollup', { keyword: 'Rollup' }), type: 'query', connectionId: connection.id, dbName: tab.dbName, query: `ALTER TABLE ${quotedTable}\nADD ROLLUP rollup_name (column1, column2);`, }); - }, [addTab, connection, tab.dbName]); + }, [addTab, connection, t, tab.dbName]); const injectTablePromptToAI = useCallback(async (tableName: string, promptKind: 'explain' | 'query') => { const dbName = tab.dbName || ''; if (!connection?.id || !dbName || !tableName) { - message.warning('当前表缺少连接上下文,无法发送给 AI'); + message.warning(t('sidebar.message.ai_table_context_missing')); return; } + const tableRef = `${dbName}.${tableName}`; let ddl = ''; const config = buildConfig(); @@ -693,13 +715,13 @@ const TableOverview: React.FC = ({ tab }) => { const prompt = promptKind === 'explain' ? [ - `请解释数据表 ${dbName}.${tableName} 的结构和业务含义。`, - '重点说明字段含义、主键/索引、潜在关联关系、典型查询场景和风险点。', + t('sidebar.ai_prompt.explain.intro', { table: tableRef }), + t('sidebar.ai_prompt.explain.detail'), ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '', ].filter(Boolean).join('\n') : [ - `请基于数据表 ${dbName}.${tableName} 生成 3 条常用查询 SQL。`, - '要求包含:数据预览查询、按关键字段过滤查询、一个聚合或统计查询。', + t('sidebar.ai_prompt.query.intro', { table: tableRef }), + t('sidebar.ai_prompt.query.detail'), ddl ? `\n\`\`\`sql\n${ddl}\n\`\`\`` : '', ].filter(Boolean).join('\n'); @@ -882,25 +904,30 @@ const TableOverview: React.FC = ({ tab }) => { ), [activeShortcutPlatform, allowTruncate, connection?.id, handleV2TableContextMenuAction, metadataDialect, pinnedSidebarTables, schemaName, tab.dbName]); const buildLegacyTableContextMenuItems = useCallback((table: TableStatRow): MenuProps['items'] => [ - { key: 'new-query', label: '新建查询', icon: , onClick: () => openQueryForTable(table.name) }, + { key: 'new-query', label: t('table_overview.menu.new_query'), icon: , onClick: () => openQueryForTable(table.name) }, { type: 'divider' }, - { key: 'design-table', label: supportsDesignWrite ? '设计表' : '表结构', icon: , onClick: () => openDesign(table.name) }, - { key: 'copy-table-name', label: '复制表名', icon: , onClick: () => handleCopyTableName(table.name) }, - { key: 'copy-structure', label: '复制表结构', icon: , onClick: () => handleCopyStructure(table.name) }, - { key: 'backup-table', label: '备份表 (SQL)', icon: , onClick: () => handleExport(table.name, 'sql') }, - { key: 'rename-table', label: '重命名表', icon: , onClick: () => handleRenameTable(table.name) }, - { key: 'danger-zone', label: '危险操作', icon: , children: [ - ...(allowTruncate ? [{ key: 'truncate-table', label: '截断表', danger: true, onClick: () => handleTableDataDangerAction(table.name, 'truncate') }] : []), - { key: 'clear-table', label: '清空表', danger: true, onClick: () => handleTableDataDangerAction(table.name, 'clear') }, - { key: 'drop-table', label: '删除表', icon: , danger: true, onClick: () => handleDeleteTable(table.name) }, + { + key: 'design-table', + label: supportsDesignWrite ? t('table_overview.menu.design_table') : t('table_overview.menu.table_structure'), + icon: , + onClick: () => openDesign(table.name), + }, + { key: 'copy-table-name', label: t('table_overview.menu.copy_table_name'), icon: , onClick: () => handleCopyTableName(table.name) }, + { key: 'copy-structure', label: t('table_overview.menu.copy_structure'), icon: , onClick: () => handleCopyStructure(table.name) }, + { key: 'backup-table', label: t('table_overview.menu.backup_table_sql'), icon: , onClick: () => handleExport(table.name, 'sql') }, + { key: 'rename-table', label: t('table_overview.menu.rename_table'), icon: , onClick: () => handleRenameTable(table.name) }, + { key: 'danger-zone', label: t('table_overview.menu.danger_operations'), icon: , children: [ + ...(allowTruncate ? [{ key: 'truncate-table', label: t('table_overview.menu.truncate_table'), danger: true, onClick: () => handleTableDataDangerAction(table.name, 'truncate') }] : []), + { key: 'clear-table', label: t('table_overview.menu.clear_table'), danger: true, onClick: () => handleTableDataDangerAction(table.name, 'clear') }, + { key: 'drop-table', label: t('table_overview.menu.delete_table'), icon: , danger: true, onClick: () => handleDeleteTable(table.name) }, ]}, { type: 'divider' }, - { key: 'export', label: '导出表数据', icon: , children: [ - { key: 'export-csv', label: '导出 CSV', onClick: () => handleExport(table.name, 'csv') }, - { key: 'export-xlsx', label: '导出 Excel (XLSX)', onClick: () => handleExport(table.name, 'xlsx') }, - { key: 'export-json', label: '导出 JSON', onClick: () => handleExport(table.name, 'json') }, - { key: 'export-md', label: '导出 Markdown', onClick: () => handleExport(table.name, 'md') }, - { key: 'export-html', label: '导出 HTML', onClick: () => handleExport(table.name, 'html') }, + { key: 'export', label: t('table_overview.menu.export_table_data'), icon: , children: [ + { key: 'export-csv', label: t('table_overview.menu.export_csv'), onClick: () => handleExport(table.name, 'csv') }, + { key: 'export-xlsx', label: t('table_overview.menu.export_xlsx'), onClick: () => handleExport(table.name, 'xlsx') }, + { key: 'export-json', label: t('table_overview.menu.export_json'), onClick: () => handleExport(table.name, 'json') }, + { key: 'export-md', label: t('table_overview.menu.export_markdown'), onClick: () => handleExport(table.name, 'md') }, + { key: 'export-html', label: t('table_overview.menu.export_html'), onClick: () => handleExport(table.name, 'html') }, ]}, ], [ allowTruncate, @@ -913,6 +940,7 @@ const TableOverview: React.FC = ({ tab }) => { openDesign, openQueryForTable, supportsDesignWrite, + t, ]); const renderOverviewSectionTitle = (section: OverviewTableSection) => { diff --git a/frontend/src/components/TriggerViewer.object-edit.test.tsx b/frontend/src/components/TriggerViewer.object-edit.test.tsx index cbecf87..a39730b 100644 --- a/frontend/src/components/TriggerViewer.object-edit.test.tsx +++ b/frontend/src/components/TriggerViewer.object-edit.test.tsx @@ -1,8 +1,10 @@ import React from 'react'; +import { readFileSync } from 'node:fs'; import { act, create } from 'react-test-renderer'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { TabData } from '../types'; +import { I18nProvider } from '../i18n/provider'; import TriggerViewer from './TriggerViewer'; const storeState = vi.hoisted(() => ({ @@ -34,6 +36,9 @@ vi.mock('../store', () => ({ })); vi.mock('../../wailsjs/go/app/App', () => backendApp); +vi.mock('../i18n/runtime', () => ({ + syncLanguageRuntime: vi.fn(async () => undefined), +})); vi.mock('@ant-design/icons', () => ({ EditOutlined: () => , @@ -64,6 +69,16 @@ const flushPromises = async (count = 6) => { } }; +const renderWithI18n = (tab: TabData) => ( + undefined} + > + + +); + const findButtonText = (node: any): string => ( (node.children || []) .map((item: any) => (typeof item === 'string' ? item : findButtonText(item))) @@ -93,14 +108,37 @@ describe('TriggerViewer object edit entry', () => { }); }); + it('keeps TriggerViewer shell and fallback copy localized', () => { + const source = readFileSync(new URL('./TriggerViewer.tsx', import.meta.url), 'utf8'); + + expect(source).not.toMatch(/DuckDB 不支持触发器|TDengine 不支持触发器|MongoDB 不支持触发器|暂不支持该数据库类型的触发器定义查看/); + expect(source).not.toMatch(/未找到触发器定义|未找到数据库连接|触发器名称为空|查询触发器定义失败/); + expect(source).not.toMatch(/当前 Sphinx 实例|已执行多套兼容查询|返回失败信息: |unknown error/); + expect(source).not.toMatch(/加载触发器定义|加载失败|修改触发器|触发器: |数据库: |对象修改|刷新最新定义失败/); + + expect(source).toContain('trigger_viewer.loading.definition'); + expect(source).toContain('trigger_viewer.error.load_failed'); + expect(source).toContain('trigger_viewer.error.connection_not_found'); + expect(source).toContain('trigger_viewer.error.trigger_name_empty'); + expect(source).toContain('trigger_viewer.error.query_failed'); + expect(source).toContain('trigger_viewer.error.query_failed_detail'); + expect(source).toContain('trigger_viewer.field.trigger'); + expect(source).toContain('trigger_viewer.field.database'); + expect(source).toContain('trigger_viewer.action.edit_object'); + expect(source).toContain('trigger_viewer.warning.refresh_latest_failed'); + expect(source).toContain('trigger_viewer.tab.edit_trigger_title'); + expect(source).toContain('trigger_viewer.editor.unsupported.duckdb'); + expect(source).toContain('trigger_viewer.editor.sphinx.failed_message_unknown'); + }); + it('opens an editable query tab for trigger definitions', async () => { let renderer: any; await act(async () => { - renderer = create(); + renderer = create(renderWithI18n(tab)); await flushPromises(); }); - const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('Edit object'))[0]; await act(async () => { button.props.onClick(); @@ -108,7 +146,7 @@ describe('TriggerViewer object edit entry', () => { expect(storeState.setActiveContext).toHaveBeenCalledWith({ connectionId: 'conn-1', dbName: 'main' }); expect(storeState.addTab).toHaveBeenCalledWith(expect.objectContaining({ - title: '修改触发器: audit.users_bi', + title: 'Edit trigger: audit.users_bi', type: 'query', connectionId: 'conn-1', dbName: 'main', @@ -126,7 +164,7 @@ describe('TriggerViewer object edit entry', () => { let renderer: any; await act(async () => { - renderer = create(); + renderer = create(renderWithI18n(tab)); await flushPromises(); }); @@ -150,11 +188,11 @@ describe('TriggerViewer object edit entry', () => { let renderer: any; await act(async () => { - renderer = create(); + renderer = create(renderWithI18n(tab)); await flushPromises(); }); - const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('Edit object'))[0]; await act(async () => { button.props.onClick(); @@ -178,11 +216,11 @@ describe('TriggerViewer object edit entry', () => { let renderer: any; await act(async () => { - renderer = create(); + renderer = create(renderWithI18n(tab)); await flushPromises(); }); - const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('Edit object'))[0]; await act(async () => { button.props.onClick(); @@ -213,11 +251,11 @@ describe('TriggerViewer object edit entry', () => { let renderer: any; await act(async () => { - renderer = create(); + renderer = create(renderWithI18n(tab)); await flushPromises(); }); - const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('Edit object'))[0]; await act(async () => { button.props.onClick(); @@ -265,16 +303,16 @@ describe('TriggerViewer object edit entry', () => { let renderer: any; await act(async () => { - renderer = create(); + })); await flushPromises(); }); - const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('Edit object'))[0]; await act(async () => { button.props.onClick(); @@ -301,11 +339,11 @@ describe('TriggerViewer object edit entry', () => { let renderer: any; await act(async () => { - renderer = create(); + renderer = create(renderWithI18n(tab)); await flushPromises(); }); - const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('Edit object'))[0]; await act(async () => { await button.props.onClick(); @@ -335,11 +373,11 @@ describe('TriggerViewer object edit entry', () => { let renderer: any; await act(async () => { - renderer = create(); + renderer = create(renderWithI18n(tab)); await flushPromises(); }); - const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('对象修改'))[0]; + const button = renderer.root.findAll((node: any) => node.type === 'button' && findButtonText(node).includes('Edit object'))[0]; await act(async () => { await button.props.onClick(); @@ -348,7 +386,7 @@ describe('TriggerViewer object edit entry', () => { expect(storeState.addTab).not.toHaveBeenCalled(); expect(String(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')[0].children.join(''))).toContain('CREATE TRIGGER users_bi BEFORE INSERT ON audit.users'); - expect(findButtonText(renderer.root)).toContain('刷新最新定义失败'); + expect(findButtonText(renderer.root)).toContain('Failed to refresh the latest definition'); expect(findButtonText(renderer.root)).toContain('refresh failed'); }); @@ -366,21 +404,21 @@ describe('TriggerViewer object edit entry', () => { let renderer: any; await act(async () => { - renderer = create(); + renderer = create(renderWithI18n(tab)); await flushPromises(); }); await act(async () => { - renderer.update(); + })); await flushPromises(); }); - expect(findButtonText(renderer.root)).toContain('加载失败'); + expect(findButtonText(renderer.root)).toContain('Load failed'); expect(findButtonText(renderer.root)).toContain('load failed'); expect(renderer.root.findAll((node: any) => node.props['data-editor'] === 'true')).toHaveLength(0); expect(findButtonText(renderer.root)).not.toContain('CREATE TRIGGER users_bi BEFORE INSERT'); diff --git a/frontend/src/components/TriggerViewer.tsx b/frontend/src/components/TriggerViewer.tsx index 8264bde..e33a94d 100644 --- a/frontend/src/components/TriggerViewer.tsx +++ b/frontend/src/components/TriggerViewer.tsx @@ -10,6 +10,7 @@ import { normalizeOceanBaseProtocol } from '../utils/oceanBaseProtocol'; import { splitQualifiedNameLast } from '../utils/qualifiedName'; import { buildEditableTriggerSql } from '../utils/triggerEditSql'; import { buildSqlServerObjectDefinitionQueries } from '../utils/sqlServerObjectDefinition'; +import { useI18n } from '../i18n/provider'; interface TriggerViewerProps { tab: TabData; @@ -102,6 +103,7 @@ const buildOracleLikeTriggerDDLFromMetadata = ( }; const TriggerViewer: React.FC = ({ tab }) => { + const { t } = useI18n(); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [triggerDefinition, setTriggerDefinition] = useState(''); @@ -156,6 +158,8 @@ const TriggerViewer: React.FC = ({ tab }) => { return driver === 'sphinx' || driver === 'sphinxql'; }; + const commentLine = (key: string, params?: Record): string => `-- ${t(key, params)}`; + const buildShowTriggerQueries = (dialect: string, triggerName: string, dbName: string): string[] => { const { schema, name } = parseSchemaAndName(triggerName); const safeTriggerName = escapeSQLLiteral(name); @@ -208,13 +212,13 @@ LIMIT 1`]; case 'sqlite': return [`SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = '${safeTriggerName}'`]; case 'duckdb': - return [`-- DuckDB 不支持触发器`]; + return [commentLine('trigger_viewer.editor.unsupported.duckdb')]; case 'tdengine': - return [`-- TDengine 不支持触发器`]; + return [commentLine('trigger_viewer.editor.unsupported.tdengine')]; case 'mongodb': - return [`-- MongoDB 不支持触发器`]; + return [commentLine('trigger_viewer.editor.unsupported.mongodb')]; default: - return [`-- 暂不支持该数据库类型的触发器定义查看`]; + return [commentLine('trigger_viewer.editor.unsupported.generic')]; } }; @@ -278,7 +282,7 @@ LIMIT 1`]; const extractTriggerDefinition = (dialect: string, data: any[], fallbackTriggerName: string, fallbackTableName: string): string => { if (!data || data.length === 0) { - return '-- 未找到触发器定义'; + return commentLine('trigger_viewer.editor.definition_not_found'); } const row = data[0] as Record; @@ -353,14 +357,14 @@ LIMIT 1`]; const loadTriggerDefinition = async (): Promise<{ success: boolean; definition?: string; error?: string }> => { const conn = connections.find(c => c.id === tab.connectionId); if (!conn) { - return { success: false, error: '未找到数据库连接' }; + return { success: false, error: t('trigger_viewer.error.connection_not_found') }; } const triggerName = tab.triggerName || ''; const dbName = tab.dbName || ''; if (!triggerName) { - return { success: false, error: '触发器名称为空' }; + return { success: false, error: t('trigger_viewer.error.trigger_name_empty') }; } const dialect = getMetadataDialect(conn); @@ -368,7 +372,7 @@ LIMIT 1`]; const sphinxLike = isSphinxConnection(conn) && dialect === 'mysql'; if (!queries.length || String(queries[0] || '').startsWith('--')) { - return { success: true, definition: String(queries[0] || '-- 暂不支持该数据库类型的触发器定义查看') }; + return { success: true, definition: String(queries[0] || commentLine('trigger_viewer.editor.unsupported.generic')) }; } try { @@ -393,27 +397,42 @@ LIMIT 1`]; if (result.success) { if (sphinxLike) { const version = await getVersionHint(config, dbName); - const versionText = version ? `(版本: ${version})` : ''; + const versionText = version ? t('trigger_viewer.editor.sphinx.version_suffix', { version }) : ''; return { success: true, - definition: `-- 当前 Sphinx 实例${versionText}未返回触发器定义。\n-- 已执行多套兼容查询,可能是版本能力限制或对象类型不支持。` + definition: [ + commentLine('trigger_viewer.editor.sphinx.empty_result', { version: versionText }), + commentLine('trigger_viewer.editor.sphinx.compat_queries_hint'), + ].join('\n'), }; } - return { success: true, definition: '-- 未找到触发器定义' }; + return { success: true, definition: commentLine('trigger_viewer.editor.definition_not_found') }; } if (sphinxLike) { const version = await getVersionHint(config, dbName); - const versionText = version ? `(版本: ${version})` : ''; + const versionText = version ? t('trigger_viewer.editor.sphinx.version_suffix', { version }) : ''; + const failedMessage = result.message + ? `${t('trigger_viewer.editor.sphinx.failed_message_label')}: ${result.message}` + : t('trigger_viewer.editor.sphinx.failed_message_unknown'); return { success: true, - definition: `-- 当前 Sphinx 实例${versionText}不支持触发器定义查询。\n-- 已自动尝试兼容语句,返回失败信息: ${result.message || 'unknown error'}` + definition: [ + commentLine('trigger_viewer.editor.sphinx.unsupported_query', { version: versionText }), + commentLine('trigger_viewer.editor.sphinx.compat_queries_hint'), + `-- ${failedMessage}`, + ].join('\n'), }; } - return { success: false, error: result.message || '查询触发器定义失败' }; + return { + success: false, + error: result.message + ? t('trigger_viewer.error.query_failed_detail', { detail: result.message }) + : t('trigger_viewer.error.query_failed'), + }; } catch (e: any) { - return { success: false, error: '查询触发器定义失败: ' + (e?.message || String(e)) }; + return { success: false, error: t('trigger_viewer.error.query_failed_detail', { detail: e?.message || String(e) }) }; } }; @@ -430,7 +449,7 @@ LIMIT 1`]; loadedDefinitionKeyRef.current = objectIdentityKey; setTriggerDefinition(String(result.definition || '')); } else { - setError(result.error || '查询触发器定义失败'); + setError(result.error || t('trigger_viewer.error.query_failed')); } setLoading(false); }; @@ -440,7 +459,7 @@ LIMIT 1`]; return () => { cancelled = true; }; - }, [tab.connectionId, tab.dbName, tab.triggerName, connections, objectIdentityKey]); + }, [tab.connectionId, tab.dbName, tab.triggerName, connections, objectIdentityKey, t]); useEffect(() => () => { isMountedRef.current = false; @@ -449,7 +468,7 @@ LIMIT 1`]; if (loading) { return (
- +
); } @@ -460,7 +479,7 @@ LIMIT 1`]; if (error && !hasDefinition) { return (
- +
); } @@ -477,7 +496,7 @@ LIMIT 1`]; return; } if (!result.success) { - setError(result.error || '查询触发器定义失败'); + setError(result.error || t('trigger_viewer.error.query_failed')); return; } const latestDefinition = String(result.definition || ''); @@ -486,11 +505,11 @@ LIMIT 1`]; setActiveContext({ connectionId: tab.connectionId, dbName }); addTab({ id: `query-edit-trigger-${tab.connectionId}-${dbName}-${Date.now()}`, - title: `修改触发器: ${triggerName}`, + title: t('trigger_viewer.tab.edit_trigger_title', { name: triggerName }), type: 'query', connectionId: tab.connectionId, dbName, - query: buildEditableTriggerSql(triggerName, latestDefinition), + query: buildEditableTriggerSql(triggerName, latestDefinition, { translate: t }), queryMode: 'object-edit', }); } finally { @@ -504,16 +523,16 @@ LIMIT 1`];
- 触发器: {tab.triggerName} - {tab.dbName && 数据库: {tab.dbName}} + {t('trigger_viewer.field.trigger')}: {tab.triggerName} + {tab.dbName && {t('trigger_viewer.field.database')}: {tab.dbName}}
{error && hasDefinition && (
- +
)}
diff --git a/frontend/src/components/V2TableContextMenu.tsx b/frontend/src/components/V2TableContextMenu.tsx index c2303d7..bde6d3d 100644 --- a/frontend/src/components/V2TableContextMenu.tsx +++ b/frontend/src/components/V2TableContextMenu.tsx @@ -227,7 +227,7 @@ export const V2TableContextMenuView: React.FC<{ { action: 'design-table', icon: , title: `${t('sidebar.menu.design_table')} · ${t('sidebar.v2_table_menu.design_table_detail')}`, kbd: primaryShortcut('D', shortcutPlatform) }, { action: 'open-new-tab', icon: , title: t('sidebar.v2_table_menu.open_in_new_tab'), kbd: primaryShortcut('Enter', shortcutPlatform) }, { action: 'new-query', icon: , title: t('sidebar.menu.new_query') }, - ...(supportsMessagePublish ? [{ action: 'publish-message' as const, icon: , title: '测试发送消息' }] : []), + ...(supportsMessagePublish ? [{ action: 'publish-message' as const, icon: , title: t('message_publish_modal.title') }] : []), ])}
{t('sidebar.v2_table_menu.metadata_section')}
@@ -439,26 +439,28 @@ export const V2SchemaContextMenuView: React.FC<{ } title={schemaName} - meta={`${dbName || '当前数据库'} · 模式操作`} + meta={t('sidebar.v2_schema_menu.meta', { + database: dbName || t('sidebar.v2_table_group_menu.current_database'), + })} pill="SCHEMA" />
-
维护
+
{t('sidebar.v2_table_menu.maintenance_section')}
{renderItems([ - { action: 'rename-schema', icon: , title: '编辑模式', kbd: 'F2', featured: true }, - { action: 'refresh-schema', icon: , title: '刷新对象树', kbd: primaryShortcut('R', shortcutPlatform) }, + { action: 'rename-schema', icon: , title: t('sidebar.v2_schema_menu.edit_schema'), kbd: 'F2', featured: true }, + { action: 'refresh-schema', icon: , title: t('sidebar.v2_database_menu.refresh_object_tree'), kbd: primaryShortcut('R', shortcutPlatform) }, ])} -
导出与备份
+
{t('sidebar.v2_database_menu.export_backup_section')}
{renderItems([ - { action: 'export-schema', icon: , title: '导出当前模式表结构 · SQL' }, - { action: 'backup-schema-sql', icon: , title: '备份当前模式全部表 · 结构 + 数据' }, + { action: 'export-schema', icon: , title: t('sidebar.v2_schema_menu.export_current_schema_sql') }, + { action: 'backup-schema-sql', icon: , title: t('sidebar.v2_schema_menu.backup_current_schema_sql') }, ])}
{renderItems([ - { action: 'drop-schema', icon: , title: '删除模式 · DROP CASCADE', tone: 'danger', kbd: '⌫' }, + { action: 'drop-schema', icon: , title: t('sidebar.v2_schema_menu.delete_schema_cascade'), tone: 'danger', kbd: '⌫' }, ])}
diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx index 0114b11..b69c984 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.test.tsx @@ -151,7 +151,7 @@ describe('AIBuiltinToolsCatalog', () => { expect(markup).toContain('messageLimit'); expect(markup).toContain('治理前端大文件'); expect(markup).toContain('inspect_codebase_hotspots'); - expect(markup).toContain('大文件和拆分热点'); + expect(markup).toContain('前端大文件与拆分热点'); expect(markup).toContain('复用历史 SQL'); expect(markup).toContain('inspect_saved_queries'); expect(markup).toContain('回看 AI 历史对话'); diff --git a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx index 5c110b9..288c175 100644 --- a/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx +++ b/frontend/src/components/ai/AIBuiltinToolsCatalog.tsx @@ -2,13 +2,13 @@ import React, { useState } from 'react'; import { SearchOutlined, ToolOutlined } from '@ant-design/icons'; import { - BUILTIN_TOOL_FLOWS, describeBuiltinToolParameters, filterBuiltinToolFlows, filterBuiltinTools, + localizeBuiltinToolFlows, } from '../../utils/aiBuiltinToolCatalog'; import { useI18n } from '../../i18n/provider'; -import { BUILTIN_AI_TOOL_INFO } from '../../utils/aiToolRegistry'; +import { localizeBuiltinAIToolInfo } from '../../utils/aiToolRegistry'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; interface AIBuiltinToolsCatalogProps { @@ -26,8 +26,10 @@ export const AIBuiltinToolsCatalog: React.FC = ({ }) => { const { t } = useI18n(); const [searchText, setSearchText] = useState(''); - const visibleFlows = filterBuiltinToolFlows(BUILTIN_TOOL_FLOWS, searchText); - const visibleTools = filterBuiltinTools(BUILTIN_AI_TOOL_INFO, searchText); + const builtinToolFlows = localizeBuiltinToolFlows(t); + const builtinToolInfo = localizeBuiltinAIToolInfo(t); + const visibleFlows = filterBuiltinToolFlows(builtinToolFlows, searchText); + const visibleTools = filterBuiltinTools(builtinToolInfo, searchText); return (
@@ -79,9 +81,9 @@ export const AIBuiltinToolsCatalog: React.FC = ({
{t('ai_settings.tools.summary', { flowVisible: visibleFlows.length, - flowTotal: BUILTIN_TOOL_FLOWS.length, + flowTotal: builtinToolFlows.length, toolVisible: visibleTools.length, - toolTotal: BUILTIN_AI_TOOL_INFO.length, + toolTotal: builtinToolInfo.length, })}
{visibleFlows.length > 0 && ( diff --git a/frontend/src/components/ai/AIChatAttachmentStrip.i18n.test.tsx b/frontend/src/components/ai/AIChatAttachmentStrip.i18n.test.tsx new file mode 100644 index 0000000..12ca59d --- /dev/null +++ b/frontend/src/components/ai/AIChatAttachmentStrip.i18n.test.tsx @@ -0,0 +1,216 @@ +import { readFileSync } from 'node:fs'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '../../i18n/provider'; +import AIChatAttachmentStrip from './AIChatAttachmentStrip'; +import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; + +vi.mock('../../i18n/runtime', () => ({ + syncLanguageRuntime: vi.fn(async () => undefined), +})); + +vi.mock('@ant-design/icons', async () => { + const React = await import('react'); + const makeIcon = (name: string) => () => React.createElement('span', { 'data-icon': name }); + return { + FileTextOutlined: makeIcon('file-text'), + WarningOutlined: makeIcon('warning'), + }; +}); + +const source = readFileSync(new URL('./AIChatAttachmentStrip.tsx', import.meta.url), 'utf8'); +const zhCnCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-CN.json', import.meta.url), 'utf8')); +const zhTwCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-TW.json', import.meta.url), 'utf8')); +const enUsCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/en-US.json', import.meta.url), 'utf8')); +const jaJpCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ja-JP.json', import.meta.url), 'utf8')); +const deDeCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/de-DE.json', import.meta.url), 'utf8')); +const ruRuCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ru-RU.json', import.meta.url), 'utf8')); + +const renderAttachmentStrip = ( + variant: 'legacy' | 'v2', + attachments: Array>, + preference: 'en-US' | 'zh-CN' = 'en-US', +) => renderToStaticMarkup( + undefined} + > + undefined} + overlayTheme={buildOverlayWorkbenchTheme(false)} + variant={variant} + /> + , +); + +const renderAttachmentStripWithoutProvider = ( + variant: 'legacy' | 'v2', + attachments: Array>, +) => renderToStaticMarkup( + undefined} + overlayTheme={buildOverlayWorkbenchTheme(false)} + variant={variant} + />, +); + +describe('AIChatAttachmentStrip i18n source guards', () => { + it('uses i18n keys instead of legacy Chinese remove aria labels', () => { + expect(source).toContain('useOptionalI18n()'); + expect(source).toContain("catalogTranslate('en-US', key, params)"); + expect(source).toContain("ai_chat.input.attachment.remove_file"); + expect(source).toContain("ai_chat.input.attachment.remove_image"); + expect(source).toContain("ai_chat.input.attachment.kind.text"); + expect(source).toContain("ai_chat.input.attachment.kind.image"); + expect(source).toContain("ai_chat.input.attachment.kind.file"); + expect(source).toContain("ai_chat.message.image_alt"); + expect(source).not.toContain('aria-label="移除附件"'); + expect(source).not.toContain('aria-label="移除图片"'); + expect(source).not.toContain('alt={`Draft ${index}`}'); + expect(source).not.toContain("return 'Text';"); + expect(source).not.toContain("return 'Image';"); + expect(source).not.toContain("return 'File';"); + }); + + it('keeps required attachment aria-label keys present in all six catalogs', () => { + const requiredKeys = [ + 'ai_chat.input.attachment.remove_file', + 'ai_chat.input.attachment.remove_image', + 'ai_chat.input.attachment.kind.text', + 'ai_chat.input.attachment.kind.image', + 'ai_chat.input.attachment.kind.file', + 'ai_chat.message.image_alt', + ]; + for (const key of requiredKeys) { + expect(zhCnCatalog[key]).toBeTruthy(); + expect(zhTwCatalog[key]).toBeTruthy(); + expect(enUsCatalog[key]).toBeTruthy(); + expect(jaJpCatalog[key]).toBeTruthy(); + expect(deDeCatalog[key]).toBeTruthy(); + expect(ruRuCatalog[key]).toBeTruthy(); + } + }); + + it('renders localized remove aria labels and attachment kind labels while preserving raw attachment names', () => { + const fileAttachment = [{ + id: 'file-1', + name: 'orders.csv', + kind: 'text', + size: 128, + mimeType: 'text/csv', + }]; + const genericAttachment = [{ + id: 'file-2', + name: 'dump.bin', + kind: 'document', + size: 64, + mimeType: 'application/octet-stream', + }]; + const imageAttachment = [{ + id: 'image-1', + name: 'draft.png', + kind: 'image', + size: 256, + mimeType: 'image/png', + dataUrl: 'data:image/png;base64,AA==', + }]; + const imageWithoutPreviewAttachment = [{ + id: 'image-2', + name: 'screenshot.png', + kind: 'image', + size: 96, + mimeType: 'image/png', + }]; + + const legacyFileMarkup = renderAttachmentStrip('legacy', fileAttachment); + const legacyImageMarkup = renderAttachmentStrip('legacy', imageAttachment); + const v2FileMarkup = renderAttachmentStrip('v2', fileAttachment); + const v2ImageMarkup = renderAttachmentStrip('v2', imageAttachment); + const zhLegacyFileMarkup = renderAttachmentStrip('legacy', fileAttachment, 'zh-CN'); + const zhV2FileMarkup = renderAttachmentStrip('v2', fileAttachment, 'zh-CN'); + const zhLegacyGenericMarkup = renderAttachmentStrip('legacy', genericAttachment, 'zh-CN'); + const zhV2GenericMarkup = renderAttachmentStrip('v2', genericAttachment, 'zh-CN'); + const zhLegacyImageNoPreviewMarkup = renderAttachmentStrip('legacy', imageWithoutPreviewAttachment, 'zh-CN'); + const zhV2ImageNoPreviewMarkup = renderAttachmentStrip('v2', imageWithoutPreviewAttachment, 'zh-CN'); + + expect(legacyFileMarkup).toContain('aria-label="Remove attachment"'); + expect(v2FileMarkup).toContain('aria-label="Remove attachment"'); + expect(legacyImageMarkup).toContain('aria-label="Remove image"'); + expect(v2ImageMarkup).toContain('aria-label="Remove image"'); + expect(legacyImageMarkup).toContain('alt="Attached image 0"'); + expect(v2ImageMarkup).toContain('alt="Attached image 0"'); + expect(legacyFileMarkup).toContain('orders.csv'); + expect(v2FileMarkup).toContain('orders.csv'); + expect(zhLegacyFileMarkup).toContain('文本'); + expect(zhV2FileMarkup).toContain('文本'); + expect(zhLegacyGenericMarkup).toContain('文件'); + expect(zhV2GenericMarkup).toContain('文件'); + expect(zhLegacyImageNoPreviewMarkup).toContain('图片'); + expect(zhV2ImageNoPreviewMarkup).toContain('图片'); + }); + + it('falls back to English attachment labels without an i18n provider while preserving raw names', () => { + const fileAttachment = [{ + id: 'file-1', + name: 'orders.csv', + kind: 'text', + size: 128, + mimeType: 'text/csv', + }]; + const genericAttachment = [{ + id: 'file-2', + name: 'dump.bin', + kind: 'document', + size: 64, + mimeType: 'application/octet-stream', + }]; + const imageAttachment = [{ + id: 'image-1', + name: 'draft.png', + kind: 'image', + size: 256, + mimeType: 'image/png', + dataUrl: 'data:image/png;base64,AA==', + }]; + const imageWithoutPreviewAttachment = [{ + id: 'image-2', + name: 'screenshot.png', + kind: 'image', + size: 96, + mimeType: 'image/png', + }]; + + expect(() => renderAttachmentStripWithoutProvider('legacy', fileAttachment)).not.toThrow(); + expect(() => renderAttachmentStripWithoutProvider('v2', imageAttachment)).not.toThrow(); + + const legacyFileMarkup = renderAttachmentStripWithoutProvider('legacy', fileAttachment); + const legacyImageMarkup = renderAttachmentStripWithoutProvider('legacy', imageAttachment); + const v2FileMarkup = renderAttachmentStripWithoutProvider('v2', fileAttachment); + const v2ImageMarkup = renderAttachmentStripWithoutProvider('v2', imageAttachment); + const legacyGenericMarkup = renderAttachmentStripWithoutProvider('legacy', genericAttachment); + const v2GenericMarkup = renderAttachmentStripWithoutProvider('v2', genericAttachment); + const legacyImageNoPreviewMarkup = renderAttachmentStripWithoutProvider('legacy', imageWithoutPreviewAttachment); + const v2ImageNoPreviewMarkup = renderAttachmentStripWithoutProvider('v2', imageWithoutPreviewAttachment); + + expect(legacyFileMarkup).toContain('aria-label="Remove attachment"'); + expect(v2FileMarkup).toContain('aria-label="Remove attachment"'); + expect(legacyImageMarkup).toContain('aria-label="Remove image"'); + expect(v2ImageMarkup).toContain('aria-label="Remove image"'); + expect(legacyImageMarkup).toContain('alt="Attached image 0"'); + expect(v2ImageMarkup).toContain('alt="Attached image 0"'); + expect(legacyFileMarkup).toContain('orders.csv'); + expect(v2FileMarkup).toContain('orders.csv'); + expect(legacyFileMarkup).toContain('Text'); + expect(v2FileMarkup).toContain('Text'); + expect(legacyGenericMarkup).toContain('File'); + expect(v2GenericMarkup).toContain('File'); + expect(legacyImageNoPreviewMarkup).toContain('Image'); + expect(v2ImageNoPreviewMarkup).toContain('Image'); + expect(legacyFileMarkup).not.toContain('ai_chat.input.attachment.remove_file'); + }); +}); diff --git a/frontend/src/components/ai/AIChatAttachmentStrip.tsx b/frontend/src/components/ai/AIChatAttachmentStrip.tsx index 52ba5d6..040d8cc 100644 --- a/frontend/src/components/ai/AIChatAttachmentStrip.tsx +++ b/frontend/src/components/ai/AIChatAttachmentStrip.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { FileTextOutlined, WarningOutlined } from '@ant-design/icons'; import type { AIChatAttachment } from '../../types'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import { formatAIChatAttachmentSize } from './aiChatAttachments'; @@ -12,32 +14,40 @@ interface AIChatAttachmentStripProps { variant: 'legacy' | 'v2'; } -const formatAttachmentKind = (attachment: AIChatAttachment): string => { +type AttachmentKindLabels = { + text: string; + image: string; + file: string; +}; + +const formatAttachmentKind = (attachment: AIChatAttachment, labels: AttachmentKindLabels): string => { if (attachment.kind === 'markdown') return 'MD'; if (attachment.kind === 'pdf') return 'PDF'; if (attachment.kind === 'word') return 'Word'; if (attachment.kind === 'excel') return 'Excel'; - if (attachment.kind === 'text') return 'Text'; - if (attachment.kind === 'image') return 'Image'; - return 'File'; + if (attachment.kind === 'text') return labels.text; + if (attachment.kind === 'image') return labels.image; + return labels.file; }; const AttachmentFileChip: React.FC<{ attachment: AIChatAttachment; + attachmentKindLabels: AttachmentKindLabels; onRemove: () => void; overlayTheme: OverlayWorkbenchTheme; + removeAriaLabel: string; variant: 'legacy' | 'v2'; -}> = ({ attachment, onRemove, overlayTheme, variant }) => { +}> = ({ attachment, attachmentKindLabels, onRemove, overlayTheme, removeAriaLabel, variant }) => { if (variant === 'v2') { return (
{attachment.name} - {formatAttachmentKind(attachment)} · {formatAIChatAttachmentSize(attachment.size)} + {formatAttachmentKind(attachment, attachmentKindLabels)} · {formatAIChatAttachmentSize(attachment.size)} {attachment.extractWarning ? : null} - +
); } @@ -63,13 +73,13 @@ const AttachmentFileChip: React.FC<{ {attachment.name} - {formatAttachmentKind(attachment)} + {formatAttachmentKind(attachment, attachmentKindLabels)} {attachment.extractWarning ? : null} @@ -107,7 +129,9 @@ export const AIChatAttachmentStrip: React.FC = ({ onRemove(index)} /> @@ -122,11 +146,11 @@ export const AIChatAttachmentStrip: React.FC = ({ {attachments.map((attachment, index) => ( attachment.kind === 'image' && attachment.dataUrl ? (
- {`Draft + {buildImageAlt(index)} @@ -67,13 +75,13 @@ export const AIChatContextPreview: React.FC = ({ onClick={onOpenContext} > - 添加 + {t('ai_chat.input.context.add')}
{contextExpanded && activeContextItems.length > 0 && (
-
当前上下文 · {activeContextItems.length}
+
{currentContextCount}
{renderContextTableChips(activeContextItems, onRemoveContext, 'gn-v2-ai-context-table-chip', { margin: 0 })}
)} @@ -92,7 +100,7 @@ export const AIChatContextPreview: React.FC = ({ style={{ background: darkMode ? 'rgba(24, 144, 255, 0.15)' : 'rgba(24, 144, 255, 0.08)', border: 'none', color: '#1890ff', borderRadius: 12, padding: '4px 10px', display: 'flex', alignItems: 'center', gap: 4, margin: 0, cursor: 'pointer', transition: 'all 0.3s' }} > - 关联上下文 ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'} + {contextLabel} ({activeContextItems.length}) {contextExpanded ? '▴' : '▾'} {contextExpanded && renderContextTableChips( diff --git a/frontend/src/components/ai/AIChatHeader.i18n.test.tsx b/frontend/src/components/ai/AIChatHeader.i18n.test.tsx index 4ab224b..0817c9d 100644 --- a/frontend/src/components/ai/AIChatHeader.i18n.test.tsx +++ b/frontend/src/components/ai/AIChatHeader.i18n.test.tsx @@ -62,6 +62,7 @@ const headerKeys = [ 'ai_chat.header.action.export', 'ai_chat.header.export_time', 'ai_chat.header.export_user', + 'app.theme.ui_version.v2.badge', ] as const; type HeaderKey = (typeof headerKeys)[number]; @@ -81,6 +82,7 @@ const placeholderExpectations: Record = { 'ai_chat.header.action.export': [], 'ai_chat.header.export_time': [], 'ai_chat.header.export_user': [], + 'app.theme.ui_version.v2.badge': [], }; const valuesExpectedToDifferFromEnglish = [ @@ -151,6 +153,22 @@ const renderHeader = async ( return renderer!; }; +const renderHeaderWithoutProvider = (overrides: Partial> = {}) => + create( + {}} + onClear={() => {}} + onSettingsClick={() => {}} + onClose={() => {}} + messages={messages} + {...overrides} + />, + ); + describe('AIChatHeader i18n', () => { beforeEach(() => { vi.unstubAllGlobals(); @@ -196,6 +214,7 @@ describe('AIChatHeader i18n', () => { expect(v2Text).toContain('Auto insights'); expect(v2Text).toContain('History'); expect(v2Text).toContain('Export'); + expect(v2Text).toContain('Beta'); expect(v2Text).not.toContain('已连接'); expect(v2Text).not.toContain('自动洞察'); }); @@ -208,6 +227,29 @@ describe('AIChatHeader i18n', () => { expect(pageText).not.toContain('新对话 · 已连接'); }); + it('falls back to English header chrome when no i18n provider is mounted', () => { + expect(() => renderHeaderWithoutProvider({ isV2Ui: false, sessionTitle: undefined })).not.toThrow(); + expect(() => renderHeaderWithoutProvider({ isV2Ui: true, sessionTitle: 'prod/main.orders' })).not.toThrow(); + + const legacyText = textContent(renderHeaderWithoutProvider({ isV2Ui: false, sessionTitle: undefined }).toJSON()); + expect(legacyText).toContain('Chat history'); + expect(legacyText).toContain('Export as Markdown'); + expect(legacyText).toContain('New chat (clear current)'); + expect(legacyText).toContain('AI settings'); + expect(legacyText).toContain('Close panel'); + expect(legacyText).not.toContain('ai_chat.header.tooltip.history'); + + const v2Text = textContent(renderHeaderWithoutProvider({ isV2Ui: true, sessionTitle: 'prod/main.orders' }).toJSON()); + expect(v2Text).toContain('prod/main.orders · Connected'); + expect(v2Text).toContain('AI work mode'); + expect(v2Text).toContain('Chat'); + expect(v2Text).toContain('Auto insights'); + expect(v2Text).toContain('History'); + expect(v2Text).toContain('Export'); + expect(v2Text).toContain('Beta'); + expect(v2Text).not.toContain('ai_chat.header.mode.chat'); + }); + it('exports Markdown with localized chrome while preserving raw title and message content', async () => { const createdBlobs: Blob[] = []; const anchor = { @@ -253,11 +295,59 @@ describe('AIChatHeader i18n', () => { expect(markdown).not.toContain('导出时间'); }); + it('exports Markdown with English fallback labels when no i18n provider is mounted', async () => { + const createdBlobs: Blob[] = []; + const anchor = { + click: vi.fn(), + href: '', + download: '', + }; + + vi.stubGlobal('document', { + createElement: vi.fn(() => anchor), + body: { + getAttribute: vi.fn(() => null), + appendChild: vi.fn(), + removeChild: vi.fn(), + }, + }); + vi.stubGlobal('URL', { + createObjectURL: vi.fn((blob: Blob) => { + createdBlobs.push(blob); + return 'blob:markdown'; + }), + revokeObjectURL: vi.fn(), + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = renderHeaderWithoutProvider({ isV2Ui: true, sessionTitle: undefined }); + }); + + const exportButton = renderer!.root.findByProps({ className: 'gn-v2-ai-export-button' }); + + await act(async () => { + exportButton.props.onClick(); + }); + + expect(anchor.click).toHaveBeenCalledTimes(1); + expect(anchor.download).toBe('New chat.md'); + expect(createdBlobs).toHaveLength(1); + + const markdown = await createdBlobs[0].text(); + expect(markdown).toContain('# New chat'); + expect(markdown).toContain('> Exported at:'); + expect(markdown).toContain('## 👤 You'); + expect(markdown).toContain('## 🤖 GoNavi AI'); + expect(markdown).not.toContain('ai_chat.header.export_time'); + }); + it('keeps source wired to i18n keys instead of fixed Chinese header chrome', () => { for (const key of headerKeys) { expect(source).toContain(key); } expect(source).toContain("t('ai_chat.panel.session.default_title')"); + expect(source).toContain("app.theme.ui_version.v2.badge"); expect(source).not.toContain('历史会话'); expect(source).not.toContain('导出为 Markdown'); expect(source).not.toContain('新对话 (清空当前)'); @@ -266,6 +356,7 @@ describe('AIChatHeader i18n', () => { expect(source).not.toContain('AI 工作模式'); expect(source).not.toContain('自动洞察'); expect(source).not.toContain('导出时间'); + expect(source).not.toContain('BETA'); }); it('translates only the connected wrapper and keeps title parameter raw', () => { diff --git a/frontend/src/components/ai/AIChatHeader.tsx b/frontend/src/components/ai/AIChatHeader.tsx index c444b10..976fa1c 100644 --- a/frontend/src/components/ai/AIChatHeader.tsx +++ b/frontend/src/components/ai/AIChatHeader.tsx @@ -3,7 +3,8 @@ import { Button, Tooltip } from 'antd'; import { HistoryOutlined, RobotOutlined, ClearOutlined, SettingOutlined, CloseOutlined, ExportOutlined, PlusOutlined, ThunderboltOutlined } from '@ant-design/icons'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import type { AIChatMessage } from '../../types'; -import { useI18n } from '../../i18n/provider'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; interface AIChatHeaderProps { darkMode: boolean; @@ -56,7 +57,9 @@ export const AIChatHeader: React.FC = ({ activeMode = 'chat', onModeChange, }) => { - const { t } = useI18n(); + const i18n = useOptionalI18n(); + const t = i18n?.t ?? ((key: string, params?: Record) => + catalogTranslate('en-US', key, params)); const resolvedSessionTitle = sessionTitle === undefined || sessionTitle === '' ? t('ai_chat.panel.session.default_title') : sessionTitle; @@ -108,7 +111,7 @@ export const AIChatHeader: React.FC = ({ GoNavi AI {t('ai_chat.header.session.connected', { title: resolvedSessionTitle })}
- BETA + {t('app.theme.ui_version.v2.badge')}
diff --git a/frontend/src/components/ai/AIChatInput.i18n.test.tsx b/frontend/src/components/ai/AIChatInput.i18n.test.tsx new file mode 100644 index 0000000..1467940 --- /dev/null +++ b/frontend/src/components/ai/AIChatInput.i18n.test.tsx @@ -0,0 +1,291 @@ +import { readFileSync } from 'node:fs'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '../../i18n/provider'; +import { AIChatInput } from './AIChatInput'; +import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; + +vi.mock('../../store', () => ({ + useStore: (selector: (state: any) => any) => selector({ + aiContexts: {}, + addAIContext: vi.fn(), + removeAIContext: vi.fn(), + }), +})); + +vi.mock('../../../wailsjs/go/app/App', () => ({ + DBGetTables: vi.fn(), + DBShowCreateTable: vi.fn(), + DBGetDatabases: vi.fn(), + DBGetColumns: vi.fn(), +})); + +vi.mock('../../i18n/runtime', () => ({ + syncLanguageRuntime: vi.fn(async () => undefined), +})); + +vi.mock('antd', async () => { + const React = await import('react'); + const actual = await vi.importActual('antd'); + return { + ...actual, + Tooltip: ({ + title, + children, + }: { + title?: React.ReactNode; + children?: React.ReactNode; + }) => React.createElement( + 'div', + { 'data-tooltip-title': typeof title === 'string' ? title : undefined }, + children, + ), + }; +}); + +const source = readFileSync(new URL('./AIChatInput.tsx', import.meta.url), 'utf8'); +const draftAttachmentsHookSource = readFileSync(new URL('./useAIChatDraftAttachments.ts', import.meta.url), 'utf8'); +const zhCnCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-CN.json', import.meta.url), 'utf8')); +const zhTwCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-TW.json', import.meta.url), 'utf8')); +const enUsCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/en-US.json', import.meta.url), 'utf8')); +const jaJpCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ja-JP.json', import.meta.url), 'utf8')); +const deDeCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/de-DE.json', import.meta.url), 'utf8')); +const ruRuCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ru-RU.json', import.meta.url), 'utf8')); + +const baseProvider = { + id: 'provider-1', + type: 'openai' as const, + name: 'OpenAI 主账号', + apiKey: '', + hasSecret: true, + baseUrl: 'https://api.openai.com/v1', + model: '', + models: [] as string[], + maxTokens: 32000, + temperature: 0.2, +}; + +const renderAIChatInput = ( + language: 'zh-CN' | 'zh-TW' | 'en-US' | 'ja-JP' | 'de-DE' | 'ru-RU', + overrides: Partial> = {}, +) => renderToStaticMarkup( + undefined} + > + {}} + draftAttachments={[]} + setDraftAttachments={() => {}} + sending={false} + onSend={() => {}} + onStop={() => {}} + handleKeyDown={() => {}} + activeConnName="" + activeContext={null} + activeProvider={baseProvider} + dynamicModels={[]} + loadingModels={false} + sendShortcutBinding={{ combo: 'Enter', enabled: true }} + composerNotice={null} + onComposerAction={() => {}} + onModelChange={() => {}} + onFetchModels={() => {}} + textareaRef={React.createRef()} + darkMode={false} + textColor="#162033" + mutedColor="rgba(16,24,40,0.55)" + overlayTheme={buildOverlayWorkbenchTheme(false)} + isV2Ui + {...overrides} + /> + , +); + +const renderAIChatInputWithoutProvider = ( + overrides: Partial> = {}, +) => renderToStaticMarkup( + {}} + draftAttachments={[]} + setDraftAttachments={() => {}} + sending={false} + onSend={() => {}} + onStop={() => {}} + handleKeyDown={() => {}} + activeConnName="" + activeContext={null} + activeProvider={baseProvider} + dynamicModels={[]} + loadingModels={false} + sendShortcutBinding={{ combo: 'Enter', enabled: true }} + composerNotice={null} + onComposerAction={() => {}} + onModelChange={() => {}} + onFetchModels={() => {}} + textareaRef={React.createRef()} + darkMode={false} + textColor="#162033" + mutedColor="rgba(16,24,40,0.55)" + overlayTheme={buildOverlayWorkbenchTheme(false)} + isV2Ui + {...overrides} + />, +); + +describe('AIChatInput i18n source guards', () => { + it('uses i18n keys instead of legacy Chinese placeholder literals', () => { + expect(source).toContain('useOptionalI18n()'); + expect(source).toContain("catalogTranslate('en-US', key, params)"); + expect(source).toContain("ai_chat.input.placeholder"); + expect(source).toContain("ai_chat.input.placeholder_compact"); + expect(source).toContain("ai_chat.input.shortcut.send_with_combo"); + expect(source).toContain("ai_chat.input.shortcut.disabled"); + expect(source).toContain("ai_chat.input.context.connection_tooltip"); + expect(source).toContain("ai_chat.input.context.memory_tooltip"); + expect(source).toMatch(/useAIChatDraftAttachments\(\{\s*[\s\S]*translate:\s*t,/); + expect(source).toMatch(/useAISlashCommandMenu\(\{\s*[\s\S]*translate:\s*t,/); + expect(draftAttachmentsHookSource).toContain('createAIChatAttachmentFromFile(file, translate)'); + expect(source).not.toContain('placeholder={`输入消息... ('); + expect(source).not.toContain('placeholder={`输入消息... ${getAIChatSendShortcutLabel'); + expect(source).not.toContain('当前数据查询上下文'); + expect(source).not.toContain('当前会话记忆已用字符。达到限制('); + }); + + it('keeps required placeholder keys present in all six catalogs', () => { + const requiredKeys = [ + 'ai_chat.input.placeholder', + 'ai_chat.input.placeholder_compact', + 'ai_chat.input.shortcut.send_with_combo', + 'ai_chat.input.shortcut.disabled', + 'ai_chat.input.context.connection_tooltip', + 'ai_chat.input.context.memory_tooltip', + ]; + for (const key of requiredKeys) { + expect(zhCnCatalog[key]).toBeTruthy(); + expect(zhTwCatalog[key]).toBeTruthy(); + expect(enUsCatalog[key]).toBeTruthy(); + expect(jaJpCatalog[key]).toBeTruthy(); + expect(deDeCatalog[key]).toBeTruthy(); + expect(ruRuCatalog[key]).toBeTruthy(); + } + }); + + it('renders localized legacy and v2 placeholders in en-US with the dynamic shortcut label', () => { + const legacyMarkup = renderAIChatInput('en-US', { + isV2Ui: false, + sendShortcutBinding: { combo: 'Meta+Enter', enabled: true }, + shortcutPlatform: 'mac', + }); + const v2Markup = renderAIChatInput('en-US', { + isV2Ui: true, + sendShortcutBinding: { combo: 'Meta+Enter', enabled: true }, + shortcutPlatform: 'mac', + }); + + expect(legacyMarkup).toContain('placeholder="Type a message... (⌘↵ to send, Shift+Enter for newline, / for commands)"'); + expect(v2Markup).toContain('placeholder="Type a message... ⌘↵ to send · / commands"'); + }); + + it('renders the disabled shortcut placeholder copy instead of falling back to Enter', () => { + const markup = renderAIChatInput('en-US', { + isV2Ui: false, + sendShortcutBinding: { combo: 'Enter', enabled: false }, + }); + + expect(markup).toContain('Shortcut sending disabled'); + expect(markup).not.toContain('Enter to send'); + }); + + it('renders localized connection context tooltips in en-US while preserving raw connection context', () => { + const legacyMarkup = renderAIChatInput('en-US', { + isV2Ui: false, + activeConnName: 'orders-db', + activeContext: { connectionId: 'conn-1', dbName: 'analytics' }, + }); + const v2Markup = renderAIChatInput('en-US', { + isV2Ui: true, + activeConnName: 'orders-db', + activeContext: { connectionId: 'conn-1', dbName: 'analytics' }, + }); + + expect(legacyMarkup).toContain('data-tooltip-title="Current data query context"'); + expect(v2Markup).toContain('data-tooltip-title="Current data query context"'); + expect(legacyMarkup).toContain('orders-db'); + expect(v2Markup).toContain('orders-db / analytics'); + }); + + it('renders localized memory usage tooltips in en-US while preserving the raw limit label', () => { + const legacyMarkup = renderAIChatInput('en-US', { + isV2Ui: false, + contextUsageChars: 12800, + maxContextChars: 32000, + }); + const v2Markup = renderAIChatInput('en-US', { + isV2Ui: true, + contextUsageChars: 12800, + maxContextChars: 32000, + }); + + expect(legacyMarkup).toContain('data-tooltip-title="Current session memory usage. Auto-compression starts when it reaches the 32k limit."'); + expect(v2Markup).toContain('data-tooltip-title="Current session memory usage. Auto-compression starts when it reaches the 32k limit."'); + expect(legacyMarkup).toContain('12.8k / 32k'); + expect(v2Markup).toContain('12.8k/32k'); + }); + + it('falls back to English placeholders and tooltips without an i18n provider while preserving raw connection context', () => { + expect(() => renderAIChatInputWithoutProvider({ + isV2Ui: false, + activeConnName: 'orders-db', + activeContext: { connectionId: 'conn-1', dbName: 'analytics' }, + contextUsageChars: 12800, + maxContextChars: 32000, + sendShortcutBinding: { combo: 'Meta+Enter', enabled: true }, + shortcutPlatform: 'mac', + })).not.toThrow(); + expect(() => renderAIChatInputWithoutProvider({ + isV2Ui: true, + activeConnName: 'orders-db', + activeContext: { connectionId: 'conn-1', dbName: 'analytics' }, + contextUsageChars: 12800, + maxContextChars: 32000, + sendShortcutBinding: { combo: 'Meta+Enter', enabled: true }, + shortcutPlatform: 'mac', + })).not.toThrow(); + + const legacyMarkup = renderAIChatInputWithoutProvider({ + isV2Ui: false, + activeConnName: 'orders-db', + activeContext: { connectionId: 'conn-1', dbName: 'analytics' }, + contextUsageChars: 12800, + maxContextChars: 32000, + sendShortcutBinding: { combo: 'Meta+Enter', enabled: true }, + shortcutPlatform: 'mac', + }); + const v2Markup = renderAIChatInputWithoutProvider({ + isV2Ui: true, + activeConnName: 'orders-db', + activeContext: { connectionId: 'conn-1', dbName: 'analytics' }, + contextUsageChars: 12800, + maxContextChars: 32000, + sendShortcutBinding: { combo: 'Meta+Enter', enabled: true }, + shortcutPlatform: 'mac', + }); + + expect(legacyMarkup).toContain('placeholder="Type a message... (⌘↵ to send, Shift+Enter for newline, / for commands)"'); + expect(v2Markup).toContain('placeholder="Type a message... ⌘↵ to send · / commands"'); + expect(legacyMarkup).toContain('data-tooltip-title="Current data query context"'); + expect(v2Markup).toContain('data-tooltip-title="Current data query context"'); + expect(legacyMarkup).toContain('data-tooltip-title="Current session memory usage. Auto-compression starts when it reaches the 32k limit."'); + expect(v2Markup).toContain('data-tooltip-title="Current session memory usage. Auto-compression starts when it reaches the 32k limit."'); + expect(legacyMarkup).toContain('orders-db / analytics'); + expect(v2Markup).toContain('orders-db / analytics'); + expect(legacyMarkup).not.toContain('ai_chat.input.placeholder'); + expect(v2Markup).not.toContain('ai_chat.input.context.connection_tooltip'); + }); +}); diff --git a/frontend/src/components/ai/AIChatInput.notice.test.tsx b/frontend/src/components/ai/AIChatInput.notice.test.tsx index a7d5b08..89ba5fc 100644 --- a/frontend/src/components/ai/AIChatInput.notice.test.tsx +++ b/frontend/src/components/ai/AIChatInput.notice.test.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it, vi } from 'vitest'; +import type { I18nParams } from '../../i18n/types'; import { AIChatInput } from './AIChatInput'; import AIChatComposerStatus from './AIChatComposerStatus'; import { buildAIChatReadinessSnapshot } from './aiChatReadiness'; @@ -22,6 +23,20 @@ vi.mock('../../../wailsjs/go/app/App', () => ({ DBGetColumns: vi.fn(), })); +vi.mock('../../i18n/provider', async () => { + const { t } = await import('../../i18n/catalog'); + const mockedI18n = { + language: 'zh-CN', + preference: 'zh-CN', + setPreference: () => undefined, + t: (key: string, params?: I18nParams) => t('zh-CN', key, params), + }; + return { + useI18n: () => mockedI18n, + useOptionalI18n: () => mockedI18n, + }; +}); + const baseProvider = { id: 'provider-1', type: 'openai' as const, diff --git a/frontend/src/components/ai/AIChatInput.tsx b/frontend/src/components/ai/AIChatInput.tsx index ef8fcfb..47ef546 100644 --- a/frontend/src/components/ai/AIChatInput.tsx +++ b/frontend/src/components/ai/AIChatInput.tsx @@ -5,8 +5,9 @@ import { useStore } from '../../store'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import type { AIComposerNotice, AIComposerNoticeAction } from '../../utils/aiComposerNotice'; import type { AIProviderConfig } from '../../types'; -import { getAIChatSendShortcutLabel } from '../../utils/aiChatSendShortcut'; -import type { ShortcutPlatform, ShortcutPlatformBinding } from '../../utils/shortcuts'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; +import { DEFAULT_SHORTCUT_OPTIONS, getShortcutDisplayLabel, type ShortcutPlatform, type ShortcutPlatformBinding } from '../../utils/shortcuts'; import AIContextSelectorModal from './AIContextSelectorModal'; import AISlashCommandMenu from './AISlashCommandMenu'; import AIChatComposerNotice from './AIChatComposerNotice'; @@ -58,6 +59,9 @@ export const AIChatInput: React.FC = ({ onModelChange, onFetchModels, textareaRef, darkMode, textColor, mutedColor, overlayTheme, contextUsageChars, maxContextChars, isV2Ui = false }) => { + const i18n = useOptionalI18n(); + const t = i18n?.t ?? ((key: string, params?: Record) => + catalogTranslate('en-US', key, params)); const aiContexts = useStore(state => state.aiContexts); const addAIContext = useStore(state => state.addAIContext); const removeAIContext = useStore(state => state.removeAIContext); @@ -70,7 +74,8 @@ export const AIChatInput: React.FC = ({ loadingModels, activeContext, activeContextItems, - }), [activeProvider, dynamicModels, loadingModels, activeContext, activeContextItems]); + translate: t, + }), [activeProvider, dynamicModels, loadingModels, activeContext, activeContextItems, t]); const composerStatusKey = React.useMemo(() => [ composerReadiness.status, composerReadiness.activeProvider?.id || '', @@ -122,6 +127,7 @@ export const AIChatInput: React.FC = ({ connectionKey, addAIContext, removeAIContext, + translate: t, }); const { @@ -131,6 +137,7 @@ export const AIChatInput: React.FC = ({ handleRemoveDraftAttachment, } = useAIChatDraftAttachments({ setDraftAttachments, + translate: t, }); const { @@ -142,6 +149,7 @@ export const AIChatInput: React.FC = ({ } = useAISlashCommandMenu({ setInput, textareaRef, + translate: t, }); const handleComposerNoticeAction = React.useCallback(() => { @@ -149,6 +157,22 @@ export const AIChatInput: React.FC = ({ onComposerAction(composerNotice.action.key); } }, [composerNotice?.action?.key, onComposerAction]); + const sendShortcutLabel = React.useMemo(() => { + if (sendShortcutBinding?.enabled === false) { + return t('ai_chat.input.shortcut.disabled'); + } + const combo = sendShortcutBinding?.combo || DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage.windows.combo; + return t('ai_chat.input.shortcut.send_with_combo', { + shortcut: getShortcutDisplayLabel(combo, shortcutPlatform), + }); + }, [sendShortcutBinding?.combo, sendShortcutBinding?.enabled, shortcutPlatform, t]); + const connectionTooltipLabel = t('ai_chat.input.context.connection_tooltip'); + const memoryLimitLabel = maxContextChars !== undefined + ? `${(maxContextChars / 1000).toFixed(0)}k` + : ''; + const memoryTooltipLabel = memoryLimitLabel + ? t('ai_chat.input.context.memory_tooltip', { limit: memoryLimitLabel }) + : ''; const composerActionHandler = typeof onComposerAction === 'function' ? onComposerAction : undefined; const composerNoticeActionHandler = composerNotice?.action?.key && composerActionHandler ? handleComposerNoticeAction @@ -222,7 +246,7 @@ export const AIChatInput: React.FC = ({ value={input} onChange={(e) => handleComposerInputChange(e.target.value)} onKeyDown={handleKeyDown as any} - placeholder={`输入消息... (${getAIChatSendShortcutLabel(sendShortcutBinding, shortcutPlatform)},Shift+Enter 换行,/ 快捷命令)`} + placeholder={t('ai_chat.input.placeholder', { shortcut: sendShortcutLabel })} variant="borderless" autoSize={{ minRows: 1, maxRows: 8 }} style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }} @@ -231,7 +255,7 @@ export const AIChatInput: React.FC = ({
{activeConnName && ( - +
= ({ /> {contextUsageChars !== undefined && maxContextChars !== undefined && ( - +
= ({ value={input} onChange={(e) => handleComposerInputChange(e.target.value)} onKeyDown={handleKeyDown as any} - placeholder={`输入消息... ${getAIChatSendShortcutLabel(sendShortcutBinding, shortcutPlatform)} · / 命令`} + placeholder={t('ai_chat.input.placeholder_compact', { shortcut: sendShortcutLabel })} variant="borderless" autoSize={{ minRows: 1, maxRows: 8 }} style={{ color: textColor, width: '100%', padding: 0, resize: 'none' }} @@ -400,7 +424,7 @@ export const AIChatInput: React.FC = ({
{activeConnName && ( - +
@@ -421,7 +445,7 @@ export const AIChatInput: React.FC = ({
{contextUsageChars !== undefined && maxContextChars !== undefined && ( - +
maxContextChars * 0.8 ? ' is-warn' : ''}`}>
@@ -107,11 +122,14 @@ const AIMCPClientInstallPanel: React.FC = ({
- {getMCPClientDetectionSummary(selectedStatus)} + {getMCPClientDetectionSummary(selectedStatus, t)} {!selectedIsRemoteClient && ( <> {' '} - 已经接入当前这份 GoNavi 时,下面的主按钮会自动禁用,避免重复写入。 + {copy( + 'ai_chat.mcp_client.install.repeat_avoidance', + 'When already connected to this GoNavi, the main button is disabled to avoid repeated writes.', + )} )}
@@ -122,7 +140,7 @@ const AIMCPClientInstallPanel: React.FC = ({ disabled={Boolean(selectedStatus?.matchesCurrent)} style={{ borderRadius: 10, fontWeight: 600, width: 208, maxWidth: '100%', height: 40 }} > - {resolveMCPClientInstallActionLabel(selectedStatus)} + {resolveMCPClientInstallActionLabel(selectedStatus, t)}
diff --git a/frontend/src/components/ai/AIMCPClientSelectorPanel.test.tsx b/frontend/src/components/ai/AIMCPClientSelectorPanel.test.tsx index 65729a3..6660e19 100644 --- a/frontend/src/components/ai/AIMCPClientSelectorPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPClientSelectorPanel.test.tsx @@ -39,15 +39,15 @@ describe('AIMCPClientSelectorPanel', () => { />, ); - expect(markup).toContain('接入外部客户端'); - expect(markup).toContain('选择目标客户端'); - expect(markup).toContain('写入或复制配置'); - expect(markup).toContain('重启或配置目标端'); + expect(markup).toContain('Connect external client'); + expect(markup).toContain('Choose target client'); + expect(markup).toContain('Write or copy config'); + expect(markup).toContain('Restart or configure target'); expect(markup).toContain('Codex'); - expect(markup).toContain('已接入'); + expect(markup).toContain('Connected'); expect(markup).toContain('OpenClaw'); - expect(markup).toContain('远程桥接'); - expect(markup).toContain('当前已选中,将复制远程接入说明'); - expect(markup).toContain('云端 Agent'); + expect(markup).toContain('Remote bridge'); + expect(markup).toContain('Selected. The remote connection guide will be copied'); + expect(markup).toContain('cloud Agents'); }); }); diff --git a/frontend/src/components/ai/AIMCPClientSelectorPanel.tsx b/frontend/src/components/ai/AIMCPClientSelectorPanel.tsx index 57e47d7..1818140 100644 --- a/frontend/src/components/ai/AIMCPClientSelectorPanel.tsx +++ b/frontend/src/components/ai/AIMCPClientSelectorPanel.tsx @@ -7,11 +7,13 @@ import { isRemoteMCPClientStatus, type MCPClientKey, } from '../../utils/mcpClientInstallStatus'; +import { useOptionalI18n } from '../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import { getMCPClientInstallStateLabel, getMCPClientOptionSummary, getMCPClientStatusTone, + translateMCPClientInstallCopy, } from './mcpClientInstallPanelState'; interface AIMCPClientSelectorPanelProps { @@ -25,9 +27,27 @@ interface AIMCPClientSelectorPanelProps { } const MCP_CLIENT_INSTALL_STEPS = [ - { step: '1', title: '选择目标客户端', detail: '本机 Claude/Codex 可自动安装,OpenClaw/Hermans 走远程接入说明。' }, - { step: '2', title: '写入或复制配置', detail: '自动安装只改用户级 MCP 配置;远程 Agent 复制桥接说明。' }, - { step: '3', title: '重启或配置目标端', detail: '本机 CLI 重启后验证;云端 Agent 配置远程 MCP 地址后验证。' }, + { + step: '1', + titleKey: 'ai_chat.mcp_client.install.selector.step.target.title', + titleFallback: 'Choose target client', + detailKey: 'ai_chat.mcp_client.install.selector.step.target.detail', + detailFallback: 'Local Claude/Codex can be installed automatically. OpenClaw/Hermans use remote connection guidance.', + }, + { + step: '2', + titleKey: 'ai_chat.mcp_client.install.selector.step.write.title', + titleFallback: 'Write or copy config', + detailKey: 'ai_chat.mcp_client.install.selector.step.write.detail', + detailFallback: 'Automatic install only changes user-level MCP config. Remote Agents copy bridge guidance.', + }, + { + step: '3', + titleKey: 'ai_chat.mcp_client.install.selector.step.restart.title', + titleFallback: 'Restart or configure target', + detailKey: 'ai_chat.mcp_client.install.selector.step.restart.detail', + detailFallback: 'Restart the local CLI to verify. Cloud Agents verify after configuring the remote MCP URL.', + }, ]; const AIMCPClientSelectorPanel: React.FC = ({ @@ -38,12 +58,26 @@ const AIMCPClientSelectorPanel: React.FC = ({ cardBorder, statusLoading, onSelectClient, -}) => ( - <> +}) => { + const i18n = useOptionalI18n(); + const t = i18n?.t; + const copy = ( + key: string, + fallback: string, + params?: Record, + ) => translateMCPClientInstallCopy(t, key, fallback, params); + + return ( + <>
-
接入外部客户端
+
+ {copy('ai_chat.mcp_client.install.selector.title', 'Connect external client')} +
- 先选择 1 个目标客户端。本机 CLI 可自动写入或更新配置;远程 Agent 需要通过 MCP 桥接/隧道访问当前 GoNavi,不应保存数据库连接密码。 + {copy( + 'ai_chat.mcp_client.install.selector.description', + 'Choose one target client first. Local CLIs can write or update config automatically; remote Agents must access current GoNavi through an MCP bridge or tunnel and should not store database passwords.', + )}
= ({ > {item.step}
-
{item.title}
+
+ {copy(item.titleKey, item.titleFallback)} +
+
+
+ {copy(item.detailKey, item.detailFallback)}
-
{item.detail}
))}
-
选择外部客户端
+
+ {copy('ai_chat.mcp_client.install.selector.choice_title', 'Select external client')} +
{statuses.map((status) => { const client = isMCPClientKey(status.client) ? status.client : 'claude-code'; const remoteClient = isRemoteMCPClientStatus(status); const active = selectedClient === client; - const tone = getMCPClientStatusTone(status, darkMode); + const tone = getMCPClientStatusTone(status, darkMode, t); return (
- {getMCPClientOptionSummary(status)} + {getMCPClientOptionSummary(status, t)}
- {getMCPClientInstallStateLabel(status)} + {getMCPClientInstallStateLabel(status, t)}
{active - ? (remoteClient ? '当前已选中,将复制远程接入说明。' : '当前已选中,将只对这个客户端执行写入或更新。') - : (remoteClient ? '点击后查看远程接入方式。' : '点击后切换到这个客户端。')} + ? (remoteClient + ? copy('ai_chat.mcp_client.install.selector.hint.active_remote', 'Selected. The remote connection guide will be copied.') + : copy('ai_chat.mcp_client.install.selector.hint.active_local', 'Selected. Only this client will be written or updated.')) + : (remoteClient + ? copy('ai_chat.mcp_client.install.selector.hint.inactive_remote', 'Click to view the remote connection method.') + : copy('ai_chat.mcp_client.install.selector.hint.inactive_local', 'Click to switch to this client.'))}
); @@ -181,6 +225,7 @@ const AIMCPClientSelectorPanel: React.FC = ({
-); + ); +}; export default AIMCPClientSelectorPanel; diff --git a/frontend/src/components/ai/AIMCPClientStatusPanel.test.tsx b/frontend/src/components/ai/AIMCPClientStatusPanel.test.tsx index 2576bd6..0937016 100644 --- a/frontend/src/components/ai/AIMCPClientStatusPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPClientStatusPanel.test.tsx @@ -30,15 +30,17 @@ describe('AIMCPClientStatusPanel', () => { />, ); - expect(markup).toContain('已选客户端状态'); - expect(markup).toContain('当前目标客户端:Hermans'); - expect(markup).toContain('需要通过远程 MCP 桥接调用当前 GoNavi'); - expect(markup).toContain('远程接入边界'); - expect(markup).toContain('不注册 execute_sql'); - expect(markup).toContain('Hermans 远程 MCP 快速配置'); - expect(markup).toContain('CLI 检测:远程 Agent 不需要检测本机 hermans 命令'); - expect(markup).toContain('刷新状态'); - expect(markup).toContain('复制配置路径'); - expect(markup).toContain('复制启动命令'); + expect(markup).toContain('Selected client status'); + expect(markup).toContain('Current target client: Hermans'); + expect(markup).toContain('needs a remote MCP bridge to call this GoNavi'); + expect(markup).toContain('Remote connection boundary'); + expect(markup).toContain('--schema-only does not register execute_sql by default'); + expect(markup).toContain('Hermans Remote MCP quick setup'); + expect(markup).toContain('Configure in cloud Agent'); + expect(markup).toContain('CLI detection: Remote Agent does not need local hermans command detection'); + expect(markup).toContain('Hermans 这类远程 Agent 请通过远程 MCP 桥接接入 Windows GoNavi,不要复制数据库密码。'); + expect(markup).toContain('Refresh status'); + expect(markup).toContain('Copy config path'); + expect(markup).toContain('Copy launch command'); }); }); diff --git a/frontend/src/components/ai/AIMCPClientStatusPanel.tsx b/frontend/src/components/ai/AIMCPClientStatusPanel.tsx index 399e0e9..0f5662a 100644 --- a/frontend/src/components/ai/AIMCPClientStatusPanel.tsx +++ b/frontend/src/components/ai/AIMCPClientStatusPanel.tsx @@ -7,6 +7,7 @@ import { buildRemoteMCPClientQuickStart, isRemoteMCPClientStatus, } from '../../utils/mcpClientInstallStatus'; +import { useOptionalI18n } from '../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import AIMCPRemoteQuickStartPanel from './AIMCPRemoteQuickStartPanel'; import { @@ -14,6 +15,7 @@ import { getMCPClientStatusTone, getSelectedMCPClientStateLine, resolveMCPClientCommandName, + translateMCPClientInstallCopy, } from './mcpClientInstallPanelState'; interface AIMCPClientStatusPanelProps { @@ -39,9 +41,16 @@ const AIMCPClientStatusPanel: React.FC = ({ onCopyConfigPath, onCopyLaunchCommand, }) => { + const i18n = useOptionalI18n(); + const t = i18n?.t; + const copy = ( + key: string, + fallback: string, + params?: Record, + ) => translateMCPClientInstallCopy(t, key, fallback, params); const selectedIsRemoteClient = isRemoteMCPClientStatus(selectedStatus); const remoteQuickStart = selectedIsRemoteClient - ? buildRemoteMCPClientQuickStart(selectedStatus) + ? buildRemoteMCPClientQuickStart(selectedStatus, t) : null; return ( @@ -58,33 +67,46 @@ const AIMCPClientStatusPanel: React.FC = ({ >
- 已选客户端状态 + {copy('ai_chat.mcp_client.install.status.title', 'Selected client status')}
- 当前目标客户端:{selectedStatus?.displayName || '未选择客户端'} + {copy( + selectedStatus?.displayName + ? 'ai_chat.mcp_client.install.status.current_target' + : 'ai_chat.mcp_client.install.status.no_client', + selectedStatus?.displayName ? 'Current target client: {{label}}' : 'No client selected', + { label: selectedStatus?.displayName || '' }, + )}
- {getMCPClientStatusSummary(selectedStatus)} + {getMCPClientStatusSummary(selectedStatus, t)}
{selectedStatus && ( + (() => { + const tone = getMCPClientStatusTone(selectedStatus, darkMode, t); + return (
- {getMCPClientStatusTone(selectedStatus, darkMode).label} + {tone.label}
+ ); + })() )}
- 当前状态:{getSelectedMCPClientStateLine(selectedStatus)} + {copy('ai_chat.mcp_client.install.status.current_state', 'Current status: {{status}}', { + status: getSelectedMCPClientStateLine(selectedStatus, t), + })}
{selectedIsRemoteClient && (
= ({ lineHeight: 1.7, }} > - 远程接入边界:数据库连接信息和密码仍保存在 Windows GoNavi;云端 Agent 默认通过 schema-only MCP 工具读取连接摘要、库表和 DDL,不注册 execute_sql。跨机器接入请使用 GoNavi Streamable HTTP 模式,并配合 token、隧道或反向代理。 + {copy( + 'ai_chat.mcp_client.install.status.remote_boundary', + 'Remote connection boundary: database connection info and passwords stay in Windows GoNavi. Cloud Agents read connection summaries, tables, and DDL through schema-only MCP tools by default, and execute_sql is not registered. For cross-machine access, use GoNavi Streamable HTTP mode with a token, tunnel, or reverse proxy.', + )}
)} {remoteQuickStart && ( @@ -110,28 +135,32 @@ const AIMCPClientStatusPanel: React.FC = ({ /> )}
- CLI 检测:{selectedIsRemoteClient - ? `远程 Agent 不需要检测本机 ${resolveMCPClientCommandName(selectedStatus)} 命令` + {copy('ai_chat.mcp_client.install.status.cli_prefix', 'CLI detection: {{status}}', { + status: selectedIsRemoteClient + ? copy('ai_chat.mcp_client.install.status.cli.remote', 'Remote Agent does not need local {{command}} command detection', { command: resolveMCPClientCommandName(selectedStatus) }) : selectedStatus?.clientDetected - ? `已检测到 ${resolveMCPClientCommandName(selectedStatus)}` - : `未检测到 ${resolveMCPClientCommandName(selectedStatus)},仍可先写配置`} + ? copy('ai_chat.mcp_client.install.status.cli.detected', 'Detected {{command}}', { command: resolveMCPClientCommandName(selectedStatus) }) + : copy('ai_chat.mcp_client.install.status.cli.not_detected', '{{command}} was not detected; config can still be written first', { command: resolveMCPClientCommandName(selectedStatus) }), + })}
{selectedStatus?.clientPath && (
- 命令路径:{selectedStatus.clientPath} + {copy('ai_chat.mcp_client.install.status.command_path', 'Command path: {{path}}', { path: selectedStatus.clientPath })}
)}
- 检测结果:{selectedStatus?.message || '未检测到接入状态'} + {copy('ai_chat.mcp_client.install.status.detection_result', 'Detection result: {{message}}', { + message: selectedStatus?.message || copy('ai_chat.mcp_client.install.status.detection_missing', 'No connection status detected'), + })}
{selectedStatus?.configPath && (
- 配置文件:{selectedStatus.configPath} + {copy('ai_chat.mcp_client.install.status.config_file', 'Config file: {{path}}', { path: selectedStatus.configPath })}
)} {selectedCommandText && (
- 启动命令:{selectedCommandText} + {copy('ai_chat.mcp_client.install.status.launch_command', 'Launch command: {{command}}', { command: selectedCommandText })}
)}
@@ -142,7 +171,7 @@ const AIMCPClientStatusPanel: React.FC = ({ onClick={onRefreshStatus} style={{ borderRadius: 8 }} > - 刷新状态 + {copy('ai_chat.mcp_client.install.status.refresh', 'Refresh status')}
diff --git a/frontend/src/components/ai/AIMCPCommandDraftPreview.test.tsx b/frontend/src/components/ai/AIMCPCommandDraftPreview.test.tsx index 214f96a..a4ce3fc 100644 --- a/frontend/src/components/ai/AIMCPCommandDraftPreview.test.tsx +++ b/frontend/src/components/ai/AIMCPCommandDraftPreview.test.tsx @@ -1,12 +1,112 @@ +import { readFileSync } from 'node:fs'; import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import AIMCPCommandDraftPreview from './AIMCPCommandDraftPreview'; +import { I18nProvider } from '../../i18n/provider'; import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +vi.mock('../../i18n/runtime', () => ({ + syncLanguageRuntime: vi.fn(async () => undefined), +})); + +const source = readFileSync(new URL('./AIMCPCommandDraftPreview.tsx', import.meta.url), 'utf8'); +const zhCnCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-CN.json', import.meta.url), 'utf8')); +const zhTwCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-TW.json', import.meta.url), 'utf8')); +const enUsCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/en-US.json', import.meta.url), 'utf8')); +const jaJpCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ja-JP.json', import.meta.url), 'utf8')); +const deDeCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/de-DE.json', import.meta.url), 'utf8')); +const ruRuCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ru-RU.json', import.meta.url), 'utf8')); + +const REQUIRED_KEYS = [ + 'ai_settings.mcp_server.command_preview.title', + 'ai_settings.mcp_server.command_preview.description', + 'ai_settings.mcp_server.command_preview.env_title', + 'ai_settings.mcp_server.command_preview.env_count', + 'ai_settings.mcp_server.command_preview.env_empty', + 'ai_settings.mcp_server.command_preview.empty_value', + 'ai_settings.mcp_server.command_preview.command_title', + 'ai_settings.mcp_server.command_preview.command_hint', + 'ai_settings.mcp_server.command_preview.args_title', + 'ai_settings.mcp_server.command_preview.args_count', + 'ai_settings.mcp_server.command_preview.args_empty', +]; + +const renderPreview = (preference: 'en-US' | 'zh-CN') => renderToStaticMarkup( + undefined} + > + + , +); + describe('AIMCPCommandDraftPreview', () => { - it('renders parsed env keys, command, and args so users can verify the split result before applying', () => { + it('uses catalog keys instead of hard-coded Chinese preview chrome', () => { + expect(source).toContain('useOptionalI18n()'); + expect(source).toContain("catalogTranslate('en-US'"); + for (const key of REQUIRED_KEYS) { + expect(source).toContain(key); + } + 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 command preview keys present in all six catalogs', () => { + for (const key of REQUIRED_KEYS) { + expect(zhCnCatalog[key]).toBeTruthy(); + expect(zhTwCatalog[key]).toBeTruthy(); + expect(enUsCatalog[key]).toBeTruthy(); + expect(jaJpCatalog[key]).toBeTruthy(); + expect(deDeCatalog[key]).toBeTruthy(); + expect(ruRuCatalog[key]).toBeTruthy(); + } + }); + + it('renders localized preview chrome while preserving raw command, args, and env keys', () => { + const enMarkup = renderPreview('en-US'); + const zhMarkup = renderPreview('zh-CN'); + + expect(enMarkup).toContain('Auto split preview'); + expect(enMarkup).toContain('Environment variables'); + expect(enMarkup).toContain('Startup command'); + expect(enMarkup).toContain('Command arguments'); + expect(enMarkup).toContain('Will write 2 environment variables.'); + expect(enMarkup).toContain('Will split into 3 separate argument tags.'); + + expect(zhMarkup).toContain('自动拆分预览'); + expect(zhMarkup).toContain('环境变量'); + expect(zhMarkup).toContain('启动命令'); + expect(zhMarkup).toContain('命令参数'); + + for (const markup of [enMarkup, zhMarkup]) { + expect(markup).toContain('OPENAI_API_KEY'); + expect(markup).toContain('HTTP_PROXY'); + expect(markup).toContain('python'); + expect(markup).toContain('your_mcp_server'); + expect(markup).toContain('--stdio'); + } + }); + + it('falls back to English without an i18n provider', () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain('自动拆分预览'); - expect(markup).toContain('环境变量'); + expect(markup).toContain('Auto split preview'); + expect(markup).toContain('Environment variables'); expect(markup).toContain('OPENAI_API_KEY'); expect(markup).toContain('HTTP_PROXY'); - expect(markup).toContain('启动命令'); + expect(markup).toContain('Startup command'); expect(markup).toContain('python'); - expect(markup).toContain('命令参数'); + expect(markup).toContain('Command arguments'); expect(markup).toContain('your_mcp_server'); expect(markup).toContain('--stdio'); }); diff --git a/frontend/src/components/ai/AIMCPCommandDraftPreview.tsx b/frontend/src/components/ai/AIMCPCommandDraftPreview.tsx index 4c7d8b7..5f7c138 100644 --- a/frontend/src/components/ai/AIMCPCommandDraftPreview.tsx +++ b/frontend/src/components/ai/AIMCPCommandDraftPreview.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import type { ParsedMCPCommandDraft } from '../../utils/mcpCommandDraft'; @@ -37,6 +39,11 @@ const AIMCPCommandDraftPreview: React.FC = ({ overlayTheme, cardBorder, }) => { + const i18n = useOptionalI18n(); + const copy = ( + key: string, + params?: Record, + ) => (i18n?.t ?? ((catalogKey, catalogParams) => catalogTranslate('en-US', catalogKey, catalogParams)))(key, params); const envKeys = Object.keys(draft.env || {}); return ( @@ -52,42 +59,56 @@ const AIMCPCommandDraftPreview: React.FC = ({ }} >
-
自动拆分预览
+
+ {copy('ai_settings.mcp_server.command_preview.title')} +
- 点击“自动拆分到下方字段”后,会把这份解析结果写进服务名称下面的启动配置区域。 + {copy('ai_settings.mcp_server.command_preview.description')}
-
环境变量
+
+ {copy('ai_settings.mcp_server.command_preview.env_title')} +
- {envKeys.length > 0 ? `会写入 ${envKeys.length} 条环境变量。` : '这条命令里没有检测到前缀环境变量。'} + {envKeys.length > 0 + ? copy('ai_settings.mcp_server.command_preview.env_count', { count: envKeys.length }) + : copy('ai_settings.mcp_server.command_preview.env_empty')}
{envKeys.length > 0 ? envKeys.map((key) => ( {key} - )) : } + )) : {copy('ai_settings.mcp_server.command_preview.empty_value')}}
-
启动命令
-
这里只会保留真正的可执行程序本身。
+
+ {copy('ai_settings.mcp_server.command_preview.command_title')} +
+
+ {copy('ai_settings.mcp_server.command_preview.command_hint')} +
{draft.command}
-
命令参数
+
+ {copy('ai_settings.mcp_server.command_preview.args_title')} +
- {draft.args.length > 0 ? `会拆成 ${draft.args.length} 个独立参数标签。` : '这条命令里没有检测到额外参数。'} + {draft.args.length > 0 + ? copy('ai_settings.mcp_server.command_preview.args_count', { count: draft.args.length }) + : copy('ai_settings.mcp_server.command_preview.args_empty')}
{draft.args.length > 0 ? draft.args.map((arg) => ( {arg} - )) : } + )) : {copy('ai_settings.mcp_server.command_preview.empty_value')}}
diff --git a/frontend/src/components/ai/AIMCPEnvHints.test.tsx b/frontend/src/components/ai/AIMCPEnvHints.test.tsx new file mode 100644 index 0000000..3a53e4a --- /dev/null +++ b/frontend/src/components/ai/AIMCPEnvHints.test.tsx @@ -0,0 +1,180 @@ +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; + +import { I18nProvider } from '../../i18n/provider'; +import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +import AIMCPEnvHints from './AIMCPEnvHints'; + +vi.mock('../../i18n/runtime', () => ({ + syncLanguageRuntime: vi.fn(async () => undefined), +})); + +const source = readFileSync(new URL('./AIMCPEnvHints.tsx', import.meta.url), 'utf8'); +const zhCnCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-CN.json', import.meta.url), 'utf8')); +const zhTwCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-TW.json', import.meta.url), 'utf8')); +const enUsCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/en-US.json', import.meta.url), 'utf8')); +const jaJpCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ja-JP.json', import.meta.url), 'utf8')); +const deDeCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/de-DE.json', import.meta.url), 'utf8')); +const ruRuCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ru-RU.json', import.meta.url), 'utf8')); + +const REQUIRED_KEYS = [ + 'ai_settings.mcp_server.env_hints.category.secret', + 'ai_settings.mcp_server.env_hints.category.endpoint', + 'ai_settings.mcp_server.env_hints.category.proxy', + 'ai_settings.mcp_server.env_hints.category.path', + 'ai_settings.mcp_server.env_hints.category.runtime', + 'ai_settings.mcp_server.env_hints.category.generic', + 'ai_settings.mcp_server.env_hints.title', + 'ai_settings.mcp_server.env_hints.summary', + 'ai_settings.mcp_server.env_hints.recognized', + 'ai_settings.mcp_server.env_hints.value_hint_prefix', + 'ai_settings.mcp_server.env_hints.empty_value', + 'ai_settings.mcp_server.env_hints.placeholder_value', + 'ai_settings.mcp_server.env_hints.warning_prefix', + 'ai_settings.mcp_server.env_hints.next_actions', + 'ai_settings.mcp_server.env_hints.action_separator', +]; + +const SHELL_CHINESE_LITERALS = [ + '密钥', + '地址', + '代理', + '路径', + '运行时', + '自定义', + '环境变量用途提示', + '已识别', + '个像密钥', + '这里只解释 key 的用途和风险', + '应填:', + '当前值为空', + '当前像示例占位值', + '注意:', + '下一步:', +]; + +const flattenRendererText = (node: any): string => { + if (node == null || typeof node === 'boolean') { + return ''; + } + if (typeof node === 'string' || typeof node === 'number') { + return String(node); + } + if (Array.isArray(node)) { + return node.map((item) => flattenRendererText(item)).join(''); + } + return flattenRendererText(node.children ?? node.props?.children); +}; + +const renderHints = ( + element: React.ReactElement, + preference?: 'zh-CN' | 'en-US', +) => { + if (!preference) { + return element; + } + return ( + undefined} + > + {element} + + ); +}; + +describe('AIMCPEnvHints', () => { + it('keeps environment hint shell copy in catalogs instead of source literals', () => { + expect(source).toContain('useOptionalI18n()'); + expect(source).toContain("catalogTranslate('en-US'"); + for (const key of REQUIRED_KEYS) { + expect(source).toContain(key); + } + for (const literal of SHELL_CHINESE_LITERALS) { + expect(source).not.toContain(literal); + } + }); + + it('keeps environment hint keys present in all six catalogs with matching placeholders', () => { + const catalogs = [zhCnCatalog, zhTwCatalog, enUsCatalog, jaJpCatalog, deDeCatalog, ruRuCatalog]; + const placeholders = (value: string) => [...value.matchAll(/\{\{([^}]+)\}\}/g)].map((match) => match[1]).sort(); + for (const key of REQUIRED_KEYS) { + const base = placeholders(enUsCatalog[key]); + for (const catalog of catalogs) { + expect(catalog[key]).toBeTruthy(); + expect(placeholders(catalog[key])).toEqual(base); + } + } + }); + + it('renders English fallback shell copy without leaking env values', async () => { + let renderer!: ReactTestRenderer; + + await act(async () => { + renderer = create( + renderHints( + , + ), + ); + }); + + const text = flattenRendererText(renderer.toJSON()); + expect(text).toContain('Environment variable usage hints'); + expect(text).toContain('Detected 2 variables'); + expect(text).toContain('1 look like secrets'); + expect(text).toContain('GITHUB_TOKEN'); + expect(text).toContain('HTTPS_PROXY'); + expect(text).toContain('Recognized'); + expect(text).toContain('Expected:'); + expect(text).not.toContain('ghp_real_secret_value'); + expect(text).not.toContain('127.0.0.1:7890'); + }); + + it('renders zh-CN shell copy from provider while preserving raw env keys', async () => { + let renderer!: ReactTestRenderer; + + await act(async () => { + renderer = create( + renderHints( + , + 'zh-CN', + ), + ); + }); + + const text = flattenRendererText(renderer.toJSON()); + expect(text).toContain('环境变量用途提示'); + expect(text).toContain('已识别 2 个变量'); + expect(text).toContain('应填:'); + expect(text).toContain('当前值为空。'); + expect(text).toContain('当前像示例占位值。'); + expect(text).toContain('注意:'); + expect(text).toContain('下一步:'); + expect(text).toContain('GITHUB_TOKEN'); + expect(text).toContain('OPENAI_API_KEY'); + expect(text).not.toContain('...'); + }); +}); diff --git a/frontend/src/components/ai/AIMCPEnvHints.tsx b/frontend/src/components/ai/AIMCPEnvHints.tsx index a67cdde..7ca4ff7 100644 --- a/frontend/src/components/ai/AIMCPEnvHints.tsx +++ b/frontend/src/components/ai/AIMCPEnvHints.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import { buildMCPEnvHintProfile } from '../../utils/mcpEnvHints'; import { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock'; @@ -14,12 +16,12 @@ interface AIMCPEnvHintsProps { } const categoryLabel = { - secret: '密钥', - endpoint: '地址', - proxy: '代理', - path: '路径', - runtime: '运行时', - generic: '自定义', + secret: 'ai_settings.mcp_server.env_hints.category.secret', + endpoint: 'ai_settings.mcp_server.env_hints.category.endpoint', + proxy: 'ai_settings.mcp_server.env_hints.category.proxy', + path: 'ai_settings.mcp_server.env_hints.category.path', + runtime: 'ai_settings.mcp_server.env_hints.category.runtime', + generic: 'ai_settings.mcp_server.env_hints.category.generic', }; const categoryColor = { @@ -39,7 +41,12 @@ const AIMCPEnvHints: React.FC = ({ darkMode, overlayTheme, }) => { - const profile = buildMCPEnvHintProfile(command, args, env); + const i18n = useOptionalI18n(); + const copy = ( + key: string, + params?: Record, + ) => (i18n?.t ?? ((catalogKey, catalogParams) => catalogTranslate('en-US', catalogKey, catalogParams)))(key, params); + const profile = buildMCPEnvHintProfile(command, args, env, copy); if (!profile) { return null; } @@ -56,9 +63,14 @@ const AIMCPEnvHints: React.FC = ({ gap: 8, }} > -
环境变量用途提示
+
+ {copy('ai_settings.mcp_server.env_hints.title')} +
- 已识别 {profile.envVarCount} 个变量,其中 {profile.secretLikeCount} 个像密钥;这里只解释 key 的用途和风险,不会显示 value。 + {copy('ai_settings.mcp_server.env_hints.summary', { + envVarCount: profile.envVarCount, + secretLikeCount: profile.secretLikeCount, + })}
{profile.items.map((item) => ( @@ -86,27 +98,35 @@ const AIMCPEnvHints: React.FC = ({ background: darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)', }} > - {categoryLabel[item.category]} + {copy(categoryLabel[item.category])} - {item.known ? 已识别 : null} + {item.known ? ( + + {copy('ai_settings.mcp_server.env_hints.recognized')} + + ) : null}
{item.label}
{item.detail}
- 应填:{item.valueHint} - {item.empty ? ' 当前值为空。' : ''} - {item.placeholder ? ' 当前像示例占位值。' : ''} + {copy('ai_settings.mcp_server.env_hints.value_hint_prefix')}{item.valueHint} + {item.empty ? copy('ai_settings.mcp_server.env_hints.empty_value') : ''} + {item.placeholder ? copy('ai_settings.mcp_server.env_hints.placeholder_value') : ''}
))} {profile.warnings.length > 0 ? (
- 注意:{profile.warnings.join(';')} + {copy('ai_settings.mcp_server.env_hints.warning_prefix', { + warnings: profile.warnings.join(copy('ai_settings.mcp_server.env_hints.action_separator')), + })}
) : null}
- 下一步:{profile.nextActions.join(';')} + {copy('ai_settings.mcp_server.env_hints.next_actions', { + actions: profile.nextActions.join(copy('ai_settings.mcp_server.env_hints.action_separator')), + })}
); diff --git a/frontend/src/components/ai/AIMCPFieldGuideCard.tsx b/frontend/src/components/ai/AIMCPFieldGuideCard.tsx index 10a8f7d..17e3b84 100644 --- a/frontend/src/components/ai/AIMCPFieldGuideCard.tsx +++ b/frontend/src/components/ai/AIMCPFieldGuideCard.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import type { MCPFieldGuide } from '../../utils/mcpServerGuidance'; import { buildMCPFieldTone, buildMCPHintStyle } from './AIMCPHelpBlock'; @@ -19,6 +21,9 @@ const AIMCPFieldGuideCard: React.FC = ({ overlayTheme, compact = false, }) => { + const i18n = useOptionalI18n(); + const copy = (key: string) => (i18n?.t ?? ((catalogKey) => catalogTranslate('en-US', catalogKey)))(key); + const example = item.exampleKey ? copy(item.exampleKey) : item.example; const tone = buildMCPFieldTone(item.fieldState, darkMode); return (
= ({ }} >
-
{item.title}
+
{copy(item.titleKey)}
= ({ background: tone.bg, }} > - {tone.label} + {copy(tone.labelKey)}
-
{item.summary}
- {!compact &&
{item.detail}
} +
{copy(item.summaryKey)}
+ {!compact &&
{copy(item.detailKey)}
}
- 应填: - {item.fill} + {copy('ai_settings.mcp_server.guide.field.fill_label')} + {copy(item.fillKey)}
- 不要填: - {item.avoid} + {copy('ai_settings.mcp_server.guide.field.avoid_label')} + {copy(item.avoidKey)}
- {item.example ? ( + {example ? (
- 示例值: + {copy('ai_settings.mcp_server.guide.field.example_label')} {' '} - {item.example} + {example}
) : null}
diff --git a/frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx b/frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx index a4e6f70..3e4ea87 100644 --- a/frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPHTTPServerPanel.test.tsx @@ -1,28 +1,38 @@ +import { readFileSync } from 'node:fs'; import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { I18nProvider } from '../../i18n/provider'; import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import AIMCPHTTPServerPanel from './AIMCPHTTPServerPanel'; -const findElement = (node: any, predicate: (element: any) => boolean): any => { - if (node == null || typeof node === 'boolean' || typeof node === 'string' || typeof node === 'number') { - return null; - } - if (Array.isArray(node)) { - for (const item of node) { - const match = findElement(item, predicate); - if (match) { - return match; - } - } - return null; - } - if (predicate(node)) { - return node; - } - return findElement(node.props?.children, predicate); -}; +vi.mock('../../i18n/runtime', () => ({ + syncLanguageRuntime: vi.fn(async () => undefined), +})); + +const source = readFileSync(new URL('./AIMCPHTTPServerPanel.tsx', import.meta.url), 'utf8'); +const zhCnCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-CN.json', import.meta.url), 'utf8')); +const zhTwCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-TW.json', import.meta.url), 'utf8')); +const enUsCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/en-US.json', import.meta.url), 'utf8')); +const jaJpCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ja-JP.json', import.meta.url), 'utf8')); +const deDeCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/de-DE.json', import.meta.url), 'utf8')); +const ruRuCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ru-RU.json', import.meta.url), 'utf8')); + +const REQUIRED_KEYS = [ + 'ai_settings.mcp_http.panel.title', + 'ai_settings.mcp_http.panel.status.running', + 'ai_settings.mcp_http.panel.status.stopped', + 'ai_settings.mcp_http.panel.description', + 'ai_settings.mcp_http.panel.switch.on', + 'ai_settings.mcp_http.panel.switch.off', + 'ai_settings.mcp_http.panel.addr_label', + 'ai_settings.mcp_http.panel.authorization_placeholder', + 'ai_settings.mcp_http.panel.running_hint', + 'ai_settings.mcp_http.panel.stopped_hint', + 'ai_settings.mcp_http.panel.copy_url', + 'ai_settings.mcp_http.panel.copy_authorization', +]; const buildPanelProps = () => ({ status: { @@ -32,7 +42,7 @@ const buildPanelProps = () => ({ url: 'http://127.0.0.1:8765/mcp', schemaOnly: true, authorizationHeader: 'Bearer gnv_test', - message: 'GoNavi MCP HTTP 服务已启动', + message: '', }, draft: { addr: '127.0.0.1:8765', @@ -51,30 +61,69 @@ const buildPanelProps = () => ({ }); describe('AIMCPHTTPServerPanel', () => { - it('renders the in-app MCP HTTP switch and remote connection details', () => { + it('uses catalog keys instead of hard-coded Chinese panel chrome', () => { + expect(source).toContain('useOptionalI18n()'); + expect(source).toContain("catalogTranslate('en-US'"); + for (const key of REQUIRED_KEYS) { + expect(source).toContain(key); + } + expect(source).not.toContain('已启动'); + expect(source).not.toContain('未启动'); + expect(source).not.toContain('监听地址 / 端口'); + expect(source).not.toContain('复制 URL'); + expect(source).not.toContain('复制 Authorization'); + }); + + it('keeps MCP HTTP panel keys present in all six catalogs', () => { + for (const key of REQUIRED_KEYS) { + expect(zhCnCatalog[key]).toBeTruthy(); + expect(zhTwCatalog[key]).toBeTruthy(); + expect(enUsCatalog[key]).toBeTruthy(); + expect(jaJpCatalog[key]).toBeTruthy(); + expect(deDeCatalog[key]).toBeTruthy(); + expect(ruRuCatalog[key]).toBeTruthy(); + } + }); + + it('renders localized panel chrome while preserving URL and Authorization raw values', () => { + const markup = renderToStaticMarkup( + undefined} + > + + , + ); + + expect(markup).toContain('GoNavi MCP HTTP service'); + expect(markup).toContain('Running'); + expect(markup).toContain('schema-only'); + expect(markup).toContain('Listen address / port'); + expect(markup).toContain('Authorization'); + expect(markup).toContain('127.0.0.1:8765'); + expect(markup).toContain('http://127.0.0.1:8765/mcp'); + expect(markup).toContain('Copy Authorization'); + expect(markup).toContain('Bearer gnv_test'); + }); + + it('falls back to English without an i18n provider', () => { const markup = renderToStaticMarkup( , ); - expect(markup).toContain('GoNavi MCP HTTP 服务'); - expect(markup).toContain('已启动'); - expect(markup).toContain('schema-only'); - expect(markup).toContain('监听地址 / 端口'); - expect(markup).toContain('Authorization'); - expect(markup).toContain('127.0.0.1:8765'); - expect(markup).toContain('http://127.0.0.1:8765/mcp'); - expect(markup).toContain('复制 Authorization'); + expect(markup).toContain('GoNavi MCP HTTP service'); + expect(markup).toContain('Running'); + expect(markup).toContain('Copy URL'); }); it('keeps Authorization read-only but revealable while running', () => { - const tree = AIMCPHTTPServerPanel(buildPanelProps()); - const passwordInput = findElement( - tree, - (node) => node.props?.placeholder === 'Bearer gnv_xxx(留空自动生成)', + const markup = renderToStaticMarkup( + , ); - expect(passwordInput).toBeTruthy(); - expect(passwordInput.props.disabled).toBe(false); - expect(passwordInput.props.readOnly).toBe(true); + expect(markup).toContain('placeholder="Bearer gnv_xxx (leave empty to generate automatically)"'); + expect(markup).toContain('readonly=""'); + expect(markup).not.toContain('placeholder="Bearer gnv_xxx (leave empty to generate automatically)" disabled=""'); }); }); diff --git a/frontend/src/components/ai/AIMCPHTTPServerPanel.tsx b/frontend/src/components/ai/AIMCPHTTPServerPanel.tsx index ab85564..44609f3 100644 --- a/frontend/src/components/ai/AIMCPHTTPServerPanel.tsx +++ b/frontend/src/components/ai/AIMCPHTTPServerPanel.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { Button, Input, Switch, Tag } from 'antd'; import { CopyOutlined } from '@ant-design/icons'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import type { AIMCPHTTPServerStatus } from '../../types'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; @@ -38,6 +40,8 @@ const AIMCPHTTPServerPanel: React.FC = ({ onCopyURL, onCopyAuthorization, }) => { + const i18n = useOptionalI18n(); + const copy = (key: string) => (i18n?.t ?? ((catalogKey) => catalogTranslate('en-US', catalogKey)))(key); const running = status?.running === true; const url = String(status?.url || '').trim(); const authorizationHeader = String(status?.authorizationHeader || '').trim(); @@ -61,24 +65,26 @@ const AIMCPHTTPServerPanel: React.FC = ({
-
GoNavi MCP HTTP 服务
+
+ {copy('ai_settings.mcp_http.panel.title')} +
- {running ? '已启动' : '未启动'} + {copy(running ? 'ai_settings.mcp_http.panel.status.running' : 'ai_settings.mcp_http.panel.status.stopped')} schema-only
- 给 OpenClaw、Hermans 等远程 Agent 使用。打开后监听本机地址,只开放连接、库表、字段和 DDL 等结构读取工具。 + {copy('ai_settings.mcp_http.panel.description')}
= ({ >
- 监听地址 / 端口 + + {copy('ai_settings.mcp_http.panel.addr_label')} + = ({ value={draft.authorizationHeader} disabled={loading} readOnly={running || loading} - placeholder="Bearer gnv_xxx(留空自动生成)" + placeholder={copy('ai_settings.mcp_http.panel.authorization_placeholder')} autoComplete="off" onChange={(event) => onDraftChange({ authorizationHeader: event.target.value })} style={inputStyle} @@ -120,8 +128,8 @@ const AIMCPHTTPServerPanel: React.FC = ({
{running - ? status.message || '服务运行中,可把 URL 和 Authorization Header 配置到远程 MCP 客户端。' - : '可自定义本机监听端口和 Bearer Token;留空 Authorization 时启动会自动生成随机 Token。'} + ? status.message || copy('ai_settings.mcp_http.panel.running_hint') + : copy('ai_settings.mcp_http.panel.stopped_hint')}
= ({ {url || 'http://127.0.0.1:8765/mcp'}
diff --git a/frontend/src/components/ai/AIMCPHelpBlock.tsx b/frontend/src/components/ai/AIMCPHelpBlock.tsx index ccfa2d8..a9aa303 100644 --- a/frontend/src/components/ai/AIMCPHelpBlock.tsx +++ b/frontend/src/components/ai/AIMCPHelpBlock.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import type { MCPFieldState } from '../../utils/mcpServerGuidance'; @@ -18,19 +20,19 @@ export const buildMCPFieldTone = (kind: MCPFieldState, darkMode: boolean) => { switch (kind) { case 'required': return { - label: '必填', + labelKey: 'ai_settings.mcp_server.help.field_state.required', color: '#b45309', bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)', }; case 'fixed': return { - label: '固定', + labelKey: 'ai_settings.mcp_server.help.field_state.fixed', color: '#2563eb', bg: darkMode ? 'rgba(59,130,246,0.18)' : 'rgba(59,130,246,0.12)', }; default: return { - label: '可选', + labelKey: 'ai_settings.mcp_server.help.field_state.optional', color: '#475569', bg: darkMode ? 'rgba(148,163,184,0.18)' : 'rgba(148,163,184,0.12)', }; @@ -56,6 +58,8 @@ const AIMCPHelpBlock: React.FC = ({ example, children, }) => { + const i18n = useOptionalI18n(); + const copy = (key: string) => (i18n?.t ?? ((catalogKey) => catalogTranslate('en-US', catalogKey)))(key); const tone = buildMCPFieldTone(fieldState, darkMode); return ( @@ -72,14 +76,14 @@ const AIMCPHelpBlock: React.FC = ({ background: tone.bg, }} > - {tone.label} + {copy(tone.labelKey)}
{description} {example ? ( <> - {' '}例如:{example} + {' '}{copy('ai_settings.mcp_server.help.example_prefix')}{example} ) : null}
diff --git a/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx b/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx index 9ae8e16..9ec9fcd 100644 --- a/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPQuickAddServerPanel.test.tsx @@ -4,6 +4,8 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import { describe, expect, it, vi } from 'vitest'; import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +import { t as translateCatalog } from '../../i18n/catalog'; +import { I18nProvider } from '../../i18n/provider'; import AIMCPQuickAddServerPanel from './AIMCPQuickAddServerPanel'; vi.mock('antd', async () => { @@ -34,6 +36,12 @@ const buildQuickAddPanel = (onAddServer = () => {}) => ( /> ); +const buildLocalizedQuickAddPanel = (language: 'en-US' | 'zh-CN') => ( + {}}> + {buildQuickAddPanel()} + +); + const flattenRendererText = (node: any): string => { if (node == null || typeof node === 'boolean') { return ''; @@ -57,21 +65,38 @@ const findTemplateButton = (renderer: ReactTestRenderer, label: string) => { describe('AIMCPQuickAddServerPanel', () => { it('renders a top-level full-command entry for creating MCP drafts', () => { + const zh = (key: string, params?: Record) => + translateCatalog('zh-CN', key, params); const markup = renderToStaticMarkup( - buildQuickAddPanel(), + buildLocalizedQuickAddPanel('zh-CN'), ); - expect(markup).toContain('一行命令快速新增'); - expect(markup).toContain('先选最接近的模板'); - expect(markup).toContain('command、args 和 env'); - expect(markup).toContain('常见启动方式模板'); - expect(markup).toContain('npx 包'); + expect(markup).toContain(zh('ai_settings.mcp_server.quick_add.title')); + expect(markup).toContain(zh('ai_settings.mcp_server.quick_add.description')); + expect(markup).toContain(zh('ai_settings.mcp_server.quick_add.templates_title')); + expect(markup).toContain(zh('ai_settings.mcp_server.template.npx.title')); expect(markup).toContain('npx -y @modelcontextprotocol/server-filesystem --stdio'); - expect(markup).toContain('Docker 镜像'); + expect(markup).toContain(zh('ai_settings.mcp_server.template.docker.title')); expect(markup).toContain('docker run --rm -i mcp/server-fetch:latest'); - expect(markup).toContain('粘贴完整命令'); + expect(markup).toContain(zh('ai_settings.mcp_server.guide.full_command.placeholder', { + example: '', + }).trim()); expect(markup).toContain('$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio'); - expect(markup).toContain('解析并新增草稿'); + expect(markup).toContain(zh('ai_settings.mcp_server.quick_add.action.parse_and_add')); + }); + + it('renders quick-add copy from the active locale', () => { + const markup = renderToStaticMarkup( + buildLocalizedQuickAddPanel('en-US'), + ); + + expect(markup).toContain('Quick add from one command'); + expect(markup).toContain('Common startup templates'); + expect(markup).toContain('Paste the full command, for example:'); + expect(markup).toContain('Parse and add draft'); + expect(markup).not.toContain(translateCatalog('zh-CN', 'ai_settings.mcp_server.quick_add.title')); + expect(markup).not.toContain(translateCatalog('zh-CN', 'ai_settings.mcp_server.quick_add.templates_title')); + expect(markup).not.toContain(translateCatalog('zh-CN', 'ai_settings.mcp_server.quick_add.action.parse_and_add')); }); it('seeds a new npx MCP draft from the quick-add template', async () => { @@ -82,7 +107,7 @@ describe('AIMCPQuickAddServerPanel', () => { renderer = create(buildQuickAddPanel(onAddServer)); }); - findTemplateButton(renderer, 'npx 包').props.onClick(); + findTemplateButton(renderer, 'npx package').props.onClick(); expect(onAddServer).toHaveBeenCalledWith(expect.objectContaining({ command: 'npx', @@ -98,7 +123,7 @@ describe('AIMCPQuickAddServerPanel', () => { renderer = create(buildQuickAddPanel(onAddServer)); }); - findTemplateButton(renderer, 'Docker 镜像').props.onClick(); + findTemplateButton(renderer, 'Docker image').props.onClick(); expect(onAddServer).toHaveBeenCalledWith(expect.objectContaining({ command: 'docker', diff --git a/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx b/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx index b67723e..49a8296 100644 --- a/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx +++ b/frontend/src/components/ai/AIMCPQuickAddServerPanel.tsx @@ -2,6 +2,9 @@ import React from 'react'; import { Button, Input } from 'antd'; import { PlusOutlined } from '@ant-design/icons'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import type { I18nParams } from '../../i18n'; +import { useOptionalI18n } from '../../i18n/provider'; import type { AIMCPServerConfig } from '../../types'; import { parseMCPCommandDraft, @@ -12,7 +15,7 @@ import { MCP_COMMAND_PARSE_EXAMPLE, } from '../../utils/mcpServerGuidance'; import { buildMCPQuickAddServerSeed } from '../../utils/mcpServerDraftSeed'; -import { MCP_SERVER_DRAFT_TEMPLATES } from '../../utils/mcpServerTemplates'; +import { MCP_SERVER_DRAFT_TEMPLATES, type MCPServerDraftTemplate } from '../../utils/mcpServerTemplates'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import AIMCPCommandDraftPreview from './AIMCPCommandDraftPreview'; import { buildMCPHintStyle, mcpLabelStyle } from './AIMCPHelpBlock'; @@ -30,21 +33,40 @@ const renderParseSummary = ( rawCommandDraft: string, parsedCommandDraft: ParseMCPCommandDraftResult, overlayTheme: OverlayWorkbenchTheme, + copy: (key: string, params?: I18nParams, fallback?: string) => string, ) => { if (!rawCommandDraft.trim()) { - return '支持带引号路径、带空格参数,以及 KEY=VALUE / $env:KEY=VALUE; / set KEY=VALUE && 环境变量前缀。'; + return copy('ai_settings.mcp_server.guide.full_command.support_hint'); } if (!parsedCommandDraft.ok || !parsedCommandDraft.draft) { - return parsedCommandDraft.error || '完整命令解析失败,请检查命令格式。'; + return parsedCommandDraft.errorKey + ? copy(parsedCommandDraft.errorKey, undefined, parsedCommandDraft.error) + : (parsedCommandDraft.error || copy( + 'ai_settings.mcp_server.command_parse.error.failed', + undefined, + 'Failed to parse the full command. Check the command format.', + )); } const envCount = Object.keys(parsedCommandDraft.draft.env || {}).length; return ( - 将解析为:命令 {parsedCommandDraft.draft.command},参数 {parsedCommandDraft.draft.args.length} 个,环境变量 {envCount} 个。 + {copy('ai_settings.mcp_server.guide.full_command.parsed_summary', { + command: parsedCommandDraft.draft.command, + argsCount: parsedCommandDraft.draft.args.length, + envCount, + })} ); }; +const localizeTemplateSeed = ( + template: MCPServerDraftTemplate, + copy: (key: string, params?: I18nParams, fallback?: string) => string, +): Partial => ({ + ...template.seed, + name: copy(template.seedNameKey, undefined, String(template.seed.name || template.title)), +}); + const AIMCPQuickAddServerPanel: React.FC = ({ cardBg, cardBorder, @@ -53,6 +75,17 @@ const AIMCPQuickAddServerPanel: React.FC = ({ overlayTheme, onAddServer, }) => { + const i18n = useOptionalI18n(); + const copy = ( + key: string, + params?: I18nParams, + fallback?: string, + ) => { + const translate = i18n?.t ?? ((catalogKey: string, catalogParams?: I18nParams) => + catalogTranslate('en-US', catalogKey, catalogParams)); + const translated = translate(key, params); + return translated === key ? (fallback ?? key) : translated; + }; const [rawCommandDraft, setRawCommandDraft] = React.useState(''); const parsedCommandDraft = parseMCPCommandDraft(rawCommandDraft); @@ -60,7 +93,7 @@ const AIMCPQuickAddServerPanel: React.FC = ({ if (!parsedCommandDraft.ok || !parsedCommandDraft.draft) { return; } - onAddServer(buildMCPQuickAddServerSeed(parsedCommandDraft.draft)); + onAddServer(buildMCPQuickAddServerSeed(parsedCommandDraft.draft, copy)); setRawCommandDraft(''); }; @@ -77,22 +110,34 @@ const AIMCPQuickAddServerPanel: React.FC = ({ }} >
-
一行命令快速新增
+
+ {copy('ai_settings.mcp_server.quick_add.title', undefined, 'Quick add from one command')} +
- 先选最接近的模板,或直接粘贴 README 里的一整行启动命令。GoNavi 会先拆成 command、args 和 env,再生成一个可继续编辑的 MCP 草稿。 + {copy( + 'ai_settings.mcp_server.quick_add.description', + undefined, + 'Choose the closest template, or paste a full startup command from the README. GoNavi will split it into command, args, and env, then create an editable MCP draft.', + )}
-
常见启动方式模板
+
+ {copy('ai_settings.mcp_server.quick_add.templates_title', undefined, 'Common startup templates')} +
- 不确定 command 和 args 怎么拆时,直接点一个模板新增草稿;每张卡片下面展示的就是 GoNavi 实际会启动的命令预览。 + {copy( + 'ai_settings.mcp_server.quick_add.templates_description', + undefined, + 'If you are not sure how to split command and args, click a template to create a draft. Each card shows the command GoNavi will actually launch.', + )}
{MCP_SERVER_DRAFT_TEMPLATES.map((template) => ( ))}
@@ -117,12 +168,12 @@ const AIMCPQuickAddServerPanel: React.FC = ({ rows={2} value={rawCommandDraft} onChange={(event) => setRawCommandDraft(event.target.value)} - placeholder={`粘贴完整命令,例如:\n${MCP_COMMAND_PARSE_EXAMPLE}`} + placeholder={copy('ai_settings.mcp_server.guide.full_command.placeholder', { example: MCP_COMMAND_PARSE_EXAMPLE })} style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)' }} />
- {renderParseSummary(rawCommandDraft, parsedCommandDraft, overlayTheme)} + {renderParseSummary(rawCommandDraft, parsedCommandDraft, overlayTheme, copy)}
{parsedCommandDraft.ok && parsedCommandDraft.draft && rawCommandDraft.trim() && ( diff --git a/frontend/src/components/ai/AIMCPRemoteQuickStartPanel.test.tsx b/frontend/src/components/ai/AIMCPRemoteQuickStartPanel.test.tsx index faf7ab0..f7ec204 100644 --- a/frontend/src/components/ai/AIMCPRemoteQuickStartPanel.test.tsx +++ b/frontend/src/components/ai/AIMCPRemoteQuickStartPanel.test.tsx @@ -1,19 +1,130 @@ import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { I18nProvider } from '../../i18n/provider'; import { buildRemoteMCPClientQuickStart } from '../../utils/mcpClientInstallStatus'; import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import AIMCPRemoteQuickStartPanel from './AIMCPRemoteQuickStartPanel'; +vi.mock('../../i18n/runtime', () => ({ + syncLanguageRuntime: vi.fn(async () => undefined), +})); + +const source = readFileSync(new URL('./AIMCPRemoteQuickStartPanel.tsx', import.meta.url), 'utf8'); +const zhCnCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-CN.json', import.meta.url), 'utf8')); +const zhTwCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-TW.json', import.meta.url), 'utf8')); +const enUsCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/en-US.json', import.meta.url), 'utf8')); +const jaJpCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ja-JP.json', import.meta.url), 'utf8')); +const deDeCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/de-DE.json', import.meta.url), 'utf8')); +const ruRuCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ru-RU.json', import.meta.url), 'utf8')); + +const REQUIRED_KEYS = [ + 'ai_settings.mcp_server.remote_quick_start.title', + 'ai_settings.mcp_server.remote_quick_start.description', + 'ai_settings.mcp_server.remote_quick_start.badge.required', + 'ai_settings.mcp_server.remote_quick_start.badge.optional', + 'ai_settings.mcp_server.remote_quick_start.fill_prefix', + 'ai_settings.mcp_server.remote_quick_start.example_prefix', + 'ai_settings.mcp_server.remote_quick_start.avoid_prefix', + 'ai_settings.mcp_server.remote_quick_start.card.cloud_agent', + 'ai_settings.mcp_server.remote_quick_start.card.cli_config', + 'ai_settings.mcp_server.remote_quick_start.card.cli_config_note', + 'ai_settings.mcp_server.remote_quick_start.card.windows_launch', + 'ai_settings.mcp_server.remote_quick_start.card.standalone_binary', + 'ai_settings.mcp_server.remote_quick_start.section.verification', + 'ai_settings.mcp_server.remote_quick_start.section.security', + 'ai_settings.mcp_server.remote_quick_start.parameter.public_url.title', + 'ai_settings.mcp_server.remote_quick_start.parameter.public_url.fill', + 'ai_settings.mcp_server.remote_quick_start.parameter.public_url.avoid', + 'ai_settings.mcp_server.remote_quick_start.parameter.bearer_token.title', + 'ai_settings.mcp_server.remote_quick_start.parameter.bearer_token.fill', + 'ai_settings.mcp_server.remote_quick_start.parameter.bearer_token.avoid', + 'ai_settings.mcp_server.remote_quick_start.parameter.local_addr.title', + 'ai_settings.mcp_server.remote_quick_start.parameter.local_addr.fill', + 'ai_settings.mcp_server.remote_quick_start.parameter.local_addr.avoid', + 'ai_settings.mcp_server.remote_quick_start.parameter.path.title', + 'ai_settings.mcp_server.remote_quick_start.parameter.path.fill', + 'ai_settings.mcp_server.remote_quick_start.parameter.path.avoid', + 'ai_settings.mcp_server.remote_quick_start.parameter.server_id.title', + 'ai_settings.mcp_server.remote_quick_start.parameter.server_id.fill', + 'ai_settings.mcp_server.remote_quick_start.parameter.server_id.avoid', + 'ai_settings.mcp_server.remote_quick_start.verification.healthz', + 'ai_settings.mcp_server.remote_quick_start.verification.configure_agent', + 'ai_settings.mcp_server.remote_quick_start.verification.inspect_schema', + 'ai_settings.mcp_server.remote_quick_start.security.credentials_stay_local', + 'ai_settings.mcp_server.remote_quick_start.security.schema_only', + 'ai_settings.mcp_server.remote_quick_start.security.token_required', + 'ai_settings.mcp_server.remote_quick_start.security.execute_sql', +]; + +const SHELL_CHINESE_LITERALS = [ + '远程 MCP 快速配置', + '下面分别给云端 Agent', + '必填', + '可选', + '应填:', + '示例:', + '避免:', + '配置到云端 Agent', + '无 GUI / CLI 生成配置', + 'Windows 启动 GoNavi MCP HTTP', + '独立二进制:', + '验证顺序', + '安全边界', +]; + +const renderPanel = ( + element: React.ReactElement, + preference?: 'zh-CN' | 'en-US', +) => { + if (!preference) { + return renderToStaticMarkup(element); + } + return renderToStaticMarkup( + undefined} + > + {element} + , + ); +}; + describe('AIMCPRemoteQuickStartPanel', () => { - it('renders remote MCP bridge parameters and safe launch snippets for cloud agents', () => { + it('keeps remote quick start shell copy in catalogs instead of source literals', () => { + expect(source).toContain('useOptionalI18n()'); + expect(source).toContain("catalogTranslate('en-US'"); + for (const key of REQUIRED_KEYS.slice(0, 14)) { + expect(source).toContain(key); + } + for (const literal of SHELL_CHINESE_LITERALS) { + expect(source).not.toContain(literal); + } + }); + + it('keeps remote quick start keys present in all six catalogs with matching placeholders', () => { + const catalogs = [zhCnCatalog, zhTwCatalog, enUsCatalog, jaJpCatalog, deDeCatalog, ruRuCatalog]; + const placeholders = (value: string) => [...value.matchAll(/\{\{([^}]+)\}\}/g)].map((match) => match[1]).sort(); + for (const key of REQUIRED_KEYS) { + const base = placeholders(enUsCatalog[key]); + for (const catalog of catalogs) { + expect(catalog[key]).toBeTruthy(); + expect(placeholders(catalog[key])).toEqual(base); + } + } + }); + + it('renders remote MCP bridge parameters and safe launch snippets in English fallback', () => { const quickStart = buildRemoteMCPClientQuickStart({ client: 'openclaw', displayName: 'OpenClaw', }); - const markup = renderToStaticMarkup( + const markup = renderPanel( { />, ); - expect(markup).toContain('OpenClaw 远程 MCP 快速配置'); - expect(markup).toContain('公网/隧道 URL'); + expect(markup).toContain('OpenClaw Remote MCP quick setup'); + expect(markup).toContain('Public/tunnel URL'); expect(markup).toContain('Bearer Token'); - expect(markup).toContain('配置到云端 Agent'); - expect(markup).toContain('无 GUI / CLI 生成配置'); - expect(markup).toContain('Windows 启动 GoNavi MCP HTTP'); + expect(markup).toContain('Required'); + expect(markup).toContain('Fill:'); + expect(markup).toContain('Configure in cloud Agent'); + expect(markup).toContain('Generate config without GUI / CLI'); + expect(markup).toContain('Start GoNavi MCP HTTP on Windows'); expect(markup).toContain('"type": "streamable-http"'); expect(markup).toContain('GoNavi.exe mcp-server remote-config --client openclaw'); expect(markup).toContain('gonavi-mcp-server http --addr 127.0.0.1:8765'); + expect(markup).toContain('--schema-only does not register execute_sql by default'); + expect(markup).not.toContain('db_password'); + }); + + it('renders zh-CN quick start copy when caller provides localized quickStart data', () => { + const quickStart = buildRemoteMCPClientQuickStart( + { + client: 'openclaw', + displayName: 'OpenClaw', + }, + (key, params) => catalogTranslate('zh-CN', key, params), + ); + + const markup = renderPanel( + , + 'zh-CN', + ); + + expect(markup).toContain('OpenClaw 远程 MCP 快速配置'); + expect(markup).toContain('公网/隧道 URL'); + expect(markup).toContain('必填'); + expect(markup).toContain('应填:'); + expect(markup).toContain('配置到云端 Agent'); expect(markup).toContain('默认 --schema-only 不注册 execute_sql'); - expect(markup).not.toContain('password'); + expect(markup).toContain('GoNavi.exe mcp-server remote-config --client openclaw'); }); }); diff --git a/frontend/src/components/ai/AIMCPRemoteQuickStartPanel.tsx b/frontend/src/components/ai/AIMCPRemoteQuickStartPanel.tsx index 5099398..7a07891 100644 --- a/frontend/src/components/ai/AIMCPRemoteQuickStartPanel.tsx +++ b/frontend/src/components/ai/AIMCPRemoteQuickStartPanel.tsx @@ -1,9 +1,11 @@ import React from 'react'; import { - REMOTE_MCP_PARAMETER_GUIDES, + buildRemoteMCPParameterGuides, type RemoteMCPClientQuickStart, } from '../../utils/mcpClientInstallStatus'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; interface AIMCPRemoteQuickStartPanelProps { @@ -58,7 +60,12 @@ const AIMCPRemoteQuickStartPanel: React.FC = ({ darkMode, overlayTheme, cardBorder, -}) => ( +}) => { + const i18n = useOptionalI18n(); + const copy = i18n?.t ?? ((key, params) => catalogTranslate('en-US', key, params)); + const parameterGuides = buildRemoteMCPParameterGuides(copy); + + return (
= ({ }} >
- {quickStart.displayName} 远程 MCP 快速配置 + {copy('ai_settings.mcp_server.remote_quick_start.title', { displayName: quickStart.displayName })}
- 下面分别给云端 Agent、无 GUI/CLI 场景和 Windows GoNavi 使用。云端只保存 MCP URL 和 Bearer Token,不保存数据库账号密码;默认 schema-only 只暴露结构工具。 + {copy('ai_settings.mcp_server.remote_quick_start.description')}
- {REMOTE_MCP_PARAMETER_GUIDES.map((item) => ( + {parameterGuides.map((item) => (
= ({ : (darkMode ? 'rgba(255,255,255,0.06)' : 'rgba(15,23,42,0.05)'), }} > - {item.required ? '必填' : '可选'} + {copy(item.required + ? 'ai_settings.mcp_server.remote_quick_start.badge.required' + : 'ai_settings.mcp_server.remote_quick_start.badge.optional')}
- 应填:{item.fill} + {copy('ai_settings.mcp_server.remote_quick_start.fill_prefix')}{item.fill}
- 示例:{item.example} + {copy('ai_settings.mcp_server.remote_quick_start.example_prefix')}{item.example}
- 避免:{item.avoid} + {copy('ai_settings.mcp_server.remote_quick_start.avoid_prefix')}{item.avoid}
))}
- + {quickStart.configJson} - + {quickStart.configCommand}
- 用于生成可粘贴到 {quickStart.displayName} 的远程 MCP 配置,不会读取或输出数据库密码。 + {copy('ai_settings.mcp_server.remote_quick_start.card.cli_config_note', { + displayName: quickStart.displayName, + })}
- + {quickStart.launchCommand}
- 独立二进制:{quickStart.standaloneCommand} + {copy('ai_settings.mcp_server.remote_quick_start.card.standalone_binary', { + command: quickStart.standaloneCommand, + })}
-
验证顺序
+
+ {copy('ai_settings.mcp_server.remote_quick_start.section.verification')} +
{quickStart.verificationSteps.map((item) => (
@@ -155,7 +185,9 @@ const AIMCPRemoteQuickStartPanel: React.FC = ({
-
安全边界
+
+ {copy('ai_settings.mcp_server.remote_quick_start.section.security')} +
{quickStart.securityNotes.map((item) => (
@@ -166,6 +198,7 @@ const AIMCPRemoteQuickStartPanel: React.FC = ({
-); + ); +}; export default AIMCPRemoteQuickStartPanel; diff --git a/frontend/src/components/ai/AIMCPServerCard.test.tsx b/frontend/src/components/ai/AIMCPServerCard.test.tsx index 1ec6bb2..a04595d 100644 --- a/frontend/src/components/ai/AIMCPServerCard.test.tsx +++ b/frontend/src/components/ai/AIMCPServerCard.test.tsx @@ -3,6 +3,7 @@ import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import AIMCPServerCard from './AIMCPServerCard'; +import { I18nProvider } from '../../i18n/provider'; import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; describe('AIMCPServerCard', () => { @@ -33,51 +34,88 @@ describe('AIMCPServerCard', () => { />, ); - expect(markup).toContain('启动命令只填可执行程序本身'); - expect(markup).toContain('推荐填写顺序'); - expect(markup).toContain('小白用户可以按这个顺序填'); - expect(markup).toContain('字段速查'); - expect(markup).toContain('保存后显示给你和 AI 看的名字'); - expect(markup).toContain('示例值:'); + expect(markup).toContain('Put only the executable itself in the startup command'); + expect(markup).toContain('Recommended fill order'); + expect(markup).toContain('New users can follow this order'); + expect(markup).toContain('Field quick reference'); + expect(markup).toContain('The name shown to you and the AI after saving'); + expect(markup).toContain('Example:'); expect(markup).toContain('Filesystem / Browser / GitHub'); expect(markup).toContain('-y / @modelcontextprotocol/server-filesystem / --stdio / server.js'); - expect(markup).toContain('当前固定为 stdio'); - expect(markup).toContain('单次工具发现或调用最多等待多久'); - expect(markup).toContain('必填'); - expect(markup).toContain('可选'); - expect(markup).toContain('固定'); - expect(markup).toContain('直接粘贴完整命令'); - expect(markup).toContain('自动拆分到下方字段'); + expect(markup).toContain('Currently fixed to stdio'); + expect(markup).toContain('Maximum wait time for one tool discovery or call'); + expect(markup).toContain('Required'); + expect(markup).toContain('Optional'); + expect(markup).toContain('Fixed'); + expect(markup).toContain('Paste the full command directly'); + expect(markup).toContain('Auto-split into the fields below'); expect(markup).toContain('$env:KEY=VALUE;'); expect(markup).toContain('set KEY=VALUE &&'); expect(markup).toContain('npx -y package --stdio'); - expect(markup).toContain('-y、@modelcontextprotocol/server-filesystem、--stdio、server.js'); - expect(markup).toContain('每个参数单独录入一个标签'); - expect(markup).toContain('当前命令 node 的参数提示'); - expect(markup).toContain('Node 脚本参数顺序建议'); - expect(markup).toContain('推荐顺序:脚本路径 -> --stdio -> 服务自己的业务参数'); - expect(markup).toContain('必填参数看起来已经齐了'); - expect(markup).toContain('每行一个 KEY=VALUE'); - expect(markup).toContain('没有等号或 key 含空格的行不会保存'); - expect(markup).toContain('不要把 npx -y package --stdio、node server.js --stdio 或 docker run -i image 整串都塞进这里'); - expect(markup).toContain('不要写 export'); - expect(markup).toContain('当前阶段只支持 stdio'); - expect(markup).toContain('实际启动命令预览'); - expect(markup).toContain('配置检查'); - expect(markup).toContain('服务名称为空'); - expect(markup).toContain('建议检查'); - expect(markup).toContain('操作说明'); - expect(markup).toContain('测试工具发现'); - expect(markup).toContain('不会保存配置'); - expect(markup).toContain('测试通过后,上方会显示这条服务实际发现到的工具'); - expect(markup).toContain('默认 20 秒'); - expect(markup).toContain('稍宽松 45 秒'); - expect(markup).toContain('慢启动 60 秒'); + expect(markup).toContain('-y / @modelcontextprotocol/server-filesystem / --stdio / server.js'); + expect(markup).toContain('Enter each argument as a separate tag'); + expect(markup).toContain('Current command node argument hints'); + expect(markup).toContain('Node script argument order'); + expect(markup).toContain('Recommended order: script path -> --stdio -> service business arguments'); + expect(markup).toContain('Required arguments look complete'); + expect(markup).toContain('Use one KEY=VALUE per line'); + expect(markup).toContain('lines without an equals sign or with spaces in the key will not be saved'); + expect(markup).toContain('Do not paste the whole npx -y package --stdio'); + expect(markup).toContain('do not write export'); + expect(markup).toContain('Only stdio is supported for now'); + expect(markup).toContain('Actual launch command preview'); + expect(markup).toContain('Configuration check'); + expect(markup).toContain('Service name is empty'); + expect(markup).toContain('Check recommended'); + expect(markup).toContain('Action guide'); + expect(markup).toContain('Test tool discovery'); + expect(markup).toContain('does not save the configuration'); + expect(markup).toContain('the tools discovered from this service will appear above'); + expect(markup).toContain('Default 20 seconds'); + expect(markup).toContain('Relaxed 45 seconds'); + expect(markup).toContain('Slow start 60 seconds'); expect(markup).toContain('npx -y @modelcontextprotocol/server-filesystem --stdio'); expect(markup).toContain('node server.js --stdio'); expect(markup).toContain('$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio'); }); + it('renders the MCP setup guide in Chinese when an i18n provider is available', () => { + const markup = renderToStaticMarkup( + {}}> + {}} + onTest={() => {}} + onSave={() => {}} + onDelete={() => {}} + /> + , + ); + + expect(markup).toContain('推荐填写顺序'); + expect(markup).toContain('字段速查'); + expect(markup).toContain('保存后显示给你和 AI 看的名字'); + expect(markup).toContain('示例值:'); + expect(markup).toContain('服务名称为空'); + expect(markup).toContain('npx -y @modelcontextprotocol/server-filesystem --stdio'); + }); + it('renders actionable validation when command and args are mixed together', () => { const markup = renderToStaticMarkup( { />, ); - expect(markup).toContain('启动命令可能填成了整行命令'); - expect(markup).toContain('把脚本名、模块名、--stdio 和环境变量拆到命令参数或环境变量里'); - expect(markup).toContain('命令参数可能缺少脚本或模块名'); + expect(markup).toContain('Startup command may contain the whole command line'); + expect(markup).toContain('Move the script name, module name, --stdio, and environment variables into arguments or environment variables'); + expect(markup).toContain('Command arguments may be missing the script or module name'); }); it('renders env key purpose hints without requiring users to guess common MCP variables', () => { @@ -140,13 +178,13 @@ describe('AIMCPServerCard', () => { />, ); - expect(markup).toContain('环境变量用途提示'); - expect(markup).toContain('只解释 key 的用途和风险,不会显示 value'); + expect(markup).toContain('Environment variable usage hints'); + expect(markup).toContain('Only the key purpose and risk are explained here; values are not shown'); expect(markup).toContain('GITHUB_TOKEN'); expect(markup).toContain('GitHub Token'); expect(markup).toContain('HTTPS_PROXY'); - expect(markup).toContain('HTTPS 代理'); - expect(markup).toContain('当前像示例占位值'); - expect(markup).toContain('密钥类变量只保存在本机配置'); + expect(markup).toContain('HTTPS proxy'); + expect(markup).toContain('Current value looks like an example placeholder'); + expect(markup).toContain('Secret-like variables are stored only in local configuration'); }); }); diff --git a/frontend/src/components/ai/AIMCPServerCard.tsx b/frontend/src/components/ai/AIMCPServerCard.tsx index 89ba87c..93eecad 100644 --- a/frontend/src/components/ai/AIMCPServerCard.tsx +++ b/frontend/src/components/ai/AIMCPServerCard.tsx @@ -1,5 +1,6 @@ import React from 'react'; +import { useOptionalI18n } from '../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types'; import { parseMCPCommandDraft } from '../../utils/mcpCommandDraft'; @@ -38,12 +39,13 @@ export const AIMCPServerCard: React.FC = ({ onSave, onDelete, }) => { + const i18n = useOptionalI18n(); const [rawCommandDraft, setRawCommandDraft] = React.useState(''); const [envDraft, setEnvDraft] = React.useState(() => formatMCPEnvDraft(server.env)); const launchPreview = buildMCPLaunchPreview(server.command, server.args); const parsedCommandDraft = parseMCPCommandDraft(rawCommandDraft); const parsedEnvDraft = parseMCPEnvDraft(envDraft); - const validation = validateMCPServerDraft(server, parsedEnvDraft); + const validation = validateMCPServerDraft(server, parsedEnvDraft, i18n?.t); React.useEffect(() => { setEnvDraft(formatMCPEnvDraft(server.env)); diff --git a/frontend/src/components/ai/AIMCPServerFormPanel.test.tsx b/frontend/src/components/ai/AIMCPServerFormPanel.test.tsx new file mode 100644 index 0000000..b9385d4 --- /dev/null +++ b/frontend/src/components/ai/AIMCPServerFormPanel.test.tsx @@ -0,0 +1,194 @@ +import { readFileSync } from 'node:fs'; +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '../../i18n/provider'; +import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +import type { ParsedMCPEnvDraft } from '../../utils/mcpEnvDraft'; +import type { MCPServerDraftValidation } from '../../utils/mcpServerValidation'; +import AIMCPServerFormPanel from './AIMCPServerFormPanel'; + +vi.mock('../../i18n/runtime', () => ({ + syncLanguageRuntime: vi.fn(async () => undefined), +})); + +const formSource = readFileSync(new URL('./AIMCPServerFormPanel.tsx', import.meta.url), 'utf8'); +const helpBlockSource = readFileSync(new URL('./AIMCPHelpBlock.tsx', import.meta.url), 'utf8'); +const zhCnCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-CN.json', import.meta.url), 'utf8')); +const zhTwCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-TW.json', import.meta.url), 'utf8')); +const enUsCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/en-US.json', import.meta.url), 'utf8')); +const jaJpCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ja-JP.json', import.meta.url), 'utf8')); +const deDeCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/de-DE.json', import.meta.url), 'utf8')); +const ruRuCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ru-RU.json', import.meta.url), 'utf8')); + +const REQUIRED_KEYS = [ + 'ai_settings.mcp_server.help.field_state.required', + 'ai_settings.mcp_server.help.field_state.fixed', + 'ai_settings.mcp_server.help.field_state.optional', + 'ai_settings.mcp_server.help.example_prefix', + 'ai_settings.mcp_server.form.name.title', + 'ai_settings.mcp_server.form.name.description', + 'ai_settings.mcp_server.form.name.placeholder', + 'ai_settings.mcp_server.form.enabled.title', + 'ai_settings.mcp_server.form.enabled.description', + 'ai_settings.mcp_server.form.enabled.option.enabled', + 'ai_settings.mcp_server.form.enabled.option.disabled', + 'ai_settings.mcp_server.form.transport.title', + 'ai_settings.mcp_server.form.transport.description', + 'ai_settings.mcp_server.form.command.title', + 'ai_settings.mcp_server.form.command.description', + 'ai_settings.mcp_server.form.command.placeholder', + 'ai_settings.mcp_server.form.timeout.title', + 'ai_settings.mcp_server.form.timeout.description', + 'ai_settings.mcp_server.form.timeout.placeholder', + 'ai_settings.mcp_server.form.timeout.preset.default', + 'ai_settings.mcp_server.form.timeout.preset.relaxed', + 'ai_settings.mcp_server.form.timeout.preset.slow', + 'ai_settings.mcp_server.form.args.title', + 'ai_settings.mcp_server.form.args.description', + 'ai_settings.mcp_server.form.args.placeholder', + 'ai_settings.mcp_server.form.launch_preview.title', + 'ai_settings.mcp_server.form.launch_preview.description', + 'ai_settings.mcp_server.form.env.title', + 'ai_settings.mcp_server.form.env.description', + 'ai_settings.mcp_server.form.env.placeholder', + 'ai_settings.mcp_server.form.env_status.invalid', + 'ai_settings.mcp_server.form.env_status.valid', + 'ai_settings.mcp_server.form.env_status.empty', + 'ai_settings.mcp_server.form.instructions.title', + 'ai_settings.mcp_server.form.instructions.test_title', + 'ai_settings.mcp_server.form.instructions.test_description', + 'ai_settings.mcp_server.form.instructions.save_title', + 'ai_settings.mcp_server.form.instructions.save_description', + 'ai_settings.mcp_server.form.instructions.tools_found', + 'ai_settings.mcp_server.form.instructions.test_first', + 'ai_settings.mcp_server.form.action.test', + 'ai_settings.mcp_server.form.action.save', + 'ai_settings.mcp_server.form.action.delete', + 'ai_settings.mcp_server.form.action.delete_confirm', + 'ai_settings.mcp_server.form.action.delete_ok', + 'ai_settings.mcp_server.form.action.delete_cancel', +]; + +const buildValidation = (): MCPServerDraftValidation => ({ + issues: [], + errorCount: 0, + warningCount: 0, + infoCount: 0, + canTest: true, + canSave: true, +}); + +const buildEnvDraft = (patch: Partial = {}): ParsedMCPEnvDraft => ({ + env: {}, + totalLines: 0, + validLines: 0, + invalidLines: [], + ...patch, +}); + +const renderPanel = (preference?: 'en-US' | 'zh-CN', parsedEnvDraft = buildEnvDraft()) => { + const panel = ( + 0 || parsedEnvDraft.invalidLines.length > 0 ? 'OPENAI_API_KEY=...' : ''} + parsedEnvDraft={parsedEnvDraft} + validation={buildValidation()} + cardBorder="rgba(0,0,0,0.08)" + inputBg="#fff" + darkMode={false} + overlayTheme={buildOverlayWorkbenchTheme(false)} + loading={false} + onChange={() => undefined} + onEnvDraftChange={() => undefined} + onTest={() => undefined} + onSave={() => undefined} + onDelete={() => undefined} + /> + ); + if (!preference) { + return renderToStaticMarkup(panel); + } + return renderToStaticMarkup( + undefined} + > + {panel} + , + ); +}; + +describe('AIMCPServerFormPanel', () => { + it('uses catalog keys instead of hard-coded Chinese form chrome', () => { + expect(formSource).toContain('useOptionalI18n()'); + expect(formSource).toContain("catalogTranslate('en-US'"); + expect(helpBlockSource).toContain('useOptionalI18n()'); + expect(helpBlockSource).toContain("catalogTranslate('en-US'"); + for (const key of REQUIRED_KEYS) { + expect(formSource + helpBlockSource).toContain(key); + } + + expect(formSource).not.toContain('服务名称'); + expect(formSource).not.toContain('启用状态'); + expect(formSource).not.toContain('启动命令'); + expect(formSource).not.toContain('操作说明'); + expect(formSource).not.toContain('测试工具发现'); + expect(formSource).not.toContain('删除这个 MCP 服务?'); + expect(helpBlockSource).not.toContain('必填'); + expect(helpBlockSource).not.toContain('例如:'); + }); + + it('keeps form keys present in all six catalogs', () => { + for (const key of REQUIRED_KEYS) { + expect(zhCnCatalog[key]).toBeTruthy(); + expect(zhTwCatalog[key]).toBeTruthy(); + expect(enUsCatalog[key]).toBeTruthy(); + expect(jaJpCatalog[key]).toBeTruthy(); + expect(deDeCatalog[key]).toBeTruthy(); + expect(ruRuCatalog[key]).toBeTruthy(); + } + }); + + it('renders English fallback without an i18n provider while keeping raw command examples unchanged', () => { + const markup = renderPanel(); + + expect(markup).toContain('Service name'); + expect(markup).toContain('Enabled'); + expect(markup).toContain('Startup command'); + expect(markup).toContain('Actual launch command preview'); + expect(markup).toContain('Action guide'); + expect(markup).toContain('Test tool discovery'); + expect(markup).toContain('Save'); + expect(markup).toContain('Delete'); + expect(markup).toContain('npx / node / uvx / python / docker'); + expect(markup).toContain('node server.js --stdio'); + expect(markup).not.toContain('服务名称'); + expect(markup).not.toContain('操作说明'); + }); + + it('renders localized env status text with count and invalid line placeholders', () => { + const markup = renderPanel('en-US', buildEnvDraft({ + env: { OPENAI_API_KEY: '...' }, + validLines: 1, + invalidLines: ['bad line'], + })); + + expect(markup).toContain('Detected 1 environment variable'); + expect(markup).toContain('1 invalid line'); + expect(markup).toContain('bad line'); + }); +}); diff --git a/frontend/src/components/ai/AIMCPServerFormPanel.tsx b/frontend/src/components/ai/AIMCPServerFormPanel.tsx index 06b2bc8..c51c942 100644 --- a/frontend/src/components/ai/AIMCPServerFormPanel.tsx +++ b/frontend/src/components/ai/AIMCPServerFormPanel.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { Button, Input, Popconfirm, Select } from 'antd'; import { DeleteOutlined } from '@ant-design/icons'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types'; import type { ParsedMCPEnvDraft } from '../../utils/mcpEnvDraft'; @@ -48,57 +50,73 @@ const AIMCPServerFormPanel: React.FC = ({ onTest, onSave, onDelete, -}) => ( - <> +}) => { + const i18n = useOptionalI18n(); + const copy = ( + key: string, + params?: Record, + ) => (i18n?.t ?? ((catalogKey, catalogParams) => catalogTranslate('en-US', catalogKey, catalogParams)))(key, params); + const envStatusText = envDraft.trim() + ? parsedEnvDraft.invalidLines.length > 0 + ? copy('ai_settings.mcp_server.form.env_status.invalid', { + validCount: parsedEnvDraft.validLines, + invalidCount: parsedEnvDraft.invalidLines.length, + invalidLines: parsedEnvDraft.invalidLines.slice(0, 2).join(' / '), + }) + : copy('ai_settings.mcp_server.form.env_status.valid', { count: parsedEnvDraft.validLines }) + : copy('ai_settings.mcp_server.form.env_status.empty'); + + return ( + <>
- + onChange({ name: event.target.value })} - placeholder="服务名称,例如:Filesystem / Browser / GitHub" + placeholder={copy('ai_settings.mcp_server.form.name.placeholder')} style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} /> - + onChange({ transport: value as AIMCPServerConfig['transport'] })} options={[{ label: 'stdio', value: 'stdio' }]} /> - + onChange({ command: event.target.value })} - placeholder="启动命令,例如:npx / node / uvx / python / docker" + placeholder={copy('ai_settings.mcp_server.form.command.placeholder')} style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} /> - + onChange({ timeoutSeconds: Number(event.target.value) || 20 })} - placeholder="超时(秒)" + placeholder={copy('ai_settings.mcp_server.form.timeout.placeholder')} style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} />
{[ - { label: '默认 20 秒', value: 20 }, - { label: '稍宽松 45 秒', value: 45 }, - { label: '慢启动 60 秒', value: 60 }, + { label: copy('ai_settings.mcp_server.form.timeout.preset.default'), value: 20 }, + { label: copy('ai_settings.mcp_server.form.timeout.preset.relaxed'), value: 45 }, + { label: copy('ai_settings.mcp_server.form.timeout.preset.slow'), value: 60 }, ].map((option) => (
- + {presetKeyFromForm === 'custom' && ( - API 格式} name="apiFormat" style={{ marginBottom: 16 }}> + {copy('ai_settings.form.api_format')}} name="apiFormat" style={{ marginBottom: 16 }}>
= ({ )} - 可用模型列表(可选配置)} name="models" style={{ marginBottom: 0 }}> -
)} @@ -323,10 +327,10 @@ const AISettingsProvidersSection: React.FC = ({
- 认证 & 连接 + {copy('ai_settings.form.section.auth_connection')}
API Key} + label={{copy('ai_settings.form.api_key')}} name="apiKey" rules={[{ validator: (_, value) => { @@ -334,13 +338,13 @@ const AISettingsProvidersSection: React.FC = ({ if (apiKey || editingProvider?.id) { return Promise.resolve(); } - return Promise.reject(new Error('请输入 API Key')); + return Promise.reject(new Error(copy('ai_settings.form.api_key_required'))); }, }]} style={{ marginBottom: 16 }} > = ({ {(presetKeyFromForm === 'custom' || presetKeyFromForm === 'ollama') && ( - API Endpoint (URL)} name="baseUrl" rules={[{ required: true, message: '请输入有效的接口地址' }]} style={{ marginBottom: 0 }}> + {copy('ai_settings.form.api_endpoint')}} name="baseUrl" rules={[{ required: true, message: copy('ai_settings.form.api_endpoint_required') }]} style={{ marginBottom: 0 }}> = ({ style={{ borderRadius: 10 }} icon={testStatus === 'success' ? : undefined} > - {testStatus === 'success' ? '连接正常' : testStatus === 'error' ? '重新测试' : '测试连接'} + {testStatus === 'success' ? copy('ai_settings.action.connection_ok') : testStatus === 'error' ? copy('ai_settings.action.retest') : copy('ai_settings.action.test')}
diff --git a/frontend/src/components/ai/AISettingsSafetySection.tsx b/frontend/src/components/ai/AISettingsSafetySection.tsx index 9da29fe..045e2ab 100644 --- a/frontend/src/components/ai/AISettingsSafetySection.tsx +++ b/frontend/src/components/ai/AISettingsSafetySection.tsx @@ -1,13 +1,21 @@ import React from 'react'; import { CheckOutlined } from '@ant-design/icons'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import type { AISafetyLevel } from '../../types'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; -const SAFETY_OPTIONS: { label: string; value: AISafetyLevel; desc: string; color: string; icon: string }[] = [ - { label: '只读模式', value: 'readonly', desc: 'AI 仅可执行 SELECT 等查询操作,最安全', color: '#22c55e', icon: '🔒' }, - { label: '读写模式', value: 'readwrite', desc: 'AI 可执行 INSERT/UPDATE/DELETE,危险操作需二次确认', color: '#f59e0b', icon: '⚠️' }, - { label: '完全模式', value: 'full', desc: 'AI 可执行所有操作(含 DDL/过程调用),高危或未识别操作会告警', color: '#ef4444', icon: '🔓' }, +const SAFETY_OPTIONS: { + labelKey: string; + value: AISafetyLevel; + descKey: string; + color: string; + icon: string; +}[] = [ + { labelKey: 'ai_settings.safety.readonly.label', value: 'readonly', descKey: 'ai_settings.safety.readonly.desc', color: '#22c55e', icon: '🔒' }, + { labelKey: 'ai_settings.safety.readwrite.label', value: 'readwrite', descKey: 'ai_settings.safety.readwrite.desc', color: '#f59e0b', icon: '⚠️' }, + { labelKey: 'ai_settings.safety.full.label', value: 'full', descKey: 'ai_settings.safety.full.desc', color: '#ef4444', icon: '🔓' }, ]; interface AISettingsSafetySectionProps { @@ -26,56 +34,61 @@ const AISettingsSafetySection: React.FC = ({ cardBg, cardBorder, onChange, -}) => ( -
-
- 控制 AI 可执行的 SQL 操作类型,保护数据安全 -
- {SAFETY_OPTIONS.map((opt) => { - const active = safetyLevel === opt.value; - return ( -
onChange(opt.value)} - style={{ - padding: '14px 16px', - borderRadius: 14, - cursor: 'pointer', - transition: 'all 0.2s ease', - border: `1.5px solid ${active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.selectedText) : cardBorder}`, - background: active ? (opt.color === '#ef4444' ? `${opt.color}15` : overlayTheme.selectedBg) : cardBg, - display: 'flex', - alignItems: 'flex-start', - gap: 14, - }} - > +}) => { + const i18n = useOptionalI18n(); + const copy = (key: string) => (i18n?.t ?? ((catalogKey) => catalogTranslate('en-US', catalogKey)))(key); + + return ( +
+
+ {copy('ai_settings.safety.description')} +
+ {SAFETY_OPTIONS.map((opt) => { + const active = safetyLevel === opt.value; + return (
onChange(opt.value)} style={{ - width: 36, - height: 36, - borderRadius: 10, - display: 'grid', - placeItems: 'center', - fontSize: 18, - flexShrink: 0, - background: active ? (opt.color === '#ef4444' ? `${opt.color}25` : overlayTheme.iconBg) : (darkMode ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.03)'), - color: active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.iconColor) : overlayTheme.mutedText, + padding: '14px 16px', + borderRadius: 14, + cursor: 'pointer', transition: 'all 0.2s ease', + border: `1.5px solid ${active ? (opt.color === '#ef4444' ? opt.color : overlayTheme.selectedText) : cardBorder}`, + background: active ? (opt.color === '#ef4444' ? `${opt.color}15` : overlayTheme.selectedBg) : cardBg, + display: 'flex', + alignItems: 'flex-start', + gap: 14, }} > - {opt.icon} -
-
-
- {opt.label} - {active && } +
+ {opt.icon} +
+
+
+ {copy(opt.labelKey)} + {active && } +
+
{copy(opt.descKey)}
-
{opt.desc}
-
- ); - })} -
-); + ); + })} +
+ ); +}; export default AISettingsSafetySection; diff --git a/frontend/src/components/ai/AISettingsSidebar.test.tsx b/frontend/src/components/ai/AISettingsSidebar.test.tsx index f5adce1..b337079 100644 --- a/frontend/src/components/ai/AISettingsSidebar.test.tsx +++ b/frontend/src/components/ai/AISettingsSidebar.test.tsx @@ -1,24 +1,78 @@ import React from 'react'; +import { readFileSync } from 'node:fs'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import AISettingsSidebar from './AISettingsSidebar'; +import { I18nProvider } from '../../i18n/provider'; +import { t as catalogTranslate } from '../../i18n/catalog'; import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +const sidebarSource = readFileSync(new URL('./AISettingsSidebar.tsx', import.meta.url), 'utf8'); + +const REQUIRED_NAV_KEYS = [ + 'ai_settings.nav.title', + 'ai_settings.nav.providers.title', + 'ai_settings.nav.providers.description', + 'ai_settings.nav.safety.title', + 'ai_settings.nav.safety.description', + 'ai_settings.nav.context.title', + 'ai_settings.nav.context.description', + 'ai_settings.nav.mcp.title', + 'ai_settings.nav.mcp.description', + 'ai_settings.nav.skills.title', + 'ai_settings.nav.skills.description', + 'ai_settings.nav.tools.title', + 'ai_settings.nav.tools.description', + 'ai_settings.nav.prompts.title', + 'ai_settings.nav.prompts.description', +] as const; + describe('AISettingsSidebar', () => { it('renders the ai settings navigation with the active section highlighted', () => { const markup = renderToStaticMarkup( - {}} - />, + {}}> + {}} + /> + , ); - expect(markup).toContain('设置导航'); - expect(markup).toContain('MCP 服务'); - expect(markup).toContain('内置工具'); + expect(markup).toContain('Settings navigation'); + expect(markup).toContain('MCP services'); + expect(markup).toContain('Built-in tools'); expect(markup).toContain('aria-pressed="true"'); }); + + it('uses catalog fallback keys for settings navigation chrome', () => { + expect(sidebarSource).toContain('useOptionalI18n()'); + expect(sidebarSource).toContain("catalogTranslate('en-US'"); + for (const key of REQUIRED_NAV_KEYS) { + expect(catalogTranslate('en-US', key)).not.toBe(key); + expect(catalogTranslate('zh-CN', key)).not.toBe(key); + expect(sidebarSource).toContain(key); + } + + for (const oldCopy of [ + '模型供应商', + '配置大模型接口与秘钥', + '安全控制', + '限制 AI 操作风险级别', + '上下文', + '配置携带的数据架构信息', + 'MCP 服务', + '把 GoNavi 接入外部客户端并管理工具源', + '配置可复用提示模块', + '内置工具', + '查看 AI 可调用的数据探针', + '内置提示词', + '查看系统预设的底层要求', + '设置导航', + ]) { + expect(sidebarSource).not.toContain(oldCopy); + } + }); }); diff --git a/frontend/src/components/ai/AISettingsSidebar.tsx b/frontend/src/components/ai/AISettingsSidebar.tsx index 2164e7a..8d1078c 100644 --- a/frontend/src/components/ai/AISettingsSidebar.tsx +++ b/frontend/src/components/ai/AISettingsSidebar.tsx @@ -8,6 +8,8 @@ import { ToolOutlined, } from '@ant-design/icons'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; export type AISettingsSectionKey = @@ -21,17 +23,17 @@ export type AISettingsSectionKey = const AI_SETTINGS_NAV_ITEMS: Array<{ key: AISettingsSectionKey; - title: string; - description: string; + titleKey: string; + descriptionKey: string; icon: React.ReactNode; }> = [ - { key: 'providers', title: '模型供应商', description: '配置大模型接口与秘钥', icon: }, - { key: 'safety', title: '安全控制', description: '限制 AI 操作风险级别', icon: }, - { key: 'context', title: '上下文', description: '配置携带的数据架构信息', icon: }, - { key: 'mcp', title: 'MCP 服务', description: '把 GoNavi 接入外部客户端并管理工具源', icon: }, - { key: 'skills', title: 'Skills', description: '配置可复用提示模块', icon: }, - { key: 'tools', title: '内置工具', description: '查看 AI 可调用的数据探针', icon: }, - { key: 'prompts', title: '内置提示词', description: '查看系统预设的底层要求', icon: }, + { key: 'providers', titleKey: 'ai_settings.nav.providers.title', descriptionKey: 'ai_settings.nav.providers.description', icon: }, + { key: 'safety', titleKey: 'ai_settings.nav.safety.title', descriptionKey: 'ai_settings.nav.safety.description', icon: }, + { key: 'context', titleKey: 'ai_settings.nav.context.title', descriptionKey: 'ai_settings.nav.context.description', icon: }, + { key: 'mcp', titleKey: 'ai_settings.nav.mcp.title', descriptionKey: 'ai_settings.nav.mcp.description', icon: }, + { key: 'skills', titleKey: 'ai_settings.nav.skills.title', descriptionKey: 'ai_settings.nav.skills.description', icon: }, + { key: 'tools', titleKey: 'ai_settings.nav.tools.title', descriptionKey: 'ai_settings.nav.tools.description', icon: }, + { key: 'prompts', titleKey: 'ai_settings.nav.prompts.title', descriptionKey: 'ai_settings.nav.prompts.description', icon: }, ]; interface AISettingsSidebarProps { @@ -46,44 +48,49 @@ const AISettingsSidebar: React.FC = ({ darkMode, overlayTheme, onSelectSection, -}) => ( -
-
设置导航
-
- {AI_SETTINGS_NAV_ITEMS.map((item) => { - const active = activeSection === item.key; - return ( - - ); - })} +}) => { + const i18n = useOptionalI18n(); + const copy = (key: string) => (i18n?.t ?? ((catalogKey) => catalogTranslate('en-US', catalogKey)))(key); + + return ( +
+
{copy('ai_settings.nav.title')}
+
+ {AI_SETTINGS_NAV_ITEMS.map((item) => { + const active = activeSection === item.key; + return ( + + ); + })} +
-
-); + ); +}; export default AISettingsSidebar; diff --git a/frontend/src/components/ai/AISettingsSkillsSection.test.tsx b/frontend/src/components/ai/AISettingsSkillsSection.test.tsx index 5317d01..e112317 100644 --- a/frontend/src/components/ai/AISettingsSkillsSection.test.tsx +++ b/frontend/src/components/ai/AISettingsSkillsSection.test.tsx @@ -1,30 +1,124 @@ import React from 'react'; +import { readFileSync } from 'node:fs'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import AISettingsSkillsSection from './AISettingsSkillsSection'; +import { I18nProvider } from '../../i18n/provider'; +import { t as catalogTranslate } from '../../i18n/catalog'; import { buildOverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; +import type { AISkillConfig } from '../../types'; + +const skillsSectionSource = readFileSync(new URL('./AISettingsSkillsSection.tsx', import.meta.url), 'utf8'); + +const REQUIRED_SKILL_KEYS = [ + 'ai_settings.skill.description', + 'ai_settings.skill.hint', + 'ai_settings.skill.action.add', + 'ai_settings.skill.empty', + 'ai_settings.skill.name_placeholder', + 'ai_settings.skill.status.enabled', + 'ai_settings.skill.status.disabled', + 'ai_settings.skill.description_placeholder', + 'ai_settings.skill.scope.global.label', + 'ai_settings.skill.scope.global.desc', + 'ai_settings.skill.scope.database.label', + 'ai_settings.skill.scope.database.desc', + 'ai_settings.skill.scope.jvm.label', + 'ai_settings.skill.scope.jvm.desc', + 'ai_settings.skill.scope.jvm_diagnostic.label', + 'ai_settings.skill.scope.jvm_diagnostic.desc', + 'ai_settings.skill.scopes_placeholder', + 'ai_settings.skill.required_tools_placeholder', + 'ai_settings.skill.system_prompt_placeholder', + 'ai_settings.skill.confirm_delete', + 'common.save', + 'common.delete', + 'common.cancel', +] as const; + +const skillDraft: AISkillConfig = { + id: 'skill-1', + name: '', + description: '', + scopes: [], + requiredTools: [], + systemPrompt: '', + enabled: true, +}; + +const renderSection = (skills: AISkillConfig[]) => renderToStaticMarkup( + {}}> + {}} + onUpdateSkillDraft={() => {}} + onSaveSkill={() => {}} + onDeleteSkill={() => {}} + /> + , +); describe('AISettingsSkillsSection', () => { - it('renders the extracted skill configuration section', () => { - const markup = renderToStaticMarkup( - {}} - onUpdateSkillDraft={() => {}} - onSaveSkill={() => {}} - onDeleteSkill={() => {}} - />, - ); + it('renders the empty skill configuration section in English', () => { + const markup = renderSection([]); - expect(markup).toContain('新增 Skill'); - expect(markup).toContain('还没有 Skill'); - expect(markup).toContain('命名的提示模块'); + expect(markup).toContain('Add Skill'); + expect(markup).toContain('No Skills yet.'); + expect(markup).toContain('named prompt module'); + }); + + it('renders skill draft placeholders and actions in English', () => { + const markup = renderSection([skillDraft]); + + expect(markup).toContain('Skill name, for example: SQL review / JVM diagnostic plan'); + expect(markup).toContain('Enabled'); + expect(markup).toContain('Select where this Skill should apply'); + expect(markup).toContain('Save'); + expect(markup).toContain('Delete'); + }); + + it('uses catalog keys for skill settings chrome', () => { + expect(skillsSectionSource).toContain('useOptionalI18n()'); + expect(skillsSectionSource).toContain("catalogTranslate('en-US'"); + for (const key of REQUIRED_SKILL_KEYS) { + expect(catalogTranslate('en-US', key)).not.toBe(key); + expect(catalogTranslate('zh-CN', key)).not.toBe(key); + expect(skillsSectionSource).toContain(key); + } + + for (const oldCopy of [ + '全局', + '所有 AI 会话都启用', + '数据库', + '仅 SQL / 数据库场景启用', + 'JVM 资源', + '仅 JVM 资源分析场景启用', + 'JVM 诊断', + '仅 JVM 诊断工作台启用', + 'Skill 不是另一条大提示词', + '启用后会按 scope 注入对应会话', + '新增 Skill', + '还没有 Skill', + 'Skill 名称,例如:SQL 审查 / JVM 诊断计划', + '已启用', + '已禁用', + '给自己看的说明', + '选择这个 Skill 要作用到哪些场景', + '可选:声明这个 Skill 依赖哪些工具', + '输入这条 Skill 要追加的 system prompt', + '保存', + '删除这个 Skill?', + '删除', + '取消', + ]) { + expect(skillsSectionSource).not.toContain(oldCopy); + } }); }); diff --git a/frontend/src/components/ai/AISettingsSkillsSection.tsx b/frontend/src/components/ai/AISettingsSkillsSection.tsx index 8bab4c8..9944250 100644 --- a/frontend/src/components/ai/AISettingsSkillsSection.tsx +++ b/frontend/src/components/ai/AISettingsSkillsSection.tsx @@ -2,6 +2,8 @@ import React from 'react'; import { Button, Input, Popconfirm, Select } from 'antd'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import type { AISkillConfig, AISkillScope } from '../../types'; import type { OverlayWorkbenchTheme } from '../../utils/overlayWorkbenchTheme'; @@ -19,11 +21,11 @@ interface AISettingsSkillsSectionProps { onDeleteSkill: (id: string) => void; } -const SKILL_SCOPE_OPTIONS: Array<{ value: AISkillScope; label: string; desc: string }> = [ - { value: 'global', label: '全局', desc: '所有 AI 会话都启用' }, - { value: 'database', label: '数据库', desc: '仅 SQL / 数据库场景启用' }, - { value: 'jvm', label: 'JVM 资源', desc: '仅 JVM 资源分析场景启用' }, - { value: 'jvmDiagnostic', label: 'JVM 诊断', desc: '仅 JVM 诊断工作台启用' }, +const SKILL_SCOPE_OPTIONS: Array<{ value: AISkillScope; labelKey: string; descKey: string }> = [ + { value: 'global', labelKey: 'ai_settings.skill.scope.global.label', descKey: 'ai_settings.skill.scope.global.desc' }, + { value: 'database', labelKey: 'ai_settings.skill.scope.database.label', descKey: 'ai_settings.skill.scope.database.desc' }, + { value: 'jvm', labelKey: 'ai_settings.skill.scope.jvm.label', descKey: 'ai_settings.skill.scope.jvm.desc' }, + { value: 'jvmDiagnostic', labelKey: 'ai_settings.skill.scope.jvm_diagnostic.label', descKey: 'ai_settings.skill.scope.jvm_diagnostic.desc' }, ]; const AISettingsSkillsSection: React.FC = ({ @@ -38,73 +40,95 @@ const AISettingsSkillsSection: React.FC = ({ onUpdateSkillDraft, onSaveSkill, onDeleteSkill, -}) => ( -
-
- Skill 不是另一条大提示词,而是“命名的提示模块 + 作用域 + 工具依赖”。当前阶段仍建议保留在主仓库内,不需要单独新建 GitHub 仓库;只有未来要做共享 skill pack 分发时,再考虑拆仓。 -
-
-
启用后会按 scope 注入对应会话;如果依赖的工具不存在,该 Skill 会被自动跳过。
- -
- {skills.length === 0 && ( -
- 还没有 Skill。你可以给数据库、JVM、诊断场景分别定义专用的 system prompt。 +}) => { + const i18n = useOptionalI18n(); + const copy = (key: string) => (i18n?.t ?? ((catalogKey) => catalogTranslate('en-US', catalogKey)))(key); + + return ( +
+
+ {copy('ai_settings.skill.description')}
- )} - {skills.map((skill) => ( -
-
+
+
{copy('ai_settings.skill.hint')}
+ +
+ {skills.length === 0 && ( +
+ {copy('ai_settings.skill.empty')} +
+ )} + {skills.map((skill) => ( +
+
+ onUpdateSkillDraft(skill.id, { name: event.target.value })} + placeholder={copy('ai_settings.skill.name_placeholder')} + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} + /> + onUpdateSkillDraft(skill.id, { name: event.target.value })} - placeholder="Skill 名称,例如:SQL 审查 / JVM 诊断计划" + value={skill.description || ''} + onChange={(event) => onUpdateSkillDraft(skill.id, { description: event.target.value })} + placeholder={copy('ai_settings.skill.description_placeholder')} style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} /> onUpdateSkillDraft(skill.id, { requiredTools: value })} + options={skillRequiredToolOptions} + placeholder={copy('ai_settings.skill.required_tools_placeholder')} + style={{ width: '100%' }} + /> + onUpdateSkillDraft(skill.id, { systemPrompt: event.target.value })} + placeholder={copy('ai_settings.skill.system_prompt_placeholder')} + style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)', resize: 'vertical' }} + /> +
+ + onDeleteSkill(skill.id)} + > + + +
- onUpdateSkillDraft(skill.id, { description: event.target.value })} - placeholder="给自己看的说明,例如:输出 SQL 前必须先确认字段名和风险" - style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}` }} - /> - onUpdateSkillDraft(skill.id, { requiredTools: value })} - options={skillRequiredToolOptions} - placeholder="可选:声明这个 Skill 依赖哪些工具" - style={{ width: '100%' }} - /> - onUpdateSkillDraft(skill.id, { systemPrompt: event.target.value })} - placeholder="输入这条 Skill 要追加的 system prompt。建议聚焦一个明确能力,不要和全局提示词重复。" - style={{ borderRadius: 10, background: inputBg, border: `1px solid ${cardBorder}`, fontFamily: 'var(--gn-font-mono)', resize: 'vertical' }} - /> -
- - onDeleteSkill(skill.id)}> - - -
-
- ))} -
-); + ))} +
+ ); +}; export default AISettingsSkillsSection; diff --git a/frontend/src/components/ai/AISlashCommandMenu.test.tsx b/frontend/src/components/ai/AISlashCommandMenu.test.tsx index 6fe9fb5..5feb61d 100644 --- a/frontend/src/components/ai/AISlashCommandMenu.test.tsx +++ b/frontend/src/components/ai/AISlashCommandMenu.test.tsx @@ -1,10 +1,45 @@ +import { readFileSync } from 'node:fs'; import React from 'react'; import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; +import { I18nProvider } from '../../i18n/provider'; import AISlashCommandMenu from './AISlashCommandMenu'; +import { filterAISlashCommands } from './aiSlashCommands'; + +const source = readFileSync(new URL('./AISlashCommandMenu.tsx', import.meta.url), 'utf8'); + +const renderWithProvider = ( + language: 'zh-CN' | 'zh-TW' | 'en-US' | 'ja-JP' | 'de-DE' | 'ru-RU', + commands = filterAISlashCommands('/'), +) => renderToStaticMarkup( + undefined} + > + {}} + /> + , +); describe('AISlashCommandMenu', () => { + it('uses optional i18n fallback keys instead of legacy Chinese empty-state literals', () => { + expect(source).toContain('useOptionalI18n()'); + expect(source).toContain("catalogTranslate('en-US', key, params)"); + expect(source).toContain("ai_chat.input.slash.empty.title"); + expect(source).toContain("ai_chat.input.slash.empty.summary"); + expect(source).not.toContain('没有匹配的快捷命令'); + expect(source).not.toContain('可以先试这些更常用的入口'); + expect(source).not.toContain('当前共提供'); + }); + it('renders an empty-state hint when the slash filter has no matches', () => { const markup = renderToStaticMarkup( { ); expect(markup).toContain('data-ai-chat-slash-empty="true"'); - expect(markup).toContain('没有匹配的快捷命令'); + expect(markup).toContain('No matching slash commands'); + expect(markup).toContain('Try these common entries first to jump into SQL generation, AI health checks, or MCP diagnostics.'); + expect(markup).toContain('There are 24 slash commands available. Search by command name, description, or keyword.'); expect(markup).toContain('/sql'); expect(markup).toContain('/health'); expect(markup).toContain('/mcpadd'); }); - it('renders grouped slash command entries when matches exist', () => { - const markup = renderToStaticMarkup( - {}} - />, - ); + it('renders grouped slash command entries with localized english copy when matches exist', () => { + const markup = renderWithProvider('en-US'); expect(markup).toContain('/sql'); - expect(markup).toContain('生成 SQL'); + expect(markup).toContain('📝 Generate SQL'); expect(markup).toContain('data-ai-chat-slash-group="generate"'); - expect(markup).toContain('SQL 生成'); - expect(markup).not.toContain('没有匹配的快捷命令'); + expect(markup).toContain('SQL generation'); + expect(markup).toContain('Diagnostic probes'); + expect(markup).not.toContain('No matching slash commands'); }); }); diff --git a/frontend/src/components/ai/AISlashCommandMenu.tsx b/frontend/src/components/ai/AISlashCommandMenu.tsx index 690a335..255845f 100644 --- a/frontend/src/components/ai/AISlashCommandMenu.tsx +++ b/frontend/src/components/ai/AISlashCommandMenu.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import { useOptionalI18n } from '../../i18n/provider'; import { DEFAULT_AI_SLASH_COMMANDS, @@ -44,7 +46,11 @@ export const AISlashCommandMenu: React.FC = ({ return null; } - const groups = groupAISlashCommands(commands); + const i18n = useOptionalI18n(); + const t = i18n?.t ?? ((key: string, params?: Record) => + catalogTranslate('en-US', key, params)); + const groups = React.useMemo(() => groupAISlashCommands(commands, t), [commands, t]); + const featuredCommands = React.useMemo(() => getFeaturedAISlashCommands(t), [t]); return (
= ({ }} >
- 没有匹配的快捷命令 + {t('ai_chat.input.slash.empty.title')}
- 可以先试这些更常用的入口,快速走到生成 SQL、AI 体检或 MCP 排查。 + {t('ai_chat.input.slash.empty.description')}
{featuredCommands.map((command) => ( @@ -121,7 +127,7 @@ export const AISlashCommandMenu: React.FC = ({ ))}
- 当前共提供 {DEFAULT_AI_SLASH_COMMANDS.length} 个 slash 命令,支持按命令名、中文说明或关键词搜索。 + {t('ai_chat.input.slash.empty.summary', { count: DEFAULT_AI_SLASH_COMMANDS.length })}
)} diff --git a/frontend/src/components/ai/aiAppHealthInsights.test.ts b/frontend/src/components/ai/aiAppHealthInsights.test.ts index 4dc4ca3..37e2415 100644 --- a/frontend/src/components/ai/aiAppHealthInsights.test.ts +++ b/frontend/src/components/ai/aiAppHealthInsights.test.ts @@ -1,8 +1,103 @@ +import { readFileSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; import { buildAIAppHealthSnapshot } from './aiAppHealthInsights'; describe('buildAIAppHealthSnapshot', () => { + it('localizes app health wrappers while keeping raw runtime details unchanged', () => { + const translate = (key: string, params?: Record) => { + const suffix = params + ? ` ${Object.entries(params).map(([paramKey, value]) => `${paramKey}=${value}`).join(',')}` + : ''; + return `T:${key}${suffix}`; + }; + + const snapshot = buildAIAppHealthSnapshot({ + providers: [{ + id: 'provider-1', + type: 'openai', + name: 'OpenAI 主账号', + apiKey: '', + hasSecret: true, + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.4', + models: ['gpt-5.4'], + maxTokens: 32000, + temperature: 0.2, + }], + activeProviderId: 'provider-1', + mcpServers: [{ + id: 'server-1', + name: 'GoNavi MCP', + transport: 'stdio', + command: 'gonavi-mcp-server', + args: ['stdio'], + env: {}, + enabled: true, + timeoutSeconds: 20, + }], + mcpClientStatuses: [{ + client: 'codex', + displayName: 'Codex', + installed: true, + matchesCurrent: true, + clientDetected: true, + clientCommand: 'codex', + clientPath: 'C:/Tools/codex.exe', + configPath: 'C:/Users/demo/.codex/config.toml', + command: 'gonavi-mcp-server', + args: ['stdio'], + message: '已接入当前 GoNavi MCP', + }], + mcpTools: [{ + alias: 'inspect_app_health', + originalName: 'inspect_app_health', + serverId: 'server-1', + serverName: 'GoNavi MCP', + title: 'Inspect app health', + }], + userPromptSettings: { + global: '回答前先核对上下文。', + database: '', + jvm: '', + jvmDiagnostic: '', + }, + tabs: [], + appLogReadResult: { + success: false, + message: 'raw log backend failure', + }, + connectionFailureReadResult: { + success: false, + message: 'raw connection log failure', + }, + translate, + } as any); + + expect(snapshot.appLog.message).toBe('T:ai_chat.inspection.app_health.app_log.unread detail=raw log backend failure'); + expect(snapshot.connectionFailures.message).toBe( + 'T:ai_chat.inspection.app_health.connection_failures.unread detail=raw connection log failure', + ); + expect(snapshot.warnings).toContain('T:ai_chat.inspection.app_health.warning.app_log_unread'); + expect(snapshot.warnings).toContain('T:ai_chat.inspection.app_health.warning.connection_failures_unread'); + expect(snapshot.warnings).toContain('T:ai_chat.inspection.app_health.warning.no_workspace_tabs'); + expect(snapshot.nextActions).toContain('T:ai_chat.inspection.app_health.next_action.enable_app_log_reading'); + expect(snapshot.nextActions).toContain('T:ai_chat.inspection.app_health.next_action.open_sql_tab'); + expect(snapshot.message).toBe('T:ai_chat.inspection.app_health.message.needs_attention count=3'); + }); + + it('keeps app health production source free of legacy Chinese wrappers', () => { + const source = readFileSync('src/components/ai/aiAppHealthInsights.ts', 'utf8'); + + expect(source).not.toContain('当前还没有记录到 AI 消息渲染异常'); + expect(source).not.toContain('GoNavi 应用日志暂不可读'); + expect(source).not.toContain('连接失败日志暂不可读'); + expect(source).not.toContain('当前无法读取 GoNavi 应用日志'); + expect(source).not.toContain('最近应用日志里有 '); + expect(source).not.toContain('当前 AI 应用健康总览通过'); + }); + it('marks the app health as degraded when logs and connection failures show runtime problems', () => { const snapshot = buildAIAppHealthSnapshot({ providers: [{ @@ -93,8 +188,8 @@ describe('buildAIAppHealthSnapshot', () => { expect(snapshot.summary.appLogErrorCount).toBe(1); expect(snapshot.summary.recentConnectionFailureCount).toBe(2); expect(snapshot.summary.activeTabTitle).toBe('订单查询'); - expect(snapshot.warnings).toContain('最近应用日志里有 1 条 ERROR,需要优先查看 inspect_app_logs'); - expect(snapshot.nextActions).toContain('调用 inspect_recent_connection_failures 查看最新连接失败根因,再决定是否检查当前连接或保存连接配置'); + expect(snapshot.warnings).toContain('Recent application logs contain 1 ERROR entries; inspect_app_logs should be checked first'); + expect(snapshot.nextActions).toContain('Call inspect_recent_connection_failures to review the latest connection failure cause, then decide whether to inspect the current connection or saved connection config'); expect(snapshot.appLog.lines).toHaveLength(0); expect(snapshot.appLog.linesOmitted).toBe(true); }); @@ -125,8 +220,8 @@ describe('buildAIAppHealthSnapshot', () => { }); expect(snapshot.status).toBe('blocked'); - expect(snapshot.blockers).toContain('当前活动供应商缺少 API Key / Secret'); - expect(snapshot.blockers).toContain('当前活动供应商缺少接口地址'); + expect(snapshot.blockers).toContain('The active provider is missing an API Key / Secret'); + expect(snapshot.blockers).toContain('The active provider is missing a base URL'); expect(snapshot.summary.chatReady).toBe(false); }); @@ -237,8 +332,8 @@ describe('buildAIAppHealthSnapshot', () => { expect(snapshot.status).toBe('degraded'); expect(snapshot.summary.hasLastAIMessageRenderError).toBe(true); expect(snapshot.summary.lastAIMessageRenderErrorId).toBe('msg-1'); - expect(snapshot.warnings).toContain('最近记录到 AI 消息渲染异常,可能影响回复气泡展示或 Markdown 渲染'); - expect(snapshot.nextActions).toContain('调用 inspect_ai_last_render_error 查看最近一次气泡渲染异常的 messageId、内容预览和组件栈'); + expect(snapshot.warnings).toContain('A recent AI message render error was recorded and may affect reply bubble display or Markdown rendering'); + expect(snapshot.nextActions).toContain('Call inspect_ai_last_render_error to review the latest bubble render error messageId, content preview, and component stack'); expect(snapshot.lastRenderError.errorMessage).toBe('Cannot read properties of undefined'); }); }); diff --git a/frontend/src/components/ai/aiAppHealthInsights.ts b/frontend/src/components/ai/aiAppHealthInsights.ts index 6cbfc1f..d4bdfe6 100644 --- a/frontend/src/components/ai/aiAppHealthInsights.ts +++ b/frontend/src/components/ai/aiAppHealthInsights.ts @@ -1,3 +1,9 @@ +import type { + I18nParams, +} from '../../i18n'; +import { + t as translateCatalog, +} from '../../i18n'; import type { AIContextItem, AIMCPClientInstallStatus, @@ -16,6 +22,7 @@ import { buildRecentConnectionFailureSnapshot } from './aiConnectionFailureInsig import { buildActiveTabSnapshot, buildWorkspaceTabsSnapshot } from './aiWorkspaceInsights'; type AIAppHealthStatus = 'ready' | 'needs_attention' | 'degraded' | 'blocked'; +type AIInspectionTranslator = (key: string, params?: I18nParams) => string; interface AILastRenderErrorHealthSnapshot { hasError: boolean; @@ -41,6 +48,17 @@ const appendUnique = (items: string[], value: string) => { items.push(trimmed); }; +const translateInspectionCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: I18nParams, +): string => { + const t = translate || ((catalogKey, catalogParams) => translateCatalog(catalogKey, catalogParams, 'en-US')); + const translated = t(key, params); + return translated && translated !== key ? translated : fallback; +}; + const normalizeAppHealthLogLimit = (value: unknown): number => { const normalized = Math.floor(Number(value) || DEFAULT_APP_HEALTH_LOG_LIMIT); if (normalized < 1) return 1; @@ -71,9 +89,15 @@ const buildUnreadLogSnapshot = (message: string, lineLimit: number) => ({ message, }); -const buildEmptyLastRenderErrorSnapshot = (): AILastRenderErrorHealthSnapshot => ({ +const buildEmptyLastRenderErrorSnapshot = ( + translate?: AIInspectionTranslator, +): AILastRenderErrorHealthSnapshot => ({ hasError: false, - summary: '当前还没有记录到 AI 消息渲染异常。', + summary: translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.last_render_error.empty_summary', + 'No AI message render errors have been recorded yet.', + ), nextActions: [], }); @@ -83,11 +107,22 @@ const summarizeAppLogSnapshot = ( keyword?: unknown; lineLimit: number; includeLogLines?: boolean; + translate?: AIInspectionTranslator; }, ) => { if (!readResult?.success) { + const detail = readResult?.message || translateInspectionCopy( + options.translate, + 'ai_chat.inspection.app_health.log_reading_unavailable', + 'The current runtime does not provide log reading capability', + ); return buildUnreadLogSnapshot( - `GoNavi 应用日志暂不可读: ${readResult?.message || '当前环境未提供日志读取能力'}`, + translateInspectionCopy( + options.translate, + 'ai_chat.inspection.app_health.app_log.unread', + `GoNavi application logs are not readable: ${detail}`, + { detail }, + ), options.lineLimit, ); } @@ -96,6 +131,7 @@ const summarizeAppLogSnapshot = ( readResult, keyword: options.keyword, lineLimit: options.lineLimit, + translate: options.translate, }); return { readable: true, @@ -110,9 +146,15 @@ const summarizeConnectionFailures = ( options: { keyword?: unknown; lineLimit: number; + translate?: AIInspectionTranslator; }, ) => { if (!readResult?.success) { + const detail = readResult?.message || translateInspectionCopy( + options.translate, + 'ai_chat.inspection.app_health.log_reading_unavailable', + 'The current runtime does not provide log reading capability', + ); return { readable: false, logPath: '', @@ -134,7 +176,12 @@ const summarizeConnectionFailures = ( latestFailure: null, recentFailures: [], nextActions: [] as string[], - message: `连接失败日志暂不可读: ${readResult?.message || '当前环境未提供日志读取能力'}`, + message: translateInspectionCopy( + options.translate, + 'ai_chat.inspection.app_health.connection_failures.unread', + `Connection failure logs are not readable: ${detail}`, + { detail }, + ), }; } @@ -144,6 +191,7 @@ const summarizeConnectionFailures = ( readResult, keyword: options.keyword, lineLimit: options.lineLimit, + translate: options.translate, }), }; }; @@ -172,7 +220,9 @@ export const buildAIAppHealthSnapshot = (params: { connectionKeyword?: unknown; lineLimit?: unknown; includeLogLines?: boolean; + translate?: AIInspectionTranslator; }) => { + const translate = params.translate; const connections = Array.isArray(params.connections) ? params.connections : []; const tabs = Array.isArray(params.tabs) ? params.tabs : []; const lineLimit = normalizeAppHealthLogLimit(params.lineLimit); @@ -197,11 +247,13 @@ export const buildAIAppHealthSnapshot = (params: { keyword: params.keyword, lineLimit, includeLogLines: params.includeLogLines === true, + translate, }); - const lastRenderError = params.lastRenderErrorSnapshot || buildEmptyLastRenderErrorSnapshot(); + const lastRenderError = params.lastRenderErrorSnapshot || buildEmptyLastRenderErrorSnapshot(translate); const connectionFailures = summarizeConnectionFailures(params.connectionFailureReadResult, { keyword: params.connectionKeyword ?? params.keyword, lineLimit, + translate, }); const workspace = buildWorkspaceTabsSnapshot({ tabs, @@ -224,36 +276,91 @@ export const buildAIAppHealthSnapshot = (params: { const nextActions = [...setupHealth.nextActions]; if (!appLog.readable) { - appendUnique(warnings, '当前无法读取 GoNavi 应用日志,启动异常和 MCP/连接错误缺少日志证据'); - appendUnique(nextActions, '确认当前运行环境支持读取 gonavi.log 后,再调用 inspect_app_logs 下钻日志细节'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.warning.app_log_unread', + 'GoNavi application logs cannot be read, so startup exceptions and MCP/connection errors lack log evidence', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.next_action.enable_app_log_reading', + 'Confirm the current runtime can read gonavi.log, then call inspect_app_logs for log details', + )); } else { const errorCount = Number(appLog.levelBreakdown.ERROR) || 0; const warnCount = Number(appLog.levelBreakdown.WARN) || 0; if (errorCount > 0) { - appendUnique(warnings, `最近应用日志里有 ${errorCount} 条 ERROR,需要优先查看 inspect_app_logs`); - appendUnique(nextActions, '调用 inspect_app_logs 查看最近 ERROR/WARN 原文,确认是否影响 AI、MCP 或数据库连接'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.warning.app_log_errors', + `Recent application logs contain ${errorCount} ERROR entries; inspect_app_logs should be checked first`, + { count: errorCount }, + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.next_action.inspect_app_log_errors', + 'Call inspect_app_logs to review recent raw ERROR/WARN lines and confirm whether they affect AI, MCP, or database connections', + )); } else if (warnCount > 0) { - appendUnique(warnings, `最近应用日志里有 ${warnCount} 条 WARN,建议确认是否为已知可忽略警告`); - appendUnique(nextActions, '如用户反馈不稳定,先调用 inspect_app_logs 查看 WARN 是否集中在 AI/MCP/连接链路'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.warning.app_log_warnings', + `Recent application logs contain ${warnCount} WARN entries; confirm whether they are known ignorable warnings`, + { count: warnCount }, + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.next_action.inspect_app_log_warnings', + 'If the user reports instability, call inspect_app_logs first to see whether WARN lines cluster around AI/MCP/connection paths', + )); } } if (!connectionFailures.readable) { - appendUnique(warnings, '当前无法读取连接失败日志,数据库连接冷却和验证失败缺少结构化证据'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.warning.connection_failures_unread', + 'Connection failure logs cannot be read, so database connection cooldown and validation failures lack structured evidence', + )); } else if (connectionFailures.failureEventCount > 0) { - appendUnique(warnings, `最近识别到 ${connectionFailures.failureEventCount} 条连接失败/冷却记录`); - appendUnique(nextActions, '调用 inspect_recent_connection_failures 查看最新连接失败根因,再决定是否检查当前连接或保存连接配置'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.warning.connection_failures_recent', + `Recently detected ${connectionFailures.failureEventCount} connection failure/cooldown records`, + { count: connectionFailures.failureEventCount }, + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.next_action.inspect_recent_connection_failures', + 'Call inspect_recent_connection_failures to review the latest connection failure cause, then decide whether to inspect the current connection or saved connection config', + )); connectionFailures.nextActions.forEach((action: string) => appendUnique(nextActions, action)); } if (workspace.totalTabs === 0) { - appendUnique(warnings, '当前工作区没有打开任何页签,AI 缺少可直接读取的活动编辑器上下文'); - appendUnique(nextActions, '如果要分析当前 SQL,先打开或选中目标 SQL 页签,再调用 inspect_active_tab'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.warning.no_workspace_tabs', + 'No tabs are open in the current workspace, so AI has no active editor context to read directly', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.next_action.open_sql_tab', + 'To analyze the current SQL, open or select the target SQL tab first, then call inspect_active_tab', + )); } if (lastRenderError.hasError) { - appendUnique(warnings, '最近记录到 AI 消息渲染异常,可能影响回复气泡展示或 Markdown 渲染'); - appendUnique(nextActions, '调用 inspect_ai_last_render_error 查看最近一次气泡渲染异常的 messageId、内容预览和组件栈'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.warning.last_render_error', + 'A recent AI message render error was recorded and may affect reply bubble display or Markdown rendering', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.next_action.inspect_last_render_error', + 'Call inspect_ai_last_render_error to review the latest bubble render error messageId, content preview, and component stack', + )); (lastRenderError.nextActions || []).forEach((action) => appendUnique(nextActions, action)); } @@ -266,12 +373,30 @@ export const buildAIAppHealthSnapshot = (params: { : 'ready'; const message = status === 'ready' - ? '当前 AI 应用健康总览通过,AI 配置、日志、连接失败和工作区上下文都没有明显异常' + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.message.ready', + 'The AI application health overview passed; AI configuration, logs, connection failures, and workspace context show no obvious issues', + ) : status === 'blocked' - ? `当前 AI 应用健康存在 ${blockers.length} 个阻塞项,优先修复供应商和发送前置条件` + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.message.blocked', + `AI application health has ${blockers.length} blockers; fix provider and send prerequisites first`, + { count: blockers.length }, + ) : status === 'degraded' - ? '当前 AI 应用健康存在运行期异常信号,建议先下钻日志或连接失败记录' - : `当前 AI 应用健康整体可用,但还有 ${warnings.length} 个建议项`; + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.message.degraded', + 'AI application health has runtime anomaly signals; drill into logs or connection failure records first', + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.app_health.message.needs_attention', + `AI application health is usable overall, but still has ${warnings.length} recommendations`, + { count: warnings.length }, + ); return { status, diff --git a/frontend/src/components/ai/aiAppLogInsights.test.ts b/frontend/src/components/ai/aiAppLogInsights.test.ts index 0216f5d..ae71db1 100644 --- a/frontend/src/components/ai/aiAppLogInsights.test.ts +++ b/frontend/src/components/ai/aiAppLogInsights.test.ts @@ -45,4 +45,24 @@ describe('buildAppLogSnapshot', () => { expect(snapshot.returnedLineCount).toBe(0); expect(snapshot.message).toContain('mcp'); }); + + it('localizes empty-state wrapper while preserving the raw keyword', () => { + const snapshot = buildAppLogSnapshot({ + readResult: { + success: true, + data: { + lines: [], + }, + }, + keyword: 'MCP 启动失败', + translate: (key, params) => { + if (key === 'ai_chat.inspection.app_log.message.no_keyword_match') { + return `no match for ${params?.keyword}`; + } + return key; + }, + }); + + expect(snapshot.message).toBe('no match for MCP 启动失败'); + }); }); diff --git a/frontend/src/components/ai/aiAppLogInsights.ts b/frontend/src/components/ai/aiAppLogInsights.ts index aa47b7f..92d44db 100644 --- a/frontend/src/components/ai/aiAppLogInsights.ts +++ b/frontend/src/components/ai/aiAppLogInsights.ts @@ -1,3 +1,6 @@ +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; + const DEFAULT_APP_LOG_LIMIT = 80; const MAX_APP_LOG_LIMIT = 200; @@ -38,6 +41,7 @@ export const buildAppLogSnapshot = (params: { readResult?: any; keyword?: unknown; lineLimit?: unknown; + translate?: AIInspectionTranslator; }) => { const data = params.readResult?.data && typeof params.readResult.data === 'object' ? params.readResult.data as Record @@ -61,7 +65,16 @@ export const buildAppLogSnapshot = (params: { message: lines.length > 0 ? '' : keyword - ? `最近日志里没有匹配关键词“${keyword}”的记录` - : '最近日志里暂无可读记录', + ? translateInspectionCopy( + params.translate, + 'ai_chat.inspection.app_log.message.no_keyword_match', + `No recent log entries matched keyword "${keyword}".`, + { keyword }, + ) + : translateInspectionCopy( + params.translate, + 'ai_chat.inspection.app_log.message.no_readable_entries', + 'No readable recent log entries are available.', + ), }; }; diff --git a/frontend/src/components/ai/aiChatAttachments.test.ts b/frontend/src/components/ai/aiChatAttachments.test.ts index 6b23a17..a2a0888 100644 --- a/frontend/src/components/ai/aiChatAttachments.test.ts +++ b/frontend/src/components/ai/aiChatAttachments.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { strToU8, zipSync } from 'fflate'; import { @@ -12,7 +13,115 @@ const makeFile = (parts: BlobPart[], name: string, type: string): File => { return Object.assign(blob, { name, lastModified: 0 }) as File; }; +const source = readFileSync(new URL('./aiChatAttachments.ts', import.meta.url), 'utf8'); + +const translateAttachmentWarning = ( + key: string, + params?: Record, +): string => ({ + 'ai_chat.input.attachment.excel.worksheet_header': `[Worksheet: ${params?.sheetName}]`, + 'ai_chat.input.attachment.kind.text': 'Text', + 'ai_chat.input.attachment.kind.markdown': 'Markdown', + 'ai_chat.input.attachment.kind.pdf': 'PDF', + 'ai_chat.input.attachment.kind.word': 'Word', + 'ai_chat.input.attachment.kind.excel': 'Excel', + 'ai_chat.input.attachment.kind.document': 'File', + 'ai_chat.input.attachment.warning.pdf_partial_text': 'PDF used lightweight text extraction; scanned or compressed-font content may not be fully readable.', + 'ai_chat.input.attachment.warning.pdf_no_text': 'No readable text was extracted from the PDF; if it is scanned or uses complex encoding, copy the body before sending.', + 'ai_chat.input.attachment.warning.legacy_office_partial_text': 'Legacy Office binary files only use lightweight text snippet extraction; convert to docx/xlsx before uploading for more complete content.', + 'ai_chat.input.attachment.warning.too_large': `File exceeds ${params?.size}; file metadata was attached but the body was not read.`, + 'ai_chat.input.attachment.warning.unsupported_type': 'This file type was attached, but body text was not extracted yet; use markdown, txt, docx, xlsx, or pdf if the model needs the content.', + 'ai_chat.input.attachment.warning.extract_failed': `Attachment body extraction failed: ${params?.detail}`, + 'ai_chat.input.attachment.prompt.content_truncated': '[Attachment body truncated]', + 'ai_chat.input.attachment.prompt.heading': `### Attachment ${params?.index}: ${params?.name}`, + 'ai_chat.input.attachment.prompt.kind': `- Type: ${params?.kind}`, + 'ai_chat.input.attachment.prompt.mime': `- MIME: ${params?.mimeType}`, + 'ai_chat.input.attachment.prompt.size': `- Size: ${params?.size}`, + 'ai_chat.input.attachment.prompt.extract_warning': `- Extraction note: ${params?.message}`, + 'ai_chat.input.attachment.prompt.text_truncated': '- Extraction note: Body text was truncated before sending.', + 'ai_chat.input.attachment.prompt.no_text': 'No readable attachment body was extracted.', + 'ai_chat.input.attachment.prompt.default_user_content': 'Continue based on the following attachment content.', + 'ai_chat.input.attachment.prompt.wrapper_start': '', + 'ai_chat.input.attachment.prompt.wrapper_end': '', +}[key] || key); + describe('aiChatAttachments', () => { + it('uses i18n keys instead of legacy Chinese PDF and legacy Office warnings', () => { + expect(source).toContain('ai_chat.input.attachment.excel.worksheet_header'); + expect(source).toContain('ai_chat.input.attachment.warning.pdf_partial_text'); + expect(source).toContain('ai_chat.input.attachment.warning.pdf_no_text'); + expect(source).toContain('ai_chat.input.attachment.warning.legacy_office_partial_text'); + expect(source).toContain('ai_chat.input.attachment.warning.too_large'); + expect(source).toContain('ai_chat.input.attachment.warning.unsupported_type'); + expect(source).toContain('ai_chat.input.attachment.warning.extract_failed'); + expect(source).toContain('ai_chat.input.attachment.prompt.heading'); + expect(source).toContain('ai_chat.input.attachment.prompt.kind'); + expect(source).toContain('ai_chat.input.attachment.prompt.mime'); + expect(source).toContain('ai_chat.input.attachment.prompt.size'); + expect(source).toContain('ai_chat.input.attachment.prompt.extract_warning'); + expect(source).toContain('ai_chat.input.attachment.prompt.text_truncated'); + expect(source).toContain('ai_chat.input.attachment.prompt.no_text'); + expect(source).toContain('ai_chat.input.attachment.prompt.default_user_content'); + expect(source).toContain('ai_chat.input.attachment.prompt.wrapper_start'); + expect(source).toContain('ai_chat.input.attachment.prompt.wrapper_end'); + expect(source).not.toContain('PDF 已使用轻量文本提取'); + expect(source).not.toContain('未从 PDF 中提取到可读文本'); + expect(source).not.toContain('旧版 Office 二进制格式仅做轻量文本片段提取'); + 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('### 附件 '); + 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 attachment prompt and worksheet keys present in all six catalogs', () => { + const catalogs = [ + '../../../../shared/i18n/zh-CN.json', + '../../../../shared/i18n/zh-TW.json', + '../../../../shared/i18n/en-US.json', + '../../../../shared/i18n/ja-JP.json', + '../../../../shared/i18n/de-DE.json', + '../../../../shared/i18n/ru-RU.json', + ].map((path) => JSON.parse(readFileSync(new URL(path, import.meta.url), 'utf8'))); + + const requiredKeys = [ + 'ai_chat.input.attachment.excel.worksheet_header', + 'ai_chat.input.attachment.kind.markdown', + 'ai_chat.input.attachment.kind.pdf', + 'ai_chat.input.attachment.kind.word', + 'ai_chat.input.attachment.kind.excel', + 'ai_chat.input.attachment.kind.document', + 'ai_chat.input.attachment.prompt.content_truncated', + 'ai_chat.input.attachment.prompt.heading', + 'ai_chat.input.attachment.prompt.kind', + 'ai_chat.input.attachment.prompt.mime', + 'ai_chat.input.attachment.prompt.size', + 'ai_chat.input.attachment.prompt.extract_warning', + 'ai_chat.input.attachment.prompt.text_truncated', + 'ai_chat.input.attachment.prompt.no_text', + 'ai_chat.input.attachment.prompt.default_user_content', + 'ai_chat.input.attachment.prompt.wrapper_start', + 'ai_chat.input.attachment.prompt.wrapper_end', + 'ai_chat.input.attachment.message.warning', + 'ai_chat.input.attachment.message.read_failed', + ]; + + for (const catalog of catalogs) { + for (const key of requiredKeys) { + expect(catalog[key]).toBeTruthy(); + } + } + }); + it('extracts markdown text so it can be sent to AI', async () => { const attachment = await createAIChatAttachmentFromFile(makeFile(['# Report\n\nhello'], 'report.md', 'text/markdown')); @@ -37,19 +146,84 @@ describe('aiChatAttachments', () => { 'xl/sharedStrings.xml': strToU8('姓名张三'), 'xl/worksheets/sheet1.xml': strToU8('0100188'), }); - const attachment = await createAIChatAttachmentFromFile(makeFile([bytes], 'score.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')); + const attachment = await createAIChatAttachmentFromFile( + makeFile([bytes], 'score.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'), + translateAttachmentWarning, + ); expect(attachment.kind).toBe('excel'); + expect(attachment.text).toContain('[Worksheet: sheet1]'); expect(attachment.text).toContain('姓名\t100'); expect(attachment.text).toContain('张三\t88'); }); it('extracts lightweight PDF literal text and keeps a warning about limitations', async () => { - const attachment = await createAIChatAttachmentFromFile(makeFile(['%PDF-1.4\nBT (Invoice total 42) Tj ET\n%%EOF'], 'invoice.pdf', 'application/pdf')); + const attachment = await createAIChatAttachmentFromFile( + makeFile(['%PDF-1.4\nBT (Invoice total 42) Tj ET\n%%EOF'], 'invoice.pdf', 'application/pdf'), + translateAttachmentWarning, + ); expect(attachment.kind).toBe('pdf'); expect(attachment.text).toContain('Invoice total 42'); - expect(attachment.extractWarning).toContain('轻量文本提取'); + expect(attachment.extractWarning).toBe('PDF used lightweight text extraction; scanned or compressed-font content may not be fully readable.'); + }); + + it('returns a localized fallback warning when a PDF yields no readable text', async () => { + const attachment = await createAIChatAttachmentFromFile( + makeFile([new Uint8Array([0x00, 0x01, 0x02, 0x03])], 'scan.pdf', 'application/pdf'), + translateAttachmentWarning, + ); + + expect(attachment.kind).toBe('pdf'); + expect(attachment.text).toBe(''); + expect(attachment.extractWarning).toBe('No readable text was extracted from the PDF; if it is scanned or uses complex encoding, copy the body before sending.'); + }); + + it('extracts lightweight legacy Office text and keeps a localized warning about the limited result', async () => { + const attachment = await createAIChatAttachmentFromFile( + makeFile(['Legacy DOC Budget 2026 forecast'], 'budget.doc', 'application/msword'), + translateAttachmentWarning, + ); + + expect(attachment.kind).toBe('word'); + expect(attachment.text).toContain('Legacy DOC Budget 2026 forecast'); + expect(attachment.extractWarning).toBe('Legacy Office binary files only use lightweight text snippet extraction; convert to docx/xlsx before uploading for more complete content.'); + }); + + it('returns a localized warning when the file exceeds the readable size limit', async () => { + const oversized = new Uint8Array(15 * 1024 * 1024 + 1); + const attachment = await createAIChatAttachmentFromFile( + makeFile([oversized], 'oversized.txt', 'text/plain'), + translateAttachmentWarning, + ); + + expect(attachment.kind).toBe('text'); + expect(attachment.extractWarning).toBe('File exceeds 15.0 MB; file metadata was attached but the body was not read.'); + expect(attachment.text).toBeUndefined(); + }); + + it('returns a localized warning for attached file types without body extraction support yet', async () => { + const attachment = await createAIChatAttachmentFromFile( + makeFile([new Uint8Array([0x01, 0x02, 0x03])], 'archive.bin', 'application/octet-stream'), + translateAttachmentWarning, + ); + + expect(attachment.kind).toBe('document'); + expect(attachment.extractWarning).toBe('This file type was attached, but body text was not extracted yet; use markdown, txt, docx, xlsx, or pdf if the model needs the content.'); + expect(attachment.text).toBeUndefined(); + }); + + it('returns a localized extraction failure wrapper and preserves raw detail', async () => { + const brokenFile = makeFile([], 'broken.txt', 'text/plain'); + brokenFile.text = async () => { + throw new Error('disk read failed'); + }; + + const attachment = await createAIChatAttachmentFromFile(brokenFile, translateAttachmentWarning); + + expect(attachment.kind).toBe('text'); + expect(attachment.extractWarning).toBe('Attachment body extraction failed: disk read failed'); + expect(attachment.text).toBeUndefined(); }); it('appends non-image attachments to the upstream user content', () => { @@ -60,10 +234,10 @@ describe('aiChatAttachments', () => { size: 12, kind: 'text', text: '核心指标下降', - }]); + }], translateAttachmentWarning as any); expect(content).toContain('帮我总结'); - expect(content).toContain('<用户上传附件>'); + expect(content).toContain(''); expect(content).toContain('核心指标下降'); }); @@ -78,4 +252,45 @@ describe('aiChatAttachments', () => { dataUrl: 'data:image/png;base64,abc', }])).toBe(''); }); + + it('builds localized attachment prompt copy instead of legacy Chinese wrappers', () => { + const prompt = buildAIChatAttachmentPromptText([{ + id: 'att-1', + name: 'report.md', + mimeType: 'text/markdown', + size: 18, + kind: 'markdown', + text: 'Revenue dropped', + textTruncated: true, + extractWarning: 'PDF used lightweight text extraction; scanned or compressed-font content may not be fully readable.', + }], translateAttachmentWarning as any); + + expect(prompt).toContain('### Attachment 1: report.md'); + expect(prompt).toContain('- Type: Markdown'); + expect(prompt).toContain('- MIME: text/markdown'); + expect(prompt).toContain('- Size: 18 B'); + expect(prompt).toContain('- Extraction note: PDF used lightweight text extraction; scanned or compressed-font content may not be fully readable.'); + expect(prompt).toContain('- Extraction note: Body text was truncated before sending.'); + expect(prompt).not.toContain('### 附件 1'); + expect(prompt).not.toContain('- 类型: '); + expect(prompt).not.toContain('- 提取说明: '); + }); + + it('uses localized default prompt wrapper and empty-body fallback when translator is provided', () => { + const content = appendAIChatAttachmentsToContent('', [{ + id: 'att-2', + name: 'empty.pdf', + mimeType: 'application/pdf', + size: 10, + kind: 'pdf', + text: '', + }], translateAttachmentWarning as any); + + expect(content).toContain('Continue based on the following attachment content.'); + expect(content).toContain(''); + expect(content).toContain(''); + expect(content).toContain('No readable attachment body was extracted.'); + expect(content).not.toContain('请根据以下附件内容继续处理。'); + expect(content).not.toContain('未提取到可发送的附件正文。'); + }); }); diff --git a/frontend/src/components/ai/aiChatAttachments.ts b/frontend/src/components/ai/aiChatAttachments.ts index 5728840..625980d 100644 --- a/frontend/src/components/ai/aiChatAttachments.ts +++ b/frontend/src/components/ai/aiChatAttachments.ts @@ -39,6 +39,48 @@ const textExtensions = new Set([ 'yml', ]); +export type AIChatAttachmentTranslator = ( + key: string, + params?: Record, +) => string; + +const translateAttachmentCopy = ( + t: AIChatAttachmentTranslator | undefined, + key: string, + fallback: string, + params?: Record, +): string => { + if (!t) { + return fallback; + } + const translated = t(key, params); + return translated && translated !== key ? translated : fallback; +}; + +const resolveAIChatAttachmentKindLabel = ( + kind: AIChatAttachmentKind, + t?: AIChatAttachmentTranslator, +): string => { + switch (kind) { + case 'text': + return translateAttachmentCopy(t, 'ai_chat.input.attachment.kind.text', 'Text'); + case 'markdown': + return translateAttachmentCopy(t, 'ai_chat.input.attachment.kind.markdown', 'Markdown'); + case 'pdf': + return translateAttachmentCopy(t, 'ai_chat.input.attachment.kind.pdf', 'PDF'); + case 'word': + return translateAttachmentCopy(t, 'ai_chat.input.attachment.kind.word', 'Word'); + case 'excel': + return translateAttachmentCopy(t, 'ai_chat.input.attachment.kind.excel', 'Excel'); + case 'document': + return translateAttachmentCopy(t, 'ai_chat.input.attachment.kind.document', 'File'); + case 'image': + return translateAttachmentCopy(t, 'ai_chat.input.attachment.kind.image', 'Image'); + default: + return kind; + } +}; + const nextAttachmentId = () => `att-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; export const formatAIChatAttachmentSize = (size: number): string => { @@ -137,7 +179,10 @@ const extractCellValue = (cellXml: string, sharedStrings: string[]): string => { return value; }; -const extractXlsxText = (entries: Record): string => { +const extractXlsxText = ( + entries: Record, + t?: AIChatAttachmentTranslator, +): string => { const sharedStrings = extractSharedStrings(entries); const sheetPaths = Object.keys(entries) .filter((path) => /^xl\/worksheets\/sheet\d+\.xml$/i.test(path)) @@ -151,7 +196,13 @@ const extractXlsxText = (entries: Record): string => { }).filter((line: string) => line.trim().length > 0); if (lines.length === 0) return ''; const sheetName = path.replace(/^xl\/worksheets\//i, '').replace(/\.xml$/i, ''); - return `[工作表: ${sheetName}]\n${lines.join('\n')}`; + const header = translateAttachmentCopy( + t, + 'ai_chat.input.attachment.excel.worksheet_header', + `[Worksheet: ${sheetName}]`, + { sheetName }, + ); + return `${header}\n${lines.join('\n')}`; }).filter(Boolean).join('\n\n'); }; @@ -172,7 +223,10 @@ const bytesToBinaryString = (bytes: Uint8Array): string => { return chunks.join(''); }; -const extractPdfText = (bytes: Uint8Array): { text: string; warning?: string } => { +const extractPdfText = ( + bytes: Uint8Array, + t?: AIChatAttachmentTranslator, +): { text: string; warning?: string } => { const raw = bytesToBinaryString(bytes); const values: string[] = []; const literalPattern = /\((?:\\.|[^\\)]){1,2000}\)/g; @@ -185,35 +239,57 @@ const extractPdfText = (bytes: Uint8Array): { text: string; warning?: string } = } const text = values.join('\n'); const warning = text - ? 'PDF 已使用轻量文本提取;扫描件或压缩字体内容可能无法完整读取。' - : '未从 PDF 中提取到可读文本;如果是扫描件或复杂编码 PDF,请复制正文后再发送。'; + ? translateAttachmentCopy( + t, + 'ai_chat.input.attachment.warning.pdf_partial_text', + 'PDF used lightweight text extraction; scanned or compressed-font content may not be fully readable.', + ) + : translateAttachmentCopy( + t, + 'ai_chat.input.attachment.warning.pdf_no_text', + 'No readable text was extracted from the PDF; if it is scanned or uses complex encoding, copy the body before sending.', + ); return { text, warning }; }; -const extractLegacyOfficeText = (bytes: Uint8Array): { text: string; warning: string } => { +const extractLegacyOfficeText = ( + bytes: Uint8Array, + t?: AIChatAttachmentTranslator, +): { text: string; warning: string } => { const raw = bytesToBinaryString(bytes); const matches = raw.match(/[A-Za-z0-9\u4e00-\u9fa5][\x20-\x7E\u4e00-\u9fa5]{3,}/g) || []; return { text: Array.from(new Set(matches)).join('\n'), - warning: '旧版 Office 二进制格式仅做轻量文本片段提取;建议转为 docx/xlsx 后上传以获得更完整正文。', + warning: translateAttachmentCopy( + t, + 'ai_chat.input.attachment.warning.legacy_office_partial_text', + 'Legacy Office binary files only use lightweight text snippet extraction; convert to docx/xlsx before uploading for more complete content.', + ), }; }; -const extractOfficeOpenXmlText = (bytes: Uint8Array, kind: AIChatAttachmentKind): string => { +const extractOfficeOpenXmlText = ( + bytes: Uint8Array, + kind: AIChatAttachmentKind, + t?: AIChatAttachmentTranslator, +): string => { const entries = unzipSync(bytes); if (kind === 'word') return extractDocxText(entries); - if (kind === 'excel') return extractXlsxText(entries); + if (kind === 'excel') return extractXlsxText(entries, t); return ''; }; const readFileAsDataUrl = (file: File): Promise => new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(String(reader.result || '')); - reader.onerror = () => reject(reader.error || new Error('读取文件失败')); + reader.onerror = () => reject(reader.error || new Error('file read failed')); reader.readAsDataURL(file); }); -export const createAIChatAttachmentFromFile = async (file: File): Promise => { +export const createAIChatAttachmentFromFile = async ( + file: File, + translate?: AIChatAttachmentTranslator, +): Promise => { const kind = resolveAIChatAttachmentKind(file); const base: Omit = { id: nextAttachmentId(), @@ -225,10 +301,16 @@ export const createAIChatAttachmentFromFile = async (file: File): Promise MAX_ATTACHMENT_BYTES) { + const size = formatAIChatAttachmentSize(MAX_ATTACHMENT_BYTES); return { ...base, kind, - extractWarning: `文件超过 ${formatAIChatAttachmentSize(MAX_ATTACHMENT_BYTES)},已附加文件信息但未读取正文。`, + extractWarning: translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.warning.too_large', + `File exceeds ${size}; file metadata was attached but the body was not read.`, + { size }, + ), }; } try { @@ -240,72 +322,141 @@ export const createAIChatAttachmentFromFile = async (file: File): Promise { +export const buildAIChatAttachmentPromptText = ( + attachments: AIChatAttachment[] = [], + translate?: AIChatAttachmentTranslator, +): string => { const documentAttachments = attachments.filter((attachment) => attachment.kind !== 'image'); if (documentAttachments.length === 0) return ''; return documentAttachments.map((attachment, index) => { const content = String(attachment.text || '').trim(); + const contentTruncatedMarker = translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.prompt.content_truncated', + '[Attachment body truncated]', + ); const truncatedContent = content.length > MAX_PROMPT_TEXT_CHARS - ? `${content.slice(0, MAX_PROMPT_TEXT_CHARS).trimEnd()}\n\n[附件正文过长,已截断]` + ? `${content.slice(0, MAX_PROMPT_TEXT_CHARS).trimEnd()}\n\n${contentTruncatedMarker}` : content; const fence = truncatedContent.includes('```') ? '~~~' : '```'; + const kindLabel = resolveAIChatAttachmentKindLabel(attachment.kind, translate); + const size = formatAIChatAttachmentSize(attachment.size); const lines = [ - `### 附件 ${index + 1}: ${attachment.name}`, - `- 类型: ${attachment.kind}`, - `- MIME: ${attachment.mimeType || 'unknown'}`, - `- 大小: ${formatAIChatAttachmentSize(attachment.size)}`, + translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.prompt.heading', + `### Attachment ${index + 1}: ${attachment.name}`, + { index: index + 1, name: attachment.name }, + ), + translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.prompt.kind', + `- Type: ${kindLabel}`, + { kind: kindLabel }, + ), + translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.prompt.mime', + `- MIME: ${attachment.mimeType || 'unknown'}`, + { mimeType: attachment.mimeType || 'unknown' }, + ), + translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.prompt.size', + `- Size: ${size}`, + { size }, + ), ]; if (attachment.extractWarning) { - lines.push(`- 提取说明: ${attachment.extractWarning}`); + lines.push(translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.prompt.extract_warning', + `- Extraction note: ${attachment.extractWarning}`, + { message: attachment.extractWarning }, + )); } if (attachment.textTruncated) { - lines.push('- 提取说明: 附件正文较长,已截断后发送。'); + lines.push(translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.prompt.text_truncated', + '- Extraction note: Body text was truncated before sending.', + )); } if (truncatedContent) { lines.push('', fence, truncatedContent, fence); } else { - lines.push('', '未提取到可发送的附件正文。'); + lines.push('', translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.prompt.no_text', + 'No readable attachment body was extracted.', + )); } return lines.join('\n'); }).join('\n\n'); }; -export const appendAIChatAttachmentsToContent = (content: string, attachments: AIChatAttachment[] = []): string => { - const attachmentPrompt = buildAIChatAttachmentPromptText(attachments); +export const appendAIChatAttachmentsToContent = ( + content: string, + attachments: AIChatAttachment[] = [], + translate?: AIChatAttachmentTranslator, +): string => { + const attachmentPrompt = buildAIChatAttachmentPromptText(attachments, translate); if (!attachmentPrompt) return content; const userContent = String(content || '').trim(); return [ - userContent || '请根据以下附件内容继续处理。', + userContent || translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.prompt.default_user_content', + 'Continue based on the following attachment content.', + ), '', - '<用户上传附件>', + translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.prompt.wrapper_start', + '', + ), attachmentPrompt, - '', + translateAttachmentCopy( + translate, + 'ai_chat.input.attachment.prompt.wrapper_end', + '', + ), ].join('\n'); }; diff --git a/frontend/src/components/ai/aiChatPanelDerivedState.test.ts b/frontend/src/components/ai/aiChatPanelDerivedState.test.ts index 269d54d..808fba4 100644 --- a/frontend/src/components/ai/aiChatPanelDerivedState.test.ts +++ b/frontend/src/components/ai/aiChatPanelDerivedState.test.ts @@ -8,6 +8,7 @@ import { inferAIChatConnectionContext, resolveAIChatPanelMode, } from './aiChatPanelDerivedState'; +import { t as translateCatalog } from '../../i18n'; describe('aiChatPanelDerivedState', () => { it('falls back to tool context matches when the active context is incomplete', () => { @@ -31,6 +32,7 @@ describe('aiChatPanelDerivedState', () => { it('builds insight cards from recent sql logs and linked table contexts', () => { const insights = buildAIChatInsights({ + translate: (key, params) => translateCatalog(key, params, 'zh-CN'), contextTableNames: ['sales.orders', 'sales.order_items', 'sales.customers', 'sales.payments'], sqlLogs: [ { @@ -71,6 +73,50 @@ describe('aiChatPanelDerivedState', () => { }); }); + it('localizes insight cards while preserving raw sql and database errors', () => { + const insights = buildAIChatInsights({ + translate: (key, params) => translateCatalog(key, params, 'en-US'), + contextTableNames: ['sales.orders', 'sales.order_items', 'sales.customers', 'sales.payments'], + sqlLogs: [ + { + id: 'log-1', + timestamp: 1, + sql: 'SELECT * FROM orders', + status: 'success', + duration: 1520, + }, + { + id: 'log-2', + timestamp: 2, + sql: 'UPDATE orders SET status = 1', + status: 'error', + duration: 120, + message: 'Deadlock found', + }, + ], + }); + + expect(insights[0]).toMatchObject({ + tone: 'info', + title: '4 linked tables', + body: 'This conversation includes structure context for sales.orders, sales.order_items, sales.customers and more tables.', + }); + expect(insights[1]).toMatchObject({ + tone: 'warn', + title: 'Slowest recent query 1,520ms', + body: 'SELECT * FROM orders', + }); + expect(insights[2]).toMatchObject({ + tone: 'warn', + title: '1 recent query failures', + body: 'Deadlock found', + }); + expect(insights[3]).toMatchObject({ + tone: 'warn', + title: 'Detected 1 write operations', + }); + }); + it('collects context table names, usage chars, panel mode, and inline history sessions', () => { expect(collectAIChatContextTableNames({ aiContexts: { diff --git a/frontend/src/components/ai/aiChatPanelDerivedState.ts b/frontend/src/components/ai/aiChatPanelDerivedState.ts index 586be67..e3cd9a0 100644 --- a/frontend/src/components/ai/aiChatPanelDerivedState.ts +++ b/frontend/src/components/ai/aiChatPanelDerivedState.ts @@ -1,5 +1,6 @@ import type { SqlLog } from '../../store'; import type { AIChatMessage, AIContextItem } from '../../types'; +import { t as translateCatalog, type I18nParams } from '../../i18n'; import type { AIToolContextEntry } from './aiLocalToolExecutor'; import type { AIChatInlineHistorySession, AIChatInsightItem, AIChatPanelMode } from './AIChatPanelModeContent'; @@ -19,6 +20,7 @@ interface CollectAIChatContextTableNamesArgs { interface BuildAIChatInsightsArgs { contextTableNames: string[]; sqlLogs: SqlLog[]; + translate?: (key: string, params?: I18nParams) => string; } export const inferAIChatConnectionContext = ({ @@ -86,6 +88,7 @@ export const collectAIChatContextTableNames = ({ export const buildAIChatInsights = ({ contextTableNames, sqlLogs, + translate = (key, params) => translateCatalog(key, params, 'en-US'), }: BuildAIChatInsightsArgs): AIChatInsightItem[] => { const recentLogs = sqlLogs.slice(0, 24); const slowest = recentLogs @@ -94,29 +97,45 @@ export const buildAIChatInsights = ({ 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 = translate('ai_chat.panel.insight.context.table_separator'); + const tablePreview = `${contextTableNames.slice(0, 3).join(tableSeparator)}${contextCount > 3 ? translate('ai_chat.panel.insight.context.more_tables_suffix') : ''}`; return [ { tone: 'info', - title: contextCount > 0 ? `已关联 ${contextCount} 张表` : '尚未关联表结构', + title: contextCount > 0 + ? translate('ai_chat.panel.insight.context.linked_title', { count: contextCount }) + : translate('ai_chat.panel.insight.context.empty_title'), body: contextCount > 0 - ? `当前对话会带上 ${contextTableNames.slice(0, 3).join('、')}${contextCount > 3 ? ' 等表' : ''} 的结构上下文。` - : '在表页打开 AI 后会自动关联当前表,也可以在输入框上方手动添加上下文。', + ? translate('ai_chat.panel.insight.context.linked_body', { tables: tablePreview }) + : translate('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 + ? translate('ai_chat.panel.insight.query.slowest_title', { duration: Math.round(slowest.duration).toLocaleString() }) + : translate('ai_chat.panel.insight.query.empty_title'), + body: slowest ? slowest.sql.slice(0, 140) : translate('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 + ? translate('ai_chat.panel.insight.status.failed_title', { count: errors.length }) + : translate('ai_chat.panel.insight.status.ok_title'), + body: errors[0]?.message || ( + recentLogs.length > 0 + ? translate('ai_chat.panel.insight.status.recent_body', { count: recentLogs.length }) + : translate('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 + ? translate('ai_chat.panel.insight.write.detected_title', { count: writeCount }) + : translate('ai_chat.panel.insight.write.readonly_title'), + body: writeCount > 0 + ? translate('ai_chat.panel.insight.write.detected_body') + : translate('ai_chat.panel.insight.write.readonly_body'), }, ]; }; diff --git a/frontend/src/components/ai/aiChatPayloadDispatch.test.ts b/frontend/src/components/ai/aiChatPayloadDispatch.test.ts index 4109bd6..44880f5 100644 --- a/frontend/src/components/ai/aiChatPayloadDispatch.test.ts +++ b/frontend/src/components/ai/aiChatPayloadDispatch.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { t as translateCatalog } from '../../i18n'; import { dispatchAIChatPayload } from './aiChatPayloadDispatch'; describe('aiChatPayloadDispatch', () => { @@ -122,6 +123,7 @@ describe('aiChatPayloadDispatch', () => { it('emits the unavailable message when the AI service is missing', async () => { const addAIChatMessage = vi.fn(); const setSending = vi.fn(); + const unavailableContent = translateCatalog('ai_chat.panel.message.service_not_ready', undefined, 'zh-CN'); (globalThis as any).window = {}; const result = await dispatchAIChatPayload({ @@ -131,13 +133,13 @@ describe('aiChatPayloadDispatch', () => { addAIChatMessage, setSending, nextMessageId: () => 'msg-unavailable', - unavailableContent: '❌ AI Service 未就绪', + unavailableContent, }); expect(result).toBe('unavailable'); expect(addAIChatMessage).toHaveBeenCalledWith('session-1', expect.objectContaining({ id: 'msg-unavailable', - content: '❌ AI Service 未就绪', + content: unavailableContent, })); expect(setSending).toHaveBeenCalledWith(false); }); @@ -162,7 +164,7 @@ describe('aiChatPayloadDispatch', () => { expect(result).toBe('unavailable'); expect(addAIChatMessage).not.toHaveBeenCalled(); expect(updateAIChatMessage).toHaveBeenCalledWith('session-1', 'assistant-connecting', expect.objectContaining({ - content: '❌ AI Service 未就绪', + content: '❌ AI Service is not ready', loading: false, phase: 'idle', })); @@ -194,7 +196,7 @@ describe('aiChatPayloadDispatch', () => { expect(result).toBe('error'); expect(addAIChatMessage).toHaveBeenCalledWith('session-1', expect.objectContaining({ id: 'msg-error', - content: '❌ 发送失败: HTTP 502: 502 Bad Gateway', + content: '❌ Send failed: HTTP 502: 502 Bad Gateway', rawError: '502 Bad Gateway', })); expect(setSending).toHaveBeenCalledWith(false); @@ -228,11 +230,65 @@ describe('aiChatPayloadDispatch', () => { expect(result).toBe('error'); expect(addAIChatMessage).not.toHaveBeenCalled(); expect(updateAIChatMessage).toHaveBeenCalledWith('session-1', 'assistant-connecting', expect.objectContaining({ - content: '❌ 发送失败: HTTP 502: 502 Bad Gateway', + content: '❌ Send failed: HTTP 502: 502 Bad Gateway', rawError: '502 Bad Gateway', loading: false, phase: 'idle', })); expect(setSending).toHaveBeenCalledWith(false); }); + + it('localizes fallback service and dispatch error messages while preserving raw error detail', async () => { + const addAIChatMessage = vi.fn(); + const updateAIChatMessage = vi.fn(); + const setSending = vi.fn(); + (globalThis as any).window = {}; + + await dispatchAIChatPayload({ + sid: 'session-1', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + addAIChatMessage, + updateAIChatMessage, + setSending, + nextMessageId: () => 'msg-unavailable', + pendingAssistantMessageId: 'assistant-connecting', + translate: (key, params) => translateCatalog(key, params, 'en-US'), + } as Parameters[0] & { + translate: (key: string, params?: Record) => string; + }); + + expect(updateAIChatMessage).toHaveBeenCalledWith('session-1', 'assistant-connecting', expect.objectContaining({ + content: '❌ AI Service is not ready', + })); + + const AIChatStream = vi.fn().mockRejectedValue(new Error('502 Bad Gateway')); + (globalThis as any).window = { + go: { + aiservice: { + Service: { AIChatStream }, + }, + }, + }; + updateAIChatMessage.mockClear(); + + await dispatchAIChatPayload({ + sid: 'session-1', + messages: [{ role: 'user', content: 'hello' }], + tools: [], + addAIChatMessage, + updateAIChatMessage, + setSending, + nextMessageId: () => 'msg-error', + pendingAssistantMessageId: 'assistant-connecting', + translate: (key, params) => translateCatalog(key, params, 'en-US'), + } as Parameters[0] & { + translate: (key: string, params?: Record) => string; + }); + + expect(updateAIChatMessage).toHaveBeenCalledWith('session-1', 'assistant-connecting', expect.objectContaining({ + content: '❌ Send failed: HTTP 502: 502 Bad Gateway', + rawError: '502 Bad Gateway', + })); + }); }); diff --git a/frontend/src/components/ai/aiChatPayloadDispatch.ts b/frontend/src/components/ai/aiChatPayloadDispatch.ts index 14660be..cbbee9e 100644 --- a/frontend/src/components/ai/aiChatPayloadDispatch.ts +++ b/frontend/src/components/ai/aiChatPayloadDispatch.ts @@ -5,6 +5,7 @@ import type { } from '../../types'; import type { AIChatToolDefinition } from '../../utils/aiToolRegistry'; import { sanitizeErrorMsg } from '../../utils/aiChatRuntime'; +import { t as translateCatalog, type I18nParams } from '../../i18n'; interface AIChatService { AIChatStream?: (sid: string, messages: any[], tools: AIChatToolDefinition[]) => Promise; @@ -27,6 +28,7 @@ interface DispatchAIChatPayloadOptions { jvmPlanContext?: JVMAIPlanContext; jvmDiagnosticPlanContext?: JVMDiagnosticPlanContext; unavailableContent?: string; + translate?: (key: string, params?: I18nParams) => string; onNonStreamSuccess?: () => void; } @@ -83,6 +85,7 @@ export const dispatchAIChatPayload = async ({ jvmPlanContext, jvmDiagnosticPlanContext, unavailableContent, + translate = (key, params) => translateCatalog(key, params, 'en-US'), onNonStreamSuccess, }: DispatchAIChatPayloadOptions): Promise<'stream' | 'send' | 'unavailable' | 'error'> => { try { @@ -94,8 +97,8 @@ export const dispatchAIChatPayload = async ({ if (service?.AIChatSend) { const result = await service.AIChatSend(messages, tools); - const rawError = result?.error || '未知错误'; - const cleanError = sanitizeErrorMsg(rawError); + const rawError = result?.error || translate('common.unknown'); + const cleanError = sanitizeErrorMsg(rawError, translate); settleAssistantMessage({ sid, @@ -119,7 +122,7 @@ export const dispatchAIChatPayload = async ({ return 'send'; } - const resolvedUnavailableContent = unavailableContent || (pendingAssistantMessageId ? '❌ AI Service 未就绪' : ''); + const resolvedUnavailableContent = unavailableContent || (pendingAssistantMessageId ? translate('ai_chat.panel.message.service_not_ready') : ''); if (resolvedUnavailableContent) { settleAssistantMessage({ sid, @@ -138,7 +141,7 @@ export const dispatchAIChatPayload = async ({ return 'unavailable'; } catch (error: any) { const rawError = error?.message || String(error); - const cleanError = sanitizeErrorMsg(rawError); + const cleanError = sanitizeErrorMsg(rawError, translate); settleAssistantMessage({ sid, addAIChatMessage, @@ -146,7 +149,7 @@ export const dispatchAIChatPayload = async ({ nextMessageId, pendingAssistantMessageId, patch: { - content: `❌ 发送失败: ${cleanError}`, + content: translate('ai_chat.panel.message.send_failed', { detail: cleanError }), rawError: cleanError !== rawError ? rawError : undefined, jvmPlanContext, jvmDiagnosticPlanContext, diff --git a/frontend/src/components/ai/aiChatReadiness.test.ts b/frontend/src/components/ai/aiChatReadiness.test.ts index 65ac298..bfd273d 100644 --- a/frontend/src/components/ai/aiChatReadiness.test.ts +++ b/frontend/src/components/ai/aiChatReadiness.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { buildAIChatReadinessSnapshot } from './aiChatReadiness'; describe('buildAIChatReadinessSnapshot', () => { - it('reports missing provider when no active provider is configured', () => { + it('defaults missing-provider status copy to English when no translator is provided', () => { const snapshot = buildAIChatReadinessSnapshot({ providers: [], activeProviderId: '', @@ -12,15 +12,18 @@ describe('buildAIChatReadinessSnapshot', () => { expect(snapshot.status).toBe('missing_provider'); expect(snapshot.ready).toBe(false); expect(snapshot.action?.key).toBe('open-settings'); - expect(snapshot.title).toContain('还没有配置 AI 供应商'); + expect(snapshot.label).toBe('Not ready'); + expect(snapshot.title).toBe('No provider available'); + expect(snapshot.description).toBe('Add and enable a model provider in AI settings first.'); + expect(snapshot.action?.label).toBe('Open AI settings'); }); - it('reports incomplete provider when secret or base url is missing', () => { + it('defaults incomplete-provider status copy to English while keeping raw provider names', () => { const snapshot = buildAIChatReadinessSnapshot({ providers: [{ id: 'provider-1', type: 'custom', - name: '自建代理', + name: 'Custom proxy', apiKey: '', hasSecret: false, baseUrl: '', @@ -34,16 +37,19 @@ describe('buildAIChatReadinessSnapshot', () => { expect(snapshot.status).toBe('provider_incomplete'); expect(snapshot.issues).toEqual(['missing_secret', 'missing_base_url']); - expect(snapshot.action?.label).toContain('修复'); - expect(snapshot.message).toContain('还缺少 密钥、接口地址'); + expect(snapshot.label).toBe('Needs fix'); + expect(snapshot.title).toBe('Custom proxy is missing API key, endpoint URL'); + expect(snapshot.description).toBe('Complete the provider configuration before sending to avoid immediate request failures.'); + expect(snapshot.action?.label).toBe('Fix provider configuration'); + expect(snapshot.message).toContain('Custom proxy is missing API key, endpoint URL'); }); - it('reports missing model and available model count when provider has no selected model', () => { + it('defaults missing-model status copy to English when no translator is provided', () => { const snapshot = buildAIChatReadinessSnapshot({ providers: [{ id: 'provider-1', type: 'openai', - name: 'OpenAI 主账号', + name: 'OpenAI Primary', apiKey: '', hasSecret: true, baseUrl: 'https://api.openai.com/v1', @@ -58,15 +64,18 @@ describe('buildAIChatReadinessSnapshot', () => { expect(snapshot.status).toBe('missing_model'); expect(snapshot.selectableModelCount).toBe(2); expect(snapshot.action?.key).toBe('reload-models'); - expect(snapshot.description).toContain('当前已发现 2 个可选模型'); + expect(snapshot.label).toBe('Model required'); + expect(snapshot.title).toBe('Select a model for OpenAI Primary'); + expect(snapshot.description).toBe('2 models are available right now. Select one before sending.'); + expect(snapshot.action?.label).toBe('Reload models'); }); - it('reports ready with context summary when provider and context are already attached', () => { + it('defaults ready status copy to English while preserving raw provider and model values', () => { const snapshot = buildAIChatReadinessSnapshot({ providers: [{ id: 'provider-1', type: 'openai', - name: 'OpenAI 主账号', + name: 'OpenAI Primary', apiKey: '', hasSecret: true, baseUrl: 'https://api.openai.com/v1', @@ -90,6 +99,8 @@ describe('buildAIChatReadinessSnapshot', () => { expect(snapshot.status).toBe('ready'); expect(snapshot.ready).toBe(true); expect(snapshot.contextAttachedCount).toBe(1); - expect(snapshot.title).toContain('OpenAI 主账号 / gpt-5.5'); + expect(snapshot.label).toBe('Ready'); + expect(snapshot.title).toBe('AI is ready: OpenAI Primary / gpt-5.5'); + expect(snapshot.description).toBe('1 table schema contexts are attached. You can send now.'); }); }); diff --git a/frontend/src/components/ai/aiChatReadiness.ts b/frontend/src/components/ai/aiChatReadiness.ts index a3de8c8..2a1e90b 100644 --- a/frontend/src/components/ai/aiChatReadiness.ts +++ b/frontend/src/components/ai/aiChatReadiness.ts @@ -1,3 +1,5 @@ +import { t as catalogTranslate } from '../../i18n/catalog'; +import type { I18nParams } from '../../i18n/types'; import type { AIContextItem, AIProviderConfig } from '../../types'; export type AIChatReadinessActionKey = 'open-settings' | 'reload-models'; @@ -45,6 +47,16 @@ export interface AIChatReadinessSnapshot { message: string; } +type AIChatReadinessTranslate = (key: string, params?: I18nParams) => string; + +const defaultTranslate: AIChatReadinessTranslate = (key, params) => + catalogTranslate('en-US', key, params); + +const joinIssueLabels = ( + labels: string[], + translate: AIChatReadinessTranslate, +): string => labels.join(translate('ai_chat.input.status.issue.separator')); + const trimText = (value: unknown): string => String(value || '').trim(); const getProviderHost = (baseUrl: string): string => { @@ -78,11 +90,14 @@ const getSelectedProvider = (params: { return providers.find((provider) => provider.id === activeProviderId) || null; }; -export const formatAIChatProviderIssueLabels = (issues: AIChatReadinessIssue[]): string[] => { +export const formatAIChatProviderIssueLabels = ( + issues: AIChatReadinessIssue[], + translate: AIChatReadinessTranslate = defaultTranslate, +): string[] => { const issueLabels: Record = { - missing_secret: '密钥', - missing_base_url: '接口地址', - missing_selected_model: '模型', + missing_secret: translate('ai_chat.input.status.issue.missing_secret'), + missing_base_url: translate('ai_chat.input.status.issue.missing_base_url'), + missing_selected_model: translate('ai_chat.input.status.issue.missing_selected_model'), }; return issues .map((issue) => issueLabels[issue]) @@ -97,7 +112,9 @@ export const buildAIChatReadinessSnapshot = (params: { loadingModels?: boolean; activeContext?: { connectionId?: string | null; dbName?: string | null } | null; activeContextItems?: AIContextItem[]; + translate?: AIChatReadinessTranslate; }): AIChatReadinessSnapshot => { + const translate = params.translate || defaultTranslate; const providers = Array.isArray(params.providers) ? params.providers : []; const activeProvider = getSelectedProvider(params); const providerCount = providers.length > 0 ? providers.length : (activeProvider ? 1 : 0); @@ -109,19 +126,20 @@ export const buildAIChatReadinessSnapshot = (params: { const selectableModelCount = dynamicModels.length > 0 ? dynamicModels.length : declaredModels.length; const hasConnectionContext = Boolean(trimText(params.activeContext?.connectionId)); const contextAttachedCount = activeContextItems.length; + const fallbackProviderName = translate('ai_chat.input.status.provider_fallback_name'); if (!activeProvider) { const title = providers.length > 0 - ? '已配置供应商,但当前没有选中生效项' - : '还没有配置 AI 供应商'; + ? translate('ai_chat.input.status.missing_provider.title.unselected') + : translate('ai_chat.input.status.missing_provider.title.none'); const description = providers.length > 0 - ? '先在 AI 设置里选中一个活动供应商,然后再发送。' - : '先在 AI 设置里添加并启用一个模型供应商。'; + ? translate('ai_chat.input.status.missing_provider.description.unselected') + : translate('ai_chat.input.status.missing_provider.description.none'); return { status: 'missing_provider', ready: false, severity: 'warning', - label: '未就绪', + label: translate('ai_chat.input.status.label.not_ready'), title, description, providerCount, @@ -132,10 +150,10 @@ export const buildAIChatReadinessSnapshot = (params: { issues: [], action: { key: 'open-settings', - label: '打开 AI 设置', + label: translate('ai_chat.input.status.action.open_settings'), }, activeProvider: null, - message: `${title}。${description}`, + message: [title, description].filter(Boolean).join(' '), }; } @@ -164,14 +182,17 @@ export const buildAIChatReadinessSnapshot = (params: { const blockingProviderIssues = issues.filter((issue) => issue !== 'missing_selected_model'); if (blockingProviderIssues.length > 0) { - const missingLabels = formatAIChatProviderIssueLabels(blockingProviderIssues); - const title = `${providerSummary.name || providerSummary.id || '当前供应商'} 还缺少 ${missingLabels.join('、')}`; - const description = '先补全供应商配置再发送,避免请求直接失败。'; + const missingLabels = formatAIChatProviderIssueLabels(blockingProviderIssues, translate); + const title = translate('ai_chat.input.status.provider_incomplete.title', { + provider: providerSummary.name || providerSummary.id || fallbackProviderName, + issues: joinIssueLabels(missingLabels, translate), + }); + const description = translate('ai_chat.input.status.provider_incomplete.description'); return { status: 'provider_incomplete', ready: false, severity: 'error', - label: '需修复', + label: translate('ai_chat.input.status.label.needs_fix'), title, description, providerCount, @@ -182,25 +203,31 @@ export const buildAIChatReadinessSnapshot = (params: { issues, action: { key: 'open-settings', - label: '修复供应商配置', + label: translate('ai_chat.input.status.action.fix_provider'), }, activeProvider: providerSummary, - message: `${title}。${description}`, + message: [title, description].filter(Boolean).join(' '), }; } if (!providerSummary.model) { const title = params.loadingModels - ? `正在加载 ${providerSummary.name || providerSummary.id || '当前供应商'} 的模型列表` - : `先为 ${providerSummary.name || providerSummary.id || '当前供应商'} 选择一个模型`; + ? translate('ai_chat.input.status.missing_model.title.loading', { + provider: providerSummary.name || providerSummary.id || fallbackProviderName, + }) + : translate('ai_chat.input.status.missing_model.title.select', { + provider: providerSummary.name || providerSummary.id || fallbackProviderName, + }); const description = selectableModelCount > 0 - ? `当前已发现 ${selectableModelCount} 个可选模型,选中后即可发送。` - : '如果列表为空,请检查供应商入口、密钥和模型权限。'; + ? translate('ai_chat.input.status.missing_model.description.available', { count: selectableModelCount }) + : translate('ai_chat.input.status.missing_model.description.empty'); return { status: params.loadingModels ? 'loading_models' : 'missing_model', ready: false, severity: params.loadingModels ? 'info' : 'warning', - label: params.loadingModels ? '加载中' : '未选模型', + label: params.loadingModels + ? translate('ai_chat.input.status.label.loading') + : translate('ai_chat.input.status.label.model_required'), title, description, providerCount, @@ -211,25 +238,28 @@ export const buildAIChatReadinessSnapshot = (params: { issues, action: { key: 'reload-models', - label: '重新加载模型', + label: translate('ai_chat.input.status.action.reload_models'), }, activeProvider: providerSummary, - message: `${title}。${description}`, + message: [title, description].filter(Boolean).join(' '), }; } - const title = `AI 已就绪:${providerSummary.name || providerSummary.id} / ${providerSummary.model}`; + const title = translate('ai_chat.input.status.ready.title', { + provider: providerSummary.name || providerSummary.id || fallbackProviderName, + model: providerSummary.model, + }); const description = contextAttachedCount > 0 - ? `当前已关联 ${contextAttachedCount} 张表结构上下文,可直接发送。` + ? translate('ai_chat.input.status.ready.description.with_context', { count: contextAttachedCount }) : hasConnectionContext - ? '已选中当前连接;如需更准的数据库语义,建议再关联表结构上下文。' - : '可直接发送;如需更准的数据库语义,建议先选中连接或关联表结构。'; + ? translate('ai_chat.input.status.ready.description.with_connection') + : translate('ai_chat.input.status.ready.description.no_context'); return { status: 'ready', ready: true, severity: 'success', - label: '已就绪', + label: translate('ai_chat.input.status.label.ready'), title, description, providerCount, @@ -239,6 +269,6 @@ export const buildAIChatReadinessSnapshot = (params: { selectableModelCount, issues: [], activeProvider: providerSummary, - message: `${title}。${description}`, + message: [title, description].filter(Boolean).join(' '), }; }; diff --git a/frontend/src/components/ai/aiChatSessionInsights.test.ts b/frontend/src/components/ai/aiChatSessionInsights.test.ts index ee71723..2e3df83 100644 --- a/frontend/src/components/ai/aiChatSessionInsights.test.ts +++ b/frontend/src/components/ai/aiChatSessionInsights.test.ts @@ -67,12 +67,74 @@ describe('aiChatSessionInsights', () => { expect(snapshot.unresolvedToolCallCount).toBe(1); expect(snapshot.consecutiveAssistantPairCount).toBe(2); expect(snapshot.emptyAssistantMessageCount).toBe(1); - expect(snapshot.warnings).toContain('有 1 个工具调用没有匹配到 tool 结果消息'); - expect(snapshot.nextActions).toContain('检查流式追加逻辑是否复用了同一个 assistantMsgId,而不是为同一轮回复新建 assistant 消息'); + expect(snapshot.warnings).toContain('1 tool calls have no matching tool result messages.'); + expect(snapshot.nextActions).toContain('Check whether streaming append logic reuses the same assistantMsgId instead of creating a new assistant message for the same reply.'); expect(snapshot.messages[1]).toMatchObject({ id: 'msg-2', toolCallNames: ['inspect_ai_runtime'], toolCallIds: ['tool-1'], }); }); + + it('localizes diagnostic wrappers while keeping raw session and tool data', () => { + const translate = (key: string, params?: Record) => { + const entries: Record = { + 'ai_chat.inspection.ai_sessions.untitled': 'Untitled localized', + 'ai_chat.inspection.message_flow.warning.unresolved_tool_calls': `missing tool results: ${params?.count}`, + 'ai_chat.inspection.message_flow.warning.consecutive_assistant': `consecutive assistant: ${params?.count}`, + 'ai_chat.inspection.message_flow.warning.empty_assistant': `empty assistant: ${params?.count}`, + 'ai_chat.inspection.message_flow.warning.loading_message': 'loading message remains', + 'ai_chat.inspection.message_flow.next_action.check_tool_results': 'check tool result writes', + 'ai_chat.inspection.message_flow.next_action.check_stream_append': 'check assistant append id', + 'ai_chat.inspection.message_flow.next_action.check_empty_assistant': 'check empty assistant cleanup', + }; + return entries[key] || key; + }; + + const sessionsSnapshot = buildAIChatSessionsSnapshot({ + aiChatSessions: [{ id: 'session-raw', title: '', updatedAt: 0 }], + aiChatHistory: { + 'session-raw': [ + { id: 'msg-user', role: 'user', content: '保留原始用户输入', timestamp: 1 }, + ], + }, + translate, + }); + + const flowSnapshot = buildAIMessageFlowSnapshot({ + aiChatSessions: [{ id: 'session-raw', title: '原始标题', updatedAt: 10 }], + aiChatHistory: { + 'session-raw': [ + { id: 'msg-user', role: 'user', content: '保留原始消息', timestamp: 1 }, + { + id: 'msg-assistant-1', + role: 'assistant', + content: '', + timestamp: 2, + tool_calls: [{ id: 'tool-raw-id', type: 'function', function: { name: 'inspect_app_logs', arguments: '{}' } }], + }, + { id: 'msg-assistant-2', role: 'assistant', content: '', timestamp: 3 }, + { id: 'msg-assistant-3', role: 'assistant', content: '仍在生成', timestamp: 4, loading: true }, + ], + }, + activeSessionId: 'session-raw', + translate, + }); + + expect(sessionsSnapshot.sessions[0].title).toBe('Untitled localized'); + expect(sessionsSnapshot.sessions[0].firstUserPromptPreview).toBe('保留原始用户输入'); + expect(flowSnapshot.title).toBe('原始标题'); + expect(flowSnapshot.warnings).toEqual([ + 'missing tool results: 1', + 'consecutive assistant: 2', + 'empty assistant: 1', + 'loading message remains', + ]); + expect(flowSnapshot.nextActions).toEqual([ + 'check tool result writes', + 'check assistant append id', + 'check empty assistant cleanup', + ]); + expect(flowSnapshot.messages[1].toolCallNames).toEqual(['inspect_app_logs']); + }); }); diff --git a/frontend/src/components/ai/aiChatSessionInsights.ts b/frontend/src/components/ai/aiChatSessionInsights.ts index e186234..4648b37 100644 --- a/frontend/src/components/ai/aiChatSessionInsights.ts +++ b/frontend/src/components/ai/aiChatSessionInsights.ts @@ -1,4 +1,6 @@ import type { AIChatMessage } from '../../types'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; interface AIChatSessionMeta { id: string; @@ -49,6 +51,7 @@ export const buildAIChatSessionsSnapshot = (params: { keyword?: unknown; limit?: unknown; includePreview?: unknown; + translate?: AIInspectionTranslator; }) => { const { aiChatSessions = [], @@ -85,7 +88,11 @@ export const buildAIChatSessionsSnapshot = (params: { return { id: sessionId, - title: String(meta?.title || '').trim() || '未命名会话', + title: String(meta?.title || '').trim() || translateInspectionCopy( + params.translate, + 'ai_chat.inspection.ai_sessions.untitled', + 'Untitled session', + ), updatedAt, isActive: sessionId === activeSessionId, messageCount: messages.length, @@ -155,6 +162,7 @@ export const buildAIMessageFlowSnapshot = (params: { limit?: unknown; includeContent?: unknown; previewLimit?: unknown; + translate?: AIInspectionTranslator; }) => { const { aiChatSessions = [], @@ -164,6 +172,7 @@ export const buildAIMessageFlowSnapshot = (params: { limit, includeContent = true, previewLimit, + translate, } = params; const requestedSessionId = String(sessionId || activeSessionId || '').trim(); @@ -209,17 +218,52 @@ export const buildAIMessageFlowSnapshot = (params: { } const warnings = [ - unresolvedToolCalls.length > 0 ? `有 ${unresolvedToolCalls.length} 个工具调用没有匹配到 tool 结果消息` : '', - consecutiveAssistantPairs.length > 0 ? `发现 ${consecutiveAssistantPairs.length} 组连续 assistant 消息,可能存在回复被拆成多个气泡` : '', - emptyAssistantMessages.length > 0 ? `发现 ${emptyAssistantMessages.length} 条空 assistant 消息` : '', - messages.some((message) => message.loading) ? '会话中仍有 loading 消息,可能还在流式生成或上次中断未清理' : '', + unresolvedToolCalls.length > 0 ? translateInspectionCopy( + translate, + 'ai_chat.inspection.message_flow.warning.unresolved_tool_calls', + `${unresolvedToolCalls.length} tool calls have no matching tool result messages.`, + { count: unresolvedToolCalls.length }, + ) : '', + consecutiveAssistantPairs.length > 0 ? translateInspectionCopy( + translate, + 'ai_chat.inspection.message_flow.warning.consecutive_assistant', + `${consecutiveAssistantPairs.length} consecutive assistant message pairs were found; one reply may have been split into multiple bubbles.`, + { count: consecutiveAssistantPairs.length }, + ) : '', + emptyAssistantMessages.length > 0 ? translateInspectionCopy( + translate, + 'ai_chat.inspection.message_flow.warning.empty_assistant', + `${emptyAssistantMessages.length} empty assistant messages were found.`, + { count: emptyAssistantMessages.length }, + ) : '', + messages.some((message) => message.loading) ? translateInspectionCopy( + translate, + 'ai_chat.inspection.message_flow.warning.loading_message', + 'The session still contains a loading message; streaming may still be active or a previous interruption was not cleaned up.', + ) : '', ].filter(Boolean); const nextActions = [ - unresolvedToolCalls.length > 0 ? '优先核对 useAIChatLocalTools 是否为每个 tool_call_id 写入 tool 消息' : '', - consecutiveAssistantPairs.length > 0 ? '检查流式追加逻辑是否复用了同一个 assistantMsgId,而不是为同一轮回复新建 assistant 消息' : '', - emptyAssistantMessages.length > 0 ? '检查异常或取消路径是否留下了空 assistant 占位消息' : '', - warnings.length === 0 ? '消息流未发现明显结构异常,可继续结合 inspect_ai_last_render_error 或 inspect_app_logs 排查渲染/运行时问题' : '', + unresolvedToolCalls.length > 0 ? translateInspectionCopy( + translate, + 'ai_chat.inspection.message_flow.next_action.check_tool_results', + 'Check whether useAIChatLocalTools writes a tool message for every tool_call_id.', + ) : '', + consecutiveAssistantPairs.length > 0 ? translateInspectionCopy( + translate, + 'ai_chat.inspection.message_flow.next_action.check_stream_append', + 'Check whether streaming append logic reuses the same assistantMsgId instead of creating a new assistant message for the same reply.', + ) : '', + emptyAssistantMessages.length > 0 ? translateInspectionCopy( + translate, + 'ai_chat.inspection.message_flow.next_action.check_empty_assistant', + 'Check whether exception or cancellation paths left empty assistant placeholder messages.', + ) : '', + warnings.length === 0 ? translateInspectionCopy( + translate, + 'ai_chat.inspection.message_flow.next_action.inspect_render_or_logs', + 'No obvious message-flow structure issue was found; continue with inspect_ai_last_render_error or inspect_app_logs for rendering/runtime diagnostics.', + ) : '', ].filter(Boolean); const recentMessages = messages.slice(-safeLimit).map((message) => { diff --git a/frontend/src/components/ai/aiCodebaseHotspotInsights.test.ts b/frontend/src/components/ai/aiCodebaseHotspotInsights.test.ts new file mode 100644 index 0000000..5872204 --- /dev/null +++ b/frontend/src/components/ai/aiCodebaseHotspotInsights.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from 'vitest'; + +import { buildCodebaseHotspotSnapshot } from './aiCodebaseHotspotInsights'; + +describe('aiCodebaseHotspotInsights', () => { + it('uses the provided translator for user-facing hotspot guidance', () => { + const snapshot = buildCodebaseHotspotSnapshot({ + keyword: 'QueryEditor', + minLines: 1000, + limit: 1, + translate: (key) => ({ + 'ai_chat.inspection.codebase_hotspots.evidence.note': 'translated evidence note', + 'ai_chat.inspection.codebase_hotspots.query_editor.why': 'translated query editor why', + 'ai_chat.inspection.codebase_hotspots.query_editor.preferred_next_slice': 'translated next slice', + 'ai_chat.inspection.codebase_hotspots.query_editor.safe_seam': 'translated safe seam', + 'ai_chat.inspection.codebase_hotspots.query_editor.suggested_slice.result_toolbar': 'translated result toolbar', + 'ai_chat.inspection.codebase_hotspots.query_editor.verification.browser_smoke': 'translated browser smoke', + 'ai_chat.inspection.codebase_hotspots.next_action.pick_ready_slice': 'translated next action', + })[key] || key, + }); + + expect(snapshot.evidence.note).toBe('translated evidence note'); + expect(snapshot.hotspots[0]?.why).toBe('translated query editor why'); + expect(snapshot.hotspots[0]?.preferredNextSlice).toBe('translated next slice'); + expect(snapshot.hotspots[0]?.safeSeam).toBe('translated safe seam'); + expect(snapshot.hotspots[0]?.suggestedSlices).toContain('translated result toolbar'); + expect(snapshot.hotspots[0]?.verificationPlan).toContain('translated browser smoke'); + expect(snapshot.nextActions).toContain('translated next action'); + }); +}); diff --git a/frontend/src/components/ai/aiCodebaseHotspotInsights.ts b/frontend/src/components/ai/aiCodebaseHotspotInsights.ts index f4ba02c..243c100 100644 --- a/frontend/src/components/ai/aiCodebaseHotspotInsights.ts +++ b/frontend/src/components/ai/aiCodebaseHotspotInsights.ts @@ -1,3 +1,6 @@ +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; + export interface CodebaseHotspotEntry { path: string; lines: number; @@ -17,164 +20,370 @@ export interface CodebaseHotspotSnapshotOptions { minLines?: number; limit?: number; includeRecommendations?: boolean; + translate?: AIInspectionTranslator; } -const CODEBASE_HOTSPOT_SNAPSHOT: CodebaseHotspotEntry[] = [ +interface LocalizableText { + key: string; + fallback: string; +} + +interface CodebaseHotspotSourceEntry { + id: string; + path: string; + lines: number; + area: string; + riskLevel: CodebaseHotspotEntry['riskLevel']; + readiness: CodebaseHotspotEntry['readiness']; + why: LocalizableText; + preferredNextSlice: LocalizableText; + safeSeam: LocalizableText; + suggestedSlices: LocalizableText[]; + testTargets: string[]; + verificationPlan: Array; +} + +const keyFor = (id: string, field: string): string => `ai_chat.inspection.codebase_hotspots.${id}.${field}`; + +const CODEBASE_HOTSPOT_SNAPSHOT: CodebaseHotspotSourceEntry[] = [ { + id: 'sidebar', path: 'frontend/src/components/Sidebar.tsx', lines: 8901, area: 'workspace-navigation', riskLevel: 'critical', readiness: 'needsCharacterizationTests', - why: '左侧树、命令面板、上下文菜单和连接动作集中在单文件,修改入口多且回归面大。', - preferredNextSlice: '外部 SQL 目录弹窗', - safeSeam: '优先抽出无状态弹窗/菜单配置,再处理依赖连接树状态的动作分发。', - suggestedSlices: ['V2 命令面板', '外部 SQL 目录弹窗', '连接树动作', '批量操作弹窗'], + why: { + key: keyFor('sidebar', 'why'), + fallback: 'The left tree, command palette, context menus, and connection actions are concentrated in one file, so change entry points are numerous and regression risk is high.', + }, + preferredNextSlice: { + key: keyFor('sidebar', 'preferred_next_slice'), + fallback: 'External SQL directory dialog', + }, + safeSeam: { + key: keyFor('sidebar', 'safe_seam'), + fallback: 'Extract stateless dialog and menu configuration first, then handle action dispatch that depends on connection tree state.', + }, + suggestedSlices: [ + { key: keyFor('sidebar', 'suggested_slice.v2_command_palette'), fallback: 'V2 command palette' }, + { key: keyFor('sidebar', 'suggested_slice.external_sql_directory_dialog'), fallback: 'External SQL directory dialog' }, + { key: keyFor('sidebar', 'suggested_slice.connection_tree_actions'), fallback: 'Connection tree actions' }, + { key: keyFor('sidebar', 'suggested_slice.batch_operation_dialogs'), fallback: 'Batch operation dialogs' }, + ], testTargets: ['Sidebar.locate-toolbar.test.tsx', 'sidebarV2Utils.test.ts'], verificationPlan: [ 'npm --prefix frontend test -- Sidebar.locate-toolbar.test.tsx sidebarV2Utils.test.ts', 'npm --prefix frontend run build', - '浏览器打开侧边栏,验证连接树、右键菜单和外部 SQL 目录入口可用。', + { + key: keyFor('sidebar', 'verification.browser_smoke'), + fallback: 'Open the sidebar in a browser and verify the connection tree, context menus, and external SQL directory entry.', + }, ], }, { + id: 'data_grid', path: 'frontend/src/components/DataGrid.tsx', lines: 8080, area: 'result-grid', riskLevel: 'critical', readiness: 'needsCharacterizationTests', - why: '结果展示、编辑、DDL、导出和列操作耦合,容易让单点修复影响查询结果区。', - preferredNextSlice: '结果导出工具栏', - safeSeam: '优先抽出纯展示工具栏和菜单项生成,不先移动数据编辑事务状态。', - suggestedSlices: ['结果导出工具栏', '列头菜单', 'DDL 视图', '单元格编辑事务提示'], + why: { + key: keyFor('data_grid', 'why'), + fallback: 'Result display, editing, DDL, export, and column operations are coupled, so a focused fix can affect the query result area.', + }, + preferredNextSlice: { + key: keyFor('data_grid', 'preferred_next_slice'), + fallback: 'Result export toolbar', + }, + safeSeam: { + key: keyFor('data_grid', 'safe_seam'), + fallback: 'Extract the pure display toolbar and menu item generation first; do not move data-editing transaction state yet.', + }, + suggestedSlices: [ + { key: keyFor('data_grid', 'suggested_slice.result_export_toolbar'), fallback: 'Result export toolbar' }, + { key: keyFor('data_grid', 'suggested_slice.column_header_menu'), fallback: 'Column header menu' }, + { key: keyFor('data_grid', 'suggested_slice.ddl_view'), fallback: 'DDL view' }, + { key: keyFor('data_grid', 'suggested_slice.cell_edit_transaction_hint'), fallback: 'Cell edit transaction hint' }, + ], testTargets: ['DataGrid.layout.test.tsx', 'DataGrid.ddl.test.tsx'], verificationPlan: [ 'npm --prefix frontend test -- DataGrid.layout.test.tsx DataGrid.ddl.test.tsx', 'npm --prefix frontend run build', - '浏览器执行查询并验证结果表、导出、列菜单和表格编辑入口。', + { + key: keyFor('data_grid', 'verification.browser_smoke'), + fallback: 'Run a query in the browser and verify the result table, export, column menu, and table editing entry.', + }, ], }, { + id: 'connection_modal', path: 'frontend/src/components/ConnectionModal.tsx', lines: 6811, area: 'connection-form', riskLevel: 'critical', readiness: 'readyToExtract', - why: '多数据源连接表单仍集中在一个组件,新增数据源或密钥规则时容易互相影响。', - preferredNextSlice: 'TLS 配置区', - safeSeam: '连接表单已有 presentation utils,可先抽出按数据源显示的配置分区组件。', - suggestedSlices: ['SSH/代理配置区', 'TLS 配置区', 'MongoDB 配置区', 'JVM 配置区'], + why: { + key: keyFor('connection_modal', 'why'), + fallback: 'Multi-source connection forms are still concentrated in one component, so adding data sources or secret rules can affect each other.', + }, + preferredNextSlice: { + key: keyFor('connection_modal', 'preferred_next_slice'), + fallback: 'TLS configuration section', + }, + safeSeam: { + key: keyFor('connection_modal', 'safe_seam'), + fallback: 'The connection form already has presentation utilities; first extract configuration sections shown per data source.', + }, + suggestedSlices: [ + { key: keyFor('connection_modal', 'suggested_slice.ssh_proxy_section'), fallback: 'SSH/proxy configuration section' }, + { key: keyFor('connection_modal', 'suggested_slice.tls_section'), fallback: 'TLS configuration section' }, + { key: keyFor('connection_modal', 'suggested_slice.mongodb_section'), fallback: 'MongoDB configuration section' }, + { key: keyFor('connection_modal', 'suggested_slice.jvm_section'), fallback: 'JVM configuration section' }, + ], testTargets: ['ConnectionModal.edit-password.test.tsx', 'connectionModalPresentation.test.ts'], verificationPlan: [ 'npm --prefix frontend test -- ConnectionModal.edit-password.test.tsx connectionModalPresentation.test.ts', 'npm --prefix frontend run build', - '浏览器打开新增/编辑连接弹窗,切换 MySQL、Oracle、MongoDB、Redis 表单。', + { + key: keyFor('connection_modal', 'verification.browser_smoke'), + fallback: 'Open the add/edit connection dialog in a browser and switch MySQL, Oracle, MongoDB, and Redis forms.', + }, ], }, { + id: 'query_editor', path: 'frontend/src/components/QueryEditor.tsx', lines: 5275, area: 'sql-editor', riskLevel: 'critical', readiness: 'readyToExtract', - why: 'SQL 编辑、执行、事务、结果区布局和快捷键状态集中,事务和结果区回归风险高。', - preferredNextSlice: '编辑器工具栏', - safeSeam: '工具栏 JSX 可通过 props 透传状态和回调,避免触碰 SQL 执行、事务和结果分页逻辑。', - suggestedSlices: ['结果区工具栏', '事务状态条', '执行日志提示', '编辑器快捷键绑定'], + why: { + key: keyFor('query_editor', 'why'), + fallback: 'SQL editing, execution, transactions, result layout, and shortcut state are concentrated, so transaction and result-panel regressions are likely.', + }, + preferredNextSlice: { + key: keyFor('query_editor', 'preferred_next_slice'), + fallback: 'Editor toolbar', + }, + safeSeam: { + key: keyFor('query_editor', 'safe_seam'), + fallback: 'The toolbar JSX can pass state and callbacks through props, avoiding SQL execution, transaction, and result pagination logic.', + }, + suggestedSlices: [ + { key: keyFor('query_editor', 'suggested_slice.result_toolbar'), fallback: 'Result area toolbar' }, + { key: keyFor('query_editor', 'suggested_slice.transaction_status_bar'), fallback: 'Transaction status bar' }, + { key: keyFor('query_editor', 'suggested_slice.execution_log_hint'), fallback: 'Execution log hint' }, + { key: keyFor('query_editor', 'suggested_slice.editor_shortcut_binding'), fallback: 'Editor shortcut binding' }, + ], testTargets: ['QueryEditor.result-panel.test.tsx', 'useSqlEditorTransactionController.test.ts'], verificationPlan: [ 'npm --prefix frontend test -- QueryEditor.external-sql-save.test.tsx useSqlEditorTransactionController.test.tsx', 'npm --prefix frontend run build', - '浏览器打开 SQL 编辑器,验证连接/库选择、运行、保存、美化、结果区显隐和 AI 菜单。', + { + key: keyFor('query_editor', 'verification.browser_smoke'), + fallback: 'Open the SQL editor in a browser and verify connection/database selection, run, save, format, result visibility, and the AI menu.', + }, ], }, { + id: 'table_designer', path: 'frontend/src/components/TableDesigner.tsx', lines: 3549, area: 'table-designer', riskLevel: 'high', readiness: 'needsCharacterizationTests', - why: '字段编辑、索引、外键、分区和 DDL 生成集中,数据库方言差异容易扩散。', - preferredNextSlice: '字段编辑表格', - safeSeam: '先补字段类型/长度/NULL/默认值快照测试,再抽字段编辑表格。', - suggestedSlices: ['字段编辑表格', '索引配置面板', '外键配置面板', '方言 DDL 预览'], + why: { + key: keyFor('table_designer', 'why'), + fallback: 'Field editing, indexes, foreign keys, partitions, and DDL generation are concentrated, so database dialect differences can spread easily.', + }, + preferredNextSlice: { + key: keyFor('table_designer', 'preferred_next_slice'), + fallback: 'Field editing table', + }, + safeSeam: { + key: keyFor('table_designer', 'safe_seam'), + fallback: 'Add field type, length, NULL, and default value snapshot tests first, then extract the field editing table.', + }, + suggestedSlices: [ + { key: keyFor('table_designer', 'suggested_slice.field_editing_table'), fallback: 'Field editing table' }, + { key: keyFor('table_designer', 'suggested_slice.index_panel'), fallback: 'Index configuration panel' }, + { key: keyFor('table_designer', 'suggested_slice.foreign_key_panel'), fallback: 'Foreign key configuration panel' }, + { key: keyFor('table_designer', 'suggested_slice.dialect_ddl_preview'), fallback: 'Dialect DDL preview' }, + ], testTargets: ['TableDesigner.*.test.tsx', 'tableDesignerSchemaSql.test.ts'], verificationPlan: [ 'npm --prefix frontend test -- tableDesignerSchemaSql.test.ts', 'npm --prefix frontend run build', - '浏览器打开对象设计,验证字段、索引、外键和 DDL 预览。', + { + key: keyFor('table_designer', 'verification.browser_smoke'), + fallback: 'Open object design in a browser and verify fields, indexes, foreign keys, and DDL preview.', + }, ], }, { + id: 'redis_viewer', path: 'frontend/src/components/RedisViewer.tsx', lines: 2120, area: 'redis-browser', riskLevel: 'high', readiness: 'readyToExtract', - why: 'Key 浏览、不同数据结构编辑、TTL、编码显示和新增弹窗集中,Redis Cluster/Sentinel 后续验证面较宽。', - preferredNextSlice: 'Key 搜索栏', - safeSeam: '先抽出搜索栏和拓扑提示,避免提前改动各数据结构编辑器。', - suggestedSlices: ['Key 搜索栏', 'String/List/Set/ZSet/Hash/Stream 编辑器', '新增 Key 弹窗'], + why: { + key: keyFor('redis_viewer', 'why'), + fallback: 'Key browsing, data-structure editing, TTL, encoding display, and the add dialog are concentrated, so Redis Cluster/Sentinel follow-up validation is broad.', + }, + preferredNextSlice: { + key: keyFor('redis_viewer', 'preferred_next_slice'), + fallback: 'Key search bar', + }, + safeSeam: { + key: keyFor('redis_viewer', 'safe_seam'), + fallback: 'Extract the search bar and topology hint first, avoiding early changes to each data-structure editor.', + }, + suggestedSlices: [ + { key: keyFor('redis_viewer', 'suggested_slice.key_search_bar'), fallback: 'Key search bar' }, + { key: keyFor('redis_viewer', 'suggested_slice.structure_editors'), fallback: 'String/List/Set/ZSet/Hash/Stream editors' }, + { key: keyFor('redis_viewer', 'suggested_slice.add_key_dialog'), fallback: 'Add key dialog' }, + ], testTargets: ['redisViewerTree.test.ts', 'RedisViewer.*.test.tsx'], verificationPlan: [ 'npm --prefix frontend test -- redisViewerTree.test.ts', 'npm --prefix frontend run build', - '浏览器打开 Redis 连接,验证 key 搜索、刷新、TTL 和新增入口。', + { + key: keyFor('redis_viewer', 'verification.browser_smoke'), + fallback: 'Open a Redis connection in a browser and verify key search, refresh, TTL, and the add entry.', + }, ], }, { + id: 'driver_manager', path: 'frontend/src/components/DriverManagerModal.tsx', lines: 1729, area: 'driver-manager', riskLevel: 'high', readiness: 'readyToExtract', - why: '驱动安装、状态展示、下载和可选代理逻辑较多,适合继续拆出状态卡片和操作区。', - preferredNextSlice: '驱动状态列表', - safeSeam: '状态列表是展示型组件,可先抽出再保留安装/下载动作在父组件。', - suggestedSlices: ['驱动状态列表', '安装操作区', '下载日志区'], + why: { + key: keyFor('driver_manager', 'why'), + fallback: 'Driver installation, status display, downloads, and optional proxy logic are substantial, so status cards and action areas are good next extraction targets.', + }, + preferredNextSlice: { + key: keyFor('driver_manager', 'preferred_next_slice'), + fallback: 'Driver status list', + }, + safeSeam: { + key: keyFor('driver_manager', 'safe_seam'), + fallback: 'The status list is presentational; extract it first while keeping install and download actions in the parent component.', + }, + suggestedSlices: [ + { key: keyFor('driver_manager', 'suggested_slice.driver_status_list'), fallback: 'Driver status list' }, + { key: keyFor('driver_manager', 'suggested_slice.install_actions'), fallback: 'Install action area' }, + { key: keyFor('driver_manager', 'suggested_slice.download_logs'), fallback: 'Download log area' }, + ], testTargets: ['DriverManagerModal.*.test.tsx'], verificationPlan: [ 'npm --prefix frontend test -- DriverManagerModal.*.test.tsx', 'npm --prefix frontend run build', - '浏览器打开驱动管理,验证状态展示、安装按钮和日志区域。', + { + key: keyFor('driver_manager', 'verification.browser_smoke'), + fallback: 'Open driver management in a browser and verify status display, install buttons, and the log area.', + }, ], }, { + id: 'data_sync', path: 'frontend/src/components/DataSyncModal.tsx', lines: 1526, area: 'data-sync', riskLevel: 'high', readiness: 'needsCharacterizationTests', - why: '数据同步连接、表映射、预检查和执行结果集中,数据库方言问题容易隐藏。', - preferredNextSlice: '同步预检查结果', - safeSeam: '先把预检查结果做成纯展示组件,保留连接选择和执行动作在父组件。', - suggestedSlices: ['连接选择区', '表映射区', '同步预检查结果', '执行日志区'], + why: { + key: keyFor('data_sync', 'why'), + fallback: 'Data sync connections, table mapping, prechecks, and execution results are concentrated, so database dialect issues can be hidden.', + }, + preferredNextSlice: { + key: keyFor('data_sync', 'preferred_next_slice'), + fallback: 'Sync precheck results', + }, + safeSeam: { + key: keyFor('data_sync', 'safe_seam'), + fallback: 'Make precheck results a pure display component first, keeping connection selection and execution actions in the parent component.', + }, + suggestedSlices: [ + { key: keyFor('data_sync', 'suggested_slice.connection_selection'), fallback: 'Connection selection area' }, + { key: keyFor('data_sync', 'suggested_slice.table_mapping'), fallback: 'Table mapping area' }, + { key: keyFor('data_sync', 'suggested_slice.precheck_results'), fallback: 'Sync precheck results' }, + { key: keyFor('data_sync', 'suggested_slice.execution_logs'), fallback: 'Execution log area' }, + ], testTargets: ['DataSyncModal.*.test.tsx'], verificationPlan: [ 'npm --prefix frontend test -- DataSyncModal.*.test.tsx', 'npm --prefix frontend run build', - '浏览器打开数据同步,验证连接选择、表映射、预检查和执行日志。', + { + key: keyFor('data_sync', 'verification.browser_smoke'), + fallback: 'Open data sync in a browser and verify connection selection, table mapping, precheck, and execution logs.', + }, ], }, { + id: 'jvm_diagnostic', path: 'frontend/src/components/JVMDiagnosticConsole.tsx', lines: 1146, area: 'jvm-diagnostics', riskLevel: 'medium', readiness: 'readyToExtract', - why: '诊断命令、输出块、权限提示和会话状态可继续拆分,降低 JVM 诊断回归面。', - preferredNextSlice: '诊断输出区', - safeSeam: '输出区主要依赖命令结果数组,可先抽成展示组件。', - suggestedSlices: ['命令输入区', '诊断输出区', '权限提示区'], + why: { + key: keyFor('jvm_diagnostic', 'why'), + fallback: 'Diagnostic commands, output blocks, permission hints, and session state can continue to be split to lower JVM diagnostics regression risk.', + }, + preferredNextSlice: { + key: keyFor('jvm_diagnostic', 'preferred_next_slice'), + fallback: 'Diagnostic output area', + }, + safeSeam: { + key: keyFor('jvm_diagnostic', 'safe_seam'), + fallback: 'The output area mainly depends on the command result array, so it can be extracted as a display component first.', + }, + suggestedSlices: [ + { key: keyFor('jvm_diagnostic', 'suggested_slice.command_input'), fallback: 'Command input area' }, + { key: keyFor('jvm_diagnostic', 'suggested_slice.diagnostic_output'), fallback: 'Diagnostic output area' }, + { key: keyFor('jvm_diagnostic', 'suggested_slice.permission_hint'), fallback: 'Permission hint area' }, + ], testTargets: ['JVMDiagnosticConsole.*.test.tsx'], verificationPlan: [ 'npm --prefix frontend test -- JVMDiagnosticConsole.*.test.tsx', 'npm --prefix frontend run build', - '浏览器打开 JVM 诊断面板,验证命令输入、输出和权限提示。', + { + key: keyFor('jvm_diagnostic', 'verification.browser_smoke'), + fallback: 'Open the JVM diagnostics panel in a browser and verify command input, output, and permission hints.', + }, ], }, ]; +const NEXT_ACTIONS: LocalizableText[] = [ + { + key: 'ai_chat.inspection.codebase_hotspots.next_action.pick_ready_slice', + fallback: 'Prefer a slice with readiness=readyToExtract and existing test coverage for small-step extraction; avoid rewriting an entire large component directly.', + }, + { + key: 'ai_chat.inspection.codebase_hotspots.next_action.confirm_safe_seam', + fallback: 'Confirm the safeSeam before every extraction, and do not cross SQL execution, transaction, connection secret, or database dialect boundaries.', + }, + { + key: 'ai_chat.inspection.codebase_hotspots.next_action.run_targeted_tests', + fallback: 'After extraction, run the corresponding component tests, related utils tests, and npm --prefix frontend run build at minimum.', + }, + { + key: 'ai_chat.inspection.codebase_hotspots.next_action.browser_smoke', + fallback: 'For visible UI extraction, open the real page in a browser for one smoke verification.', + }, +]; + +const translateText = ( + translate: AIInspectionTranslator | undefined, + { key, fallback }: LocalizableText, +): string => translateInspectionCopy(translate, key, fallback); + const normalizeKeyword = (value: unknown): string => String(value || '').trim().toLowerCase(); const clampNumber = (value: unknown, fallback: number, min: number, max: number): number => { @@ -185,6 +394,25 @@ const clampNumber = (value: unknown, fallback: number, min: number, max: number) return Math.max(min, Math.min(max, Math.floor(parsed))); }; +const localizeEntry = ( + entry: CodebaseHotspotSourceEntry, + translate: AIInspectionTranslator | undefined, +): CodebaseHotspotEntry => ({ + path: entry.path, + lines: entry.lines, + area: entry.area, + riskLevel: entry.riskLevel, + readiness: entry.readiness, + why: translateText(translate, entry.why), + preferredNextSlice: translateText(translate, entry.preferredNextSlice), + safeSeam: translateText(translate, entry.safeSeam), + suggestedSlices: entry.suggestedSlices.map((item) => translateText(translate, item)), + testTargets: entry.testTargets, + verificationPlan: entry.verificationPlan.map((item) => ( + typeof item === 'string' ? item : translateText(translate, item) + )), +}); + const matchesKeyword = (entry: CodebaseHotspotEntry, keyword: string): boolean => { if (!keyword) { return true; @@ -193,7 +421,10 @@ const matchesKeyword = (entry: CodebaseHotspotEntry, keyword: string): boolean = entry.path, entry.area, entry.riskLevel, + entry.readiness, entry.why, + entry.preferredNextSlice, + entry.safeSeam, ...entry.suggestedSlices, ...entry.testTargets, ].some((item) => item.toLowerCase().includes(keyword)); @@ -204,11 +435,13 @@ export const buildCodebaseHotspotSnapshot = ({ minLines, limit, includeRecommendations = true, + translate, }: CodebaseHotspotSnapshotOptions = {}) => { const normalizedKeyword = normalizeKeyword(keyword); const normalizedMinLines = clampNumber(minLines, 1000, 1, 20000); const normalizedLimit = clampNumber(limit, 8, 1, 30); - const matched = CODEBASE_HOTSPOT_SNAPSHOT + const localizedEntries = CODEBASE_HOTSPOT_SNAPSHOT.map((entry) => localizeEntry(entry, translate)); + const matched = localizedEntries .filter((entry) => entry.lines >= normalizedMinLines) .filter((entry) => matchesKeyword(entry, normalizedKeyword)) .slice(0, normalizedLimit); @@ -220,7 +453,11 @@ export const buildCodebaseHotspotSnapshot = ({ source: 'static_maintainability_snapshot', evidence: { measuredAt: '2026-06-12', - note: '基于当前仓库前端文件行数热点快照;拆分前应优先选择 readyToExtract 且已有测试覆盖的 slice。', + note: translateInspectionCopy( + translate, + 'ai_chat.inspection.codebase_hotspots.evidence.note', + 'Based on the current repository frontend file-line hotspot snapshot; before extraction, prefer slices that are readyToExtract and already covered by tests.', + ), }, filters: { keyword: normalizedKeyword || undefined, @@ -250,12 +487,7 @@ export const buildCodebaseHotspotSnapshot = ({ verificationPlan: includeRecommendations ? entry.verificationPlan : [], })), nextActions: includeRecommendations - ? [ - '优先选择 readiness=readyToExtract 且已有测试覆盖的 slice 做小步拆分,避免直接重写整个大组件。', - '每次拆分都要先确认 safeSeam,不跨越 SQL 执行、事务、连接密钥或数据库方言边界。', - '拆分后至少运行对应组件测试、相关 utils 测试和 npm --prefix frontend run build。', - '涉及可见 UI 的拆分需要用浏览器打开真实页面做一次冒烟验证。', - ] + ? NEXT_ACTIONS.map((item) => translateText(translate, item)) : [], }; }; diff --git a/frontend/src/components/ai/aiConnectionCapabilitiesInsights.test.ts b/frontend/src/components/ai/aiConnectionCapabilitiesInsights.test.ts index f532b9f..3211460 100644 --- a/frontend/src/components/ai/aiConnectionCapabilitiesInsights.test.ts +++ b/frontend/src/components/ai/aiConnectionCapabilitiesInsights.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { t as translateCatalog } from '../../i18n/catalog'; import type { SavedConnection, TabData } from '../../types'; import { buildConnectionCapabilitiesSnapshot } from './aiConnectionCapabilitiesInsights'; @@ -96,6 +97,7 @@ describe('aiConnectionCapabilitiesInsights', () => { const snapshot = buildConnectionCapabilitiesSnapshot({ connectionId: 'conn-kafka', connections, + translate: (key, params) => translateCatalog('zh-CN', key, params), }); if (!snapshot.hasConnection || !snapshot.capabilities) { diff --git a/frontend/src/components/ai/aiConnectionCapabilitiesInsights.ts b/frontend/src/components/ai/aiConnectionCapabilitiesInsights.ts index 8c68fdc..7b97043 100644 --- a/frontend/src/components/ai/aiConnectionCapabilitiesInsights.ts +++ b/frontend/src/components/ai/aiConnectionCapabilitiesInsights.ts @@ -3,6 +3,7 @@ import { getDataSourceCapabilities, resolveDataSourceType, } from '../../utils/dataSourceCapabilities'; +import { translateInspectionCopy, type AIInspectionTranslator } from './aiInspectionI18n'; export const buildConnectionCapabilitiesSnapshot = (params: { connectionId?: string | null; @@ -10,6 +11,7 @@ export const buildConnectionCapabilitiesSnapshot = (params: { tabs?: TabData[]; activeTabId?: string | null; connections: SavedConnection[]; + translate?: AIInspectionTranslator; }) => { const { connectionId, @@ -17,6 +19,7 @@ export const buildConnectionCapabilitiesSnapshot = (params: { tabs = [], activeTabId = null, connections, + translate, } = params; const trimmedExplicitConnectionId = String(connectionId || '').trim(); @@ -31,7 +34,11 @@ export const buildConnectionCapabilitiesSnapshot = (params: { if (!fallbackConnectionId) { return { hasConnection: false, - message: '当前没有可用于能力分析的连接', + message: translateInspectionCopy( + translate, + 'ai_chat.inspection.connection_capabilities.no_connection', + 'No connection is available for capability analysis', + ), }; } @@ -40,7 +47,11 @@ export const buildConnectionCapabilitiesSnapshot = (params: { return { hasConnection: false, connectionId: fallbackConnectionId, - message: '目标连接在本地缓存中不存在', + message: translateInspectionCopy( + translate, + 'ai_chat.inspection.connection_capabilities.cache_missing', + 'The target connection does not exist in the local cache', + ), }; } @@ -68,17 +79,49 @@ export const buildConnectionCapabilitiesSnapshot = (params: { const uiHints = [ capabilities.forceReadOnlyQueryResult - ? '当前数据源的查询结果默认按只读方式展示,不提供直接编辑结果集。' - : '当前数据源的查询结果在满足定位条件时可进入编辑路径。', + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.connection_capabilities.hint.readonly_result', + 'Query results for this data source are shown as read-only by default and cannot be edited directly.', + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.connection_capabilities.hint.editable_result', + 'Query results for this data source can enter the edit path when row locating conditions are available.', + ), capabilities.preferManualTotalCount - ? '结果总数优先走手动统计或延迟统计,避免直接依赖快速总数。' - : '结果总数可以优先使用常规统计路径。', + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.connection_capabilities.hint.manual_total_count', + 'Total result counts should prefer manual or deferred counting instead of relying directly on fast totals.', + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.connection_capabilities.hint.regular_total_count', + 'Total result counts can prefer the regular counting path.', + ), capabilities.supportsApproximateTableCount - ? '表浏览场景允许显示近似行数,减少大表统计开销。' - : '表浏览场景默认不使用近似行数。', + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.connection_capabilities.hint.approximate_table_count', + 'Table browsing can show approximate row counts to reduce large-table counting overhead.', + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.connection_capabilities.hint.exact_table_count', + 'Table browsing does not use approximate row counts by default.', + ), capabilities.supportsMessagePublish - ? '当前数据源提供测试发送消息入口,适合做 Topic/Queue 的联调验证。' - : '当前数据源未暴露测试发送消息入口。', + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.connection_capabilities.hint.message_publish_supported', + 'This data source provides a test message publishing entry, suitable for Topic/Queue integration checks.', + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.connection_capabilities.hint.message_publish_unsupported', + 'This data source does not expose a test message publishing entry.', + ), ]; return { @@ -96,6 +139,15 @@ export const buildConnectionCapabilitiesSnapshot = (params: { supportedActions, restrictions, uiHints, - message: `当前连接 ${connection.name} (${resolvedType || connection.config?.type || 'unknown'}) 已解析出 ${supportedActions.length} 项前端能力信号`, + message: translateInspectionCopy( + translate, + 'ai_chat.inspection.connection_capabilities.summary', + `Current connection ${connection.name} (${resolvedType || connection.config?.type || 'unknown'}) exposes ${supportedActions.length} frontend capability signals`, + { + connectionName: connection.name, + type: resolvedType || connection.config?.type || 'unknown', + count: supportedActions.length, + }, + ), }; }; diff --git a/frontend/src/components/ai/aiConnectionFailureInsights.test.ts b/frontend/src/components/ai/aiConnectionFailureInsights.test.ts index 1678aa5..90ce524 100644 --- a/frontend/src/components/ai/aiConnectionFailureInsights.test.ts +++ b/frontend/src/components/ai/aiConnectionFailureInsights.test.ts @@ -3,6 +3,216 @@ import { describe, expect, it } from 'vitest'; import { buildRecentConnectionFailureSnapshot } from './aiConnectionFailureInsights'; describe('buildRecentConnectionFailureSnapshot', () => { + it.each([ + [ + 'en-US', + 'The connection failed recently and is in cooldown. Retry after 29s; last error: Failed to verify the established connection: 127.0.0.1:3306 post-connect check returned mismatch', + 'Retry after 29s', + ], + [ + 'zh-TW', + '\u9023\u7dda\u6700\u8fd1\u5931\u6557\uff0c\u6b63\u5728\u51b7\u537b\u4e2d\uff0c\u8acb\u65bc 29s \u5f8c\u91cd\u8a66\uff1b\u4e0a\u6b21\u932f\u8aa4\uff1a\u9023\u7dda\u5efa\u7acb\u5f8c\u9a57\u8b49\u5931\u6557\uff1a127.0.0.1:3306 post-connect check returned mismatch', + '\u8acb\u65bc 29s \u5f8c\u91cd\u8a66', + ], + [ + 'ja-JP', + '\u63a5\u7d9a\u306f\u76f4\u8fd1\u3067\u5931\u6557\u3057\u3066\u304a\u308a\u3001\u73fe\u5728\u30af\u30fc\u30eb\u30c0\u30a6\u30f3\u4e2d\u3067\u3059\u300229s \u5f8c\u306b\u518d\u8a66\u884c\u3057\u3066\u304f\u3060\u3055\u3044\u3002\u524d\u56de\u306e\u30a8\u30e9\u30fc: \u63a5\u7d9a\u78ba\u7acb\u5f8c\u306e\u691c\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f: 127.0.0.1:3306 post-connect check returned mismatch', + '29s \u5f8c\u306b\u518d\u8a66\u884c', + ], + [ + 'de-DE', + 'Die Verbindung ist vor Kurzem fehlgeschlagen und befindet sich in einer Abk\u00fchlphase. Versuchen Sie es in 29s erneut; letzter Fehler: Verbindung konnte nach dem Aufbau nicht verifiziert werden: 127.0.0.1:3306 post-connect check returned mismatch', + 'Versuchen Sie es in 29s erneut', + ], + [ + 'ru-RU', + '\u041f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043d\u0435\u0434\u0430\u0432\u043d\u043e \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043b\u043e\u0441\u044c \u043e\u0448\u0438\u0431\u043a\u043e\u0439 \u0438 \u0441\u0435\u0439\u0447\u0430\u0441 \u043d\u0430\u0445\u043e\u0434\u0438\u0442\u0441\u044f \u043d\u0430 \u043e\u0445\u043b\u0430\u0436\u0434\u0435\u043d\u0438\u0438. \u041f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u0447\u0435\u0440\u0435\u0437 29s; \u043f\u043e\u0441\u043b\u0435\u0434\u043d\u044f\u044f \u043e\u0448\u0438\u0431\u043a\u0430: \u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u0441\u043b\u0435 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f: 127.0.0.1:3306 post-connect check returned mismatch', + '\u041f\u043e\u0432\u0442\u043e\u0440\u0438\u0442\u0435 \u043f\u043e\u043f\u044b\u0442\u043a\u0443 \u0447\u0435\u0440\u0435\u0437 29s', + ], + ])('classifies localized cooldown validation wrapper %s', (_locale, errorChain, cooldownMarker) => { + const snapshot = buildRecentConnectionFailureSnapshot({ + readResult: { + success: true, + data: { + lines: [ + `2026/06/21 10:05:00.000000 [ERROR] DBGetDatabases 获取连接失败:类型=mysql 地址=127.0.0.1:3306 数据库=crm 用户=root;错误链:${errorChain}`, + ], + }, + }, + }); + + expect(snapshot.failureEventCount).toBe(1); + expect(snapshot.primaryCategory).toBe('validation'); + expect(snapshot.latestFailure?.category).toBe('validation'); + expect(snapshot.latestFailure?.cooldownSeconds).toBe(29); + expect(snapshot.latestFailure?.rootCause).toContain('127.0.0.1:3306'); + expect(snapshot.latestFailure?.rootCause).toContain('post-connect check returned mismatch'); + expect(snapshot.latestFailure?.rootCause).not.toContain(cooldownMarker); + }); + + it.each([ + '2026/06/21 10:00:00.000000 [ERROR] 建立数据库连接失败:类型=mysql 地址=127.0.0.1:3306 数据库=crm 用户=root;错误链:Database connection timed out: mysql 127.0.0.1:3306/crm: network timeout', + '2026/06/21 10:00:01.000000 [ERROR] 建立数据库连接失败:类型=mysql 地址=127.0.0.1:3306 数据库=crm 用户=root;错误链:資料庫連線逾時:mysql 127.0.0.1:3306/crm:網路逾時', + '2026/06/21 10:00:02.000000 [ERROR] 建立数据库连接失败:类型=mysql 地址=127.0.0.1:3306 数据库=crm 用户=root;错误链:データベース接続がタイムアウトしました: mysql 127.0.0.1:3306/crm: タイムアウト', + '2026/06/21 10:00:03.000000 [ERROR] 建立数据库连接失败:类型=mysql 地址=127.0.0.1:3306 数据库=crm 用户=root;错误链:Zeitüberschreitung bei der Datenbankverbindung: mysql 127.0.0.1:3306/crm', + '2026/06/21 10:00:04.000000 [ERROR] 建立数据库连接失败:类型=mysql 地址=127.0.0.1:3306 数据库=crm 用户=root;错误链:Тайм-аут подключения к базе данных: mysql 127.0.0.1:3306/crm', + ])('classifies localized timeout wrapper %s as timeout', (line) => { + const snapshot = buildRecentConnectionFailureSnapshot({ + readResult: { + success: true, + data: { + logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log', + lines: [line], + }, + }, + }); + + expect(snapshot.failureEventCount).toBe(1); + expect(snapshot.primaryCategory).toBe('timeout'); + expect(snapshot.latestFailure?.category).toBe('timeout'); + expect(snapshot.latestFailure?.rootCause).toContain('127.0.0.1:3306/crm'); + expect(snapshot.nextActions.join('\n')).toContain('target address, port, firewall'); + }); + + it.each([ + 'Failed to connect to the SSH gateway through the proxy: Failed to parse the local proxy forward address: broken-local-forward', + '代理連線 SSH 閘道失敗:無法解析代理本地轉發位址:broken-local-forward', + 'プロキシ経由で SSH ゲートウェイに接続できませんでした: ローカルプロキシ転送アドレスを解析できません: broken-local-forward', + 'Verbindung zum SSH-Gateway über den Proxy fehlgeschlagen: Lokale Proxy-Weiterleitungsadresse konnte nicht geparst werden: broken-local-forward', + 'Не удалось подключиться к SSH-шлюзу через прокси: Не удалось разобрать локальный адрес прокси-переадресации: broken-local-forward', + ])('classifies localized SSH proxy gateway wrapper %s as ssh', (errorChain) => { + const snapshot = buildRecentConnectionFailureSnapshot({ + readResult: { + success: true, + data: { + lines: [ + `2026/06/21 10:10:00.000000 [ERROR] 建立数据库连接失败:类型=mysql 地址=127.0.0.1:3306 数据库=crm 用户=root;错误链:${errorChain}`, + ], + }, + }, + }); + + expect(snapshot.failureEventCount).toBe(1); + expect(snapshot.primaryCategory).toBe('ssh'); + expect(snapshot.sshFailureCount).toBe(1); + expect(snapshot.latestFailure?.category).toBe('ssh'); + expect(snapshot.latestFailure?.address).toBe('127.0.0.1:3306'); + expect(snapshot.nextActions.join('\n')).toContain('SSH jump host address'); + }); + + it.each([ + [ + 'en-US', + 'ClickHouse connection validation failed: used user-selected Native protocol for 127.0.0.1:8123. unexpected client protocol', + ], + [ + 'zh-TW', + 'ClickHouse \u9023\u7dda\u9a57\u8b49\u5931\u6557\uff1a\u5df2\u4f9d\u4f7f\u7528\u8005\u9078\u64c7\u4f7f\u7528 Native \u5354\u8b70\u9023\u7dda 127.0.0.1:8123\u3002unexpected client protocol', + ], + [ + 'ja-JP', + 'ClickHouse \u63a5\u7d9a\u691c\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f: \u30e6\u30fc\u30b6\u30fc\u304c\u9078\u629e\u3057\u305f Native \u30d7\u30ed\u30c8\u30b3\u30eb\u3067 127.0.0.1:8123 \u306b\u63a5\u7d9a\u3057\u307e\u3057\u305f\u3002unexpected client protocol', + ], + [ + 'de-DE', + 'ClickHouse-Verbindungsvalidierung fehlgeschlagen: Benutzergew\u00e4hltes Protokoll Native wurde f\u00fcr 127.0.0.1:8123 verwendet. unexpected client protocol', + ], + [ + 'ru-RU', + '\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f ClickHouse \u043d\u0435 \u0443\u0434\u0430\u043b\u0430\u0441\u044c: \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0439 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u043c \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b Native \u0434\u043b\u044f 127.0.0.1:8123. unexpected client protocol', + ], + ])('classifies localized ClickHouse validation wrapper %s as validation', (_locale, errorChain) => { + const snapshot = buildRecentConnectionFailureSnapshot({ + readResult: { + success: true, + data: { + lines: [ + `2026/06/21 10:12:00.000000 [ERROR] DBGetDatabases 获取连接失败:类型=clickhouse 地址=127.0.0.1:8123 数据库=default 用户=default;错误链:${errorChain}`, + ], + }, + }, + }); + + expect(snapshot.failureEventCount).toBe(1); + expect(snapshot.primaryCategory).toBe('validation'); + expect(snapshot.latestFailure?.category).toBe('validation'); + expect(snapshot.latestFailure?.address).toBe('127.0.0.1:8123'); + expect(snapshot.latestFailure?.rootCause).toContain('unexpected client protocol'); + expect(snapshot.nextActions.join('\n')).toContain('driver protocol'); + }); + + it.each([ + 'MongoDB connect failed: primary credentials: SCRAM conversation aborted', + 'MongoDB connect failed: \u4e3b\u5eab\u6191\u8b49: SCRAM conversation aborted', + 'MongoDB connect failed: \u30d7\u30e9\u30a4\u30de\u30ea\u8a8d\u8a3c\u60c5\u5831: SCRAM conversation aborted', + 'MongoDB connect failed: Prim\u00e4r-Anmeldedaten: SCRAM conversation aborted', + 'MongoDB connect failed: \u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 primary: SCRAM conversation aborted', + ])('classifies localized Mongo credential labels %s as authentication', (errorChain) => { + const snapshot = buildRecentConnectionFailureSnapshot({ + readResult: { + success: true, + data: { + lines: [ + `2026/06/21 10:14:00.000000 [ERROR] \u5efa\u7acb\u6570\u636e\u5e93\u8fde\u63a5\u5931\u8d25\uff1a\u7c7b\u578b=mongodb \u5730\u5740=127.0.0.1:27017 \u6570\u636e\u5e93=admin \u7528\u6237=root\uff1b\u9519\u8bef\u94fe\uff1a${errorChain}`, + ], + }, + }, + }); + + expect(snapshot.failureEventCount).toBe(1); + expect(snapshot.primaryCategory).toBe('authentication'); + expect(snapshot.latestFailure?.category).toBe('authentication'); + expect(snapshot.latestFailure?.address).toBe('127.0.0.1:27017'); + expect(snapshot.latestFailure?.rootCause).toContain('SCRAM conversation aborted'); + expect(snapshot.nextActions.join('\n')).toContain('username, password, authentication database'); + }); + + it.each([ + [ + 'en-US', + 'ClickHouse connection validation failed: used user-selected Native protocol for 127.0.0.1:8123. unexpected client protocol (detail log: C:/Users/demo/.GoNavi/Logs/gonavi.log)', + 'detail log:', + ], + [ + 'zh-TW', + 'ClickHouse \u9023\u7dda\u9a57\u8b49\u5931\u6557\uff1a\u5df2\u4f9d\u4f7f\u7528\u8005\u9078\u64c7\u4f7f\u7528 Native \u5354\u8b70\u9023\u7dda 127.0.0.1:8123\u3002unexpected client protocol\uff08\u8a73\u7d30\u65e5\u8a8c\uff1aC:/Users/demo/.GoNavi/Logs/gonavi.log\uff09', + '\u8a73\u7d30\u65e5\u8a8c\uff1a', + ], + [ + 'ja-JP', + 'ClickHouse \u63a5\u7d9a\u691c\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f: \u30e6\u30fc\u30b6\u30fc\u304c\u9078\u629e\u3057\u305f Native \u30d7\u30ed\u30c8\u30b3\u30eb\u3067 127.0.0.1:8123 \u306b\u63a5\u7d9a\u3057\u307e\u3057\u305f\u3002unexpected client protocol\uff08\u8a73\u7d30\u30ed\u30b0\uff1aC:/Users/demo/.GoNavi/Logs/gonavi.log\uff09', + '\u8a73\u7d30\u30ed\u30b0\uff1a', + ], + [ + 'de-DE', + 'ClickHouse-Verbindungsvalidierung fehlgeschlagen: Benutzergew\u00e4hltes Protokoll Native wurde f\u00fcr 127.0.0.1:8123 verwendet. unexpected client protocol (Detailprotokoll: C:/Users/demo/.GoNavi/Logs/gonavi.log)', + 'Detailprotokoll:', + ], + [ + 'ru-RU', + '\u041f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f ClickHouse \u043d\u0435 \u0443\u0434\u0430\u043b\u0430\u0441\u044c: \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u043d \u0432\u044b\u0431\u0440\u0430\u043d\u043d\u044b\u0439 \u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u0435\u043b\u0435\u043c \u043f\u0440\u043e\u0442\u043e\u043a\u043e\u043b Native \u0434\u043b\u044f 127.0.0.1:8123. unexpected client protocol (\u043f\u043e\u0434\u0440\u043e\u0431\u043d\u044b\u0439 \u0436\u0443\u0440\u043d\u0430\u043b: C:/Users/demo/.GoNavi/Logs/gonavi.log)', + '\u043f\u043e\u0434\u0440\u043e\u0431\u043d\u044b\u0439 \u0436\u0443\u0440\u043d\u0430\u043b:', + ], + ])('strips localized detail log hints from root cause for %s', (_locale, errorChain, hintMarker) => { + const snapshot = buildRecentConnectionFailureSnapshot({ + readResult: { + success: true, + data: { + lines: [ + `2026/06/21 10:16:00.000000 [ERROR] DBGetDatabases 获取连接失败:类型=clickhouse 地址=127.0.0.1:8123 数据库=default 用户=default;错误链:${errorChain}`, + ], + }, + }, + }); + + expect(snapshot.failureEventCount).toBe(1); + expect(snapshot.primaryCategory).toBe('validation'); + expect(snapshot.latestFailure?.category).toBe('validation'); + expect(snapshot.latestFailure?.rootCause).toContain('unexpected client protocol'); + expect(snapshot.latestFailure?.rootCause).not.toContain(hintMarker); + expect(snapshot.latestFailure?.rootCause).not.toContain('C:/Users/demo/.GoNavi/Logs/gonavi.log'); + }); + it('summarizes recent validation failures and cooldown hits from gonavi.log lines', () => { const snapshot = buildRecentConnectionFailureSnapshot({ readResult: { @@ -44,7 +254,7 @@ describe('buildRecentConnectionFailureSnapshot', () => { expect(snapshot.failureEventCount).toBe(1); expect(snapshot.primaryCategory).toBe('parameter_compatibility'); expect(snapshot.recentFailures[0]?.rootCause).toContain('%2Cutf8'); - expect(snapshot.nextActions.join('\n')).toContain('连接参数'); + expect(snapshot.nextActions.join('\n')).toContain('connection parameters'); }); it('returns an empty-state message when the tail has no connection-related failures', () => { @@ -62,6 +272,38 @@ describe('buildRecentConnectionFailureSnapshot', () => { }); expect(snapshot.failureEventCount).toBe(0); - expect(snapshot.message).toContain('没有识别到连接失败'); + expect(snapshot.message).toContain('No connection failures'); + }); + + it('localizes controlled snapshot copy while preserving raw log evidence', () => { + const translate = (key: string, params?: Record) => { + const messages: Record = { + 'ai_chat.inspection.connection_failures.category.parameter_compatibility': 'PARAM LABEL', + 'ai_chat.inspection.connection_failures.next_action.parameter_compatibility': 'CHECK DSN', + 'ai_chat.inspection.connection_failures.next_action.check_current_connection': `CHECK CURRENT ${params?.address || ''}`, + 'ai_chat.inspection.connection_failures.message.detected': `FOUND ${params?.count || ''} ${params?.categoryLabel || ''}`, + }; + return messages[key] || key; + }; + + const snapshot = buildRecentConnectionFailureSnapshot({ + readResult: { + success: true, + data: { + lines: [ + '2026/06/07 15:50:35.000000 [ERROR] DBGetDatabases 获取连接失败:类型=mysql 地址=127.0.0.1:48749 数据库=(default) 用户=root;错误链:连接最近失败,正在冷却中,请 29s 后重试;上次错误:连接建立后验证失败:127.0.0.1:48749 [禁用 multiStatements 兼容重试] 验证失败: Error 1064 (42000): You have an error in your SQL syntax near \'%2Cutf8\' at line 1', + ], + }, + }, + translate, + }); + + expect(snapshot.primaryCategoryLabel).toBe('PARAM LABEL'); + expect(snapshot.categorySummary[0]?.label).toBe('PARAM LABEL'); + expect(snapshot.recentFailures[0]?.categoryLabel).toBe('PARAM LABEL'); + expect(snapshot.latestFailure?.rootCause).toContain('%2Cutf8'); + expect(snapshot.nextActions).toContain('CHECK DSN'); + expect(snapshot.nextActions).toContain('CHECK CURRENT 127.0.0.1:48749'); + expect(snapshot.message).toBe('FOUND 1 PARAM LABEL'); }); }); diff --git a/frontend/src/components/ai/aiConnectionFailureInsights.ts b/frontend/src/components/ai/aiConnectionFailureInsights.ts index 17ad271..b91c5bf 100644 --- a/frontend/src/components/ai/aiConnectionFailureInsights.ts +++ b/frontend/src/components/ai/aiConnectionFailureInsights.ts @@ -1,7 +1,84 @@ +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; +import type { I18nParams } from '../../i18n'; + const DEFAULT_CONNECTION_FAILURE_LOG_LIMIT = 120; const MAX_CONNECTION_FAILURE_LOG_LIMIT = 240; const MAX_RECENT_FAILURES = 8; +const LOCALIZED_TIMEOUT_KEYWORDS = [ + 'timed out', + '\u9023\u7dda\u903e\u6642', + '\u903e\u6642', + '\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8', + 'zeit\u00fcberschreitung', + '\u0442\u0430\u0439\u043c-\u0430\u0443\u0442', +]; + +const LOCALIZED_VALIDATION_PREFIXES = [ + '\u8fde\u63a5\u5efa\u7acb\u540e\u9a8c\u8bc1\u5931\u8d25\uff1a', + '\u9023\u7dda\u5efa\u7acb\u5f8c\u9a57\u8b49\u5931\u6557\uff1a', + 'Failed to verify the established connection: ', + '\u63a5\u7d9a\u78ba\u7acb\u5f8c\u306e\u691c\u8a3c\u306b\u5931\u6557\u3057\u307e\u3057\u305f: ', + 'Verbindung konnte nach dem Aufbau nicht verifiziert werden: ', + '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u0440\u043e\u0432\u0435\u0440\u0438\u0442\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u0435 \u043f\u043e\u0441\u043b\u0435 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u0438\u044f: ', +]; + +const LOCALIZED_VALIDATION_KEYWORDS = [ + '\u9a8c\u8bc1\u5931\u8d25', + '\u9a57\u8b49\u5931\u6557', + 'validation failed', + '\u691c\u8a3c\u306b\u5931\u6557', + 'verbindungsvalidierung fehlgeschlagen', + '\u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0430 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f', +]; + +const LOCALIZED_MONGO_CREDENTIAL_LABELS = [ + 'primary credentials', + 'replica credentials', + '\u4e3b\u5eab\u6191\u8b49', + '\u5f9e\u5eab\u6191\u8b49', + '\u30d7\u30e9\u30a4\u30de\u30ea\u8a8d\u8a3c\u60c5\u5831', + '\u30ec\u30d7\u30ea\u30ab\u8a8d\u8a3c\u60c5\u5831', + 'prim\u00e4r-anmeldedaten', + 'replikat-anmeldedaten', + '\u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 primary', + '\u0443\u0447\u0435\u0442\u043d\u044b\u0435 \u0434\u0430\u043d\u043d\u044b\u0435 \u0440\u0435\u043f\u043b\u0438\u043a\u0438', +]; + +const LOCALIZED_COOLDOWN_ERROR_MARKERS = [ + '\u4e0a\u6b21\u9519\u8bef\uff1a', + '\u4e0a\u6b21\u932f\u8aa4\uff1a', + 'last error: ', + '\u524d\u56de\u306e\u30a8\u30e9\u30fc: ', + 'letzter Fehler: ', + '\u043f\u043e\u0441\u043b\u0435\u0434\u043d\u044f\u044f \u043e\u0448\u0438\u0431\u043a\u0430: ', +]; + +const LOCALIZED_DETAIL_LOG_HINT_PATTERNS = [ + /((?:详细日志|詳細日誌|詳細ログ):[^)]+)/gu, + /\s*\((?:detail log|Detailprotokoll|подробный журнал): [^)]+\)/giu, +]; + +const LOCALIZED_COOLDOWN_SECONDS_PATTERNS = [ + /(?:\u5269\u4f59=|\u8bf7\s*)(\d+)s(?:\s*\u540e\u91cd\u8bd5)?/u, + /\u8acb\u65bc\s*(\d+)s?\s*\u5f8c\u91cd\u8a66/u, + /Retry after\s+(\d+)s/iu, + /(\d+)s\s*\u5f8c\u306b\u518d\u8a66\u884c/u, + /in\s+(\d+)s\s+erneut/iu, + /\u0447\u0435\u0440\u0435\u0437\s+(\d+)s/iu, +]; + +const LOCALIZED_SSH_FAILURE_PREFIXES = [ + 'SSH \u8fde\u63a5\u5efa\u7acb\u5931\u8d25\uff1a', + '\u4ee3\u7406\u8fde\u63a5 SSH \u7f51\u5173\u5931\u8d25\uff1a', + '\u4ee3\u7406\u9023\u7dda SSH \u9598\u9053\u5931\u6557\uff1a', + 'Failed to connect to the SSH gateway through the proxy: ', + '\u30d7\u30ed\u30ad\u30b7\u7d4c\u7531\u3067 SSH \u30b2\u30fc\u30c8\u30a6\u30a7\u30a4\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f: ', + 'Verbindung zum SSH-Gateway \u00fcber den Proxy fehlgeschlagen: ', + '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a SSH-\u0448\u043b\u044e\u0437\u0443 \u0447\u0435\u0440\u0435\u0437 \u043f\u0440\u043e\u043a\u0441\u0438: ', +]; + type ConnectionFailureCategory = | 'cooldown' | 'parameter_compatibility' @@ -35,17 +112,62 @@ interface ConnectionFailureEvent { } const CONNECTION_FAILURE_CATEGORY_LABELS: Record = { - cooldown: '冷却重试', - parameter_compatibility: '连接参数/兼容性', - validation: '连接验证失败', - authentication: '认证失败', - timeout: '连接超时', - network: '网络不可达', - ssh: 'SSH 隧道失败', - startup: '驱动/进程启动失败', - other: '其他连接异常', + cooldown: 'Connection cooldown', + parameter_compatibility: 'Connection parameter/compatibility issue', + validation: 'Connection validation failed', + authentication: 'Authentication failed', + timeout: 'Connection timeout', + network: 'Network unreachable', + ssh: 'SSH tunnel failed', + startup: 'Driver/process startup failed', + other: 'Other connection anomaly', }; +const CONNECTION_FAILURE_CATEGORY_KEYS: Record = { + cooldown: 'ai_chat.inspection.connection_failures.category.cooldown', + parameter_compatibility: 'ai_chat.inspection.connection_failures.category.parameter_compatibility', + validation: 'ai_chat.inspection.connection_failures.category.validation', + authentication: 'ai_chat.inspection.connection_failures.category.authentication', + timeout: 'ai_chat.inspection.connection_failures.category.timeout', + network: 'ai_chat.inspection.connection_failures.category.network', + ssh: 'ai_chat.inspection.connection_failures.category.ssh', + startup: 'ai_chat.inspection.connection_failures.category.startup', + other: 'ai_chat.inspection.connection_failures.category.other', +}; + +const localizeConnectionFailureCategory = ( + category: ConnectionFailureCategory, + translate?: AIInspectionTranslator, +): string => translateInspectionCopy( + translate, + CONNECTION_FAILURE_CATEGORY_KEYS[category], + CONNECTION_FAILURE_CATEGORY_LABELS[category], +); + +const localizeConnectionFailureAction = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: I18nParams, +): string => translateInspectionCopy( + translate, + `ai_chat.inspection.connection_failures.next_action.${key}`, + fallback, + params, +); + +const localizeConnectionFailureMessage = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: I18nParams, +): string => translateInspectionCopy( + translate, + `ai_chat.inspection.connection_failures.message.${key}`, + fallback, + params, +); + const normalizeLogLines = (input: unknown): string[] => Array.isArray(input) ? input.map((line) => String(line || '').trim()).filter(Boolean) @@ -92,49 +214,78 @@ const extractAddressCandidates = (text: string): string[] => { }; const sanitizeRootCause = (value: string): string => - String(value || '') - .replace(/(详细日志:[^)]+)/g, '') + LOCALIZED_DETAIL_LOG_HINT_PATTERNS.reduce( + (text, pattern) => text.replace(pattern, ''), + String(value || ''), + ) .replace(/\s+/g, ' ') .replace(/\s*->\s*/g, ' -> ') .trim(); +const findFirstIncludedMarker = (value: string, markers: string[]): { marker: string; index: number } | null => { + let matched: { marker: string; index: number } | null = null; + markers.forEach((marker) => { + const index = value.indexOf(marker); + if (index < 0) { + return; + } + if (!matched || index < matched.index) { + matched = { marker, index }; + } + }); + return matched; +}; + +const startsWithAnyMarker = (value: string, markers: string[]): boolean => + markers.some((marker) => value.startsWith(marker)); + +const normalizeExtractedRootCause = (value: string): string => { + const trimmed = String(value || '').trim(); + const cooldownMarker = findFirstIncludedMarker(trimmed, LOCALIZED_COOLDOWN_ERROR_MARKERS); + if (cooldownMarker) { + return sanitizeRootCause(trimmed.slice(cooldownMarker.index + cooldownMarker.marker.length)); + } + + const sshFailureMarker = findFirstIncludedMarker(trimmed, LOCALIZED_SSH_FAILURE_PREFIXES); + if (sshFailureMarker) { + return sanitizeRootCause(trimmed.slice(sshFailureMarker.index + sshFailureMarker.marker.length)); + } + + const validationMarker = findFirstIncludedMarker(trimmed, LOCALIZED_VALIDATION_PREFIXES); + if (validationMarker) { + return sanitizeRootCause(trimmed.slice(validationMarker.index)); + } + + return sanitizeRootCause(trimmed); +}; + const extractRootCause = (payload: string): string => { const errorChainIndex = payload.indexOf('错误链:'); if (errorChainIndex >= 0) { - return sanitizeRootCause(payload.slice(errorChainIndex + '错误链:'.length)); + return normalizeExtractedRootCause(payload.slice(errorChainIndex + '错误链:'.length)); } const reasonIndex = payload.indexOf('原因='); if (reasonIndex >= 0) { - return sanitizeRootCause(payload.slice(reasonIndex + '原因='.length)); + return normalizeExtractedRootCause(payload.slice(reasonIndex + '原因='.length)); } - const cooldownIndex = payload.indexOf('连接最近失败,正在冷却中'); - if (cooldownIndex >= 0) { - return sanitizeRootCause(payload.slice(cooldownIndex)); - } - - const sshFailureIndex = payload.indexOf('SSH 连接建立失败:'); - if (sshFailureIndex >= 0) { - return sanitizeRootCause(payload.slice(sshFailureIndex + 'SSH 连接建立失败:'.length)); - } - - const validationIndex = payload.indexOf('连接建立后验证失败:'); - if (validationIndex >= 0) { - return sanitizeRootCause(payload.slice(validationIndex)); - } - - return sanitizeRootCause(payload); + return normalizeExtractedRootCause(payload); }; const extractCooldownSeconds = (payload: string, rootCause: string): number | null => { const target = `${payload} ${rootCause}`; - const match = target.match(/(?:剩余=|请\s*)(\d+)s(?:\s*后重试)?/); - if (!match) { - return null; + for (const pattern of LOCALIZED_COOLDOWN_SECONDS_PATTERNS) { + const match = target.match(pattern); + if (!match) { + continue; + } + const value = Number(match[1]); + if (Number.isFinite(value)) { + return value; + } } - const value = Number(match[1]); - return Number.isFinite(value) ? value : null; + return null; }; const inferConnectionFailureCategory = ( @@ -145,9 +296,14 @@ const inferConnectionFailureCategory = ( const lowerText = text.toLowerCase(); const errorText = rootCause || payload; const lowerErrorText = errorText.toLowerCase(); + const hasLocalizedTimeoutKeyword = LOCALIZED_TIMEOUT_KEYWORDS.some((keyword) => lowerErrorText.includes(keyword.toLowerCase())); + const hasLocalizedValidationPrefix = LOCALIZED_VALIDATION_PREFIXES.some((keyword) => text.includes(keyword)); + const hasLocalizedValidationKeyword = LOCALIZED_VALIDATION_KEYWORDS.some((keyword) => lowerText.includes(keyword.toLowerCase())); + const hasLocalizedMongoCredentialLabel = LOCALIZED_MONGO_CREDENTIAL_LABELS.some((label) => lowerText.includes(label.toLowerCase())); + const hasLocalizedSSHFailurePrefix = LOCALIZED_SSH_FAILURE_PREFIXES.some((marker) => text.includes(marker)); if ( - text.includes('SSH 连接建立失败') + hasLocalizedSSHFailurePrefix || /ssh\s+(dial|handshake|tunnel)/i.test(text) || /ssh.*(认证失败|连接失败|超时)/i.test(text) ) { @@ -157,8 +313,9 @@ const inferConnectionFailureCategory = ( lowerErrorText.includes('connect timeout') || lowerErrorText.includes('i/o timeout') || lowerErrorText.includes('deadline exceeded') - || errorText.includes('连接超时') - || /超时(?!\=)/.test(errorText) + || hasLocalizedTimeoutKeyword + || errorText.includes('\u8fde\u63a5\u8d85\u65f6') + || /\u8d85\u65f6(?!\=)/.test(errorText) ) { return 'timeout'; } @@ -166,6 +323,7 @@ const inferConnectionFailureCategory = ( lowerText.includes('access denied') || lowerText.includes('authentication failed') || lowerText.includes('password') + || hasLocalizedMongoCredentialLabel || text.includes('凭据') || text.includes('认证') || text.includes('登录失败') @@ -184,7 +342,7 @@ const inferConnectionFailureCategory = ( ) { return 'parameter_compatibility'; } - if (text.includes('验证失败')) { + if (hasLocalizedValidationKeyword || hasLocalizedValidationPrefix) { return 'validation'; } if ( @@ -209,7 +367,7 @@ const detectConnectionFailureEventType = (payload: string): ConnectionFailureEve if (payload.includes('命中数据库连接失败冷却:') || payload.includes('连接最近失败,正在冷却中')) { return 'cooldown_hit'; } - if (payload.includes('SSH 连接建立失败:')) { + if (startsWithAnyMarker(payload, LOCALIZED_SSH_FAILURE_PREFIXES)) { return 'ssh_failure'; } if (payload.includes('建立数据库连接失败:')) { @@ -221,7 +379,10 @@ const detectConnectionFailureEventType = (payload: string): ConnectionFailureEve return null; }; -const buildConnectionFailureEvent = (line: string): ConnectionFailureEvent | null => { +const buildConnectionFailureEvent = ( + line: string, + translate?: AIInspectionTranslator, +): ConnectionFailureEvent | null => { const timestamp = extractLogTimestamp(line); const { level, payload } = extractLogLevelAndPayload(line); const eventType = detectConnectionFailureEventType(payload); @@ -242,7 +403,7 @@ const buildConnectionFailureEvent = (line: string): ConnectionFailureEvent | nul level, eventType, category, - categoryLabel: CONNECTION_FAILURE_CATEGORY_LABELS[category], + categoryLabel: localizeConnectionFailureCategory(category, translate), connectionType: extractedType, address, dbName: extractField(payload, '数据库'), @@ -260,38 +421,78 @@ const appendUnique = (items: string[], value: string) => { items.push(normalized); }; -const buildNextActions = (events: ConnectionFailureEvent[]): string[] => { +const buildNextActions = ( + events: ConnectionFailureEvent[], + translate?: AIInspectionTranslator, +): string[] => { const categories = new Set(events.map((event) => event.category)); const hasCooldownEvent = events.some((event) => event.eventType === 'cooldown_hit'); const nextActions: string[] = []; const latestEvent = events[events.length - 1]; if (hasCooldownEvent || categories.has('cooldown')) { - appendUnique(nextActions, '先修复上一次真实连接错误,再重试;只反复刷新会持续命中连接冷却。'); + appendUnique(nextActions, localizeConnectionFailureAction( + translate, + 'cooldown', + 'Fix the previous real connection error before retrying; repeated refreshes will keep hitting the connection cooldown.', + )); } if (categories.has('parameter_compatibility')) { - appendUnique(nextActions, '优先核对连接参数、DSN 和协议兼容性,尤其是 multiStatements、charset、额外 query 参数和 URL 编码。'); + appendUnique(nextActions, localizeConnectionFailureAction( + translate, + 'parameter_compatibility', + 'Check connection parameters, DSN, and protocol compatibility first, especially multiStatements, charset, extra query parameters, and URL encoding.', + )); } if (categories.has('validation')) { - appendUnique(nextActions, '检查服务端返回的验证失败细节,确认当前数据库类型、驱动协议、库名或 Service Name 与目标服务匹配。'); + appendUnique(nextActions, localizeConnectionFailureAction( + translate, + 'validation', + 'Inspect the server validation failure details and confirm that database type, driver protocol, database name, or Service Name matches the target service.', + )); } if (categories.has('authentication')) { - appendUnique(nextActions, '核对用户名、密码、认证库、租户或 Service Name 是否正确,确认服务端是否允许当前账号登录。'); + appendUnique(nextActions, localizeConnectionFailureAction( + translate, + 'authentication', + 'Verify the username, password, authentication database, tenant, or Service Name, and confirm the server allows this account to log in.', + )); } if (categories.has('ssh')) { - appendUnique(nextActions, '核对 SSH 跳板机地址、端口、账号和隧道目标地址,必要时先验证跳板机到数据库的连通性。'); + appendUnique(nextActions, localizeConnectionFailureAction( + translate, + 'ssh', + 'Check the SSH jump host address, port, account, and tunnel target address; if needed, verify connectivity from the jump host to the database first.', + )); } if (categories.has('timeout') || categories.has('network')) { - appendUnique(nextActions, '检查目标地址、端口、防火墙、代理和隧道链路是否可达,确认服务端当前确实在监听。'); + appendUnique(nextActions, localizeConnectionFailureAction( + translate, + 'network', + 'Check whether the target address, port, firewall, proxy, and tunnel path are reachable, and confirm the server is actually listening.', + )); } if (categories.has('startup')) { - appendUnique(nextActions, '检查驱动进程或外部依赖是否能正常启动,必要时先在本机或目标主机单独验证启动命令。'); + appendUnique(nextActions, localizeConnectionFailureAction( + translate, + 'startup', + 'Check whether the driver process or external dependency can start normally; if needed, validate the startup command locally or on the target host first.', + )); } if (latestEvent?.address) { - appendUnique(nextActions, `如需核对当前界面里的连接是否还是同一目标,可再调用 inspect_current_connection 检查是否仍指向 ${latestEvent.address}。`); + appendUnique(nextActions, localizeConnectionFailureAction( + translate, + 'check_current_connection', + `If you need to confirm the connection currently shown in the UI still targets the same endpoint, call inspect_current_connection to check whether it still points to ${latestEvent.address}.`, + { address: latestEvent.address }, + )); } if (nextActions.length === 0) { - appendUnique(nextActions, '先结合 inspect_current_connection 和 inspect_saved_connections 核对当前连接配置,再扩大日志窗口继续排查。'); + appendUnique(nextActions, localizeConnectionFailureAction( + translate, + 'inspect_config', + 'First use inspect_current_connection and inspect_saved_connections to verify the current connection configuration, then widen the log window if more evidence is needed.', + )); } return nextActions; }; @@ -300,7 +501,9 @@ export const buildRecentConnectionFailureSnapshot = (params: { readResult?: any; keyword?: unknown; lineLimit?: unknown; + translate?: AIInspectionTranslator; }) => { + const translate = params.translate; const data = params.readResult?.data && typeof params.readResult.data === 'object' ? params.readResult.data as Record : {}; @@ -308,7 +511,7 @@ export const buildRecentConnectionFailureSnapshot = (params: { const keyword = String(data.keyword || params.keyword || '').trim(); const requestedLineLimit = normalizeConnectionFailureLimit(data.requestedLineLimit ?? params.lineLimit); const events = lines - .map((line) => buildConnectionFailureEvent(line)) + .map((line) => buildConnectionFailureEvent(line, translate)) .filter((event): event is ConnectionFailureEvent => Boolean(event)); const latestEvent = events[events.length - 1]; @@ -336,7 +539,7 @@ export const buildRecentConnectionFailureSnapshot = (params: { .sort((left, right) => right[1] - left[1]) .map(([category, count]) => ({ category, - label: CONNECTION_FAILURE_CATEGORY_LABELS[category], + label: localizeConnectionFailureCategory(category, translate), count, })); const primaryCategory = categorySummary[0]?.category || ''; @@ -361,7 +564,7 @@ export const buildRecentConnectionFailureSnapshot = (params: { hasRecentFailures: events.length > 0, primaryCategory, primaryCategoryLabel: primaryCategory - ? CONNECTION_FAILURE_CATEGORY_LABELS[primaryCategory as ConnectionFailureCategory] + ? localizeConnectionFailureCategory(primaryCategory as ConnectionFailureCategory, translate) : '', cooldownHitCount: events.filter((event) => event.eventType === 'cooldown_hit').length, validationFailureCount: events.filter((event) => event.category === 'validation').length, @@ -402,11 +605,28 @@ export const buildRecentConnectionFailureSnapshot = (params: { rootCause: event.rootCause, rawLine: event.rawLine, })), - nextActions: buildNextActions(events), + nextActions: buildNextActions(events, translate), message: events.length > 0 - ? `最近日志里识别到 ${events.length} 条连接相关异常,最新一条是 ${latestEvent?.categoryLabel || '连接异常'}` + ? localizeConnectionFailureMessage( + translate, + 'detected', + `Recent logs identified ${events.length} connection-related anomalies; latest category is ${latestEvent?.categoryLabel || 'connection anomaly'}`, + { + count: events.length, + categoryLabel: latestEvent?.categoryLabel || localizeConnectionFailureCategory('other', translate), + }, + ) : keyword - ? `最近日志里没有找到与“${keyword}”相关的连接失败记录` - : '最近日志里没有识别到连接失败、验证失败或连接冷却记录', + ? localizeConnectionFailureMessage( + translate, + 'no_keyword_match', + `No recent connection failure records matched keyword "${keyword}"`, + { keyword }, + ) + : localizeConnectionFailureMessage( + translate, + 'none', + 'No connection failures, validation failures, or connection cooldown records were identified in recent logs', + ), }; }; diff --git a/frontend/src/components/ai/aiConnectionInsights.test.ts b/frontend/src/components/ai/aiConnectionInsights.test.ts index 9b19b70..1403566 100644 --- a/frontend/src/components/ai/aiConnectionInsights.test.ts +++ b/frontend/src/components/ai/aiConnectionInsights.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; +import { t as translateCatalog } from '../../i18n/catalog'; import type { SavedConnection, TabData } from '../../types'; import { buildCurrentConnectionSnapshot } from './aiConnectionInsights'; @@ -103,7 +104,37 @@ describe('buildCurrentConnectionSnapshot', () => { expect(snapshot).toEqual({ hasActiveConnection: false, - message: '当前没有活动连接', + message: 'No active connection is currently selected', + }); + }); + + it('localizes empty-state messages while keeping raw ids unchanged', () => { + const missingActiveSnapshot = buildCurrentConnectionSnapshot({ + activeContext: null, + tabs: [], + activeTabId: null, + connections: [baseConnection], + translate: (key, params) => translateCatalog('en-US', key, params), + }); + const staleConnectionSnapshot = buildCurrentConnectionSnapshot({ + activeContext: { + connectionId: 'conn-missing', + dbName: 'crm', + }, + tabs: [], + activeTabId: null, + connections: [baseConnection], + translate: (key, params) => translateCatalog('en-US', key, params), + }); + + expect(missingActiveSnapshot).toEqual({ + hasActiveConnection: false, + message: 'No active connection is currently selected', + }); + expect(staleConnectionSnapshot).toEqual({ + hasActiveConnection: false, + connectionId: 'conn-missing', + message: 'The current active connection does not exist in the local cache', }); }); }); diff --git a/frontend/src/components/ai/aiConnectionInsights.ts b/frontend/src/components/ai/aiConnectionInsights.ts index 0be9c5b..8dc2d88 100644 --- a/frontend/src/components/ai/aiConnectionInsights.ts +++ b/frontend/src/components/ai/aiConnectionInsights.ts @@ -1,16 +1,31 @@ import type { SavedConnection, TabData } from '../../types'; +import { t as translateCatalog, type I18nParams } from '../../i18n'; + +type AIInspectionTranslator = (key: string, params?: I18nParams) => string; + +const translateInspectionCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, +): string => { + const t = translate || ((catalogKey, params) => translateCatalog(catalogKey, params, 'en-US')); + const translated = t(key); + return translated && translated !== key ? translated : fallback; +}; export const buildCurrentConnectionSnapshot = (params: { activeContext?: { connectionId: string; dbName?: string } | null; tabs?: TabData[]; activeTabId?: string | null; connections: SavedConnection[]; + translate?: AIInspectionTranslator; }) => { const { activeContext = null, tabs = [], activeTabId = null, connections, + translate, } = params; const activeTab = tabs.find((tab) => tab.id === activeTabId); const connectionId = String(activeContext?.connectionId || activeTab?.connectionId || '').trim(); @@ -19,7 +34,11 @@ export const buildCurrentConnectionSnapshot = (params: { if (!connectionId) { return { hasActiveConnection: false, - message: '当前没有活动连接', + message: translateInspectionCopy( + translate, + 'ai_chat.inspection.current_connection.no_active', + 'No active connection is currently selected', + ), }; } @@ -28,7 +47,11 @@ export const buildCurrentConnectionSnapshot = (params: { return { hasActiveConnection: false, connectionId, - message: '当前活动连接在本地缓存中不存在', + message: translateInspectionCopy( + translate, + 'ai_chat.inspection.current_connection.cache_missing', + 'The current active connection does not exist in the local cache', + ), }; } diff --git a/frontend/src/components/ai/aiContextBudgetInsights.test.ts b/frontend/src/components/ai/aiContextBudgetInsights.test.ts index b197609..bfdc605 100644 --- a/frontend/src/components/ai/aiContextBudgetInsights.test.ts +++ b/frontend/src/components/ai/aiContextBudgetInsights.test.ts @@ -3,6 +3,57 @@ import { describe, expect, it } from 'vitest'; import { buildAIContextBudgetSnapshot } from './aiContextBudgetInsights'; describe('aiContextBudgetInsights', () => { + it('localizes controlled warnings and actions while preserving raw context identifiers', () => { + const translate = (key: string, params?: Record) => { + const messages: Record = { + 'ai_chat.inspection.context_budget.warning.high_risk': 'HIGH CONTEXT', + 'ai_chat.inspection.context_budget.warning.large_messages': 'LARGE MESSAGES', + 'ai_chat.inspection.context_budget.warning.unresolved_tool_calls': `OPEN TOOLS ${params?.count || ''}`, + 'ai_chat.inspection.context_budget.next_action.summarize_or_new_session': 'SUMMARIZE', + 'ai_chat.inspection.context_budget.next_action.inspect_message_flow': 'CHECK FLOW', + }; + return messages[key] || key; + }; + + const snapshot = buildAIContextBudgetSnapshot({ + activeSessionId: 'session-1', + aiChatSessions: [{ id: 'session-1', title: '上下文预算排查', updatedAt: 1 }], + aiChatHistory: { + 'session-1': [ + { + id: 'msg-1', + role: 'assistant', + content: 'x'.repeat(70000), + timestamp: 1, + tool_calls: [{ id: 'tool-1', type: 'function', function: { name: 'inspect_app_logs', arguments: '{}' } }], + }, + ], + }, + aiContexts: { + 'conn-1:crm': [ + { dbName: 'crm', tableName: 'orders', ddl: 'CREATE TABLE orders(id bigint);' }, + ], + }, + skills: [{ + id: 'skill-1', + name: 'SQL 审查', + systemPrompt: '先检查风险', + enabled: true, + scopes: ['database'], + }], + translate, + }); + + expect(snapshot.title).toBe('上下文预算排查'); + expect(snapshot.schemaContext.largestTables[0]?.tableName).toBe('orders'); + expect(snapshot.promptsAndSkills.enabledSkillNames).toContain('SQL 审查'); + expect(snapshot.warnings).toContain('HIGH CONTEXT'); + expect(snapshot.warnings).toContain('LARGE MESSAGES'); + expect(snapshot.warnings).toContain('OPEN TOOLS 1'); + expect(snapshot.nextActions).toContain('SUMMARIZE'); + expect(snapshot.nextActions).toContain('CHECK FLOW'); + }); + it('summarizes context budget sources and warns when schema/tool results are oversized', () => { const longToolResult = 'x'.repeat(22000); const longDDL = `CREATE TABLE big_table (${Array.from({ length: 300 }, (_, index) => `c${index} varchar(255)`).join(',')})`; @@ -60,8 +111,8 @@ describe('aiContextBudgetInsights', () => { expect(snapshot.schemaContext.largestTables[0]).toMatchObject({ tableName: 'big_table' }); expect(snapshot.toolCatalog.mcpToolCount).toBe(1); expect(snapshot.promptsAndSkills.enabledSkillNames).toContain('结构审查'); - expect(snapshot.warnings).toContain('最近工具结果较长,可能导致后续回答被日志或大结果集稀释'); - expect(snapshot.nextActions).toContain('降低 inspect_app_logs / inspect_recent_sql_logs / includeDDL / includeLogLines 的返回量'); + expect(snapshot.warnings).toContain('Recent tool results are long and may dilute later answers with logs or large result sets'); + expect(snapshot.nextActions).toContain('Reduce the returned volume from inspect_app_logs / inspect_recent_sql_logs / includeDDL / includeLogLines'); }); it('reports missing sessions and unresolved tool calls', () => { @@ -84,8 +135,8 @@ describe('aiContextBudgetInsights', () => { expect(snapshot.foundSession).toBe(false); expect(snapshot.messageWindow.unresolvedToolCallCount).toBe(1); - expect(snapshot.warnings).toContain('未找到目标 AI 会话,消息体量统计只覆盖空窗口'); - expect(snapshot.warnings).toContain('最近消息窗口内有 1 个未闭环工具调用'); - expect(snapshot.nextActions).toContain('先调用 inspect_ai_message_flow 确认工具调用是否缺少 tool 结果消息'); + expect(snapshot.warnings).toContain('The target AI session was not found, so message volume statistics only cover an empty window'); + expect(snapshot.warnings).toContain('The recent message window contains 1 unclosed tool calls'); + expect(snapshot.nextActions).toContain('Call inspect_ai_message_flow first to confirm whether tool calls are missing tool result messages'); }); }); diff --git a/frontend/src/components/ai/aiContextBudgetInsights.ts b/frontend/src/components/ai/aiContextBudgetInsights.ts index 51dcffe..cafc415 100644 --- a/frontend/src/components/ai/aiContextBudgetInsights.ts +++ b/frontend/src/components/ai/aiContextBudgetInsights.ts @@ -1,3 +1,6 @@ +import type { + I18nParams, +} from '../../i18n'; import type { AIChatMessage, AIContextItem, @@ -5,6 +8,8 @@ import type { AISkillConfig, AIUserPromptSettings, } from '../../types'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; type ContextRiskLevel = 'low' | 'medium' | 'high' | 'critical'; @@ -19,6 +24,7 @@ interface BuildAIContextBudgetSnapshotOptions { mcpTools?: AIMCPToolDescriptor[]; skills?: AISkillConfig[]; userPromptSettings?: AIUserPromptSettings; + translate?: AIInspectionTranslator; } const DEFAULT_MESSAGE_LIMIT = 40; @@ -64,6 +70,18 @@ const appendUnique = (items: string[], item: string) => { } }; +const translateBudgetCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: I18nParams, +): string => translateInspectionCopy( + translate, + `ai_chat.inspection.context_budget.${key}`, + fallback, + params, +); + const getMessagePayloadChars = (message: AIChatMessage): number => charCount(message.content) + charCount(message.thinking) @@ -81,6 +99,7 @@ export const buildAIContextBudgetSnapshot = ({ mcpTools = [], skills = [], userPromptSettings, + translate, }: BuildAIContextBudgetSnapshotOptions) => { const requestedSessionId = String(sessionId || activeSessionId || '').trim(); const allMessages = requestedSessionId ? (aiChatHistory[requestedSessionId] || []) : []; @@ -155,40 +174,109 @@ export const buildAIContextBudgetSnapshot = ({ const nextActions: string[] = []; if (!requestedSessionId || !session) { - appendUnique(warnings, '未找到目标 AI 会话,消息体量统计只覆盖空窗口'); - appendUnique(nextActions, '先打开或选中目标 AI 会话,再重新调用 inspect_ai_context_budget'); + appendUnique(warnings, translateBudgetCopy( + translate, + 'warning.missing_session', + 'The target AI session was not found, so message volume statistics only cover an empty window', + )); + appendUnique(nextActions, translateBudgetCopy( + translate, + 'next_action.open_session', + 'Open or select the target AI session first, then call inspect_ai_context_budget again', + )); } if (riskLevel === 'critical') { - appendUnique(warnings, '当前 AI 输入上下文体量达到 critical,可能导致回复慢、截断或模型忽略关键约束'); + appendUnique(warnings, translateBudgetCopy( + translate, + 'warning.critical_risk', + 'The current AI input context has reached critical volume and may cause slow replies, truncation, or ignored constraints', + )); } else if (riskLevel === 'high') { - appendUnique(warnings, '当前 AI 输入上下文体量偏高,复杂问题前建议先收窄上下文'); + appendUnique(warnings, translateBudgetCopy( + translate, + 'warning.high_risk', + 'The current AI input context is large; narrow the context before complex questions', + )); } if (ddlChars >= 60000 || contextEntries.length >= 30) { - appendUnique(warnings, '已挂载表结构较多或 DDL 较长,可能挤占用户问题和工具结果空间'); - appendUnique(nextActions, '只保留本轮相关表,必要时改用 inspect_table_bundle 按需读取目标表'); + appendUnique(warnings, translateBudgetCopy( + translate, + 'warning.large_schema_context', + 'Many table schemas or long DDL are mounted and may crowd out the user question and tool results', + )); + appendUnique(nextActions, translateBudgetCopy( + translate, + 'next_action.narrow_tables', + 'Keep only tables relevant to this turn; if needed, use inspect_table_bundle to read target tables on demand', + )); } if (messagePayloadChars >= 40000) { - appendUnique(warnings, '当前会话最近消息内容较长,可能影响后续回复稳定性'); - appendUnique(nextActions, '新开会话或先让 AI 总结当前结论,再继续下一轮复杂任务'); + appendUnique(warnings, translateBudgetCopy( + translate, + 'warning.large_messages', + 'Recent messages in this session are long and may affect the stability of later replies', + )); + appendUnique(nextActions, translateBudgetCopy( + translate, + 'next_action.summarize_or_new_session', + 'Start a new session or ask AI to summarize the current conclusion before continuing the next complex task', + )); } if (toolResultChars >= 20000) { - appendUnique(warnings, '最近工具结果较长,可能导致后续回答被日志或大结果集稀释'); - appendUnique(nextActions, '降低 inspect_app_logs / inspect_recent_sql_logs / includeDDL / includeLogLines 的返回量'); + appendUnique(warnings, translateBudgetCopy( + translate, + 'warning.large_tool_results', + 'Recent tool results are long and may dilute later answers with logs or large result sets', + )); + appendUnique(nextActions, translateBudgetCopy( + translate, + 'next_action.reduce_tool_results', + 'Reduce the returned volume from inspect_app_logs / inspect_recent_sql_logs / includeDDL / includeLogLines', + )); } if (mcpTools.length >= 40 || mcpSchemaChars >= 30000) { - appendUnique(warnings, '当前暴露的 MCP 工具或 schema 较多,模型选择工具时可能更容易走偏'); - appendUnique(nextActions, '临时禁用无关 MCP 服务,或先调用 inspect_ai_tool_catalog 按关键词收窄工具路线'); + appendUnique(warnings, translateBudgetCopy( + translate, + 'warning.large_mcp_catalog', + 'Many MCP tools or schemas are exposed, which may make the model more likely to choose the wrong tool', + )); + appendUnique(nextActions, translateBudgetCopy( + translate, + 'next_action.narrow_tools', + 'Temporarily disable unrelated MCP services, or call inspect_ai_tool_catalog by keyword first to narrow the tool route', + )); } if (enabledSkills.length >= 8 || skillPromptChars >= 16000) { - appendUnique(warnings, '当前启用 Skills 较多或提示词较长,可能叠加冲突约束'); - appendUnique(nextActions, '仅保留本轮任务相关 Skills,完成后再恢复其它 Skills'); + appendUnique(warnings, translateBudgetCopy( + translate, + 'warning.large_skills', + 'Many Skills are enabled or prompts are long, which may stack conflicting constraints', + )); + appendUnique(nextActions, translateBudgetCopy( + translate, + 'next_action.reduce_skills', + 'Keep only Skills relevant to this turn, then restore other Skills after finishing', + )); } if (unresolvedToolCallIds.size > 0) { - appendUnique(warnings, `最近消息窗口内有 ${unresolvedToolCallIds.size} 个未闭环工具调用`); - appendUnique(nextActions, '先调用 inspect_ai_message_flow 确认工具调用是否缺少 tool 结果消息'); + appendUnique(warnings, translateBudgetCopy( + translate, + 'warning.unresolved_tool_calls', + `The recent message window contains ${unresolvedToolCallIds.size} unclosed tool calls`, + { count: unresolvedToolCallIds.size }, + )); + appendUnique(nextActions, translateBudgetCopy( + translate, + 'next_action.inspect_message_flow', + 'Call inspect_ai_message_flow first to confirm whether tool calls are missing tool result messages', + )); } if (warnings.length === 0) { - appendUnique(nextActions, '当前上下文体量可控,可继续按具体问题调用更窄的结构、日志或 SQL 风险探针'); + appendUnique(nextActions, translateBudgetCopy( + translate, + 'next_action.continue_narrow_probe', + 'The current context volume is manageable; continue by calling narrower schema, log, or SQL risk probes for the specific question', + )); } const largestMessages = inspectedMessages diff --git a/frontend/src/components/ai/aiContextInsights.ts b/frontend/src/components/ai/aiContextInsights.ts index 5e4fc0a..0982dfb 100644 --- a/frontend/src/components/ai/aiContextInsights.ts +++ b/frontend/src/components/ai/aiContextInsights.ts @@ -1,4 +1,5 @@ import type { AIContextItem, SavedConnection } from '../../types'; +import { translateInspectionCopy, type AIInspectionTranslator } from './aiInspectionI18n'; const DEFAULT_DDL_PREVIEW_LIMIT = 320; const DEFAULT_DDL_INCLUDE_LIMIT = 4000; @@ -49,6 +50,7 @@ export const buildAIContextSnapshot = (params: { connections: SavedConnection[]; includeDDL?: boolean; ddlLimit?: unknown; + translate?: AIInspectionTranslator; }) => { const { activeContext = null, @@ -56,6 +58,7 @@ export const buildAIContextSnapshot = (params: { connections, includeDDL = false, ddlLimit, + translate, } = params; const contextKey = buildConnectionKey(activeContext); const activeContextItems = aiContexts[contextKey] || []; @@ -79,7 +82,16 @@ export const buildAIContextSnapshot = (params: { ddlLimit: normalizeDDLLimit(ddlLimit), })), message: activeContextItems.length > 0 - ? `当前已关联 ${activeContextItems.length} 张表结构上下文` - : '当前没有已关联的 AI 表结构上下文', + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.ai_context.linked_summary', + `Currently linked table schema contexts: ${activeContextItems.length}`, + { count: activeContextItems.length }, + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.ai_context.none_linked', + 'No AI table schema context is currently linked', + ), }; }; diff --git a/frontend/src/components/ai/aiDatabaseBundleTools.i18n.test.ts b/frontend/src/components/ai/aiDatabaseBundleTools.i18n.test.ts new file mode 100644 index 0000000..f506833 --- /dev/null +++ b/frontend/src/components/ai/aiDatabaseBundleTools.i18n.test.ts @@ -0,0 +1,150 @@ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it, vi } from 'vitest'; + +import type { AIToolCall, SavedConnection } from '../../types'; +import { executeLocalAIToolCall } from './aiLocalToolExecutor'; + +const REQUIRED_KEYS = [ + 'ai_chat.inspection.database_bundle.error.db_name_required', + 'ai_chat.inspection.database_bundle.error.table_name_required', + 'ai_chat.inspection.database_bundle.error.database_overview_failed', + 'ai_chat.inspection.database_bundle.error.table_snapshot_failed', + 'ai_chat.inspection.database_bundle.error.unknown', + 'ai_chat.inspection.database_bundle.warning.all_columns_failed', + 'ai_chat.inspection.database_bundle.warning.columns_failed', + 'ai_chat.inspection.database_bundle.warning.ddl_failed', + 'ai_chat.inspection.database_bundle.warning.foreign_keys_failed', + 'ai_chat.inspection.database_bundle.warning.indexes_failed', + 'ai_chat.inspection.database_bundle.warning.sample_rows_failed', + 'ai_chat.inspection.database_bundle.warning.tables_failed', + 'ai_chat.inspection.database_bundle.warning.tables_failed_with_column_fallback', + 'ai_chat.inspection.database_bundle.warning.triggers_failed', +]; + +const buildConnection = (): SavedConnection => ({ + id: 'conn-1', + name: 'Primary', + config: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + user: 'root', + }, +}); + +const buildToolCall = (name: string, args: Record): AIToolCall => ({ + id: `call-${name}`, + type: 'function', + function: { + name, + arguments: JSON.stringify(args), + }, +}); + +const translate = (key: string, params?: Record) => + `T:${key}${params?.detail ? ` detail=${params.detail}` : ''}`; + +describe('aiDatabaseBundleTools i18n', () => { + it('localizes table bundle warning wrappers while preserving raw details', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_table_bundle', { + connectionId: 'conn-1', + dbName: 'crm', + tableName: 'orders', + includeSampleRows: true, + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + getColumns: vi.fn().mockResolvedValue({ success: false, message: 'driver timeout on C:/db/orders.frm' }), + getIndexes: vi.fn().mockResolvedValue({ success: false, message: 'index metadata unavailable' }), + getForeignKeys: vi.fn().mockResolvedValue({ success: false, message: 'foreign key metadata unavailable' }), + getTriggers: vi.fn().mockResolvedValue({ success: false, message: 'trigger metadata unavailable' }), + showCreateTable: vi.fn().mockResolvedValue({ success: false, message: 'DDL service returned HTTP 503' }), + query: vi.fn().mockRejectedValue(new Error('SELECT preview failed: permission denied')), + }, + }); + + expect(result.success).toBe(true); + const payload = JSON.parse(result.content) as { warnings: string[] }; + expect(payload.warnings).toContain( + 'T:ai_chat.inspection.database_bundle.warning.columns_failed detail=driver timeout on C:/db/orders.frm', + ); + expect(payload.warnings.some((warning) => ( + warning.startsWith('T:ai_chat.inspection.database_bundle.warning.ddl_failed detail=') + && warning.includes('DDL service returned HTTP 503') + && warning.includes('driver timeout on C:/db/orders.frm') + ))).toBe(true); + expect(payload.warnings).toContain( + 'T:ai_chat.inspection.database_bundle.warning.sample_rows_failed detail=Error: SELECT preview failed: permission denied', + ); + expect(result.content).not.toContain('字段列表获取失败'); + expect(result.content).not.toContain('样例数据获取失败'); + }); + + it('localizes database bundle warnings and required-argument errors', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_database_bundle', { + connectionId: 'conn-1', + dbName: 'crm', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn().mockResolvedValue({ success: false, message: 'table list RPC failed' }), + getAllColumns: vi.fn().mockResolvedValue({ + success: true, + data: [{ TableName: 'orders', Name: 'id', Type: 'bigint' }], + }), + }, + }); + const missingDbName = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_database_bundle', { + connectionId: 'conn-1', + dbName: '', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain( + 'T:ai_chat.inspection.database_bundle.warning.tables_failed_with_column_fallback detail=table list RPC failed', + ); + expect(result.content).not.toContain('表列表获取失败'); + expect(missingDbName.content).toBe('T:ai_chat.inspection.database_bundle.error.db_name_required'); + }); + + it('defines database bundle copy in every locale', () => { + const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU']; + for (const locale of locales) { + const catalog = JSON.parse( + readFileSync(new URL(`../../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8'), + ) as Record; + for (const key of REQUIRED_KEYS) { + expect(catalog[key], `${locale}:${key}`).toBeTruthy(); + } + } + }); + + it('keeps legacy Chinese database bundle wrappers out of the source', () => { + const source = readFileSync(new URL('./aiDatabaseBundleTools.ts', import.meta.url), 'utf8'); + expect(source).not.toMatch( + /字段列表获取失败|索引定义获取失败|外键关系获取失败|触发器获取失败|DDL 获取失败|样例数据获取失败|表列表获取失败|字段摘要获取失败|获取表结构快照失败|获取数据库结构总览失败|不能为空|未知错误/, + ); + }); +}); diff --git a/frontend/src/components/ai/aiDatabaseBundleTools.ts b/frontend/src/components/ai/aiDatabaseBundleTools.ts index 7492287..5a66575 100644 --- a/frontend/src/components/ai/aiDatabaseBundleTools.ts +++ b/frontend/src/components/ai/aiDatabaseBundleTools.ts @@ -1,7 +1,9 @@ import type { SavedConnection } from '../../types'; +import type { I18nParams } from '../../i18n'; import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool'; import type { AILocalToolRuntime } from './aiLocalToolRuntime'; +import { translateInspectionCopy } from './aiInspectionI18n'; import { buildPreviewSQLForTable, normalizeColumns, @@ -21,17 +23,69 @@ interface InspectDatabaseBundleOptions { args: Record; connection: SavedConnection; runtime: AILocalToolRuntime; + translate?: (key: string, params?: I18nParams) => string; } +const DATABASE_BUNDLE_KEY_PREFIX = 'ai_chat.inspection.database_bundle'; + +const translateDatabaseBundleCopy = ( + translate: InspectDatabaseBundleOptions['translate'], + key: string, + fallback: string, + params?: I18nParams, +) => translateInspectionCopy( + translate, + `${DATABASE_BUNDLE_KEY_PREFIX}.${key}`, + fallback, + params, +); + +const unknownError = (translate: InspectDatabaseBundleOptions['translate']) => + translateDatabaseBundleCopy( + translate, + 'error.unknown', + 'Unknown error', + ); + +const fulfilledDetail = ( + result: PromiseSettledResult, + valueSelector: (value: any) => unknown, + translate: InspectDatabaseBundleOptions['translate'], +) => result.status === 'fulfilled' + ? String(valueSelector(result.value) || unknownError(translate)) + : String(result.reason); + +const databaseBundleWarning = ( + translate: InspectDatabaseBundleOptions['translate'], + key: string, + fallbackLabel: string, + detail: string, +) => translateDatabaseBundleCopy( + translate, + `warning.${key}`, + `${fallbackLabel}: ${detail}`, + { detail }, +); + export const inspectTableBundle = async ({ args, connection, runtime, + translate, }: InspectDatabaseBundleOptions): Promise => { try { const safeDbName = args.dbName ? String(args.dbName).trim() : ''; const safeTable = args.tableName ? String(args.tableName).trim() : ''; - if (!safeTable) return { content: 'tableName 不能为空', success: false }; + if (!safeTable) { + return { + content: translateDatabaseBundleCopy( + translate, + 'error.table_name_required', + 'tableName is required', + ), + success: false, + }; + } const includeSampleRows = args.includeSampleRows === true; const sampleLimit = normalizePreviewLimit(args.sampleLimit ?? 10); const rpcConfig = buildRpcConnectionConfig(connection.config) as any; @@ -68,27 +122,52 @@ export const inspectTableBundle = async ({ if (columnsResult.status === 'fulfilled' && columnsResult.value?.success && Array.isArray(columnsResult.value.data)) { payload.columns = normalizeColumns(columnsResult.value.data); } else { - warnings.push(`字段列表获取失败:${columnsResult.status === 'fulfilled' ? (columnsResult.value?.message || '未知错误') : String(columnsResult.reason)}`); + warnings.push(databaseBundleWarning( + translate, + 'columns_failed', + 'Failed to fetch column list', + fulfilledDetail(columnsResult, (value) => value?.message, translate), + )); } if (indexesResult.status === 'fulfilled' && indexesResult.value?.success && Array.isArray(indexesResult.value.data)) { payload.indexes = indexesResult.value.data; } else { - warnings.push(`索引定义获取失败:${indexesResult.status === 'fulfilled' ? (indexesResult.value?.message || '未知错误') : String(indexesResult.reason)}`); + warnings.push(databaseBundleWarning( + translate, + 'indexes_failed', + 'Failed to fetch index definitions', + fulfilledDetail(indexesResult, (value) => value?.message, translate), + )); } if (foreignKeysResult.status === 'fulfilled' && foreignKeysResult.value?.success && Array.isArray(foreignKeysResult.value.data)) { payload.foreignKeys = foreignKeysResult.value.data; } else { - warnings.push(`外键关系获取失败:${foreignKeysResult.status === 'fulfilled' ? (foreignKeysResult.value?.message || '未知错误') : String(foreignKeysResult.reason)}`); + warnings.push(databaseBundleWarning( + translate, + 'foreign_keys_failed', + 'Failed to fetch foreign key relationships', + fulfilledDetail(foreignKeysResult, (value) => value?.message, translate), + )); } if (triggersResult.status === 'fulfilled' && triggersResult.value?.success && Array.isArray(triggersResult.value.data)) { payload.triggers = triggersResult.value.data; } else { - warnings.push(`触发器获取失败:${triggersResult.status === 'fulfilled' ? (triggersResult.value?.message || '未知错误') : String(triggersResult.reason)}`); + warnings.push(databaseBundleWarning( + translate, + 'triggers_failed', + 'Failed to fetch triggers', + fulfilledDetail(triggersResult, (value) => value?.message, translate), + )); } if (ddlResult.status === 'fulfilled' && ddlResult.value?.success) { payload.ddl = ddlResult.value.content; } else { - warnings.push(`DDL 获取失败:${ddlResult.status === 'fulfilled' ? (ddlResult.value?.content || '未知错误') : String(ddlResult.reason)}`); + warnings.push(databaseBundleWarning( + translate, + 'ddl_failed', + 'Failed to fetch DDL', + fulfilledDetail(ddlResult, (value) => value?.content, translate), + )); } if (includeSampleRows) { if (sampleRowsResult.status === 'fulfilled' && sampleRowsResult.value?.success) { @@ -99,7 +178,12 @@ export const inspectTableBundle = async ({ rows: rows.slice(0, sampleLimit), }; } else { - warnings.push(`样例数据获取失败:${sampleRowsResult.status === 'fulfilled' ? (sampleRowsResult.value?.message || '未知错误') : String(sampleRowsResult.reason)}`); + warnings.push(databaseBundleWarning( + translate, + 'sample_rows_failed', + 'Failed to fetch sample rows', + fulfilledDetail(sampleRowsResult, (value) => value?.message, translate), + )); } } if (warnings.length > 0) { @@ -107,7 +191,16 @@ export const inspectTableBundle = async ({ } return { content: JSON.stringify(payload), success: true }; } catch (error: any) { - return { content: `获取表结构快照失败: ${error?.message || error}`, success: false }; + const detail = String(error?.message || error); + return { + content: translateDatabaseBundleCopy( + translate, + 'error.table_snapshot_failed', + `Failed to build table structure snapshot: ${detail}`, + { detail }, + ), + success: false, + }; } }; @@ -115,10 +208,20 @@ export const inspectDatabaseBundle = async ({ args, connection, runtime, + translate, }: InspectDatabaseBundleOptions): Promise => { try { const safeDbName = args.dbName ? String(args.dbName).trim() : ''; - if (!safeDbName) return { content: 'dbName 不能为空', success: false }; + if (!safeDbName) { + return { + content: translateDatabaseBundleCopy( + translate, + 'error.db_name_required', + 'dbName is required', + ), + success: false, + }; + } const includeColumns = args.includeColumns !== false; const tableLimit = normalizeTableLimit(args.tableLimit); const perTableColumnLimit = normalizePerTableColumnLimit(args.perTableColumnLimit); @@ -139,14 +242,34 @@ export const inspectDatabaseBundle = async ({ tableNames = normalizeTableList(tablesResult.value.data).filter(Boolean); } else if (tableNamesFromColumns.length > 0) { tableNames = tableNamesFromColumns; - warnings.push(`表列表获取失败,已退回字段摘要推断:${tablesResult.status === 'fulfilled' ? (tablesResult.value?.message || '未知错误') : String(tablesResult.reason)}`); + warnings.push(databaseBundleWarning( + translate, + 'tables_failed_with_column_fallback', + 'Failed to fetch table list, fell back to column summary inference', + fulfilledDetail(tablesResult, (value) => value?.message, translate), + )); } else { - warnings.push(`表列表获取失败:${tablesResult.status === 'fulfilled' ? (tablesResult.value?.message || '未知错误') : String(tablesResult.reason)}`); + warnings.push(databaseBundleWarning( + translate, + 'tables_failed', + 'Failed to fetch table list', + fulfilledDetail(tablesResult, (value) => value?.message, translate), + )); } if (includeColumns && allColumnsResult.status === 'fulfilled' && (!allColumnsResult.value?.success || !Array.isArray(allColumnsResult.value.data))) { - warnings.push(`字段摘要获取失败:${allColumnsResult.value?.message || '未知错误'}`); + warnings.push(databaseBundleWarning( + translate, + 'all_columns_failed', + 'Failed to fetch column summary', + String(allColumnsResult.value?.message || unknownError(translate)), + )); } else if (includeColumns && allColumnsResult.status === 'rejected') { - warnings.push(`字段摘要获取失败:${String(allColumnsResult.reason)}`); + warnings.push(databaseBundleWarning( + translate, + 'all_columns_failed', + 'Failed to fetch column summary', + String(allColumnsResult.reason), + )); } const uniqueTableNames = Array.from(new Set(tableNames.filter(Boolean))); const visibleTableNames = uniqueTableNames.slice(0, tableLimit); @@ -181,6 +304,15 @@ export const inspectDatabaseBundle = async ({ } return { content: JSON.stringify(payload), success: true }; } catch (error: any) { - return { content: `获取数据库结构总览失败: ${error?.message || error}`, success: false }; + const detail = String(error?.message || error); + return { + content: translateDatabaseBundleCopy( + translate, + 'error.database_overview_failed', + `Failed to build database structure overview: ${detail}`, + { detail }, + ), + success: false, + }; } }; diff --git a/frontend/src/components/ai/aiDatabaseToolExecutor.i18n.test.ts b/frontend/src/components/ai/aiDatabaseToolExecutor.i18n.test.ts new file mode 100644 index 0000000..d31b0e1 --- /dev/null +++ b/frontend/src/components/ai/aiDatabaseToolExecutor.i18n.test.ts @@ -0,0 +1,505 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; + +import type { AIToolCall, SavedConnection } from '../../types'; +import { executeLocalAIToolCall } from './aiLocalToolExecutor'; + +const buildConnection = (): SavedConnection => ({ + id: 'conn-1', + name: 'Primary', + config: { + type: 'oracle', + host: '127.0.0.1', + port: 1521, + user: 'system', + }, +}); + +const buildSqlExecutionConnection = (): SavedConnection => ({ + id: 'conn-1', + name: 'Primary', + config: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + user: 'root', + }, +}); + +const buildToolCall = (name: string, args: Record): AIToolCall => ({ + id: `call-${name}`, + type: 'function', + function: { + name, + arguments: JSON.stringify(args), + }, +}); + +const translate = (key: string, params?: Record) => { + const renderedParams = params + ? Object.entries(params).map(([name, value]) => `${name}=${value}`).join('|') + : ''; + return `T:${key}${renderedParams ? ` ${renderedParams}` : ''}`; +}; + +describe('aiDatabaseToolExecutor i18n', () => { + it('uses the translated missing-connection wrapper for database tool calls', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('get_databases', { + connectionId: 'missing-conn', + }), + connections: [], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: {}, + }); + + expect(result.success).toBe(false); + expect(result.content).toBe('T:ai_chat.panel.tool_error.connection_not_found'); + expect(result.content).not.toContain('Connection not found'); + }); + + it.each([ + { + toolName: 'get_databases', + runtimeMethod: 'getDatabases', + expectedKey: 'ai_chat.panel.tool_error.fetch_databases_failed', + oldText: 'Failed to fetch DBs', + }, + { + toolName: 'get_tables', + runtimeMethod: 'getTables', + expectedKey: 'ai_chat.panel.tool_error.fetch_tables_failed', + oldText: 'Failed to fetch Tables', + args: { dbName: 'HR' }, + }, + { + toolName: 'get_all_columns', + runtimeMethod: 'getAllColumns', + expectedKey: 'ai_chat.panel.tool_error.fetch_all_columns_failed', + oldText: 'Failed to fetch all columns', + args: { dbName: 'HR' }, + }, + { + toolName: 'get_columns', + runtimeMethod: 'getColumns', + expectedKey: 'ai_chat.panel.tool_error.fetch_columns_failed', + oldText: 'Failed to fetch columns', + args: { dbName: 'HR', tableName: 'EMPLOYEES' }, + }, + { + toolName: 'get_indexes', + runtimeMethod: 'getIndexes', + expectedKey: 'ai_chat.panel.tool_error.fetch_indexes_failed', + oldText: 'Failed to fetch indexes', + args: { dbName: 'HR', tableName: 'EMPLOYEES' }, + }, + { + toolName: 'get_foreign_keys', + runtimeMethod: 'getForeignKeys', + expectedKey: 'ai_chat.panel.tool_error.fetch_foreign_keys_failed', + oldText: 'Failed to fetch foreign keys', + args: { dbName: 'HR', tableName: 'EMPLOYEES' }, + }, + { + toolName: 'get_triggers', + runtimeMethod: 'getTriggers', + expectedKey: 'ai_chat.panel.tool_error.fetch_triggers_failed', + oldText: 'Failed to fetch triggers', + args: { dbName: 'HR', tableName: 'EMPLOYEES' }, + }, + ])('uses translated wrappers when $toolName returns no message detail', async ({ + toolName, + runtimeMethod, + expectedKey, + oldText, + args = {}, + }) => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall(toolName, { + connectionId: 'conn-1', + ...args, + }), + connections: [buildSqlExecutionConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + [runtimeMethod]: vi.fn().mockResolvedValue({ success: false }), + }, + }); + + expect(result.success).toBe(false); + expect(result.content).toBe(`T:${expectedKey} detail=T:ai_chat.inspection.diagnostics.error.unknown`); + expect(result.content).not.toContain(oldText); + }); + + it.each([ + { + toolName: 'get_databases', + runtimeMethod: 'getDatabases', + expectedKey: 'ai_chat.panel.tool_error.fetch_databases_failed', + oldText: '获取数据库列表失败', + }, + { + toolName: 'get_tables', + runtimeMethod: 'getTables', + expectedKey: 'ai_chat.panel.tool_error.fetch_tables_failed', + oldText: '获取表列表失败', + args: { dbName: 'HR' }, + }, + { + toolName: 'get_all_columns', + runtimeMethod: 'getAllColumns', + expectedKey: 'ai_chat.panel.tool_error.fetch_all_columns_failed', + oldText: '获取全库字段摘要失败', + args: { dbName: 'HR' }, + }, + { + toolName: 'get_columns', + runtimeMethod: 'getColumns', + expectedKey: 'ai_chat.panel.tool_error.fetch_columns_failed', + oldText: '获取字段列表失败', + args: { dbName: 'HR', tableName: 'EMPLOYEES' }, + }, + { + toolName: 'get_indexes', + runtimeMethod: 'getIndexes', + expectedKey: 'ai_chat.panel.tool_error.fetch_indexes_failed', + oldText: '获取索引定义失败', + args: { dbName: 'HR', tableName: 'EMPLOYEES' }, + }, + { + toolName: 'get_foreign_keys', + runtimeMethod: 'getForeignKeys', + expectedKey: 'ai_chat.panel.tool_error.fetch_foreign_keys_failed', + oldText: '获取外键关系失败', + args: { dbName: 'HR', tableName: 'EMPLOYEES' }, + }, + { + toolName: 'get_triggers', + runtimeMethod: 'getTriggers', + expectedKey: 'ai_chat.panel.tool_error.fetch_triggers_failed', + oldText: '获取触发器定义失败', + args: { dbName: 'HR', tableName: 'EMPLOYEES' }, + }, + ])('uses translated wrapper when $toolName throws while preserving raw detail', async ({ + toolName, + runtimeMethod, + expectedKey, + oldText, + args = {}, + }) => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall(toolName, { + connectionId: 'conn-1', + ...args, + }), + connections: [buildSqlExecutionConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + [runtimeMethod]: vi.fn().mockRejectedValue(new Error('driver raw detail')), + }, + }); + + expect(result.success).toBe(false); + expect(result.content).toBe(`T:${expectedKey} detail=driver raw detail`); + expect(result.content).not.toContain(oldText); + }); + + it('uses translated wrappers for preview table validation and exception paths', async () => { + const missingTable = await executeLocalAIToolCall({ + toolCall: buildToolCall('preview_table_rows', { + connectionId: 'conn-1', + dbName: 'HR', + tableName: ' ', + }), + connections: [buildSqlExecutionConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: {}, + }); + + expect(missingTable.success).toBe(false); + expect(missingTable.content).toBe('T:ai_chat.panel.tool_error.table_name_required'); + expect(missingTable.content).not.toContain('tableName 不能为空'); + + const failedPreview = await executeLocalAIToolCall({ + toolCall: buildToolCall('preview_table_rows', { + connectionId: 'conn-1', + dbName: 'HR', + tableName: 'EMPLOYEES', + }), + connections: [buildSqlExecutionConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + query: vi.fn().mockRejectedValue(new Error('database timeout')), + }, + }); + + expect(failedPreview.success).toBe(false); + expect(failedPreview.content).toBe('T:ai_chat.panel.tool_error.preview_table_rows_failed detail=database timeout'); + expect(failedPreview.content).not.toContain('预览表样例数据失败'); + }); + + it('uses translated wrappers for execute_sql safety and failure paths while preserving raw details', async () => { + const blocked = await executeLocalAIToolCall({ + toolCall: buildToolCall('execute_sql', { + connectionId: 'conn-1', + dbName: 'HR', + sql: 'UPDATE EMPLOYEES SET SALARY = SALARY + 1', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + checkSQL: vi.fn().mockResolvedValue({ + allowed: false, + operationType: 'UPDATE', + }), + }, + }); + + expect(blocked.success).toBe(false); + expect(blocked.content).toBe('T:ai_chat.panel.tool_error.sql_blocked operationType=UPDATE'); + expect(blocked.content).not.toContain('安全策略拦截'); + + const failed = await executeLocalAIToolCall({ + toolCall: buildToolCall('execute_sql', { + connectionId: 'conn-1', + dbName: 'HR', + sql: 'INSERT INTO AUDIT_LOG SELECT * FROM EMPLOYEES', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + checkSQL: vi.fn().mockResolvedValue({ allowed: true, operationType: 'INSERT' }), + query: vi.fn().mockResolvedValue({ success: false }), + }, + }); + + expect(failed.success).toBe(false); + expect(failed.content).toBe('T:ai_chat.panel.tool_error.sql_execute_failed'); + expect(failed.content).not.toContain('SQL 执行失败'); + + const thrown = await executeLocalAIToolCall({ + toolCall: buildToolCall('execute_sql', { + connectionId: 'conn-1', + dbName: 'HR', + sql: 'INSERT INTO AUDIT_LOG SELECT * FROM EMPLOYEES', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + checkSQL: vi.fn().mockResolvedValue({ allowed: true, operationType: 'INSERT' }), + query: vi.fn().mockRejectedValue(new Error('ORA-01013 raw detail')), + }, + }); + + expect(thrown.success).toBe(false); + expect(thrown.content).toBe('T:ai_chat.panel.tool_error.sql_execute_exception detail=ORA-01013 raw detail'); + expect(thrown.content).not.toContain('SQL 执行异常'); + }); + + it('uses the translated truncation marker for database and table list results without storing it as table context', async () => { + const databases = await executeLocalAIToolCall({ + toolCall: buildToolCall('get_databases', { + connectionId: 'conn-1', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn().mockResolvedValue({ + success: true, + data: Array.from({ length: 51 }, (_, index) => ({ Database: `DB_${index}` })), + }), + }, + }); + + expect(JSON.parse(databases.content).at(-1)).toBe('T:ai_chat.panel.error.truncated_suffix'); + expect(databases.content).not.toContain('...(截断)'); + + const toolContextMap = new Map(); + const tables = await executeLocalAIToolCall({ + toolCall: buildToolCall('get_tables', { + connectionId: 'conn-1', + dbName: 'HR', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap, + translate, + runtime: { + getTables: vi.fn().mockResolvedValue({ + success: true, + data: Array.from({ length: 151 }, (_, index) => ({ name: `TABLE_${index}` })), + }), + }, + }); + + expect(JSON.parse(tables.content).at(-1)).toBe('T:ai_chat.panel.error.truncated_suffix'); + expect(tables.content).not.toContain('...(截断)'); + expect(toolContextMap.get('conn-1:HR')?.tables).not.toContain('T:ai_chat.panel.error.truncated_suffix'); + }); + + it('preserves a real table name that matches the translated truncation marker in tool context', async () => { + const toolContextMap = new Map(); + + await executeLocalAIToolCall({ + toolCall: buildToolCall('get_tables', { + connectionId: 'conn-1', + dbName: 'HR', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap, + translate, + runtime: { + getTables: vi.fn().mockResolvedValue({ + success: true, + data: [ + { name: 'T:ai_chat.panel.error.truncated_suffix' }, + { name: 'EMPLOYEES' }, + ], + }), + }, + }); + + expect(toolContextMap.get('conn-1:HR')?.tables).toContain('T:ai_chat.panel.error.truncated_suffix'); + }); + + it('uses translated wrappers for get_columns field guidance while preserving raw table and column data', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('get_columns', { + connectionId: 'conn-1', + dbName: 'HR', + tableName: 'EMPLOYEES', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + getColumns: vi.fn().mockResolvedValue({ + success: true, + data: [{ Field: 'EMPLOYEE_ID', Type: 'NUMBER' }], + }), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain( + 'T:ai_chat.inspection.table_schema.warning.columns_contract tableName=EMPLOYEES', + ); + expect(result.content).toContain('T:ai_chat.inspection.table_schema.warning.available_fields fields=EMPLOYEE_ID'); + expect(result.content).toContain('T:ai_chat.inspection.table_schema.warning.detail detail='); + expect(result.content).toContain('"field":"EMPLOYEE_ID"'); + expect(result.content).not.toContain('以下为'); + expect(result.content).not.toContain('可用字段'); + expect(result.content).not.toContain('详细信息'); + }); + + it('passes the translator into table schema DDL fallback wrappers', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('get_table_ddl', { + connectionId: 'conn-1', + dbName: 'HR', + tableName: 'EMPLOYEES', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + showCreateTable: vi.fn().mockResolvedValue({ + success: false, + message: 'ORA-31603: object not found or insufficient privileges', + }), + getColumns: vi.fn().mockResolvedValue({ + success: true, + data: [{ Name: 'EMPLOYEE_ID', Type: 'NUMBER' }], + }), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain( + 'T:ai_chat.inspection.table_schema.warning.ddl_fallback tableName=EMPLOYEES', + ); + expect(result.content).toContain('EMPLOYEE_ID'); + expect(result.content).not.toContain('DDL 获取失败'); + }); + + it('uses translated wrapper when get_table_ddl throws before the schema resolver can handle it', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('get_table_ddl', { + connectionId: 'conn-1', + dbName: 'HR', + tableName: 'EMPLOYEES', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + showCreateTable: vi.fn().mockRejectedValue(new Error('driver panic')), + getColumns: vi.fn(), + }, + }); + + expect(result.success).toBe(false); + expect(result.content).toBe('T:ai_chat.inspection.table_schema.error.ddl_failed detail=driver panic'); + expect(result.content).not.toContain('获取建表语句失败'); + }); + + it.each([ + '資料庫連線逾時:mysql 127.0.0.1:3306/HR:網路逾時', + 'データベース接続がタイムアウトしました: mysql 127.0.0.1:3306/HR: タイムアウト', + ])('still counts localized timeout wrapper %s as a probe failure', async (localizedTimeoutMessage) => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('get_databases', { + connectionId: 'conn-1', + }), + connections: [buildSqlExecutionConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn().mockResolvedValue({ + success: false, + message: localizedTimeoutMessage, + }), + }, + }); + + expect(result.success).toBe(false); + expect(result.content).toBe(localizedTimeoutMessage); + expect(result.countsAsProbeFailure).toBe(true); + }); + + it('keeps localized probe-failure keywords out of production Han literals', () => { + const source = readFileSync(new URL('./aiDatabaseToolExecutor.ts', import.meta.url), 'utf8'); + + expect(source).not.toContain('连接失败'); + expect(source).not.toContain('连接异常'); + expect(source).not.toContain('连接超时'); + expect(source).not.toContain('连接已关闭'); + expect(source).not.toContain('网络超时'); + expect(source).not.toContain('网络异常'); + }); +}); diff --git a/frontend/src/components/ai/aiDatabaseToolExecutor.ts b/frontend/src/components/ai/aiDatabaseToolExecutor.ts index 492864b..ff27da0 100644 --- a/frontend/src/components/ai/aiDatabaseToolExecutor.ts +++ b/frontend/src/components/ai/aiDatabaseToolExecutor.ts @@ -1,4 +1,5 @@ import type { SavedConnection } from '../../types'; +import { t as translateCatalog, type I18nParams } from '../../i18n'; import { buildRpcConnectionConfig } from '../../utils/connectionRpcConfig'; import { buildAIReadonlyPreviewSQL } from '../../utils/aiSqlLimit'; import { resolveAITableSchemaToolResult } from '../../utils/aiTableSchemaTool'; @@ -18,6 +19,7 @@ interface ExecuteDatabaseToolCallOptions { connections: SavedConnection[]; toolContextMap: Map; runtime: AILocalToolRuntime; + translate?: (key: string, params?: I18nParams) => string; } interface ToolExecutionResult { @@ -26,19 +28,26 @@ interface ToolExecutionResult { countsAsProbeFailure?: boolean; } +type DatabaseToolTranslate = (key: string, params?: I18nParams) => string; + const findConnection = (connections: SavedConnection[], connectionId: string) => connections.find((connection) => connection.id === connectionId); const resolveConnectionOrFailure = ( connections: SavedConnection[], connectionId: string, + translate?: DatabaseToolTranslate, ): { connection: SavedConnection | null; failure?: ToolExecutionResult } => { const connection = findConnection(connections, connectionId); if (!connection) { return { connection: null, failure: { - content: 'Connection not found', + content: translateDatabaseToolCopy( + translate, + 'ai_chat.panel.tool_error.connection_not_found', + 'Connection not found', + ), success: false, countsAsProbeFailure: true, }, @@ -63,12 +72,16 @@ const CONNECTION_ERROR_KEYWORDS = [ 'i/o timeout', 'timeout', 'eof', - '连接失败', - '连接异常', - '连接超时', - '连接已关闭', - '网络超时', - '网络异常', + '\u8fde\u63a5\u5931\u8d25', + '\u8fde\u63a5\u5f02\u5e38', + '\u8fde\u63a5\u8d85\u65f6', + '\u8fde\u63a5\u5df2\u5173\u95ed', + '\u7f51\u7edc\u8d85\u65f6', + '\u7f51\u7edc\u5f02\u5e38', + '\u903e\u6642', + '\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8', + 'zeit\u00fcberschreitung', + '\u0442\u0430\u0439\u043c-\u0430\u0443\u0442', ]; const countsAsProbeFailure = (message: unknown): boolean => { @@ -79,10 +92,64 @@ const countsAsProbeFailure = (message: unknown): boolean => { return CONNECTION_ERROR_KEYWORDS.some((keyword) => text.includes(keyword)); }; +const translateDatabaseToolCopy = ( + translate: DatabaseToolTranslate | undefined, + key: string, + fallback: string, + params?: I18nParams, +): string => { + const t = translate || ((catalogKey, catalogParams) => translateCatalog(catalogKey, catalogParams, 'en-US')); + const translated = t(key, params); + return translated && translated !== key ? translated : fallback; +}; + +const rawErrorDetail = (error: any): string => String(error?.message || error); + +const translateDatabaseToolError = ( + translate: DatabaseToolTranslate | undefined, + key: string, + fallbackPrefix: string, + detail: string, +): string => + translateDatabaseToolCopy( + translate, + key, + `${fallbackPrefix}: ${detail}`, + { detail }, + ); + +const translateDatabaseToolUnknownDetail = ( + translate: DatabaseToolTranslate | undefined, +): string => + translateDatabaseToolCopy( + translate, + 'ai_chat.inspection.diagnostics.error.unknown', + 'unknown error', + ); + +const translateDatabaseToolUnknownFailure = ( + translate: DatabaseToolTranslate | undefined, + key: string, + fallbackPrefix: string, +): string => + translateDatabaseToolError( + translate, + key, + fallbackPrefix, + translateDatabaseToolUnknownDetail(translate), + ); + +const translateTruncatedSuffix = (translate: DatabaseToolTranslate | undefined): string => + translateDatabaseToolCopy( + translate, + 'ai_chat.panel.error.truncated_suffix', + '...(truncated)', + ); + export async function executeDatabaseToolCall( options: ExecuteDatabaseToolCallOptions, ): Promise { - const { toolName, args, connections, toolContextMap, runtime } = options; + const { toolName, args, connections, toolContextMap, runtime, translate } = options; switch (toolName) { case 'get_connections': { @@ -98,29 +165,39 @@ export async function executeDatabaseToolCall( }; } case 'get_databases': { - const resolved = resolveConnectionOrFailure(connections, args.connectionId); + const resolved = resolveConnectionOrFailure(connections, args.connectionId, translate); if (resolved.failure || !resolved.connection) return resolved.failure || null; try { const result = await runtime.getDatabases(buildRpcConnectionConfig(resolved.connection.config) as any); if (result?.success && Array.isArray(result.data)) { let databaseNames = result.data.map((row: any) => row.Database || row.database || Object.values(row)[0]); if (databaseNames.length > 50) { - databaseNames = [...databaseNames.slice(0, 50), '...(截断)']; + databaseNames = [...databaseNames.slice(0, 50), translateTruncatedSuffix(translate)]; } return { content: JSON.stringify(databaseNames), success: true }; } return { - content: result?.message || 'Failed to fetch DBs', + content: result?.message || translateDatabaseToolUnknownFailure( + translate, + 'ai_chat.panel.tool_error.fetch_databases_failed', + 'Failed to fetch database list', + ), success: false, countsAsProbeFailure: countsAsProbeFailure(result?.message), }; } catch (error: any) { - const message = `获取数据库列表失败: ${error?.message || error}`; + const detail = rawErrorDetail(error); + const message = translateDatabaseToolError( + translate, + 'ai_chat.panel.tool_error.fetch_databases_failed', + 'Failed to fetch database list', + detail, + ); return { content: message, success: false, countsAsProbeFailure: countsAsProbeFailure(message) }; } } case 'get_tables': { - const resolved = resolveConnectionOrFailure(connections, args.connectionId); + const resolved = resolveConnectionOrFailure(connections, args.connectionId, translate); if (resolved.failure || !resolved.connection) return resolved.failure || null; try { const rawDbName = args.dbName || args.database; @@ -128,28 +205,40 @@ export async function executeDatabaseToolCall( const result = await runtime.getTables(buildRpcConnectionConfig(resolved.connection.config) as any, safeDbName); if (result?.success && Array.isArray(result.data)) { let tableNames = normalizeTableList(result.data); + const contextTableNames = tableNames.slice(0, 150); + const truncatedSuffix = translateTruncatedSuffix(translate); if (tableNames.length > 150) { - tableNames = [...tableNames.slice(0, 150), '...(截断)']; + tableNames = [...tableNames.slice(0, 150), truncatedSuffix]; } toolContextMap.set(`${args.connectionId}:${safeDbName}`, { connectionId: args.connectionId, dbName: safeDbName, - tables: tableNames.filter((tableName) => tableName !== '...(截断)'), + tables: contextTableNames, }); return { content: JSON.stringify(tableNames), success: true }; } return { - content: result?.message || 'Failed to fetch Tables', + content: result?.message || translateDatabaseToolUnknownFailure( + translate, + 'ai_chat.panel.tool_error.fetch_tables_failed', + 'Failed to fetch table list', + ), success: false, countsAsProbeFailure: countsAsProbeFailure(result?.message), }; } catch (error: any) { - const message = `获取表列表失败: ${error?.message || error}`; + const detail = rawErrorDetail(error); + const message = translateDatabaseToolError( + translate, + 'ai_chat.panel.tool_error.fetch_tables_failed', + 'Failed to fetch table list', + detail, + ); return { content: message, success: false, countsAsProbeFailure: countsAsProbeFailure(message) }; } } case 'get_all_columns': { - const resolved = resolveConnectionOrFailure(connections, args.connectionId); + const resolved = resolveConnectionOrFailure(connections, args.connectionId, translate); if (resolved.failure || !resolved.connection) return resolved.failure || null; try { const safeDbName = args.dbName ? String(args.dbName).trim() : ''; @@ -170,17 +259,27 @@ export async function executeDatabaseToolCall( }; } return { - content: result?.message || 'Failed to fetch all columns', + content: result?.message || translateDatabaseToolUnknownFailure( + translate, + 'ai_chat.panel.tool_error.fetch_all_columns_failed', + 'Failed to fetch database column summary', + ), success: false, countsAsProbeFailure: countsAsProbeFailure(result?.message), }; } catch (error: any) { - const message = `获取全库字段摘要失败: ${error?.message || error}`; + const detail = rawErrorDetail(error); + const message = translateDatabaseToolError( + translate, + 'ai_chat.panel.tool_error.fetch_all_columns_failed', + 'Failed to fetch database column summary', + detail, + ); return { content: message, success: false, countsAsProbeFailure: countsAsProbeFailure(message) }; } } case 'get_columns': { - const resolved = resolveConnectionOrFailure(connections, args.connectionId); + const resolved = resolveConnectionOrFailure(connections, args.connectionId, translate); if (resolved.failure || !resolved.connection) return resolved.failure || null; try { const safeDbName = args.dbName ? String(args.dbName).trim() : ''; @@ -189,69 +288,129 @@ export async function executeDatabaseToolCall( if (result?.success && Array.isArray(result.data)) { const columns = normalizeColumns(result.data); const fieldNames = columns.map((column) => column.field).join(', '); + const detail = JSON.stringify(columns); return { - content: `⚠️ 以下为 ${safeTable} 表的真实字段列表。生成 SQL 时只能使用这些 field 值作为列名,必须原样使用,禁止修改、缩写或自行拼凑字段名。\n可用字段:${fieldNames}\n详细信息:${JSON.stringify(columns)}`, + content: [ + translateDatabaseToolCopy( + translate, + 'ai_chat.inspection.table_schema.warning.columns_contract', + `The following is the real field list for table ${safeTable}. When generating SQL, use only these field values as column names exactly as shown; do not modify, abbreviate, or invent column names.`, + { tableName: safeTable }, + ), + translateDatabaseToolCopy( + translate, + 'ai_chat.inspection.table_schema.warning.available_fields', + `Available fields: ${fieldNames}`, + { fields: fieldNames }, + ), + translateDatabaseToolCopy( + translate, + 'ai_chat.inspection.table_schema.warning.detail', + `Details: ${detail}`, + { detail }, + ), + ].join('\n'), success: true, }; } return { - content: result?.message || 'Failed to fetch columns', + content: result?.message || translateDatabaseToolUnknownFailure( + translate, + 'ai_chat.panel.tool_error.fetch_columns_failed', + 'Failed to fetch column list', + ), success: false, countsAsProbeFailure: countsAsProbeFailure(result?.message), }; } catch (error: any) { - const message = `获取字段列表失败: ${error?.message || error}`; + const detail = rawErrorDetail(error); + const message = translateDatabaseToolError( + translate, + 'ai_chat.panel.tool_error.fetch_columns_failed', + 'Failed to fetch column list', + detail, + ); return { content: message, success: false, countsAsProbeFailure: countsAsProbeFailure(message) }; } } case 'get_indexes': { - const resolved = resolveConnectionOrFailure(connections, args.connectionId); + const resolved = resolveConnectionOrFailure(connections, args.connectionId, translate); if (resolved.failure || !resolved.connection) return resolved.failure || null; try { const safeDbName = args.dbName ? String(args.dbName).trim() : ''; const safeTable = args.tableName ? String(args.tableName).trim() : ''; const result = await runtime.getIndexes(buildRpcConnectionConfig(resolved.connection.config) as any, safeDbName, safeTable); return { - content: result?.success && Array.isArray(result.data) ? JSON.stringify(result.data) : (result?.message || 'Failed to fetch indexes'), + content: result?.success && Array.isArray(result.data) ? JSON.stringify(result.data) : (result?.message || translateDatabaseToolUnknownFailure( + translate, + 'ai_chat.panel.tool_error.fetch_indexes_failed', + 'Failed to fetch index definitions', + )), success: !!result?.success && Array.isArray(result.data), countsAsProbeFailure: result?.success ? false : countsAsProbeFailure(result?.message), }; } catch (error: any) { - const message = `获取索引定义失败: ${error?.message || error}`; + const detail = rawErrorDetail(error); + const message = translateDatabaseToolError( + translate, + 'ai_chat.panel.tool_error.fetch_indexes_failed', + 'Failed to fetch index definitions', + detail, + ); return { content: message, success: false, countsAsProbeFailure: countsAsProbeFailure(message) }; } } case 'get_foreign_keys': { - const resolved = resolveConnectionOrFailure(connections, args.connectionId); + const resolved = resolveConnectionOrFailure(connections, args.connectionId, translate); if (resolved.failure || !resolved.connection) return resolved.failure || null; try { const safeDbName = args.dbName ? String(args.dbName).trim() : ''; const safeTable = args.tableName ? String(args.tableName).trim() : ''; const result = await runtime.getForeignKeys(buildRpcConnectionConfig(resolved.connection.config) as any, safeDbName, safeTable); return { - content: result?.success && Array.isArray(result.data) ? JSON.stringify(result.data) : (result?.message || 'Failed to fetch foreign keys'), + content: result?.success && Array.isArray(result.data) ? JSON.stringify(result.data) : (result?.message || translateDatabaseToolUnknownFailure( + translate, + 'ai_chat.panel.tool_error.fetch_foreign_keys_failed', + 'Failed to fetch foreign key relationships', + )), success: !!result?.success && Array.isArray(result.data), countsAsProbeFailure: result?.success ? false : countsAsProbeFailure(result?.message), }; } catch (error: any) { - const message = `获取外键关系失败: ${error?.message || error}`; + const detail = rawErrorDetail(error); + const message = translateDatabaseToolError( + translate, + 'ai_chat.panel.tool_error.fetch_foreign_keys_failed', + 'Failed to fetch foreign key relationships', + detail, + ); return { content: message, success: false, countsAsProbeFailure: countsAsProbeFailure(message) }; } } case 'get_triggers': { - const resolved = resolveConnectionOrFailure(connections, args.connectionId); + const resolved = resolveConnectionOrFailure(connections, args.connectionId, translate); if (resolved.failure || !resolved.connection) return resolved.failure || null; try { const safeDbName = args.dbName ? String(args.dbName).trim() : ''; const safeTable = args.tableName ? String(args.tableName).trim() : ''; const result = await runtime.getTriggers(buildRpcConnectionConfig(resolved.connection.config) as any, safeDbName, safeTable); return { - content: result?.success && Array.isArray(result.data) ? JSON.stringify(result.data) : (result?.message || 'Failed to fetch triggers'), + content: result?.success && Array.isArray(result.data) ? JSON.stringify(result.data) : (result?.message || translateDatabaseToolUnknownFailure( + translate, + 'ai_chat.panel.tool_error.fetch_triggers_failed', + 'Failed to fetch trigger definitions', + )), success: !!result?.success && Array.isArray(result.data), countsAsProbeFailure: result?.success ? false : countsAsProbeFailure(result?.message), }; } catch (error: any) { - const message = `获取触发器定义失败: ${error?.message || error}`; + const detail = rawErrorDetail(error); + const message = translateDatabaseToolError( + translate, + 'ai_chat.panel.tool_error.fetch_triggers_failed', + 'Failed to fetch trigger definitions', + detail, + ); return { content: message, success: false, countsAsProbeFailure: countsAsProbeFailure(message) }; } } @@ -266,6 +425,7 @@ export async function executeDatabaseToolCall( tableName: safeTable, fetchDDL: () => runtime.showCreateTable(rpcConfig, safeDbName, safeTable), fetchColumns: () => runtime.getColumns(rpcConfig, safeDbName, safeTable), + translate, }); return { content: result.content, @@ -273,19 +433,25 @@ export async function executeDatabaseToolCall( countsAsProbeFailure: result.success ? false : countsAsProbeFailure(result.content), }; } catch (error: any) { - const message = `获取建表语句失败: ${error?.message || error}`; + const detail = String(error?.message || error); + const message = translateDatabaseToolCopy( + translate, + 'ai_chat.inspection.table_schema.error.ddl_failed', + `Failed to fetch table DDL: ${detail}`, + { detail }, + ); return { content: message, success: false, countsAsProbeFailure: countsAsProbeFailure(message) }; } } case 'inspect_table_bundle': { const resolved = resolveConnectionOrFailure(connections, args.connectionId); if (resolved.failure || !resolved.connection) return resolved.failure || null; - return inspectTableBundle({ args, connection: resolved.connection, runtime }); + return inspectTableBundle({ args, connection: resolved.connection, runtime, translate }); } case 'inspect_database_bundle': { const resolved = resolveConnectionOrFailure(connections, args.connectionId); if (resolved.failure || !resolved.connection) return resolved.failure || null; - return inspectDatabaseBundle({ args, connection: resolved.connection, runtime }); + return inspectDatabaseBundle({ args, connection: resolved.connection, runtime, translate }); } case 'preview_table_rows': { const resolved = resolveConnectionOrFailure(connections, args.connectionId); @@ -293,7 +459,16 @@ export async function executeDatabaseToolCall( try { const safeDbName = args.dbName ? String(args.dbName).trim() : ''; const safeTable = args.tableName ? String(args.tableName).trim() : ''; - if (!safeTable) return { content: 'tableName 不能为空', success: false }; + if (!safeTable) { + return { + content: translateDatabaseToolCopy( + translate, + 'ai_chat.panel.tool_error.table_name_required', + 'tableName cannot be empty', + ), + success: false, + }; + } const safeLimit = normalizePreviewLimit(args.limit); const previewSQL = buildPreviewSQLForTable(resolved.connection, safeTable, safeLimit); const result = await runtime.query(buildRpcConnectionConfig(resolved.connection.config) as any, safeDbName, previewSQL); @@ -316,7 +491,13 @@ export async function executeDatabaseToolCall( countsAsProbeFailure: countsAsProbeFailure(result?.message), }; } catch (error: any) { - const message = `预览表样例数据失败: ${error?.message || error}`; + const detail = rawErrorDetail(error); + const message = translateDatabaseToolError( + translate, + 'ai_chat.panel.tool_error.preview_table_rows_failed', + 'Failed to preview table rows', + detail, + ); return { content: message, success: false, countsAsProbeFailure: countsAsProbeFailure(message) }; } } @@ -330,7 +511,12 @@ export async function executeDatabaseToolCall( const checkResult = await runtime.checkSQL(safeSql); if (checkResult && checkResult.allowed === false) { return { - content: `安全策略拦截:当前安全级别不允许执行 ${checkResult.operationType} 类型的 SQL。请将 SQL 展示给用户,让用户手动执行。`, + content: translateDatabaseToolCopy( + translate, + 'ai_chat.panel.tool_error.sql_blocked', + `Security policy blocked this request: the current safety level does not allow ${checkResult.operationType} SQL. Show the SQL to the user and ask them to run it manually.`, + { operationType: checkResult.operationType }, + ), success: false, countsAsProbeFailure: false, }; @@ -359,12 +545,22 @@ export async function executeDatabaseToolCall( }; } return { - content: result?.message || 'SQL 执行失败', + content: result?.message || translateDatabaseToolCopy( + translate, + 'ai_chat.panel.tool_error.sql_execute_failed', + 'SQL execution failed', + ), success: false, countsAsProbeFailure: countsAsProbeFailure(result?.message), }; } catch (error: any) { - const message = `SQL 执行异常: ${error?.message || error}`; + const detail = rawErrorDetail(error); + const message = translateDatabaseToolCopy( + translate, + 'ai_chat.panel.tool_error.sql_execute_exception', + `SQL execution exception: ${detail}`, + { detail }, + ); return { content: message, success: false, countsAsProbeFailure: countsAsProbeFailure(message) }; } } diff --git a/frontend/src/components/ai/aiInspectionI18n.ts b/frontend/src/components/ai/aiInspectionI18n.ts new file mode 100644 index 0000000..ee104c7 --- /dev/null +++ b/frontend/src/components/ai/aiInspectionI18n.ts @@ -0,0 +1,15 @@ +import type { I18nParams } from '../../i18n'; +import { t as translateCatalog } from '../../i18n'; + +export type AIInspectionTranslator = (key: string, params?: I18nParams) => string; + +export const translateInspectionCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: I18nParams, +): string => { + const t = translate || ((catalogKey, catalogParams) => translateCatalog(catalogKey, catalogParams, 'en-US')); + const translated = t(key, params); + return translated && translated !== key ? translated : fallback; +}; diff --git a/frontend/src/components/ai/aiLastRenderErrorInsights.ts b/frontend/src/components/ai/aiLastRenderErrorInsights.ts index 4b31f83..367ae5d 100644 --- a/frontend/src/components/ai/aiLastRenderErrorInsights.ts +++ b/frontend/src/components/ai/aiLastRenderErrorInsights.ts @@ -1,6 +1,15 @@ +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; + const DEFAULT_PREVIEW_LIMIT = 240; const DEFAULT_STACK_LIMIT = 1200; +const copy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, +) => translateInspectionCopy(translate, key, fallback); + const truncateText = (value: unknown, limit: number) => { const text = String(value || ''); if (!text) { @@ -25,22 +34,38 @@ const resolveGlobalRenderError = () => { return null; }; -export const buildAILastRenderErrorSnapshot = () => { +export const buildAILastRenderErrorSnapshot = (translate?: AIInspectionTranslator) => { const renderError = resolveGlobalRenderError(); if (!renderError) { return { hasError: false, - summary: '当前还没有记录到 AI 消息渲染异常。', + summary: copy( + translate, + 'ai_chat.inspection.last_render_error.empty_summary', + 'No AI message render errors have been recorded yet.', + ), nextActions: [ - '如果用户反馈 AI 某条消息空白、白块或只出现局部报错,再重新触发问题后读取这里。', - '如果是整块 AI 面板异常,再结合 inspect_ai_setup_health 和 inspect_app_logs 一起看。', + copy( + translate, + 'ai_chat.inspection.last_render_error.empty_next_action.reproduce', + 'If the user reports a blank AI message, white block, or localized render error, reproduce it and read this snapshot again.', + ), + copy( + translate, + 'ai_chat.inspection.last_render_error.empty_next_action.inspect_health', + 'If the entire AI panel is failing, combine this with inspect_ai_setup_health and inspect_app_logs.', + ), ], }; } return { hasError: true, - summary: '已记录到最近一次 AI 消息渲染异常,可据此定位是哪条消息、哪段渲染逻辑和报错栈摘要。', + summary: copy( + translate, + 'ai_chat.inspection.last_render_error.recorded_summary', + 'A recent AI message render error was recorded, including the message, render path, and stack summary needed for diagnosis.', + ), messageId: String(renderError.messageId || ''), role: String(renderError.role || ''), recordedAt: typeof renderError.recordedAt === 'number' ? renderError.recordedAt : null, @@ -49,8 +74,16 @@ export const buildAILastRenderErrorSnapshot = () => { stackPreview: truncateText(renderError.stack, DEFAULT_STACK_LIMIT), componentStackPreview: truncateText(renderError.componentStack, DEFAULT_STACK_LIMIT), nextActions: [ - '先按 messageId 和 contentPreview 对照当前会话,确认是哪条气泡触发的渲染异常。', - '如果需要继续缩小范围,再结合最近一次用户输入、工具结果和相关组件代码排查。', + copy( + translate, + 'ai_chat.inspection.last_render_error.next_action.match_message', + 'Match messageId and contentPreview against the current conversation to identify which bubble triggered the render error.', + ), + copy( + translate, + 'ai_chat.inspection.last_render_error.next_action.narrow_scope', + 'If more narrowing is needed, compare the latest user input, tool results, and related component code.', + ), ], }; }; diff --git a/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts index cc2f8e5..e427a4f 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.aiConfigInspection.test.ts @@ -230,6 +230,63 @@ describe('aiLocalToolExecutor AI config inspection tools', () => { expect(result.content).toContain('OpenAI 主账号'); }); + it('uses the provided translator for the current chat readiness snapshot while keeping raw provider data', async () => { + const translate = (key: string, params?: Record) => ({ + 'ai_chat.input.status.label.model_required': 'T_MODEL_REQUIRED', + 'ai_chat.input.status.missing_model.title.select': `T_SELECT_${params?.provider || ''}`, + 'ai_chat.input.status.missing_model.description.available': `T_AVAILABLE_${params?.count || ''}`, + 'ai_chat.input.status.action.reload_models': 'T_RELOAD_MODELS', + }[key] || key); + + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_chat_readiness', {}), + connections: [buildConnection()], + mcpTools: [], + dynamicModels: ['gpt-5.5', 'gpt-4.1-mini'], + activeContext: { + connectionId: 'conn-1', + dbName: 'demo', + }, + aiContexts: { + 'conn-1:demo': [{ + dbName: 'demo', + tableName: 'orders', + ddl: 'CREATE TABLE orders (...)', + }], + }, + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + getAIRuntimeState: vi.fn().mockResolvedValue({ + activeProviderId: 'provider-1', + providers: [{ + id: 'provider-1', + type: 'openai', + name: 'OpenAI 主账号', + apiKey: '', + hasSecret: true, + baseUrl: 'https://api.openai.com/v1', + model: '', + models: ['gpt-5.5', 'gpt-4.1-mini'], + maxTokens: 32000, + temperature: 0.2, + }], + }), + }, + }); + + expect(result.success).toBe(true); + const snapshot = JSON.parse(String(result.content)); + expect(snapshot.status).toBe('missing_model'); + expect(snapshot.label).toBe('T_MODEL_REQUIRED'); + expect(snapshot.title).toBe('T_SELECT_OpenAI 主账号'); + expect(snapshot.description).toBe('T_AVAILABLE_2'); + expect(snapshot.action?.label).toBe('T_RELOAD_MODELS'); + expect(snapshot.activeProvider?.name).toBe('OpenAI 主账号'); + }); + it('returns the ai tool catalog so the model can choose probes and build arguments', async () => { const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_ai_tool_catalog', { @@ -268,7 +325,7 @@ describe('aiLocalToolExecutor AI config inspection tools', () => { expect(result.content).toContain('"name":"fullCommand"'); expect(result.content).toContain('"alias":"github_create_issue"'); expect(result.content).toContain('"requiredParameters":["owner","repo","title"]'); - expect(result.content).toContain('调用带参数工具前'); + expect(result.content).toContain('Before calling tools with parameters'); }); it('returns the current mcp setup snapshot so the model can inspect configured servers and client install state', async () => { @@ -355,15 +412,62 @@ describe('aiLocalToolExecutor AI config inspection tools', () => { expect(result.success).toBe(true); expect(result.content).toContain('"supportsWholeCommandAutoSplit":true'); expect(result.content).toContain('"fullCommandPasteExample":"$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio"'); - expect(result.content).toContain('"title":"启动命令"'); + expect(result.content).toContain('"title":"Startup command"'); expect(result.content).toContain('"example":"npx / node / uvx / python / docker"'); expect(result.content).toContain('PowerShell $env:KEY=VALUE;'); - expect(result.content).toContain('"title":"npx 包"'); + expect(result.content).toContain('"title":"npx package"'); expect(result.content).toContain('"exampleLaunchPreview":"npx -y @modelcontextprotocol/server-filesystem --stdio"'); - expect(result.content).toContain('"title":"uvx 工具"'); + expect(result.content).toContain('"title":"uvx tool"'); expect(result.content).toContain('"exampleLaunchPreview":"uvx some-mcp-server"'); }); + it('uses the provided translator for the builtin mcp authoring guide while keeping command examples raw', async () => { + const translate = (key: string) => ({ + 'ai_settings.mcp_server.guide.step.template.title': 'T_STEP_TEMPLATE_TITLE', + 'ai_settings.mcp_server.guide.step.template.detail': 'T_STEP_TEMPLATE_DETAIL', + 'ai_settings.mcp_server.guide.field.command.title': 'T_FIELD_COMMAND_TITLE', + 'ai_settings.mcp_server.guide.field.command.summary': 'T_FIELD_COMMAND_SUMMARY', + 'ai_settings.mcp_server.guide.field.command.detail': 'T_FIELD_COMMAND_DETAIL', + 'ai_settings.mcp_server.template.npx.title': 'T_TEMPLATE_NPX_TITLE', + 'ai_settings.mcp_server.template.npx.description': 'T_TEMPLATE_NPX_DESCRIPTION', + 'ai_settings.mcp_server.template.npx.detail': 'T_TEMPLATE_NPX_DETAIL', + 'ai_settings.mcp_server.guide.note.full_command': 'T_NOTE_FULL_COMMAND', + }[key] || key); + + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_mcp_authoring_guide', {}), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + const snapshot = JSON.parse(String(result.content)); + expect(snapshot.recommendedSteps[0]).toMatchObject({ + step: '1', + title: 'T_STEP_TEMPLATE_TITLE', + detail: 'T_STEP_TEMPLATE_DETAIL', + }); + expect(snapshot.fieldGuides.find((item: any) => item.key === 'command')).toMatchObject({ + title: 'T_FIELD_COMMAND_TITLE', + summary: 'T_FIELD_COMMAND_SUMMARY', + detail: 'T_FIELD_COMMAND_DETAIL', + }); + expect(snapshot.templates.find((item: any) => item.key === 'npx')).toMatchObject({ + title: 'T_TEMPLATE_NPX_TITLE', + description: 'T_TEMPLATE_NPX_DESCRIPTION', + detail: 'T_TEMPLATE_NPX_DETAIL', + exampleLaunchPreview: 'npx -y @modelcontextprotocol/server-filesystem --stdio', + }); + expect(snapshot.notes).toContain('T_NOTE_FULL_COMMAND'); + expect(snapshot.fullCommandPasteExample).toBe('$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio'); + }); + it('validates an mcp draft with the real command splitter and server validator', async () => { const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_mcp_draft', { @@ -426,6 +530,10 @@ describe('aiLocalToolExecutor AI config inspection tools', () => { }); it('returns mcp tool input schemas so the model can build arguments from discovered tool metadata', async () => { + const translate = (key: string, params?: Record) => ({ + 'ai_chat.inspection.mcp_tool_schema.usage.required_params': `T_REQUIRED_${params?.alias}_${params?.parameters}`, + }[key] || key); + const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_mcp_tool_schema', { alias: 'github_create_issue', @@ -450,6 +558,7 @@ describe('aiLocalToolExecutor AI config inspection tools', () => { }, }], toolContextMap: new Map(), + translate, runtime: { getDatabases: vi.fn(), getTables: vi.fn(), @@ -461,7 +570,7 @@ describe('aiLocalToolExecutor AI config inspection tools', () => { expect(result.content).toContain('"requiredParameters":["owner","repo","title"]'); expect(result.content).toContain('"path":"state"'); expect(result.content).toContain('"enumValues":["open","closed"]'); - expect(result.content).toContain('调用 github_create_issue 前必须提供:owner, repo, title'); + expect(result.content).toContain('T_REQUIRED_github_create_issue_owner, repo, title'); }); it('returns the current ai guidance snapshot so the model can inspect active prompts and enabled skills', async () => { diff --git a/frontend/src/components/ai/aiLocalToolExecutor.aiSetupHealth.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.aiSetupHealth.test.ts index 2d54b7e..043babd 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.aiSetupHealth.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.aiSetupHealth.test.ts @@ -109,7 +109,9 @@ describe('aiLocalToolExecutor inspect_ai_setup_health', () => { expect(result.content).toContain('"chatStatus":"ready"'); expect(result.content).toContain('"enabledMCPServerCount":1'); expect(result.content).toContain('"currentExternalClientCount":0'); - expect(result.content).toContain('如需让外部 Agent 使用 GoNavi MCP'); - expect(result.content).toContain('当前聊天已就绪,但还没有挂载任何表结构上下文'); + expect(result.content).toContain('To let external Agents use GoNavi MCP'); + expect(result.content).toContain('Chat is ready, but no table schema context is attached yet'); + expect(result.content).toContain('结构审查'); + expect(result.content).toContain('回答前先核对上下文。'); }); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts index a9ced1b..16efa84 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.aiUpstreamLogInspection.test.ts @@ -69,7 +69,7 @@ describe('aiLocalToolExecutor inspect_ai_upstream_logs', () => { success: true, data: { logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log', - keyword: 'AI 上游请求', + keyword: 'requestId=', requestedLineLimit: 160, lines: [ '2026/06/11 13:20:00.000000 [INFO] AI 上游请求开始:requestId=openai-tools-123 provider=openai method=POST endpoint=https://api.example.com/v1/chat/completions body={"model":"gpt-5.5","stream":true,"messages":[{"role":"system","content":"system secret password=abc123"},{"role":"user","content":"user private text"}],"tools":[{"type":"function","function":{"name":"inspect_app_health","description":"inspect app","parameters":{"type":"object","properties":{}}}}],"tool_choice":"auto","response_format":{"type":"json_object"},"api_key":"sk-should-not-leak"}', @@ -93,8 +93,9 @@ describe('aiLocalToolExecutor inspect_ai_upstream_logs', () => { }); expect(result.success).toBe(true); - expect(readAppLogTail).toHaveBeenCalledWith(160, 'AI 上游请求'); + expect(readAppLogTail).toHaveBeenCalledWith(160, 'requestId='); expect(result.content).toContain('"payloadSummaryEnabled":true'); + expect(result.content).toContain('"keyword":""'); expect(result.content).not.toContain('"bodyPreview"'); expect(result.content).not.toContain('password=abc123'); expect(result.content).not.toContain('user private text'); @@ -132,12 +133,18 @@ describe('aiLocalToolExecutor inspect_ai_upstream_logs', () => { }, }), }, + translate: (key) => ({ + 'ai_chat.inspection.upstream_logs.message.empty': 'translated executor empty message', + 'ai_chat.inspection.upstream_logs.next_action.confirm_logging': 'translated executor confirm logging', + 'ai_chat.inspection.upstream_logs.next_action.send_message': 'translated executor send message', + 'ai_chat.inspection.upstream_logs.next_action.read_warn_error': 'translated executor read warn error', + })[key] || key, }); expect(result.success).toBe(true); expect(result.content).toContain('"upstreamEventCount":0'); - expect(result.content).toContain('请先发送一次 AI 消息'); - expect(result.content).toContain('扩大 lineLimit'); + expect(result.content).toContain('translated executor empty message'); + expect(result.content).toContain('translated executor send message'); }); it('summarizes CLI upstream requests that complete without an HTTP status code', async () => { diff --git a/frontend/src/components/ai/aiLocalToolExecutor.appHealthInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.appHealthInspection.test.ts index e736014..431d091 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.appHealthInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.appHealthInspection.test.ts @@ -121,4 +121,45 @@ describe('aiLocalToolExecutor inspect_app_health', () => { expect(result.content).toContain('inspect_recent_connection_failures'); expect(readAppLogTail).toHaveBeenCalledWith(120, ''); }); + + it('passes the local translator into inspect_app_health snapshot wrappers', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_app_health', { + lineLimit: 120, + }), + connections: [buildConnection()], + tabs: [], + activeTabId: null, + mcpTools: [], + toolContextMap: new Map(), + translate: (key, params) => { + const suffix = params + ? ` ${Object.entries(params).map(([paramKey, value]) => `${paramKey}=${value}`).join(',')}` + : ''; + return `T:${key}${suffix}`; + }, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + getAIRuntimeState: vi.fn().mockResolvedValue({ + activeProviderId: '', + providers: [], + safetyLevel: 'readonly', + contextLevel: 'schema_only', + }), + getMCPServers: vi.fn().mockResolvedValue([]), + getMCPClientInstallStatuses: vi.fn().mockResolvedValue([]), + readAppLogTail: vi.fn() + .mockResolvedValueOnce({ success: false, message: 'raw log backend failure' }) + .mockResolvedValueOnce({ success: false, message: 'raw connection log failure' }), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('T:ai_chat.inspection.app_health.app_log.unread detail=raw log backend failure'); + expect(result.content).toContain( + 'T:ai_chat.inspection.app_health.connection_failures.unread detail=raw connection log failure', + ); + expect(result.content).toContain('T:ai_chat.inspection.app_health.warning.no_workspace_tabs'); + }); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.codebaseHotspots.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.codebaseHotspots.test.ts index 818e63d..9747b0e 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.codebaseHotspots.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.codebaseHotspots.test.ts @@ -46,12 +46,37 @@ describe('aiLocalToolExecutor inspect_codebase_hotspots', () => { expect(result.content).toContain('frontend/src/components/QueryEditor.tsx'); expect(result.content).toContain('"riskLevel":"critical"'); expect(result.content).toContain('"readiness":"readyToExtract"'); - expect(result.content).toContain('"preferredNextSlice":"编辑器工具栏"'); - expect(result.content).toContain('工具栏 JSX 可通过 props 透传状态和回调'); - expect(result.content).toContain('事务状态条'); + expect(result.content).toContain('"preferredNextSlice":"Editor toolbar"'); + expect(result.content).toContain('The toolbar JSX can pass state and callbacks through props'); + expect(result.content).toContain('Transaction status bar'); expect(result.content).toContain('QueryEditor.result-panel.test.tsx'); expect(result.content).toContain('QueryEditor.external-sql-save.test.tsx'); - expect(result.content).toContain('浏览器打开 SQL 编辑器'); + expect(result.content).toContain('Open the SQL editor in a browser'); expect(result.content).not.toContain('import React'); }); + + it('passes translator into codebase hotspot diagnostics', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_codebase_hotspots', { + keyword: 'QueryEditor', + minLines: 1000, + limit: 1, + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + translate: (key) => ({ + 'ai_chat.inspection.codebase_hotspots.query_editor.why': 'translated executor why', + 'ai_chat.inspection.codebase_hotspots.query_editor.preferred_next_slice': 'translated executor slice', + })[key] || key, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"why":"translated executor why"'); + expect(result.content).toContain('"preferredNextSlice":"translated executor slice"'); + }); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.connectionInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.connectionInspection.test.ts index 928d211..4b348af 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.connectionInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.connectionInspection.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { t as translateCatalog } from '../../i18n/catalog'; import type { AIToolCall, SavedConnection } from '../../types'; import { executeLocalAIToolCall } from './aiLocalToolExecutor'; @@ -76,6 +77,26 @@ describe('aiLocalToolExecutor connection inspection tools', () => { expect(result.content).toContain('"activeTabType":"query"'); }); + it('localizes current connection empty-state snapshots through the local tool executor', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_current_connection', {}), + connections: [buildConnection()], + activeContext: null, + tabs: [], + activeTabId: null, + mcpTools: [], + toolContextMap: new Map(), + translate: (key, params) => translateCatalog('en-US', key, params), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"message":"No active connection is currently selected"'); + }); + it('returns the current connection capability snapshot so the model can inspect supported UI actions', async () => { const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_connection_capabilities', {}), @@ -111,6 +132,44 @@ describe('aiLocalToolExecutor connection inspection tools', () => { expect(result.content).toContain('force_readonly_query_result'); }); + it('localizes connection capability snapshot messages while preserving raw connection metadata', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_connection_capabilities', {}), + connections: [{ + id: 'conn-1', + name: '分析库', + config: { + type: 'clickhouse', + host: '10.10.1.30', + port: 8123, + user: 'default', + database: 'analytics', + }, + }], + activeContext: { + connectionId: 'conn-1', + dbName: 'analytics', + }, + mcpTools: [], + toolContextMap: new Map(), + translate: (key, params) => translateCatalog('en-US', key, params), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"message":"Current connection 分析库 (clickhouse) exposes'); + expect(result.content).toContain('frontend capability signals'); + expect(result.content).toContain('Query results for this data source are shown as read-only by default'); + expect(result.content).toContain('"connectionName":"分析库"'); + expect(result.content).toContain('"resolvedType":"clickhouse"'); + expect(result.content).toContain('force_readonly_query_result'); + expect(result.content).not.toContain('当前连接'); + expect(result.content).not.toContain('当前数据源'); + }); + it('returns the local saved connections snapshot so the model can find matching data sources by type or keyword', async () => { const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_saved_connections', { @@ -166,12 +225,12 @@ describe('aiLocalToolExecutor connection inspection tools', () => { it('returns a Redis topology snapshot with Sentinel and Cluster risks', async () => { const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_redis_topology', { - keyword: '订单', + keyword: 'orders', }), connections: [ { id: 'redis-sentinel', - name: '订单 Redis Sentinel', + name: 'Orders Redis Sentinel', config: { type: 'redis', host: 'sentinel-a.local', @@ -186,7 +245,7 @@ describe('aiLocalToolExecutor connection inspection tools', () => { }, { id: 'redis-cluster', - name: '缓存集群', + name: 'Cache Cluster', config: { type: 'redis', host: '10.10.1.10', @@ -211,8 +270,8 @@ describe('aiLocalToolExecutor connection inspection tools', () => { expect(result.content).toContain('"totalRedisConnections":2'); expect(result.content).toContain('"totalMatched":1'); expect(result.content).toContain('"topology":"sentinel"'); - expect(result.content).toContain('Sentinel master 名称为空'); - expect(result.content).toContain('Sentinel 主地址端口是 6379'); + expect(result.content).toContain('Sentinel master name is empty'); + expect(result.content).toContain('port 6379'); expect(result.content).not.toContain('redis-secret'); expect(result.content).not.toContain('sentinel-secret'); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.externalSqlInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.externalSqlInspection.test.ts index 69df613..4a373cb 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.externalSqlInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.externalSqlInspection.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it, vi } from 'vitest'; +import type { I18nParams } from '../../i18n'; +import { t as translateCatalog } from '../../i18n/catalog'; import type { AIToolCall, ExternalSQLDirectory, SavedConnection } from '../../types'; +import { executeConnectionWorkspaceSnapshotToolCall } from './aiSnapshotInspectionConnectionToolExecutor'; import { executeLocalAIToolCall } from './aiLocalToolExecutor'; const buildConnection = (): SavedConnection => ({ @@ -155,7 +158,83 @@ describe('aiLocalToolExecutor external SQL inspection tools', () => { }); expect(result.success).toBe(false); - expect(result.content).toContain('目标文件不在已配置的外部 SQL 目录中'); + expect(result.content).toContain('The target file is outside configured external SQL directories'); expect(readSQLFile).not.toHaveBeenCalled(); }); + + it('localizes external sql file read failures and keeps runtime details raw', async () => { + const externalSQLDirectories: ExternalSQLDirectory[] = [ + { + id: 'dir-1', + name: 'Report scripts', + path: 'D:/sql/reports', + connectionId: 'conn-1', + dbName: 'crm', + createdAt: 1, + }, + ]; + const translate = (key: string, params?: I18nParams) => + translateCatalog('en-US', key, params); + + const missingFilePath = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_external_sql_file', { + filePath: '', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + externalSQLDirectories, + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + const outsideDirectory = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_external_sql_file', { + filePath: 'D:/private/secret.sql', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + externalSQLDirectories, + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + const unsupportedRuntime = await executeConnectionWorkspaceSnapshotToolCall({ + toolName: 'inspect_external_sql_file', + args: { + filePath: 'D:/sql/reports/daily.sql', + }, + connections: [buildConnection()], + externalSQLDirectories, + translate, + runtime: { + readSQLFile: undefined, + }, + }); + const rawRuntimeDetail = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_external_sql_file', { + filePath: 'D:/sql/reports/daily.sql', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + externalSQLDirectories, + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + readSQLFile: vi.fn().mockRejectedValue(new Error('EACCES: D:/sql/reports/daily.sql')), + }, + }); + + expect(missingFilePath.content).toBe('Failed to read external SQL file: filePath is required'); + expect(outsideDirectory.content).toBe('Failed to read external SQL file: The target file is outside configured external SQL directories'); + expect(unsupportedRuntime?.content).toBe('Failed to read external SQL file: The current runtime does not support reading local SQL files yet'); + expect(rawRuntimeDetail.content).toBe('Failed to read external SQL file: EACCES: D:/sql/reports/daily.sql'); + }); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.lastRenderError.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.lastRenderError.test.ts index 02fb953..da1857d 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.lastRenderError.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.lastRenderError.test.ts @@ -1,5 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { t as translateCatalog } from '../../i18n/catalog'; import type { AIToolCall } from '../../types'; import { executeLocalAIToolCall } from './aiLocalToolExecutor'; @@ -64,6 +65,38 @@ describe('aiLocalToolExecutor inspect_ai_last_render_error', () => { expect(result.success).toBe(true); expect(result.content).toContain('"hasError":false'); - expect(result.content).toContain('当前还没有记录到 AI 消息渲染异常'); + expect(result.content).toContain('No AI message render errors have been recorded yet'); + }); + + it('localizes the render error snapshot while preserving raw diagnostic fields', async () => { + (globalThis as Record).__gonaviLastAIMessageRenderError = { + messageId: 'msg-raw-1', + role: 'assistant', + contentPreview: '原始 AI 回复预览 raw', + message: 'Cannot read properties of undefined', + stack: 'TypeError: Cannot read properties of undefined\n at Bubble.tsx:12:3', + componentStack: '\n at AIMessageBubble', + recordedAt: 1780700000000, + }; + + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_last_render_error', {}), + connections: [], + mcpTools: [], + toolContextMap: new Map(), + translate: (key, params) => translateCatalog('en-US', key, params), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('A recent AI message render error was recorded'); + expect(result.content).toContain('Match messageId and contentPreview against the current conversation'); + expect(result.content).toContain('"messageId":"msg-raw-1"'); + expect(result.content).toContain('"contentPreview":"原始 AI 回复预览 raw"'); + expect(result.content).toContain('Cannot read properties of undefined'); + expect(result.content).toContain('AIMessageBubble'); }); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.mcpDockerSetup.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.mcpDockerSetup.test.ts index 6165023..b41fd2c 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.mcpDockerSetup.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.mcpDockerSetup.test.ts @@ -55,6 +55,50 @@ describe('aiLocalToolExecutor inspect_mcp_docker_setup', () => { expect(result.toolName).toBe('inspect_mcp_docker_setup'); expect(result.content).toContain('"dockerServerCount":1'); expect(result.content).toContain('"docker-interactive-missing"'); - expect(result.content).toContain('Docker 首次拉起可能较慢'); + expect(result.content).toContain('Docker may be slow on first startup'); + }); + + it('passes the translator into docker mcp setup inspection snapshots', async () => { + const translate = (key: string) => ({ + 'ai_chat.inspection.mcp_docker.next_action.add_interactive': 'T_ADD_INTERACTIVE', + 'ai_chat.inspection.mcp_docker.next_action.timeout': 'T_TIMEOUT', + 'ai_chat.inspection.mcp_docker.warning.incomplete': 'T_INCOMPLETE', + 'ai_chat.inspection.mcp_docker.next_action.fix_key_args': 'T_FIX_KEY_ARGS', + 'ai_chat.inspection.mcp_docker.message.with_incomplete': 'T_MESSAGE', + }[key] || key); + + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_mcp_docker_setup', {}), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + getMCPServers: vi.fn().mockResolvedValue([ + { + id: 'docker-broken', + name: 'Docker Broken', + transport: 'stdio', + command: 'docker', + args: ['run', '--rm', 'ghcr.io/acme/mcp-server:latest'], + env: {}, + enabled: true, + timeoutSeconds: 10, + }, + ]), + }, + }); + + if (!result.success) { + throw new Error(result.content); + } + expect(result.content).toContain('T_ADD_INTERACTIVE'); + expect(result.content).toContain('T_TIMEOUT'); + expect(result.content).toContain('T_INCOMPLETE'); + expect(result.content).toContain('T_FIX_KEY_ARGS'); + expect(result.content).toContain('T_MESSAGE'); + expect(result.content).toContain('ghcr.io/acme/mcp-server:latest'); }); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.mcpRemoteAccess.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.mcpRemoteAccess.test.ts index 1da3858..06ffda7 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.mcpRemoteAccess.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.mcpRemoteAccess.test.ts @@ -50,12 +50,23 @@ describe('aiLocalToolExecutor inspect_mcp_remote_access', () => { }, ]), }, + translate: (key, params) => { + if (key === 'ai_chat.inspection.mcp_remote.message.with_public_url') { + return `translated remote access ${params?.publicUrl}`; + } + if (key === 'ai_chat.inspection.mcp_remote.security.recommended_bind_address') { + return 'translated bind address'; + } + return key; + }, }); expect(result.success).toBe(true); expect(result.content).toContain('"mode":"streamable-http"'); expect(result.content).toContain('"publicUrl":"https://mcp.example.com/gonavi/mcp"'); - expect(result.content).toContain('Authorization: Bearer <随机token>'); + expect(result.content).toContain('translated remote access https://mcp.example.com/gonavi/mcp'); + expect(result.content).toContain('translated bind address'); + expect(result.content).toContain('Authorization: Bearer '); expect(result.content).toContain('"displayName":"Hermans"'); expect(result.content).toContain('"cloudAgentNeedsDatabasePassword":false'); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.mcpRuntimeFailureInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.mcpRuntimeFailureInspection.test.ts index 4afc8e8..bcef15d 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.mcpRuntimeFailureInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.mcpRuntimeFailureInspection.test.ts @@ -62,7 +62,7 @@ describe('aiLocalToolExecutor inspect_mcp_runtime_failures', () => { expect(result.content).toContain('"command_not_found":1'); expect(result.content).toContain('"name":"GitHub"'); expect(result.content).toContain('"envKeys":["GITHUB_TOKEN"]'); - expect(result.content).toContain('检查 command 是否只填可执行程序本身'); + expect(result.content).toContain('Check that command contains only the executable name'); expect(result.content).not.toContain('secret-value'); }); @@ -83,7 +83,89 @@ describe('aiLocalToolExecutor inspect_mcp_runtime_failures', () => { }); expect(result.success).toBe(false); - expect(result.content).toContain('读取 MCP 运行期失败日志失败'); + expect(result.content).toContain('Failed to read MCP runtime failure logs'); expect(result.content).toContain('log file missing'); }); + + it('uses translator for MCP runtime failure diagnostics without translating raw fields', async () => { + const readAppLogTail = vi.fn().mockResolvedValue({ + success: true, + data: { + logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log', + keyword: 'GitHub', + requestedLineLimit: 160, + lines: [ + '2026/06/11 10:00:00.000000 [WARN] 列出 MCP 工具失败(server=GitHub): exec: "uvx": executable file not found in %PATH%', + ], + }, + }); + const getMCPServers = vi.fn().mockResolvedValue([{ + id: 'github', + name: 'GitHub', + transport: 'stdio', + command: 'uvx', + args: ['mcp-server-github', '--stdio'], + env: { GITHUB_TOKEN: 'secret-value' }, + enabled: true, + timeoutSeconds: 20, + }]); + const translate = (key: string, params?: Record) => ({ + 'ai_chat.inspection.mcp_runtime.next_action.command_not_found': 'T_ACTION_COMMAND', + 'ai_chat.inspection.mcp_runtime.next_action.enabled_without_tools': 'T_ACTION_DISCOVERY', + 'ai_chat.inspection.mcp_runtime.next_action.fix_discovery_first': 'T_ACTION_FIX_DISCOVERY', + 'ai_chat.inspection.mcp_runtime.warning.failure_events': `T_WARNING_FAILURES_${params?.count}`, + 'ai_chat.inspection.mcp_runtime.warning.enabled_without_tools': `T_WARNING_WITHOUT_TOOLS_${params?.count}`, + 'ai_chat.inspection.mcp_runtime.message.failure_events': `T_MESSAGE_FAILURES_${params?.count}`, + }[key] || key); + + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_mcp_runtime_failures', { + serverName: 'GitHub', + }), + connections: [], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + readAppLogTail, + getMCPServers, + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('T_ACTION_COMMAND'); + expect(result.content).toContain('T_WARNING_FAILURES_1'); + expect(result.content).toContain('T_MESSAGE_FAILURES_1'); + expect(result.content).toContain('"name":"GitHub"'); + expect(result.content).toContain('"envKeys":["GITHUB_TOKEN"]'); + expect(result.content).toContain('exec: \\"uvx\\": executable file not found in %PATH%'); + expect(result.content).not.toContain('secret-value'); + }); + + it('uses translator for MCP runtime log read failures while preserving raw detail', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_mcp_runtime_failures', {}), + connections: [], + mcpTools: [], + toolContextMap: new Map(), + translate: (key, params) => ( + key === 'ai_chat.inspection.mcp_runtime.error.read_logs_failed' + ? `READ_FAILED::${params?.detail}` + : key + ), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + readAppLogTail: vi.fn().mockResolvedValue({ + success: false, + message: 'log file missing', + }), + }, + }); + + expect(result.success).toBe(false); + expect(result.content).toBe('READ_FAILED::log file missing'); + }); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.shortcutInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.shortcutInspection.test.ts index f16b540..09b480b 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.shortcutInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.shortcutInspection.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from 'vitest'; import type { AIToolCall } from '../../types'; +import { setCurrentLanguage } from '../../i18n'; import { executeLocalAIToolCall } from './aiLocalToolExecutor'; import { cloneShortcutOptions, @@ -21,6 +22,8 @@ const buildToolCall = ( describe('aiLocalToolExecutor inspect_shortcuts', () => { it('returns the real shortcut snapshot so the model can answer Win/Mac shortcut questions from state', async () => { + setCurrentLanguage('en-US'); + const shortcutOptions = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS); shortcutOptions.toggleQueryResultsPanel.windows = { combo: 'Ctrl+Shift+Y', diff --git a/frontend/src/components/ai/aiLocalToolExecutor.sqlRiskInspection.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.sqlRiskInspection.test.ts index bb2639c..d083ef1 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.sqlRiskInspection.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.sqlRiskInspection.test.ts @@ -70,7 +70,61 @@ describe('aiLocalToolExecutor sql risk inspection', () => { }, }); expect(payload.activityKinds).toContain('write'); - expect(payload.warnings).toContain('UPDATE 缺少 WHERE 条件,可能更新整表数据'); - expect(payload.warnings).toContain('当前 AI 安全策略不允许执行 UPDATE 类型 SQL'); + expect(payload.warnings).toContain('UPDATE is missing a WHERE clause and may update the entire table.'); + expect(payload.warnings).toContain('The current AI safety policy does not allow UPDATE SQL.'); + }); + + it('passes the translator into inspect_sql_risk snapshots', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_sql_risk', { + sql: 'DELETE FROM accounts', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + checkSQL: vi.fn().mockResolvedValue(undefined), + }, + translate: (key) => ({ + 'ai_chat.inspection.sql_risk.warning.data_change': 'translated executor data change', + 'ai_chat.inspection.sql_risk.warning.delete_missing_where': 'translated executor delete missing where', + 'ai_chat.inspection.sql_risk.next_action.explain_and_confirm': 'translated executor explain', + 'ai_chat.inspection.sql_risk.next_action.confirm_write_scope': 'translated executor confirm scope', + })[key] || key, + }); + + const payload = JSON.parse(result.content); + expect(result.success).toBe(true); + expect(payload.warnings).toContain('translated executor data change'); + expect(payload.warnings).toContain('translated executor delete missing where'); + expect(payload.nextActions).toEqual([ + 'translated executor explain', + 'translated executor confirm scope', + ]); + expect(payload.sqlPreview).toBe('DELETE FROM accounts'); + }); + + it('localizes inspect_sql_risk failure wrapper while preserving raw detail', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_sql_risk', { + sql: 'UPDATE users SET status = 0', + }), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + checkSQL: vi.fn().mockRejectedValue(new Error('raw safety service failure')), + }, + translate: (key, params) => ({ + 'ai_chat.inspection.sql_risk.error.inspect_failed': `translated SQL risk failure: ${params?.detail}`, + })[key] || key, + }); + + expect(result.success).toBe(false); + expect(result.content).toBe('translated SQL risk failure: raw safety service failure'); }); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.test.ts b/frontend/src/components/ai/aiLocalToolExecutor.test.ts index 1f50ca5..7e39bf6 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.test.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; +import { t as translateCatalog } from '../../i18n/catalog'; import type { AIMCPToolDescriptor, AIToolCall, SavedConnection } from '../../types'; import { buildToolResultMessage, executeLocalAIToolCall } from './aiLocalToolExecutor'; @@ -81,6 +82,27 @@ describe('aiLocalToolExecutor', () => { expect(result.content).toContain('SELECT id, status FROM orders'); }); + it('localizes empty active-tab snapshots through the local tool executor', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_active_tab', { + includeContent: true, + }), + connections: [buildConnection()], + tabs: [], + activeTabId: null, + mcpTools: [], + toolContextMap: new Map(), + translate: (key, params) => translateCatalog('en-US', key, params), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"message":"No active tab is currently selected"'); + }); + it('returns a workspace tab overview so the model can inspect which editors are currently open', async () => { const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_workspace_tabs', { @@ -169,6 +191,43 @@ describe('aiLocalToolExecutor', () => { expect(result.content).toContain('CREATE TABLE orders'); }); + it('localizes AI context snapshot messages while preserving raw table metadata and DDL', async () => { + const result = await executeLocalAIToolCall({ + toolCall: buildToolCall('inspect_ai_context', { + includeDDL: true, + ddlLimit: 120, + }), + connections: [buildConnection()], + activeContext: { + connectionId: 'conn-1', + dbName: 'crm', + }, + aiContexts: { + 'conn-1:crm': [ + { + dbName: 'crm', + tableName: 'orders', + ddl: 'CREATE TABLE orders (id bigint primary key, status varchar(32));', + }, + ], + }, + mcpTools: [], + toolContextMap: new Map(), + translate: (key, params) => translateCatalog('en-US', key, params), + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + + expect(result.success).toBe(true); + expect(result.content).toContain('"message":"Currently linked table schema contexts: 1"'); + expect(result.content).toContain('"dbName":"crm"'); + expect(result.content).toContain('"tableName":"orders"'); + expect(result.content).toContain('CREATE TABLE orders'); + expect(result.content).not.toContain('当前已关联'); + }); + it('blocks execute_sql when the AI safety check rejects the statement', async () => { const query = vi.fn(); const result = await executeLocalAIToolCall({ @@ -197,7 +256,8 @@ describe('aiLocalToolExecutor', () => { }); expect(result.success).toBe(false); - expect(result.content).toContain('安全策略拦截'); + expect(result.content).toContain('Security policy blocked this request'); + expect(result.content).not.toContain('安全策略拦截'); expect(query).not.toHaveBeenCalled(); }); @@ -357,6 +417,65 @@ describe('aiLocalToolExecutor', () => { expect(message.tool_name).toBe('自定义探针'); }); + it('localizes local executor tool error wrappers while preserving raw names and details', async () => { + const translate = vi.fn((key: string, params?: Record) => { + if (params?.functionName) return `T:${key} functionName=${params.functionName}`; + if (params?.detail) return `T:${key} detail=${params.detail}`; + return `T:${key}`; + }); + const mcpTools: AIMCPToolDescriptor[] = [{ + alias: 'external_probe', + originalName: 'raw_external_probe', + serverId: 'server-1', + serverName: 'Demo MCP', + title: 'Demo Probe', + description: '', + }]; + + const unknownFunction = await executeLocalAIToolCall({ + toolCall: buildToolCall('missing_tool', {}), + connections: [buildConnection()], + mcpTools: [], + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + }, + }); + const emptyMcpError = await executeLocalAIToolCall({ + toolCall: buildToolCall('external_probe', {}), + connections: [buildConnection()], + mcpTools, + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + callMCPTool: vi.fn().mockResolvedValue({ isError: true, content: '' }), + }, + }); + const thrownMcpError = await executeLocalAIToolCall({ + toolCall: buildToolCall('external_probe', {}), + connections: [buildConnection()], + mcpTools, + toolContextMap: new Map(), + translate, + runtime: { + getDatabases: vi.fn(), + getTables: vi.fn(), + callMCPTool: vi.fn().mockRejectedValue(new Error('raw upstream 503')), + }, + }); + + expect(unknownFunction.success).toBe(false); + expect(unknownFunction.content).toBe('T:ai_chat.panel.tool_error.unknown_function functionName=missing_tool'); + expect(emptyMcpError.success).toBe(false); + expect(emptyMcpError.content).toBe('T:ai_chat.panel.tool_error.mcp_failed'); + expect(thrownMcpError.success).toBe(false); + expect(thrownMcpError.content).toBe('T:ai_chat.panel.tool_error.mcp_failed_with_detail detail=raw upstream 503'); + }); + it('previews sample rows for a table without forcing the model to handwrite select limit sql', async () => { const query = vi.fn().mockResolvedValue({ success: true, @@ -561,13 +680,13 @@ describe('aiLocalToolExecutor', () => { expect(result.content).not.toContain('SELECT * FROM users LIMIT 10'); }); - it('returns sql editor transaction settings, active dml semantics, and pending transactions', async () => { + it('localizes sql editor transaction settings, active dml semantics, and pending transactions', async () => { const result = await executeLocalAIToolCall({ toolCall: buildToolCall('inspect_sql_editor_transaction', {}), connections: [buildConnection()], tabs: [{ id: 'tab-query-1', - title: '订单更新', + title: 'Order update', type: 'query', connectionId: 'conn-1', dbName: 'crm', @@ -577,6 +696,7 @@ describe('aiLocalToolExecutor', () => { activeTabId: 'tab-query-1', mcpTools: [], toolContextMap: new Map(), + translate: (key, params) => translateCatalog('en-US', key, params), sqlLogs: [{ id: 'log-1', timestamp: 10, @@ -612,7 +732,10 @@ describe('aiLocalToolExecutor', () => { expect(result.content).toContain('"usesManagedTransaction":true'); expect(result.content).toContain('"pendingTransactionCount":1'); expect(result.content).toContain('"activePendingTransaction"'); - expect(result.content).toContain('自动提交,但 DML 仍会先进入托管事务'); + expect(result.content).toContain('Auto commit is enabled, but DML still enters a managed transaction'); + expect(result.content).toContain('Ask the user to click \\"Commit\\" or \\"Rollback\\" in the result transaction bar'); + expect(result.content).toContain('DML opens a managed transaction first and auto-commits about 3 seconds after successful execution'); + expect(result.content).toContain('SQL editor runs INSERT/UPDATE/DELETE/MERGE/REPLACE DML inside a managed transaction'); expect(result.content).toContain('UPDATE orders SET status'); }); diff --git a/frontend/src/components/ai/aiLocalToolExecutor.ts b/frontend/src/components/ai/aiLocalToolExecutor.ts index 131cdb4..774aa52 100644 --- a/frontend/src/components/ai/aiLocalToolExecutor.ts +++ b/frontend/src/components/ai/aiLocalToolExecutor.ts @@ -1,4 +1,5 @@ import type { SqlLog } from '../../store'; +import type { I18nParams } from '../../i18n'; import type { AIChatMessage, AIContextItem, @@ -41,6 +42,7 @@ export interface ExecuteLocalAIToolCallOptions { skills?: AISkillConfig[]; userPromptSettings?: AIUserPromptSettings; dynamicModels?: string[]; + translate?: (key: string, params?: I18nParams) => string; runtime?: Partial; } @@ -54,6 +56,13 @@ export interface ExecuteLocalAIToolCallResult { const buildToolName = (toolCall: AIToolCall, descriptor?: AIMCPToolDescriptor) => descriptor?.title || descriptor?.originalName || toolCall.function.name; +const translateToolError = ( + translate: ExecuteLocalAIToolCallOptions['translate'] | undefined, + key: string, + fallback: string, + params?: I18nParams, +) => translate?.(key, params) || fallback; + export async function executeLocalAIToolCall({ toolCall, connections, @@ -73,6 +82,7 @@ export async function executeLocalAIToolCall({ skills = [], userPromptSettings, dynamicModels = [], + translate, runtime, }: ExecuteLocalAIToolCallOptions): Promise { const mergedRuntime: AILocalToolRuntime = { ...buildDefaultLocalToolRuntime(), ...(runtime || {}) }; @@ -100,6 +110,7 @@ export async function executeLocalAIToolCall({ skills, userPromptSettings, dynamicModels, + translate, runtime: mergedRuntime, }); if (snapshotInspectionResult) { @@ -117,6 +128,7 @@ export async function executeLocalAIToolCall({ connections, toolContextMap, runtime: mergedRuntime, + translate, }); if (databaseToolResult) { return { @@ -129,7 +141,12 @@ export async function executeLocalAIToolCall({ if (!descriptor) { return { - content: `Unknown function: ${toolCall.function.name}`, + content: translateToolError( + translate, + 'ai_chat.panel.tool_error.unknown_function', + `Unknown function: ${toolCall.function.name}`, + { functionName: toolCall.function.name }, + ), success: false, toolName: buildToolName(toolCall), }; @@ -137,14 +154,29 @@ export async function executeLocalAIToolCall({ try { const result = await mergedRuntime.callMCPTool?.(toolCall.function.name, toolCall.function.arguments || '{}'); + const content = result?.content + ? String(result.content) + : result?.isError + ? translateToolError( + translate, + 'ai_chat.panel.tool_error.mcp_failed', + 'MCP tool call failed', + ) + : ''; return { - content: String(result?.content || (result?.isError ? 'MCP 工具调用失败' : '')), + content, success: !!result && !result.isError, toolName: buildToolName(toolCall, descriptor), }; } catch (error: any) { + const detail = error?.message || String(error); return { - content: `MCP 工具调用失败: ${error?.message || error}`, + content: translateToolError( + translate, + 'ai_chat.panel.tool_error.mcp_failed_with_detail', + `MCP tool call failed: ${detail}`, + { detail }, + ), success: false, toolName: buildToolName(toolCall, descriptor), }; diff --git a/frontend/src/components/ai/aiMCPAuthoringGuideInsights.ts b/frontend/src/components/ai/aiMCPAuthoringGuideInsights.ts index 55a7254..47b22be 100644 --- a/frontend/src/components/ai/aiMCPAuthoringGuideInsights.ts +++ b/frontend/src/components/ai/aiMCPAuthoringGuideInsights.ts @@ -6,35 +6,42 @@ import { MCP_SERVER_FILL_STEPS, buildMCPLaunchPreview, } from '../../utils/mcpServerGuidance'; +import { t as catalogTranslate } from '../../i18n/catalog'; import { MCP_SERVER_DRAFT_TEMPLATES } from '../../utils/mcpServerTemplates'; +import { translateInspectionCopy, type AIInspectionTranslator } from './aiInspectionI18n'; -export const buildMCPAuthoringGuideSnapshot = () => ({ +const copy = ( + translate: AIInspectionTranslator | undefined, + key: string, +) => translateInspectionCopy(translate, key, catalogTranslate('en-US', key)); + +export const buildMCPAuthoringGuideSnapshot = (translate?: AIInspectionTranslator) => ({ fullCommandPasteExample: MCP_COMMAND_PARSE_EXAMPLE, commandExamples: MCP_COMMAND_EXAMPLES, supportsWholeCommandAutoSplit: true, recommendedSteps: MCP_SERVER_FILL_STEPS.map((item) => ({ step: item.step, - title: item.title, - detail: item.detail, + title: copy(translate, item.titleKey), + detail: copy(translate, item.detailKey), })), fieldGuides: MCP_FIELD_GUIDES.map((item) => ({ key: item.key, - title: item.title, - summary: item.summary, - detail: item.detail, - example: item.example || '', + title: copy(translate, item.titleKey), + summary: copy(translate, item.summaryKey), + detail: copy(translate, item.detailKey), + example: item.exampleKey ? copy(translate, item.exampleKey) : item.example || '', required: item.fieldState === 'required', fixed: item.fieldState === 'fixed', })), templates: MCP_SERVER_DRAFT_TEMPLATES.map((template) => ({ key: template.key, - title: template.title, - description: template.description, - detail: template.detail, + title: translateInspectionCopy(translate, template.titleKey, template.title), + description: translateInspectionCopy(translate, template.descriptionKey, template.description), + detail: translateInspectionCopy(translate, template.detailKey, template.detail), exampleLaunchPreview: buildMCPLaunchPreview( String(template.seed.command || ''), Array.isArray(template.seed.args) ? template.seed.args : [], ), })), - notes: MCP_AUTHORING_NOTES, + notes: MCP_AUTHORING_NOTES.map((key) => copy(translate, key)), }); diff --git a/frontend/src/components/ai/aiMCPDockerInsights.test.ts b/frontend/src/components/ai/aiMCPDockerInsights.test.ts index c0c3ba0..ffa41e9 100644 --- a/frontend/src/components/ai/aiMCPDockerInsights.test.ts +++ b/frontend/src/components/ai/aiMCPDockerInsights.test.ts @@ -37,7 +37,7 @@ describe('aiMCPDockerInsights', () => { expect(snapshot.servers[0].docker.image).toBe(''); expect(snapshot.servers[0].nextActions.join('\n')).toContain('run'); expect(snapshot.servers[0].nextActions.join('\n')).toContain('-i'); - expect(snapshot.warnings).toContain('有 1 个 Docker MCP 缺少 run、-i 或镜像名等关键参数'); + expect(snapshot.warnings).toContain('1 Docker MCP server is missing key arguments such as run, -i, or image name'); }); it('handles complete docker run options without treating option values as images', () => { @@ -86,4 +86,47 @@ describe('aiMCPDockerInsights', () => { expect(snapshot.servers[0].discoveredToolCount).toBe(1); expect(snapshot.servers[0].nextActions).toEqual([]); }); + + it('localizes docker mcp setup wrapper copy while preserving raw docker args and image names', () => { + const translate = (key: string, params?: Record) => ({ + 'ai_chat.inspection.mcp_docker.next_action.add_run': 'T_ADD_RUN', + 'ai_chat.inspection.mcp_docker.next_action.add_interactive': 'T_ADD_INTERACTIVE', + 'ai_chat.inspection.mcp_docker.next_action.add_image': 'T_ADD_IMAGE', + 'ai_chat.inspection.mcp_docker.next_action.timeout': 'T_TIMEOUT', + 'ai_chat.inspection.mcp_docker.warning.incomplete': `T_INCOMPLETE_${params?.count}`, + 'ai_chat.inspection.mcp_docker.next_action.fix_key_args': 'T_FIX_KEY_ARGS', + 'ai_chat.inspection.mcp_docker.warning.no_tools': `T_NO_TOOLS_${params?.count}`, + 'ai_chat.inspection.mcp_docker.next_action.refresh_tools': 'T_REFRESH_TOOLS', + 'ai_chat.inspection.mcp_docker.message.with_incomplete': `T_MESSAGE_${params?.total}_${params?.count}`, + }[key] || key); + + const snapshot = buildMCPDockerSetupSnapshot({ + mcpServers: [ + { + id: 'docker-broken', + name: 'Docker Broken', + transport: 'stdio', + command: 'docker', + args: ['--rm'], + env: {}, + enabled: true, + timeoutSeconds: 10, + }, + ], + mcpTools: [], + translate, + } as Parameters[0] & { translate: typeof translate }); + + expect(snapshot.servers[0].nextActions).toEqual([ + 'T_ADD_RUN', + 'T_ADD_INTERACTIVE', + 'T_ADD_IMAGE', + 'T_TIMEOUT', + ]); + expect(snapshot.warnings).toEqual(['T_INCOMPLETE_1', 'T_NO_TOOLS_1']); + expect(snapshot.nextActions).toEqual(['T_FIX_KEY_ARGS', 'T_REFRESH_TOOLS']); + expect(snapshot.message).toBe('T_MESSAGE_1_1'); + expect(snapshot.servers[0].command).toBe('docker'); + expect(snapshot.servers[0].args).toEqual(['--rm']); + }); }); diff --git a/frontend/src/components/ai/aiMCPDockerInsights.ts b/frontend/src/components/ai/aiMCPDockerInsights.ts index c833a40..9f8f36a 100644 --- a/frontend/src/components/ai/aiMCPDockerInsights.ts +++ b/frontend/src/components/ai/aiMCPDockerInsights.ts @@ -1,9 +1,18 @@ import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types'; import { buildMCPLaunchPreview } from '../../utils/mcpServerGuidance'; import { validateMCPServerDraft } from '../../utils/mcpServerValidation'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; const toTrimmedString = (value: unknown): string => String(value ?? '').trim(); +const translateMCPDockerCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: Parameters[1], +): string => translateInspectionCopy(translate, key, fallback, params); + const normalizeCommandName = (command: unknown): string => { const raw = toTrimmedString(command); const lastPathPart = raw.split(/[\\/]/u).pop() || raw; @@ -68,25 +77,50 @@ const buildDockerNextActions = (params: { timeoutSeconds: number; issueKeys: Set; discoveredToolCount: number; + translate?: AIInspectionTranslator; }): string[] => { const actions: string[] = []; if (!params.hasRun) { - actions.push('在 args 中补充 run,例如 docker run --rm -i '); + actions.push(translateMCPDockerCopy( + params.translate, + 'ai_chat.inspection.mcp_docker.next_action.add_run', + 'Add run to args, for example docker run --rm -i ', + )); } if (!params.hasInteractive) { - actions.push('在 args 中补充 -i 或 --interactive,确保 MCP stdio 不会立即断开'); + actions.push(translateMCPDockerCopy( + params.translate, + 'ai_chat.inspection.mcp_docker.next_action.add_interactive', + 'Add -i or --interactive to args so MCP stdio does not close immediately', + )); } if (!params.image) { - actions.push('在 docker run 选项之后补充 README 提供的镜像名'); + actions.push(translateMCPDockerCopy( + params.translate, + 'ai_chat.inspection.mcp_docker.next_action.add_image', + 'Add the image name from README after docker run options', + )); } if (params.timeoutSeconds < 20 || params.issueKeys.has('timeout-out-of-range')) { - actions.push('Docker 首次拉起可能较慢,建议 timeoutSeconds 使用 45 或 60'); + actions.push(translateMCPDockerCopy( + params.translate, + 'ai_chat.inspection.mcp_docker.next_action.timeout', + 'Docker may be slow on first startup; use timeoutSeconds 45 or 60', + )); } if (params.enabled && params.discoveredToolCount === 0 && actions.length === 0) { - actions.push('配置结构看起来完整但未发现工具,建议点击“测试工具发现”确认 Docker、镜像和容器内依赖可用'); + actions.push(translateMCPDockerCopy( + params.translate, + 'ai_chat.inspection.mcp_docker.next_action.no_tools', + 'The configuration structure looks complete but no tools were discovered; click "Test tool discovery" to confirm Docker, the image, and in-container dependencies are available', + )); } if (!params.enabled) { - actions.push('该 Docker MCP 当前未启用;确认配置后再启用并测试工具发现'); + actions.push(translateMCPDockerCopy( + params.translate, + 'ai_chat.inspection.mcp_docker.next_action.disabled', + 'This Docker MCP is disabled; enable it and test tool discovery after confirming the configuration', + )); } return actions; }; @@ -96,12 +130,14 @@ export const buildMCPDockerSetupSnapshot = (params: { mcpTools?: AIMCPToolDescriptor[]; includeDisabled?: boolean; serverId?: string; + translate?: AIInspectionTranslator; }) => { const { mcpServers = [], mcpTools = [], includeDisabled = true, serverId = '', + translate, } = params; const dockerServers = (Array.isArray(mcpServers) ? mcpServers : []) @@ -153,6 +189,7 @@ export const buildMCPDockerSetupSnapshot = (params: { timeoutSeconds, issueKeys, discoveredToolCount, + translate, }), }; }) @@ -168,18 +205,49 @@ export const buildMCPDockerSetupSnapshot = (params: { const nextActions: string[] = []; if (dockerServers.length === 0) { - nextActions.push('如果 README 提供的是 docker run -i --rm ,可在 MCP 设置中选择“Docker 镜像”模板新建服务'); + nextActions.push(translateMCPDockerCopy( + translate, + 'ai_chat.inspection.mcp_docker.next_action.create_from_readme', + 'If README provides docker run -i --rm , create a server from the "Docker image" template in MCP settings', + )); } if (incompleteServerCount > 0) { - warnings.push(`有 ${incompleteServerCount} 个 Docker MCP 缺少 run、-i 或镜像名等关键参数`); - nextActions.push('先修复 Docker MCP 关键参数,再重新测试工具发现'); + warnings.push(translateMCPDockerCopy( + translate, + 'ai_chat.inspection.mcp_docker.warning.incomplete', + '{{count}} Docker MCP server is missing key arguments such as run, -i, or image name', + { count: incompleteServerCount }, + )); + nextActions.push(translateMCPDockerCopy( + translate, + 'ai_chat.inspection.mcp_docker.next_action.fix_key_args', + 'Fix key Docker MCP arguments first, then test tool discovery again', + )); } else if (warningServerCount > 0) { - warnings.push(`有 ${warningServerCount} 个 Docker MCP 仍存在配置告警`); - nextActions.push('打开对应 Docker MCP 服务,按配置检查提示确认参数和超时时间'); + warnings.push(translateMCPDockerCopy( + translate, + 'ai_chat.inspection.mcp_docker.warning.config_warnings', + '{{count}} Docker MCP server still has configuration warnings', + { count: warningServerCount }, + )); + nextActions.push(translateMCPDockerCopy( + translate, + 'ai_chat.inspection.mcp_docker.next_action.open_services', + 'Open the affected Docker MCP services and confirm arguments and timeout from the configuration hints', + )); } if (serversWithoutDiscoveredTools > 0) { - warnings.push(`有 ${serversWithoutDiscoveredTools} 个已启用 Docker MCP 暂未发现工具`); - nextActions.push('确认本机 Docker 可用、镜像已拉取,并点击“测试工具发现”刷新工具列表'); + warnings.push(translateMCPDockerCopy( + translate, + 'ai_chat.inspection.mcp_docker.warning.no_tools', + '{{count}} enabled Docker MCP server has not discovered tools yet', + { count: serversWithoutDiscoveredTools }, + )); + nextActions.push(translateMCPDockerCopy( + translate, + 'ai_chat.inspection.mcp_docker.next_action.refresh_tools', + 'Confirm local Docker is available, the image is pulled, and click "Test tool discovery" to refresh the tool list', + )); } return { @@ -194,8 +262,22 @@ export const buildMCPDockerSetupSnapshot = (params: { nextActions, message: dockerServers.length > 0 ? incompleteServerCount > 0 - ? `当前有 ${dockerServers.length} 个 Docker MCP,其中 ${incompleteServerCount} 个关键参数不完整` - : `当前有 ${dockerServers.length} 个 Docker MCP,其中 ${enabledDockerServerCount} 个已启用` - : '当前没有 Docker MCP 服务', + ? translateMCPDockerCopy( + translate, + 'ai_chat.inspection.mcp_docker.message.with_incomplete', + 'There are {{total}} Docker MCP servers; {{count}} have incomplete key arguments', + { total: dockerServers.length, count: incompleteServerCount }, + ) + : translateMCPDockerCopy( + translate, + 'ai_chat.inspection.mcp_docker.message.with_enabled', + 'There are {{total}} Docker MCP servers; {{enabled}} are enabled', + { total: dockerServers.length, enabled: enabledDockerServerCount }, + ) + : translateMCPDockerCopy( + translate, + 'ai_chat.inspection.mcp_docker.message.empty', + 'No Docker MCP servers are configured', + ), }; }; diff --git a/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts b/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts index fddc18d..a9eaec5 100644 --- a/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts +++ b/frontend/src/components/ai/aiMCPDraftInspectionInsights.test.ts @@ -1,7 +1,16 @@ import { describe, expect, it } from 'vitest'; +import { MCP_SERVER_DRAFT_TEMPLATES } from '../../utils/mcpServerTemplates'; import { buildMCPDraftInspectionSnapshot } from './aiMCPDraftInspectionInsights'; +const templateTitle = (key: string) => { + const template = MCP_SERVER_DRAFT_TEMPLATES.find((item) => item.key === key); + if (!template) { + throw new Error(`Missing MCP draft template: ${key}`); + } + return template.title; +}; + describe('aiMCPDraftInspectionInsights', () => { it('parses a full MCP launch command and returns reusable field values', () => { const snapshot = buildMCPDraftInspectionSnapshot({ @@ -29,7 +38,7 @@ describe('aiMCPDraftInspectionInsights', () => { known: true, }], }); - expect(snapshot.draft.envHints?.nextActions.join('\n')).toContain('密钥类变量只保存在本机配置'); + expect(snapshot.draft.envHints?.nextActions.join('\n')).toContain('Secret-like variables are stored only in local configuration'); expect(snapshot.draft.timeoutSeconds).toBe(45); expect(snapshot.draft.suggestedServerSeed).toMatchObject({ name: 'mcp-server-github', @@ -42,11 +51,11 @@ describe('aiMCPDraftInspectionInsights', () => { }); expect(snapshot.draft.recommendedTemplate).toMatchObject({ key: 'uvx', - title: 'uvx 工具', + title: templateTitle('uvx'), confidence: 'high', }); expect(snapshot.validation.canSave).toBe(true); - expect(snapshot.nextActions).toContain('当前草稿可以保存并测试工具发现;如果发现 0 个工具,再检查服务是否支持 stdio。'); + expect(snapshot.nextActions).toContain('The current draft can be saved and tested for tool discovery; if it discovers 0 tools, check whether the service supports stdio.'); expect(JSON.stringify(snapshot)).not.toContain('ghp test'); }); @@ -67,8 +76,8 @@ describe('aiMCPDraftInspectionInsights', () => { 'env-invalid-lines', 'timeout-out-of-range', ])); - expect(snapshot.nextActions.join('\n')).toContain('把整行命令放到完整命令框自动拆分'); - expect(snapshot.nextActions.join('\n')).toContain('环境变量改成每行 KEY=VALUE'); + expect(snapshot.nextActions.join('\n')).toContain('Put the whole command into the full command field for auto-splitting'); + expect(snapshot.nextActions.join('\n')).toContain('Write environment variables as one KEY=VALUE per line'); }); it('applies the docker template and explains docker-specific missing args', () => { @@ -81,7 +90,7 @@ describe('aiMCPDraftInspectionInsights', () => { expect(snapshot.draft.command).toBe('docker'); expect(snapshot.draft.recommendedTemplate).toMatchObject({ key: 'docker', - title: 'Docker 镜像', + title: templateTitle('docker'), }); expect(snapshot.draft.suggestedServerSeed).toMatchObject({ name: 'docker', @@ -90,7 +99,58 @@ describe('aiMCPDraftInspectionInsights', () => { }); expect(snapshot.validation.issues.map((issue) => issue.key)).toContain('docker-interactive-missing'); expect(snapshot.validation.issues.map((issue) => issue.key)).toContain('docker-image-missing'); - expect(snapshot.nextActions.join('\n')).toContain('Docker MCP 的 args 里补 -i'); - expect(snapshot.nextActions.join('\n')).toContain('Docker MCP 的 args 里补 README 提供的镜像名'); + expect(snapshot.nextActions.join('\n')).toContain('Add -i or --interactive to Docker MCP args'); + expect(snapshot.nextActions.join('\n')).toContain('Add the image name from README to Docker MCP args'); + }); + + it('localizes draft inspection wrapper copy while preserving raw command details', () => { + const translate = (key: string) => ({ + 'ai_chat.inspection.mcp_draft.default_name': 'T_DEFAULT_DRAFT', + 'ai_chat.inspection.mcp_draft.parse.no_full_command': 'T_NO_FULL_COMMAND', + 'ai_chat.inspection.mcp_draft.next_action.command_whole_line': 'T_SPLIT_COMMAND', + 'ai_chat.inspection.mcp_draft.next_action.env_lines': 'T_ENV_LINES', + 'ai_chat.inspection.mcp_draft.next_action.timeout': 'T_TIMEOUT', + 'ai_chat.inspection.mcp_draft.next_action.send_full_command': 'T_SEND_FULL_COMMAND', + }[key] || key); + + const snapshot = buildMCPDraftInspectionSnapshot({ + command: 'npx -y @modelcontextprotocol/server-filesystem --stdio', + args: ['env', 'GITHUB_TOKEN=abc'], + envText: 'export TOKEN=abc', + timeoutSeconds: 1, + translate, + } as Parameters[0] & { translate: typeof translate }); + + expect(snapshot.draft.name).toBe('T_DEFAULT_DRAFT'); + expect(snapshot.parse.error).toBe('T_NO_FULL_COMMAND'); + expect(snapshot.nextActions).toEqual([ + 'T_SPLIT_COMMAND', + 'T_ENV_LINES', + 'T_TIMEOUT', + 'T_SEND_FULL_COMMAND', + ]); + expect(snapshot.draft.command).toBe('npx -y @modelcontextprotocol/server-filesystem --stdio'); + expect(snapshot.draft.args).toEqual(['env', 'GITHUB_TOKEN=***']); + expect(JSON.stringify(snapshot)).not.toContain('GITHUB_TOKEN=abc'); + }); + + it('localizes recommended template copy while keeping launch preview raw', () => { + const translate = (key: string) => ({ + 'ai_settings.mcp_server.template.uvx.title': 'T_TEMPLATE_UVX_TITLE', + 'ai_settings.mcp_server.template.uvx.description': 'T_TEMPLATE_UVX_DESCRIPTION', + }[key] || key); + + const snapshot = buildMCPDraftInspectionSnapshot({ + fullCommand: 'uvx mcp-server-fetch --stdio', + translate, + } as Parameters[0] & { translate: typeof translate }); + + expect(snapshot.draft.recommendedTemplate).toMatchObject({ + key: 'uvx', + title: 'T_TEMPLATE_UVX_TITLE', + description: 'T_TEMPLATE_UVX_DESCRIPTION', + exampleLaunchPreview: 'uvx some-mcp-server', + confidence: 'high', + }); }); }); diff --git a/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts b/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts index 47de81c..186e71a 100644 --- a/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts +++ b/frontend/src/components/ai/aiMCPDraftInspectionInsights.ts @@ -8,9 +8,17 @@ import { buildMCPLaunchPreview } from '../../utils/mcpServerGuidance'; import { buildMCPServerDraftSeed } from '../../utils/mcpServerDraftSeed'; import { MCP_SERVER_DRAFT_TEMPLATES } from '../../utils/mcpServerTemplates'; import { validateMCPServerDraft } from '../../utils/mcpServerValidation'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; const toTrimmedString = (value: unknown): string => String(value ?? '').trim(); +const translateMCPDraftCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, +): string => translateInspectionCopy(translate, key, fallback); + const normalizeArgs = (value: unknown): string[] => { if (Array.isArray(value)) { return value.map(toTrimmedString).filter(Boolean); @@ -70,12 +78,17 @@ const redactSensitiveArgValues = (args: string[]): string[] => { const buildRedactedFullCommand = ( fullCommand: string, parsedCommand: ParseMCPCommandDraftResult | null, + translate?: AIInspectionTranslator, ): string => { if (!fullCommand) { return ''; } if (!parsedCommand?.ok || !parsedCommand.draft) { - return '[解析失败,原始命令已隐藏]'; + return translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.redacted_parse_failed', + '[Parse failed, original command hidden]', + ); } return [ ...Object.keys(parsedCommand.draft.env || {}).sort().map((key) => `${key}=***`), @@ -91,7 +104,11 @@ const getTemplateSeed = (templateKey: unknown): Partial => { return MCP_SERVER_DRAFT_TEMPLATES.find((template) => template.key === normalizedKey)?.seed || {}; }; -const resolveRecommendedTemplate = (command: string, args: string[]) => { +const resolveRecommendedTemplate = ( + command: string, + args: string[], + translate?: AIInspectionTranslator, +) => { const normalizedCommand = toTrimmedString(command).toLowerCase(); if (!normalizedCommand) { return null; @@ -110,8 +127,16 @@ const resolveRecommendedTemplate = (command: string, args: string[]) => { return { key: commandTemplate.key, - title: commandTemplate.title, - description: commandTemplate.description, + title: translateMCPDraftCopy( + translate, + commandTemplate.titleKey, + commandTemplate.title, + ), + description: translateMCPDraftCopy( + translate, + commandTemplate.descriptionKey, + commandTemplate.description, + ), exampleLaunchPreview: buildMCPLaunchPreview( toTrimmedString(commandTemplate.seed.command), Array.isArray(commandTemplate.seed.args) ? commandTemplate.seed.args : [], @@ -125,54 +150,104 @@ const buildNextActions = (params: { warningCount: number; issueKeys: Set; hasFullCommand: boolean; + translate?: AIInspectionTranslator; }): string[] => { - const { errorCount, warningCount, issueKeys, hasFullCommand } = params; + const { errorCount, warningCount, issueKeys, hasFullCommand, translate } = params; const actions: string[] = []; if (issueKeys.has('command-missing')) { - actions.push('先粘贴 README 里的完整启动命令,或至少填写 node、npx、uvx、python、exe 之一作为 command。'); + actions.push(translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.next_action.command_missing', + 'Paste the full startup command from README first, or at least fill node, npx, uvx, python, or exe as command.', + )); } if (issueKeys.has('command-whole-line')) { - actions.push('把整行命令放到完整命令框自动拆分;command 只保留可执行程序,脚本名、包名和 --stdio 放到 args。'); + actions.push(translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.next_action.command_whole_line', + 'Put the whole command into the full command field for auto-splitting; keep only the executable in command, and put scripts, packages, and --stdio into args.', + )); } if (issueKeys.has('args-missing-for-launcher')) { - actions.push('给启动器补齐参数:npx 通常需要 -y 和包名,node 需要 server.js,python 需要 -m 模块名,uvx 需要包名,docker 需要 run、-i 和镜像名。'); + actions.push(translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.next_action.args_missing_for_launcher', + 'Complete launcher arguments: npx usually needs -y and a package name, node needs server.js, python needs -m and a module name, uvx needs a package name, and docker needs run, -i, and an image name.', + )); } if (issueKeys.has('docker-run-missing')) { - actions.push('Docker MCP 的 command 填 docker,args 里单独补 run。'); + actions.push(translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.next_action.docker_run', + 'For Docker MCP, set command to docker and add run separately in args.', + )); } if (issueKeys.has('docker-interactive-missing')) { - actions.push('Docker MCP 的 args 里补 -i 或 --interactive,避免 stdio 连接立即断开。'); + actions.push(translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.next_action.docker_interactive', + 'Add -i or --interactive to Docker MCP args so the stdio connection does not close immediately.', + )); } if (issueKeys.has('docker-image-missing')) { - actions.push('Docker MCP 的 args 里补 README 提供的镜像名,例如 mcp/server-fetch:latest。'); + actions.push(translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.next_action.docker_image', + 'Add the image name from README to Docker MCP args, for example mcp/server-fetch:latest.', + )); } if (issueKeys.has('args-contain-env-or-shell-glue') || issueKeys.has('env-invalid-lines')) { - actions.push('环境变量改成每行 KEY=VALUE;不要把 export、set、env、&& 或 $env:KEY=VALUE; 放进 args。'); + actions.push(translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.next_action.env_lines', + 'Write environment variables as one KEY=VALUE per line; do not put export, set, env, &&, or $env:KEY=VALUE; into args.', + )); } if (issueKeys.has('timeout-out-of-range')) { - actions.push('把 timeout 调整到 20 秒;慢启动服务可改成 45 或 60 秒。'); + actions.push(translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.next_action.timeout', + 'Set timeout to 20 seconds; slow-starting services can use 45 or 60 seconds.', + )); } if (errorCount === 0 && warningCount === 0) { - actions.push('当前草稿可以保存并测试工具发现;如果发现 0 个工具,再检查服务是否支持 stdio。'); + actions.push(translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.next_action.ready_to_save', + 'The current draft can be saved and tested for tool discovery; if it discovers 0 tools, check whether the service supports stdio.', + )); } else if (errorCount === 0) { - actions.push('当前草稿可以测试,但建议先处理 warning,避免工具发现超时或发现 0 个工具。'); + actions.push(translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.next_action.can_test_with_warnings', + 'The current draft can be tested, but handle warnings first to avoid tool discovery timeouts or discovering 0 tools.', + )); } if (!hasFullCommand) { - actions.push('如果仍不确定怎么拆,优先把原始完整命令传给 fullCommand 让 GoNavi 试算。'); + actions.push(translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.next_action.send_full_command', + 'If you are still unsure how to split it, pass the original full command to fullCommand and let GoNavi calculate it.', + )); } return actions; }; export const buildMCPDraftInspectionSnapshot = (args: Record = {}) => { + const translate = typeof args.translate === 'function' ? args.translate as AIInspectionTranslator : undefined; const templateSeed = getTemplateSeed(args.templateKey); const fullCommand = toTrimmedString(args.fullCommand ?? args.commandLine ?? args.rawCommand); const parsedCommand = fullCommand ? parseMCPCommandDraft(fullCommand) : null; const envDraftText = toTrimmedString(args.envText ?? args.envDraft); const parsedEnvDraft = envDraftText ? parseMCPEnvDraft(envDraftText) : undefined; - const baseName = toTrimmedString(templateSeed.name) || 'MCP 草稿'; + const baseName = toTrimmedString(templateSeed.name) || translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.default_name', + 'MCP draft', + ); let command = toTrimmedString(templateSeed.command); let commandArgs = Array.isArray(templateSeed.args) ? templateSeed.args.map(toTrimmedString).filter(Boolean) : []; let env: Record = { ...(templateSeed.env || {}) }; @@ -207,23 +282,23 @@ export const buildMCPDraftInspectionSnapshot = (args: Record = }; const validation = validateMCPServerDraft(server, parsedEnvDraft); const issueKeys = new Set(validation.issues.map((issue) => issue.key)); - const recommendedTemplate = resolveRecommendedTemplate(command, commandArgs); - const argumentHintProfile = buildMCPArgumentHintProfile(command, commandArgs); + const recommendedTemplate = resolveRecommendedTemplate(command, commandArgs, translate); + const argumentHintProfile = buildMCPArgumentHintProfile(command, commandArgs, translate); const redactedCommandArgs = redactSensitiveArgValues(commandArgs); - const envHintProfile = buildMCPEnvHintProfile(command, commandArgs, env); + const envHintProfile = buildMCPEnvHintProfile(command, commandArgs, env, translate); const suggestedServerSeed = buildMCPServerDraftSeed({ name: toTrimmedString(args.name ?? args.serverName) || undefined, command, args: commandArgs, env, timeoutSeconds: server.timeoutSeconds, - }); + }, translate); return { input: { hasFullCommand: Boolean(fullCommand), templateKey: toTrimmedString(args.templateKey), - fullCommand: buildRedactedFullCommand(fullCommand, parsedCommand), + fullCommand: buildRedactedFullCommand(fullCommand, parsedCommand, translate), }, parse: parsedCommand ? { @@ -236,7 +311,11 @@ export const buildMCPDraftInspectionSnapshot = (args: Record = } : { ok: false, - error: '未提供 fullCommand,已按分字段草稿校验。', + error: translateMCPDraftCopy( + translate, + 'ai_chat.inspection.mcp_draft.parse.no_full_command', + 'No fullCommand was provided; validated the split-field draft instead.', + ), command: '', args: [], envKeys: [], @@ -255,7 +334,7 @@ export const buildMCPDraftInspectionSnapshot = (args: Record = summary: argumentHintProfile.summary, orderHint: argumentHintProfile.orderHint, steps: argumentHintProfile.steps, - argumentDetailHints: buildMCPArgumentDetailHints(argumentHintProfile.commandName, commandArgs), + argumentDetailHints: buildMCPArgumentDetailHints(argumentHintProfile.commandName, redactedCommandArgs, translate), businessHints: argumentHintProfile.businessHints, nextActions: argumentHintProfile.nextActions, } : null, @@ -302,6 +381,7 @@ export const buildMCPDraftInspectionSnapshot = (args: Record = warningCount: validation.warningCount, issueKeys, hasFullCommand: Boolean(fullCommand), + translate, }), }; }; diff --git a/frontend/src/components/ai/aiMCPInsights.test.ts b/frontend/src/components/ai/aiMCPInsights.test.ts index 0dbefaa..ff37e75 100644 --- a/frontend/src/components/ai/aiMCPInsights.test.ts +++ b/frontend/src/components/ai/aiMCPInsights.test.ts @@ -1,8 +1,60 @@ +import { readFileSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; import { buildMCPSetupSnapshot } from './aiMCPInsights'; describe('aiMCPInsights', () => { + it('localizes mcp setup wrappers while keeping command, paths, and client messages raw', () => { + const snapshot = buildMCPSetupSnapshot({ + mcpServers: [{ + id: 'server-1', + name: 'Broken', + transport: 'stdio', + command: '', + args: [], + env: {}, + enabled: true, + timeoutSeconds: 1, + }], + mcpClientStatuses: [{ + client: 'codex', + displayName: 'Codex', + installed: true, + matchesCurrent: true, + clientDetected: true, + clientCommand: 'codex', + clientPath: 'C:/Tools/codex.exe', + configPath: 'C:/Users/demo/.codex/config.toml', + command: 'gonavi-mcp-server', + args: ['stdio'], + message: '已接入当前 GoNavi MCP', + }], + mcpTools: [], + translate: (key, params) => { + const suffix = params + ? ` ${Object.entries(params).map(([paramKey, value]) => `${paramKey}=${value}`).join(',')}` + : ''; + return `T:${key}${suffix}`; + }, + }); + + expect(snapshot.warnings).toContain('T:ai_chat.inspection.mcp.warning.config_errors count=1'); + expect(snapshot.nextActions).toContain('T:ai_chat.inspection.mcp.next_action.fix_config_errors'); + expect(snapshot.message).toBe('T:ai_chat.inspection.mcp.message.with_issues serverCount=1,enabledCount=1,issueCount=2'); + expect(snapshot.clients[0].message).toBe('已接入当前 GoNavi MCP'); + expect(snapshot.clients[0].configPath).toBe('C:/Users/demo/.codex/config.toml'); + }); + + it('keeps mcp setup production source free of legacy Chinese wrappers', () => { + const source = readFileSync('src/components/ai/aiMCPInsights.ts', 'utf8'); + + expect(source).not.toContain('存在启动配置错误'); + expect(source).not.toContain('先修复 MCP 服务配置检查里的错误项'); + expect(source).not.toContain('当前共配置'); + expect(source).not.toContain('当前还没有配置任何 MCP 服务'); + }); + it('builds a combined snapshot for local mcp servers, tools, and external client install state', () => { const snapshot = buildMCPSetupSnapshot({ mcpServers: [ @@ -87,8 +139,8 @@ describe('aiMCPInsights', () => { 'command-missing', 'timeout-out-of-range', ]); - expect(snapshot.warnings).toContain('有 1 个 MCP 服务存在启动配置错误,测试和工具发现可能失败'); - expect(snapshot.nextActions).toContain('先修复 MCP 服务配置检查里的错误项,再重新测试服务'); - expect(snapshot.message).toContain('2 个配置检查项需要确认'); + expect(snapshot.warnings).toContain('1 MCP server has launch configuration errors; testing and tool discovery may fail'); + expect(snapshot.nextActions).toContain('Fix the MCP server configuration errors first, then test the server again'); + expect(snapshot.message).toContain('2 configuration checks need attention'); }); }); diff --git a/frontend/src/components/ai/aiMCPInsights.ts b/frontend/src/components/ai/aiMCPInsights.ts index 22eb0ce..5dca57d 100644 --- a/frontend/src/components/ai/aiMCPInsights.ts +++ b/frontend/src/components/ai/aiMCPInsights.ts @@ -1,5 +1,7 @@ import type { AIMCPClientInstallStatus, AIMCPServerConfig, AIMCPToolDescriptor } from '../../types'; import { validateMCPServerDraft } from '../../utils/mcpServerValidation'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; const SERVER_TOOL_PREVIEW_LIMIT = 20; @@ -24,11 +26,13 @@ export const buildMCPSetupSnapshot = (params: { mcpServers?: AIMCPServerConfig[]; mcpClientStatuses?: AIMCPClientInstallStatus[]; mcpTools?: AIMCPToolDescriptor[]; + translate?: AIInspectionTranslator; }) => { const { mcpServers = [], mcpClientStatuses = [], mcpTools = [], + translate, } = params; const normalizedServers = sortByName( @@ -93,11 +97,29 @@ export const buildMCPSetupSnapshot = (params: { const nextActions: string[] = []; if (serversWithConfigurationErrors > 0) { - warnings.push(`有 ${serversWithConfigurationErrors} 个 MCP 服务存在启动配置错误,测试和工具发现可能失败`); - nextActions.push('先修复 MCP 服务配置检查里的错误项,再重新测试服务'); + warnings.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.mcp.warning.config_errors', + `${serversWithConfigurationErrors} MCP server has launch configuration errors; testing and tool discovery may fail`, + { count: serversWithConfigurationErrors }, + )); + nextActions.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.mcp.next_action.fix_config_errors', + 'Fix the MCP server configuration errors first, then test the server again', + )); } else if (serversWithConfigurationWarnings > 0) { - warnings.push(`有 ${serversWithConfigurationWarnings} 个 MCP 服务存在启动配置告警,建议在排查工具发现失败前先确认`); - nextActions.push('打开对应 MCP 服务,按配置检查提示拆分启动命令、参数和超时时间'); + warnings.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.mcp.warning.config_warnings', + `${serversWithConfigurationWarnings} MCP server has launch configuration warnings; confirm them before diagnosing tool discovery failures`, + { count: serversWithConfigurationWarnings }, + )); + nextActions.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.mcp.next_action.fix_config_warnings', + 'Open the affected MCP server and split launch command, arguments, and timeout according to the configuration check hints', + )); } return { @@ -119,8 +141,22 @@ export const buildMCPSetupSnapshot = (params: { nextActions, message: normalizedServers.length > 0 ? serverConfigurationIssueCount > 0 - ? `当前共配置 ${normalizedServers.length} 个 MCP 服务,其中 ${enabledServerCount} 个已启用,${serverConfigurationIssueCount} 个配置检查项需要确认` - : `当前共配置 ${normalizedServers.length} 个 MCP 服务,其中 ${enabledServerCount} 个已启用` - : '当前还没有配置任何 MCP 服务', + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.mcp.message.with_issues', + `${normalizedServers.length} MCP server is configured; ${enabledServerCount} enabled; ${serverConfigurationIssueCount} configuration checks need attention`, + { serverCount: normalizedServers.length, enabledCount: enabledServerCount, issueCount: serverConfigurationIssueCount }, + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.mcp.message.configured', + `${normalizedServers.length} MCP server is configured; ${enabledServerCount} enabled`, + { serverCount: normalizedServers.length, enabledCount: enabledServerCount }, + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.mcp.message.empty', + 'No MCP servers are configured yet', + ), }; }; diff --git a/frontend/src/components/ai/aiMCPRemoteAccessInsights.test.ts b/frontend/src/components/ai/aiMCPRemoteAccessInsights.test.ts index d02a8ec..0c95439 100644 --- a/frontend/src/components/ai/aiMCPRemoteAccessInsights.test.ts +++ b/frontend/src/components/ai/aiMCPRemoteAccessInsights.test.ts @@ -3,6 +3,70 @@ import { describe, expect, it } from 'vitest'; import { buildMCPRemoteAccessSnapshot } from './aiMCPRemoteAccessInsights'; describe('aiMCPRemoteAccessInsights', () => { + it('uses the provided translator for remote access guidance copy while preserving raw endpoints', () => { + const translate = (key: string, params?: Record) => { + const values: Record = { + 'ai_chat.inspection.mcp_remote.strategy.cloudflare_tunnel.title': 'Cloudflare tunnel translated', + 'ai_chat.inspection.mcp_remote.strategy.cloudflare_tunnel.detail': 'Translated tunnel detail', + 'ai_chat.inspection.mcp_remote.strategy.cloudflare_tunnel.risk': 'Translated tunnel risk', + 'ai_chat.inspection.mcp_remote.message.with_public_url': `Translated access message ${params?.publicUrl}`, + 'ai_chat.inspection.mcp_remote.next_action.configure_agent': 'Translated configure agent action', + 'ai_chat.inspection.mcp_remote.security.recommended_bind_address': '127.0.0.1 translated recommendation', + 'ai_settings.mcp_server.remote_quick_start.guide.title': `Translated guide - ${params?.displayName}`, + 'ai_settings.mcp_server.remote_quick_start.guide.goal_heading': 'Translated goal heading:', + 'ai_settings.mcp_server.remote_quick_start.guide.goal.credentials_stay_local': 'Translated credentials stay local.', + 'ai_settings.mcp_server.remote_quick_start.guide.goal.tools_only': 'Translated tools-only boundary.', + 'ai_settings.mcp_server.remote_quick_start.guide.goal.schema_only': 'Translated schema-only boundary.', + 'ai_settings.mcp_server.remote_quick_start.guide.boundary_heading': 'Translated boundary heading:', + 'ai_settings.mcp_server.remote_quick_start.guide.boundary.local_stdio': 'Translated local stdio boundary.', + 'ai_settings.mcp_server.remote_quick_start.guide.boundary.remote_cloud': 'Translated remote cloud boundary.', + 'ai_settings.mcp_server.remote_quick_start.guide.access_heading': 'Translated access heading:', + 'ai_settings.mcp_server.remote_quick_start.guide.step.keep_windows_accessible': 'Translated keep Windows accessible.', + 'ai_settings.mcp_server.remote_quick_start.guide.step.run_command': `Translated run ${params?.launchCommand}.`, + 'ai_settings.mcp_server.remote_quick_start.guide.step.configure_remote_server': `Translated configure ${params?.displayName}.`, + 'ai_settings.mcp_server.remote_quick_start.guide.step.inspect_schema': 'Translated inspect schema.', + 'ai_settings.mcp_server.remote_quick_start.guide.config_heading': 'Translated config heading:', + 'ai_settings.mcp_server.remote_quick_start.guide.config_command_heading': 'Translated config command heading:', + 'ai_settings.mcp_server.remote_quick_start.guide.launch_command_heading': 'Translated launch command heading:', + 'ai_settings.mcp_server.remote_quick_start.guide.env_fallback': `Translated env fallback ${params?.standaloneCommand}`, + 'ai_settings.mcp_server.remote_quick_start.guide.execute_sql_note': 'Translated execute_sql note.', + 'ai_settings.mcp_server.remote_quick_start.guide.current_hint': `Translated current hint ${params?.message}`, + }; + return values[key] || key; + }; + + const snapshot = buildMCPRemoteAccessSnapshot({ + publicUrl: 'https://mcp.example.com/gonavi', + exposeStrategy: 'cloudflare_tunnel', + tokenConfigured: true, + translate, + mcpClientStatuses: [ + { + client: 'openclaw', + displayName: 'OpenClaw', + installMode: 'remote', + installed: false, + matchesCurrent: false, + clientDetected: false, + clientCommand: 'openclaw', + message: 'OpenClaw raw status', + }, + ], + }); + + expect(snapshot.message).toBe('Translated access message https://mcp.example.com/gonavi/mcp'); + expect(snapshot.selectedStrategy.title).toBe('Cloudflare tunnel translated'); + expect(snapshot.selectedStrategy.detail).toBe('Translated tunnel detail'); + expect(snapshot.selectedStrategy.risk).toBe('Translated tunnel risk'); + expect(snapshot.nextActions).toContain('Translated configure agent action'); + expect(snapshot.securityBoundary.recommendedBindAddress).toBe('127.0.0.1 translated recommendation'); + expect(snapshot.endpoint.publicUrl).toBe('https://mcp.example.com/gonavi/mcp'); + expect(snapshot.endpoint.authHeader).toBe('Authorization: Bearer '); + expect(snapshot.launchCommands.appBinary).toContain('--token '); + expect(snapshot.remoteClients.find((client) => client.client === 'openclaw')?.guide).toContain('Translated guide - OpenClaw'); + expect(snapshot.remoteClients.find((client) => client.client === 'openclaw')?.guide).toContain('Translated current hint OpenClaw raw status'); + }); + it('builds a secure remote mcp access guide for cloud agents', () => { const snapshot = buildMCPRemoteAccessSnapshot({ publicUrl: 'https://mcp.example.com/gonavi', @@ -24,16 +88,16 @@ describe('aiMCPRemoteAccessInsights', () => { expect(snapshot.mode).toBe('streamable-http'); expect(snapshot.endpoint.publicUrl).toBe('https://mcp.example.com/gonavi/mcp'); - expect(snapshot.endpoint.authHeader).toBe('Authorization: Bearer <随机token>'); + expect(snapshot.endpoint.authHeader).toBe('Authorization: Bearer '); expect(snapshot.launchCommands.appBinary).toContain('GoNavi.exe mcp-server http'); expect(snapshot.selectedStrategy.key).toBe('cloudflare_tunnel'); expect(snapshot.remoteClients.some((client) => client.client === 'openclaw')).toBe(true); - expect(snapshot.remoteClients.find((client) => client.client === 'openclaw')?.guide).toContain('数据库连接、账号和密码继续保存在 Windows'); + expect(snapshot.remoteClients.find((client) => client.client === 'openclaw')?.guide).toContain('Database connections, accounts, and passwords stay in Windows GoNavi'); expect(snapshot.securityBoundary.databaseSecretsStayLocal).toBe(true); expect(snapshot.securityBoundary.cloudAgentNeedsDatabasePassword).toBe(false); expect(snapshot.securityBoundary.mutatingSqlStillRequiresAllowMutating).toBe(true); expect(snapshot.warnings).toHaveLength(0); - expect(snapshot.nextActions.join('\n')).toContain('不要把数据库密码复制到云端 Agent'); + expect(snapshot.nextActions.join('\n')).toContain('do not copy database passwords to the cloud Agent'); }); it('warns when public url or bearer token readiness is missing', () => { @@ -44,7 +108,7 @@ describe('aiMCPRemoteAccessInsights', () => { expect(snapshot.endpoint.localUrl).toBe('http://127.0.0.1:8765/mcp'); expect(snapshot.remoteClients.map((client) => client.client)).toEqual(['openclaw', 'hermans']); - expect(snapshot.warnings).toContain('尚未提供云端 Agent 可访问的 MCP URL;远程 Agent 不能直接访问 Windows 本机 127.0.0.1。'); - expect(snapshot.warnings).toContain('尚未确认 Bearer Token;HTTP MCP 必须配置随机 token,不能无鉴权暴露。'); + expect(snapshot.warnings).toContain('No MCP URL reachable by the cloud Agent was provided; a remote Agent cannot directly access Windows local 127.0.0.1.'); + expect(snapshot.warnings).toContain('Bearer Token readiness is not confirmed; HTTP MCP must use a random token and must not be exposed without authentication.'); }); }); diff --git a/frontend/src/components/ai/aiMCPRemoteAccessInsights.ts b/frontend/src/components/ai/aiMCPRemoteAccessInsights.ts index a626181..b3270bd 100644 --- a/frontend/src/components/ai/aiMCPRemoteAccessInsights.ts +++ b/frontend/src/components/ai/aiMCPRemoteAccessInsights.ts @@ -4,6 +4,8 @@ import { isRemoteMCPClientStatus, normalizeMCPClientStatuses, } from '../../utils/mcpClientInstallStatus'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; type MCPRemoteExposeStrategyKey = | 'reverse_proxy' @@ -14,45 +16,67 @@ type MCPRemoteExposeStrategyKey = const DEFAULT_HTTP_ADDR = '127.0.0.1:8765'; const DEFAULT_HTTP_PATH = '/mcp'; +const TOKEN_PLACEHOLDER = ''; const REMOTE_EXPOSE_STRATEGIES: Array<{ key: MCPRemoteExposeStrategyKey; - title: string; - detail: string; - risk: string; }> = [ { key: 'reverse_proxy', - title: '内网反向代理', - detail: '适合 Windows GoNavi 和云端 Agent 之间已有可信内网或网关的团队环境。', - risk: '需要网关层继续限制来源 IP、TLS 和 Bearer Token,不要裸露公网。', }, { key: 'ssh_reverse_tunnel', - title: 'SSH 反向隧道', - detail: '适合临时把 Windows 本机的 127.0.0.1:8765 映射到云端 Linux。配置简单,但要保证 SSH 账号和端口受控。', - risk: '隧道断开后云端 Agent 会不可用,适合 PoC 或受控运维环境。', }, { key: 'cloudflare_tunnel', - title: 'Cloudflare Tunnel', - detail: '适合没有固定公网入口的 Windows 机器,通过 Cloudflare Access 叠加身份校验。', - risk: '必须启用 Access / Zero Trust 规则,不能只依赖随机 URL。', }, { key: 'tailscale', - title: 'Tailscale / WireGuard', - detail: '适合把 Windows GoNavi 和云端 Agent 放进同一个私有网络,优先走内网地址。', - risk: '需要控制 ACL,只允许目标 Agent 访问 GoNavi MCP 端口。', }, { key: 'custom', - title: '自定义桥接', - detail: '适合已有企业网关、堡垒机或专用 MCP 网关的环境。', - risk: '需要明确 TLS、鉴权、审计和来源限制,避免把本机数据库能力暴露给未知 Agent。', }, ]; +const MCP_REMOTE_STRATEGY_FALLBACKS: Record = { + reverse_proxy: { + title: 'Internal reverse proxy', + detail: 'Use this when Windows GoNavi and the cloud Agent already share a trusted intranet or gateway.', + risk: 'Keep source IP, TLS, and Bearer Token restrictions at the gateway; do not expose it directly to the public internet.', + }, + ssh_reverse_tunnel: { + title: 'SSH reverse tunnel', + detail: 'Use this to temporarily map Windows 127.0.0.1:8765 to cloud Linux. It is simple, but the SSH account and port must be controlled.', + risk: 'The cloud Agent becomes unavailable if the tunnel disconnects, so this fits PoC or controlled operations environments.', + }, + cloudflare_tunnel: { + title: 'Cloudflare Tunnel', + detail: 'Use this for Windows machines without a fixed public entry point, with Cloudflare Access layered for identity checks.', + risk: 'Access / Zero Trust rules must be enabled; do not rely only on a random URL.', + }, + tailscale: { + title: 'Tailscale / WireGuard', + detail: 'Use this when Windows GoNavi and the cloud Agent can join the same private network and prefer an intranet address.', + risk: 'Control ACLs so only the target Agent can reach the GoNavi MCP port.', + }, + custom: { + title: 'Custom bridge', + detail: 'Use this when an enterprise gateway, bastion host, or dedicated MCP gateway already exists.', + risk: 'Define TLS, authentication, audit, and source restrictions clearly to avoid exposing local database capabilities to unknown Agents.', + }, +}; + +const translateMCPRemoteCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: Parameters[1], +): string => translateInspectionCopy(translate, key, fallback, params); + const normalizePath = (value: unknown): string => { const raw = String(value || DEFAULT_HTTP_PATH).trim(); if (!raw) { @@ -79,16 +103,41 @@ const normalizePublicUrl = (value: unknown, path: string): string => { }; const buildHttpLaunchCommand = (binary: string, addr: string, path: string): string => - `${binary} mcp-server http --addr ${addr} --path ${path} --token <随机token>`; + `${binary} mcp-server http --addr ${addr} --path ${path} --token ${TOKEN_PLACEHOLDER}`; const buildStandaloneLaunchCommand = (addr: string, path: string): string => - `gonavi-mcp-server http --addr ${addr} --path ${path} --token <随机token>`; + `gonavi-mcp-server http --addr ${addr} --path ${path} --token ${TOKEN_PLACEHOLDER}`; const resolveStrategy = (value: unknown) => { const key = String(value || '').trim() as MCPRemoteExposeStrategyKey; return REMOTE_EXPOSE_STRATEGIES.find((item) => item.key === key) || REMOTE_EXPOSE_STRATEGIES[0]; }; +const translateStrategy = ( + strategy: { key: MCPRemoteExposeStrategyKey }, + translate: AIInspectionTranslator | undefined, +) => { + const fallback = MCP_REMOTE_STRATEGY_FALLBACKS[strategy.key]; + return { + ...strategy, + title: translateMCPRemoteCopy( + translate, + `ai_chat.inspection.mcp_remote.strategy.${strategy.key}.title`, + fallback.title, + ), + detail: translateMCPRemoteCopy( + translate, + `ai_chat.inspection.mcp_remote.strategy.${strategy.key}.detail`, + fallback.detail, + ), + risk: translateMCPRemoteCopy( + translate, + `ai_chat.inspection.mcp_remote.strategy.${strategy.key}.risk`, + fallback.risk, + ), + }; +}; + export const buildMCPRemoteAccessSnapshot = (params: { mcpClientStatuses?: AIMCPClientInstallStatus[]; publicUrl?: string; @@ -96,7 +145,9 @@ export const buildMCPRemoteAccessSnapshot = (params: { path?: string; exposeStrategy?: MCPRemoteExposeStrategyKey | string; tokenConfigured?: boolean; + translate?: AIInspectionTranslator; } = {}) => { + const { translate } = params; const localAddr = normalizeLocalAddr(params.localAddr); const path = normalizePath(params.path); const localUrl = `http://${localAddr}${path}`; @@ -110,47 +161,84 @@ export const buildMCPRemoteAccessSnapshot = (params: { displayName: status.displayName, installMode: status.installMode || 'remote', message: status.message || '', - guide: buildRemoteMCPClientGuide(status), + guide: buildRemoteMCPClientGuide(status, translate), })); const warnings: string[] = []; const nextActions: string[] = [ - '在 Windows 本机启动 GoNavi MCP HTTP 模式,并确认 /healthz 可访问。', - '通过隧道、反向代理或私有网络只暴露 /mcp 给指定云端 Agent。', - '在 OpenClaw/Hermans 里配置 Streamable HTTP MCP URL 和 Authorization Bearer Token。', - '先调用 get_connections 获取 connectionId,再读取库表结构;不要把数据库密码复制到云端 Agent。', + translateMCPRemoteCopy( + translate, + 'ai_chat.inspection.mcp_remote.next_action.start_local_http', + 'Start GoNavi MCP HTTP mode on Windows and confirm /healthz is reachable.', + ), + translateMCPRemoteCopy( + translate, + 'ai_chat.inspection.mcp_remote.next_action.expose_mcp_only', + 'Expose only /mcp to the target cloud Agent through a tunnel, reverse proxy, or private network.', + ), + translateMCPRemoteCopy( + translate, + 'ai_chat.inspection.mcp_remote.next_action.configure_agent', + 'Configure Streamable HTTP MCP URL and Authorization Bearer Token in OpenClaw/Hermans.', + ), + translateMCPRemoteCopy( + translate, + 'ai_chat.inspection.mcp_remote.next_action.inspect_connections', + 'Call get_connections first to obtain connectionId, then read schemas; do not copy database passwords to the cloud Agent.', + ), ]; if (!publicUrl) { - warnings.push('尚未提供云端 Agent 可访问的 MCP URL;远程 Agent 不能直接访问 Windows 本机 127.0.0.1。'); + warnings.push(translateMCPRemoteCopy( + translate, + 'ai_chat.inspection.mcp_remote.warning.missing_public_url', + 'No MCP URL reachable by the cloud Agent was provided; a remote Agent cannot directly access Windows local 127.0.0.1.', + )); } else if (!/^https:\/\//iu.test(publicUrl) && !/^http:\/\/(127\.0\.0\.1|localhost|\[::1\])/iu.test(publicUrl)) { - warnings.push('远程 MCP URL 不是 HTTPS;如果不是私有网络地址,建议加 TLS 或放到受控隧道后面。'); + warnings.push(translateMCPRemoteCopy( + translate, + 'ai_chat.inspection.mcp_remote.warning.non_https_public_url', + 'The remote MCP URL is not HTTPS; if it is not a private network address, add TLS or place it behind a controlled tunnel.', + )); } if (params.tokenConfigured === false) { - warnings.push('尚未确认 Bearer Token;HTTP MCP 必须配置随机 token,不能无鉴权暴露。'); + warnings.push(translateMCPRemoteCopy( + translate, + 'ai_chat.inspection.mcp_remote.warning.missing_token', + 'Bearer Token readiness is not confirmed; HTTP MCP must use a random token and must not be exposed without authentication.', + )); } return { mode: 'streamable-http', message: publicUrl - ? `远程 Agent 应通过 ${publicUrl} 访问 GoNavi MCP,并使用 Bearer Token 鉴权` - : '远程 Agent 需要通过受控隧道或反向代理访问 Windows GoNavi MCP HTTP 入口', + ? translateMCPRemoteCopy( + translate, + 'ai_chat.inspection.mcp_remote.message.with_public_url', + 'The remote Agent should access GoNavi MCP through {{publicUrl}} and authenticate with Bearer Token', + { publicUrl }, + ) + : translateMCPRemoteCopy( + translate, + 'ai_chat.inspection.mcp_remote.message.no_public_url', + 'The remote Agent needs to access the Windows GoNavi MCP HTTP endpoint through a controlled tunnel or reverse proxy', + ), endpoint: { localAddr, path, localUrl, publicUrl, healthCheckPath: '/healthz', - authHeader: 'Authorization: Bearer <随机token>', + authHeader: `Authorization: Bearer ${TOKEN_PLACEHOLDER}`, }, launchCommands: { appBinary: buildHttpLaunchCommand('GoNavi.exe', localAddr, path), standaloneBinary: buildStandaloneLaunchCommand(localAddr, path), - tokenEnvFallback: 'GONAVI_MCP_HTTP_TOKEN=<随机token> gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp', + tokenEnvFallback: `GONAVI_MCP_HTTP_TOKEN=${TOKEN_PLACEHOLDER} gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp`, }, - selectedStrategy, - exposeStrategies: REMOTE_EXPOSE_STRATEGIES, + selectedStrategy: translateStrategy(selectedStrategy, translate), + exposeStrategies: REMOTE_EXPOSE_STRATEGIES.map((strategy) => translateStrategy(strategy, translate)), remoteClients, securityBoundary: { databaseSecretsStayLocal: true, @@ -158,7 +246,11 @@ export const buildMCPRemoteAccessSnapshot = (params: { httpBearerTokenRequired: true, executeSqlStillRequiresAISafetyPolicy: true, mutatingSqlStillRequiresAllowMutating: true, - recommendedBindAddress: '127.0.0.1,除非前面有受控网关或私有网络', + recommendedBindAddress: translateMCPRemoteCopy( + translate, + 'ai_chat.inspection.mcp_remote.security.recommended_bind_address', + '127.0.0.1 unless a controlled gateway or private network is in front', + ), }, warnings, nextActions, diff --git a/frontend/src/components/ai/aiMCPRuntimeFailureInsights.test.ts b/frontend/src/components/ai/aiMCPRuntimeFailureInsights.test.ts index a2ad60b..7641e6a 100644 --- a/frontend/src/components/ai/aiMCPRuntimeFailureInsights.test.ts +++ b/frontend/src/components/ai/aiMCPRuntimeFailureInsights.test.ts @@ -3,6 +3,48 @@ import { describe, expect, it } from 'vitest'; import { buildMCPRuntimeFailureSnapshot } from './aiMCPRuntimeFailureInsights'; describe('buildMCPRuntimeFailureSnapshot', () => { + it.each([ + [ + 'zh-TW', + '2026/06/11 10:00:00.000000 [ERROR] \u555f\u52d5 GoNavi MCP HTTP \u670d\u52d9\u5931\u6557: listen tcp 127.0.0.1:8765: bind: permission denied', + '2026/06/11 10:00:01.000000 [ERROR] GoNavi MCP HTTP \u670d\u52d9\u7570\u5e38\u9000\u51fa: MCP HTTP \u5b50\u7a0b\u5e8f\u5df2\u9000\u51fa', + ], + [ + 'ja-JP', + '2026/06/11 10:00:00.000000 [ERROR] GoNavi MCP HTTP \u30b5\u30fc\u30d3\u30b9\u306e\u8d77\u52d5\u306b\u5931\u6557\u3057\u307e\u3057\u305f: listen tcp 127.0.0.1:8765: bind: permission denied', + '2026/06/11 10:00:01.000000 [ERROR] GoNavi MCP HTTP \u30b5\u30fc\u30d3\u30b9\u304c\u7570\u5e38\u7d42\u4e86\u3057\u307e\u3057\u305f: MCP HTTP \u30b5\u30d6\u30d7\u30ed\u30bb\u30b9\u304c\u7d42\u4e86\u3057\u307e\u3057\u305f', + ], + [ + 'de-DE', + '2026/06/11 10:00:00.000000 [ERROR] Starten des GoNavi MCP HTTP-Dienstes fehlgeschlagen: listen tcp 127.0.0.1:8765: bind: permission denied', + '2026/06/11 10:00:01.000000 [ERROR] Der GoNavi MCP HTTP-Dienst wurde unerwartet beendet: Der MCP HTTP-Unterprozess wurde beendet', + ], + [ + 'ru-RU', + '2026/06/11 10:00:00.000000 [ERROR] \u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0441\u043b\u0443\u0436\u0431\u0443 GoNavi MCP HTTP: listen tcp 127.0.0.1:8765: bind: permission denied', + '2026/06/11 10:00:01.000000 [ERROR] \u0421\u043b\u0443\u0436\u0431\u0430 GoNavi MCP HTTP \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043b\u0430\u0441\u044c \u0430\u0432\u0430\u0440\u0438\u0439\u043d\u043e: \u041f\u043e\u0434\u043f\u0440\u043e\u0446\u0435\u0441\u0441 MCP HTTP \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043b\u0441\u044f', + ], + ])('classifies localized MCP HTTP runtime wrappers for %s', (_locale, startLine, exitLine) => { + const snapshot = buildMCPRuntimeFailureSnapshot({ + readResult: { + data: { + lines: [startLine, exitLine], + }, + }, + includeLines: true, + }); + + expect(snapshot.failureEventCount).toBe(2); + expect(snapshot.breakdown).toMatchObject({ + http_start_failed: 1, + http_process_exited: 1, + permission: 1, + process_exit: 1, + }); + expect(snapshot.nextActions.join('\n')).toContain('Check executable permissions'); + expect(snapshot.nextActions.join('\n')).toContain('Run the launch command in a terminal separately'); + }); + it('classifies MCP list-tools failures and joins them with configured servers', () => { const snapshot = buildMCPRuntimeFailureSnapshot({ readResult: { @@ -54,11 +96,136 @@ describe('buildMCPRuntimeFailureSnapshot', () => { recentFailureCount: 1, probableCauses: ['timeout'], }); - expect(snapshot.nextActions.join('\n')).toContain('检查 command 是否只填可执行程序本身'); - expect(snapshot.nextActions.join('\n')).toContain('提高 timeoutSeconds 到 45 或 60'); + expect(snapshot.nextActions.join('\n')).toContain('Check that command contains only the executable name'); + expect(snapshot.nextActions.join('\n')).toContain('Raise timeoutSeconds to 45 or 60'); expect(JSON.stringify(snapshot)).not.toContain('secret-value'); }); + it('classifies English MCP discovery and tool-call wrappers after backend localization', () => { + const snapshot = buildMCPRuntimeFailureSnapshot({ + readResult: { + data: { + lines: [ + '2026/06/11 10:00:00.000000 [WARN] Failed to list MCP tools(server=Filesystem): MCP command cannot be empty', + '2026/06/11 10:00:01.000000 [WARN] MCP tool call failed(server=Filesystem): MCP command cannot be empty', + ], + }, + }, + mcpServers: [{ + id: 'filesystem', + name: 'Filesystem', + transport: 'stdio', + command: '', + args: [], + env: {}, + enabled: true, + timeoutSeconds: 20, + }], + mcpTools: [], + }); + + expect(snapshot.failureEventCount).toBe(2); + expect(snapshot.breakdown).toMatchObject({ + list_tools_failed: 1, + tool_call_failed: 1, + command_required: 2, + }); + expect(snapshot.failureServerNames).toEqual(['Filesystem']); + expect(snapshot.events.map((event) => event.kind)).toEqual([ + 'list_tools_failed', + 'tool_call_failed', + ]); + }); + + it.each([ + [ + 'en-US', + '2026/06/11 10:00:00.000000 [WARN] 列出 MCP 工具失败(server=Filesystem): MCP command cannot be empty', + ], + [ + 'zh-TW', + '2026/06/11 10:00:00.000000 [WARN] 列出 MCP 工具失败(server=Filesystem): MCP 命令不能為空', + ], + [ + 'ja-JP', + '2026/06/11 10:00:00.000000 [WARN] 列出 MCP 工具失败(server=Filesystem): MCP コマンドは空にできません', + ], + [ + 'de-DE', + '2026/06/11 10:00:00.000000 [WARN] 列出 MCP 工具失败(server=Filesystem): MCP-Befehl darf nicht leer sein', + ], + [ + 'ru-RU', + '2026/06/11 10:00:00.000000 [WARN] 列出 MCP 工具失败(server=Filesystem): Команда MCP не может быть пустой', + ], + ])('extracts command-required cause from localized MCP discovery failure logs for %s', (_locale, line) => { + const snapshot = buildMCPRuntimeFailureSnapshot({ + readResult: { + data: { + lines: [line], + }, + }, + mcpServers: [{ + id: 'filesystem', + name: 'Filesystem', + transport: 'stdio', + command: '', + args: [], + env: {}, + enabled: true, + timeoutSeconds: 20, + }], + mcpTools: [], + }); + + expect(snapshot.failureEventCount).toBe(1); + expect(snapshot.breakdown).toMatchObject({ + list_tools_failed: 1, + command_required: 1, + }); + expect(snapshot.events[0]?.cause).toBe('command_required'); + expect(snapshot.nextActions.join('\n')).toContain('startup command'); + }); + + it.each([ + [ + 'zh-CN', + '2026/06/11 10:00:00.000000 [WARN] \u5217\u51fa MCP \u5de5\u5177\u5931\u8d25(server=RemoteHTTP): \u6682\u4e0d\u652f\u6301\u7684 MCP \u4f20\u8f93\u65b9\u5f0f\uff1ahttp', + ], + [ + 'zh-TW', + '2026/06/11 10:00:00.000000 [WARN] \u5217\u51fa MCP \u5de5\u5177\u5931\u8d25(server=RemoteHTTP): \u66ab\u4e0d\u652f\u63f4\u7684 MCP \u50b3\u8f38\u65b9\u5f0f\uff1ahttp', + ], + ])('extracts transport cause from localized MCP transport-unsupported discovery logs for %s', (_locale, line) => { + const snapshot = buildMCPRuntimeFailureSnapshot({ + readResult: { + data: { + lines: [line], + }, + }, + mcpServers: [{ + id: 'remote-http', + name: 'RemoteHTTP', + transport: 'stdio', + command: '', + args: [], + env: {}, + enabled: true, + timeoutSeconds: 20, + }], + mcpTools: [], + }); + + expect(snapshot.failureEventCount).toBe(1); + expect(snapshot.breakdown).toMatchObject({ + list_tools_failed: 1, + transport: 1, + }); + expect(snapshot.events[0]?.cause).toBe('transport'); + expect(snapshot.nextActions.join('\n')).toContain('stdio only'); + expect(snapshot.nextActions.join('\n')).toContain('HTTP MCP'); + }); + it('detects HTTP MCP process failures and redacts secret-like log values', () => { const snapshot = buildMCPRuntimeFailureSnapshot({ readResult: { @@ -83,6 +250,29 @@ describe('buildMCPRuntimeFailureSnapshot', () => { expect(snapshot.lines?.join('\n')).not.toContain('abcdef1234567890'); }); + it('classifies English MCP HTTP runtime failures after backend localization', () => { + const snapshot = buildMCPRuntimeFailureSnapshot({ + readResult: { + data: { + lines: [ + '2026/06/11 10:00:00.000000 [ERROR] Failed to start GoNavi MCP HTTP service: listen tcp 127.0.0.1:8765: bind: permission denied', + '2026/06/11 10:00:01.000000 [ERROR] GoNavi MCP HTTP service stopped unexpectedly: MCP HTTP subprocess exited', + ], + }, + }, + includeLines: true, + }); + + expect(snapshot.failureEventCount).toBe(2); + expect(snapshot.breakdown).toMatchObject({ + http_start_failed: 1, + http_process_exited: 1, + permission: 1, + process_exit: 1, + }); + expect(snapshot.lines?.join('\n')).toContain('GoNavi MCP HTTP service stopped unexpectedly'); + }); + it('returns an actionable empty state when no MCP failures are found', () => { const snapshot = buildMCPRuntimeFailureSnapshot({ readResult: { @@ -111,8 +301,62 @@ describe('buildMCPRuntimeFailureSnapshot', () => { }); expect(snapshot.failureEventCount).toBe(0); - expect(snapshot.message).toContain('没有发现 MCP 启动、工具发现或工具调用失败信号'); - expect(snapshot.nextActions.join('\n')).toContain('扩大 lineLimit'); + expect(snapshot.message).toContain('No MCP startup, tool discovery, or tool call failure signal'); + expect(snapshot.nextActions.join('\n')).toContain('increase lineLimit'); expect(snapshot.serverSummaries[0].discoveredToolCount).toBe(1); }); + + it('localizes runtime failure wrapper copy while preserving raw diagnostic details', () => { + const translate = (key: string, params?: Record) => ({ + 'ai_chat.inspection.mcp_runtime.next_action.command_not_found': '检查翻译: command missing', + 'ai_chat.inspection.mcp_runtime.next_action.enabled_without_tools': '检查翻译: refresh discovery', + 'ai_chat.inspection.mcp_runtime.next_action.fix_discovery_first': '检查翻译: discovery first', + 'ai_chat.inspection.mcp_runtime.warning.failure_events': `警告翻译: failures=${params?.count}`, + 'ai_chat.inspection.mcp_runtime.warning.enabled_without_tools': `警告翻译: withoutTools=${params?.count}`, + 'ai_chat.inspection.mcp_runtime.message.failure_events': `消息翻译: failures=${params?.count}`, + }[key] || key); + + const snapshot = buildMCPRuntimeFailureSnapshot({ + readResult: { + data: { + logPath: 'C:/Users/demo/.GoNavi/Logs/gonavi.log', + lines: [ + '2026/06/11 10:00:00.000000 [WARN] 列出 MCP 工具失败(server=GitHub): exec: "uvx": executable file not found in %PATH%', + ], + }, + }, + mcpServers: [{ + id: 'github', + name: 'GitHub', + transport: 'stdio', + command: 'uvx', + args: ['mcp-server-github', '--stdio'], + env: { GITHUB_TOKEN: 'secret-value' }, + enabled: true, + timeoutSeconds: 20, + }], + mcpTools: [], + includeLines: true, + translate, + } as Parameters[0] & { translate: typeof translate }); + + expect(snapshot.message).toBe('消息翻译: failures=1'); + expect(snapshot.warnings).toEqual([ + '警告翻译: failures=1', + '警告翻译: withoutTools=1', + ]); + expect(snapshot.nextActions).toEqual([ + '检查翻译: command missing', + '检查翻译: refresh discovery', + '检查翻译: discovery first', + ]); + expect(snapshot.events[0].serverName).toBe('GitHub'); + expect(snapshot.events[0].linePreview).toContain('exec: "uvx": executable file not found in %PATH%'); + expect(snapshot.serverSummaries[0]).toMatchObject({ + name: 'GitHub', + launchCommandPreview: 'uvx mcp-server-github --stdio', + envKeys: ['GITHUB_TOKEN'], + }); + expect(JSON.stringify(snapshot)).not.toContain('secret-value'); + }); }); diff --git a/frontend/src/components/ai/aiMCPRuntimeFailureInsights.ts b/frontend/src/components/ai/aiMCPRuntimeFailureInsights.ts index 9a9d19c..0a3d8dc 100644 --- a/frontend/src/components/ai/aiMCPRuntimeFailureInsights.ts +++ b/frontend/src/components/ai/aiMCPRuntimeFailureInsights.ts @@ -1,9 +1,66 @@ import type { AIMCPServerConfig, AIMCPToolDescriptor } from '../../types'; import { buildMCPLaunchPreview } from '../../utils/mcpServerGuidance'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; const DEFAULT_MCP_RUNTIME_LOG_LIMIT = 160; const MAX_MCP_RUNTIME_LOG_LIMIT = 200; +const MCP_HTTP_START_FAILED_MARKERS = [ + 'GoNavi MCP HTTP \u670d\u52a1\u542f\u52a8\u5931\u8d25', + '\u542f\u52a8 GoNavi MCP HTTP \u670d\u52a1\u5931\u8d25', + '\u555f\u52d5 GoNavi MCP HTTP \u670d\u52d9\u5931\u6557', + 'Failed to start GoNavi MCP HTTP service', + 'GoNavi MCP HTTP \u30b5\u30fc\u30d3\u30b9\u306e\u8d77\u52d5\u306b\u5931\u6557\u3057\u307e\u3057\u305f', + 'Starten des GoNavi MCP HTTP-Dienstes fehlgeschlagen', + '\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u043f\u0443\u0441\u0442\u0438\u0442\u044c \u0441\u043b\u0443\u0436\u0431\u0443 GoNavi MCP HTTP', +]; + +const MCP_HTTP_PROCESS_EXITED_MARKERS = [ + 'GoNavi MCP HTTP \u670d\u52a1\u5f02\u5e38\u9000\u51fa', + 'GoNavi MCP HTTP \u670d\u52d9\u7570\u5e38\u9000\u51fa', + 'GoNavi MCP HTTP service stopped unexpectedly', + 'GoNavi MCP HTTP \u30b5\u30fc\u30d3\u30b9\u304c\u7570\u5e38\u7d42\u4e86\u3057\u307e\u3057\u305f', + 'Der GoNavi MCP HTTP-Dienst wurde unerwartet beendet', + '\u0421\u043b\u0443\u0436\u0431\u0430 GoNavi MCP HTTP \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043b\u0430\u0441\u044c \u0430\u0432\u0430\u0440\u0438\u0439\u043d\u043e', +]; + +const MCP_HTTP_SUBPROCESS_EXITED_MARKERS = [ + 'MCP HTTP \u5b50\u8fdb\u7a0b\u5df2\u9000\u51fa', + 'MCP HTTP \u5b50\u7a0b\u5e8f\u5df2\u9000\u51fa', + 'MCP HTTP subprocess exited', + 'MCP HTTP \u30b5\u30d6\u30d7\u30ed\u30bb\u30b9\u304c\u7d42\u4e86\u3057\u307e\u3057\u305f', + 'Der MCP HTTP-Unterprozess wurde beendet', + '\u041f\u043e\u0434\u043f\u0440\u043e\u0446\u0435\u0441\u0441 MCP HTTP \u0437\u0430\u0432\u0435\u0440\u0448\u0438\u043b\u0441\u044f', +]; + +const MCP_COMMAND_REQUIRED_MARKERS = [ + 'MCP \u670d\u52a1\u547d\u4ee4\u4e0d\u80fd\u4e3a\u7a7a', + 'MCP \u547d\u4ee4\u4e0d\u80fd\u4e3a\u7a7a', + 'MCP \u547d\u4ee4\u4e0d\u80fd\u70ba\u7a7a', + 'MCP command cannot be empty', + 'MCP \u30b3\u30de\u30f3\u30c9\u306f\u7a7a\u306b\u3067\u304d\u307e\u305b\u3093', + 'MCP-Befehl darf nicht leer sein', + '\u041a\u043e\u043c\u0430\u043d\u0434\u0430 MCP \u043d\u0435 \u043c\u043e\u0436\u0435\u0442 \u0431\u044b\u0442\u044c \u043f\u0443\u0441\u0442\u043e\u0439', +]; + +const MCP_TRANSPORT_UNSUPPORTED_MARKERS = [ + '\u6682\u4e0d\u652f\u6301\u7684 MCP transport', + '\u6682\u4e0d\u652f\u6301\u7684 MCP \u4f20\u8f93\u65b9\u5f0f', + '\u66ab\u4e0d\u652f\u63f4\u7684 MCP \u50b3\u8f38\u65b9\u5f0f', +]; + +const MCP_LIST_TOOLS_FAILED_MARKERS = [ + '\u5217\u51fa MCP \u5de5\u5177\u5931\u8d25', + 'Failed to list MCP tools', +]; + +const MCP_TOOL_CALL_FAILED_MARKERS = [ + '\u8c03\u7528 MCP \u5de5\u5177\u5931\u8d25', + 'MCP \u5de5\u5177\u8c03\u7528\u5931\u8d25', + 'MCP tool call failed', +]; + type MCPRuntimeFailureKind = | 'list_tools_failed' | 'tool_call_failed' @@ -22,6 +79,7 @@ type MCPRuntimeCause = | 'stdio_closed' | 'process_exit' | 'argument_error' + | 'command_required' | 'transport' | 'unknown'; @@ -74,12 +132,15 @@ const extractServerName = (line: string): string => { return String(match?.[1] || '').trim(); }; +const includesAny = (line: string, markers: string[]): boolean => + markers.some((marker) => line.includes(marker)); + const detectFailureKind = (line: string): MCPRuntimeFailureKind | null => { - if (line.includes('列出 MCP 工具失败')) return 'list_tools_failed'; - if (line.includes('调用 MCP 工具失败') || line.includes('MCP 工具调用失败')) return 'tool_call_failed'; - if (line.includes('GoNavi MCP HTTP 服务启动失败')) return 'http_start_failed'; - if (line.includes('GoNavi MCP HTTP 服务异常退出') || line.includes('MCP HTTP 子进程已退出')) return 'http_process_exited'; - if (line.includes('MCP 服务命令不能为空') || line.includes('暂不支持的 MCP transport')) return 'configuration_error'; + if (includesAny(line, MCP_LIST_TOOLS_FAILED_MARKERS)) return 'list_tools_failed'; + if (includesAny(line, MCP_TOOL_CALL_FAILED_MARKERS)) return 'tool_call_failed'; + if (includesAny(line, MCP_HTTP_START_FAILED_MARKERS)) return 'http_start_failed'; + if (includesAny(line, MCP_HTTP_PROCESS_EXITED_MARKERS) || includesAny(line, MCP_HTTP_SUBPROCESS_EXITED_MARKERS)) return 'http_process_exited'; + if (includesAny(line, MCP_COMMAND_REQUIRED_MARKERS) || includesAny(line, MCP_TRANSPORT_UNSUPPORTED_MARKERS)) return 'configuration_error'; if (line.toLowerCase().includes('mcp') && line.includes('[ERROR]')) return 'mcp_error'; if (line.toLowerCase().includes('mcp') && line.includes('[WARN]')) return 'mcp_warning'; return null; @@ -87,6 +148,12 @@ const detectFailureKind = (line: string): MCPRuntimeFailureKind | null => { const detectCause = (line: string): MCPRuntimeCause => { const lower = line.toLowerCase(); + if (includesAny(line, MCP_COMMAND_REQUIRED_MARKERS)) { + return 'command_required'; + } + if (includesAny(line, MCP_TRANSPORT_UNSUPPORTED_MARKERS)) { + return 'transport'; + } if (/(executable file not found|not found|no such file|cannot find|找不到|无法找到)/iu.test(line)) { return 'command_not_found'; } @@ -105,7 +172,7 @@ const detectCause = (line: string): MCPRuntimeCause => { if (/(stdio|eof|closed pipe|broken pipe|stdin|stdout|标准输入|标准输出)/iu.test(line)) { return 'stdio_closed'; } - if (/(exit status|exited|异常退出|子进程已退出|process exited)/iu.test(line)) { + if (/(exit status|exited|异常退出|子进程已退出|process exited)/iu.test(line) || includesAny(line, MCP_HTTP_SUBPROCESS_EXITED_MARKERS)) { return 'process_exit'; } if (/(invalid character|invalid json|arguments|参数|schema|unmarshal)/iu.test(line)) { @@ -117,20 +184,44 @@ const detectCause = (line: string): MCPRuntimeCause => { return 'unknown'; }; -const causeNextAction: Record = { - command_not_found: '检查 command 是否只填可执行程序本身,并确认该命令在 PATH 中或使用绝对路径。', - timeout: '提高 timeoutSeconds 到 45 或 60,并确认服务启动后会保持 stdio 连接。', - permission: '检查可执行文件权限、杀毒/系统拦截和工作目录访问权限。', - auth: '检查环境变量里的 Token/API Key 是否已配置、未过期且权限范围足够。', - network: '检查 MCP 依赖的远端地址、代理、VPN 或本机端口是否可达。', - stdio_closed: '确认 README 要求的 --stdio/stdin 参数已填写;Docker 场景确认 args 包含 -i。', - process_exit: '单独在终端运行启动命令,查看进程启动后为什么立即退出。', - argument_error: '先调用 inspect_mcp_tool_schema 读取真实 inputSchema,再修正工具 arguments JSON。', - transport: '当前 GoNavi 新增 MCP 只支持 stdio,HTTP MCP 请使用 GoNavi HTTP 服务或对应远程接入说明。', - unknown: '结合 inspect_mcp_setup 查看配置,再调用 inspect_app_logs 扩大日志窗口确认原始错误。', +const translateMCPRuntimeCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: Record, +): string => ( + translate + ? translateInspectionCopy(translate, key, fallback, params) + : fallback +); + +const causeNextActionFallback: Record = { + command_not_found: 'Check that command contains only the executable name, and confirm it is on PATH or uses an absolute path.', + timeout: 'Raise timeoutSeconds to 45 or 60, and confirm the service keeps the stdio connection open after startup.', + permission: 'Check executable permissions, antivirus/system blocking, and working directory access.', + auth: 'Check whether the Token/API Key in environment variables is configured, unexpired, and has enough scope.', + network: 'Check whether the remote endpoint, proxy, VPN, or local port required by MCP is reachable.', + stdio_closed: 'Confirm the --stdio/stdin argument required by README is set; for Docker, confirm args includes -i.', + process_exit: 'Run the launch command in a terminal separately and inspect why the process exits immediately after startup.', + argument_error: 'Call inspect_mcp_tool_schema first to read the real inputSchema, then fix the tool arguments JSON.', + command_required: 'Fill startup command with the executable only. Move script names, modules, --stdio, and extra flags into args, then run Test tool discovery again.', + transport: 'New MCP servers in GoNavi currently support stdio only; for HTTP MCP, use the GoNavi HTTP service or the matching remote access guide.', + unknown: 'Inspect the configuration with inspect_mcp_setup, then call inspect_app_logs with a larger log window to confirm the raw error.', }; -const parseFailureEvent = (line: string): MCPRuntimeFailureEvent | null => { +const getCauseNextAction = ( + cause: MCPRuntimeCause, + translate?: AIInspectionTranslator, +): string => translateMCPRuntimeCopy( + translate, + `ai_chat.inspection.mcp_runtime.next_action.${cause}`, + causeNextActionFallback[cause], +); + +const parseFailureEvent = ( + line: string, + translate?: AIInspectionTranslator, +): MCPRuntimeFailureEvent | null => { const kind = detectFailureKind(line); if (!kind) { return null; @@ -142,7 +233,7 @@ const parseFailureEvent = (line: string): MCPRuntimeFailureEvent | null => { level: detectLevel(line), serverName: extractServerName(line), linePreview: redactLogLine(line), - nextAction: causeNextAction[cause], + nextAction: getCauseNextAction(cause, translate), }; }; @@ -189,17 +280,30 @@ const buildServerSummaries = ( const collectNextActions = ( events: MCPRuntimeFailureEvent[], serverSummaries: ReturnType, + translate?: AIInspectionTranslator, ): string[] => { const actions = Array.from(new Set(events.map((event) => event.nextAction))); const enabledServersWithoutTools = serverSummaries.filter((server) => server.enabled && server.discoveredToolCount === 0); if (enabledServersWithoutTools.length > 0) { - actions.push('有已启用 MCP 服务暂未发现工具,优先点击“测试工具发现”刷新并确认启动命令可独立运行。'); + actions.push(translateMCPRuntimeCopy( + translate, + 'ai_chat.inspection.mcp_runtime.next_action.enabled_without_tools', + 'Some enabled MCP servers have no discovered tools; click "Test tool discovery" first and confirm the launch command runs independently.', + )); } if (events.some((event) => event.kind === 'list_tools_failed')) { - actions.push('工具列表为空时先修复启动/发现失败,再排查单个工具 arguments。'); + actions.push(translateMCPRuntimeCopy( + translate, + 'ai_chat.inspection.mcp_runtime.next_action.fix_discovery_first', + 'When the tool list is empty, fix startup/discovery failure before diagnosing individual tool arguments.', + )); } if (actions.length === 0) { - actions.push('最近日志未发现 MCP 失败信号;如果刚刚复现过问题,请扩大 lineLimit 或改用 serverName 精确过滤。'); + actions.push(translateMCPRuntimeCopy( + translate, + 'ai_chat.inspection.mcp_runtime.next_action.expand_logs', + 'No MCP failure signal was found in recent logs; if you just reproduced the issue, increase lineLimit or filter precisely with serverName.', + )); } return actions; }; @@ -212,7 +316,9 @@ export const buildMCPRuntimeFailureSnapshot = (params: { serverName?: unknown; lineLimit?: unknown; includeLines?: unknown; + translate?: AIInspectionTranslator; }) => { + const { translate } = params; const data = params.readResult?.data && typeof params.readResult.data === 'object' ? params.readResult.data as Record : {}; @@ -223,7 +329,7 @@ export const buildMCPRuntimeFailureSnapshot = (params: { const includeLines = params.includeLines === true; const lines = normalizeLogLines(data.lines); const events = lines - .map(parseFailureEvent) + .map((line) => parseFailureEvent(line, translate)) .filter((event): event is MCPRuntimeFailureEvent => Boolean(event)) .filter((event) => !serverNameFilter || event.serverName.toLowerCase().includes(serverNameFilter) || event.linePreview.toLowerCase().includes(serverNameFilter)) .filter((event) => !textFilter || event.linePreview.toLowerCase().includes(textFilter)); @@ -236,11 +342,21 @@ export const buildMCPRuntimeFailureSnapshot = (params: { const warnings: string[] = []; if (events.length > 0) { - warnings.push(`最近日志中发现 ${events.length} 条 MCP 运行期异常信号。`); + warnings.push(translateMCPRuntimeCopy( + translate, + 'ai_chat.inspection.mcp_runtime.warning.failure_events', + `${events.length} MCP runtime failure signal was found in recent logs.`, + { count: events.length }, + )); } const serversWithoutTools = serverSummaries.filter((server) => server.enabled && server.discoveredToolCount === 0).length; if (serversWithoutTools > 0) { - warnings.push(`有 ${serversWithoutTools} 个已启用 MCP 服务当前未发现工具。`); + warnings.push(translateMCPRuntimeCopy( + translate, + 'ai_chat.inspection.mcp_runtime.warning.enabled_without_tools', + `${serversWithoutTools} enabled MCP server currently has no discovered tools.`, + { count: serversWithoutTools }, + )); } return { @@ -257,10 +373,19 @@ export const buildMCPRuntimeFailureSnapshot = (params: { events, serverSummaries, warnings, - nextActions: collectNextActions(events, serverSummaries), + nextActions: collectNextActions(events, serverSummaries, translate), lines: includeLines ? lines.map(redactLogLine) : undefined, message: events.length > 0 - ? `最近日志中发现 ${events.length} 条 MCP 运行期异常信号` - : '最近日志里没有发现 MCP 启动、工具发现或工具调用失败信号。', + ? translateMCPRuntimeCopy( + translate, + 'ai_chat.inspection.mcp_runtime.message.failure_events', + `${events.length} MCP runtime failure signal was found in recent logs`, + { count: events.length }, + ) + : translateMCPRuntimeCopy( + translate, + 'ai_chat.inspection.mcp_runtime.message.no_failure_events', + 'No MCP startup, tool discovery, or tool call failure signal was found in recent logs.', + ), }; }; diff --git a/frontend/src/components/ai/aiMCPToolSchemaInsights.test.ts b/frontend/src/components/ai/aiMCPToolSchemaInsights.test.ts index 6a47dba..f23c69c 100644 --- a/frontend/src/components/ai/aiMCPToolSchemaInsights.test.ts +++ b/frontend/src/components/ai/aiMCPToolSchemaInsights.test.ts @@ -44,8 +44,8 @@ describe('aiMCPToolSchemaInsights', () => { expect(snapshot.tools[0].parameters.map((item) => item.path)).toContain('metadata.milestone'); expect(snapshot.tools[0].parameters.find((item) => item.path === 'priority')?.enumValues).toEqual(['low', 'medium', 'high']); expect(snapshot.tools[0].parameters.find((item) => item.path === 'labels')?.arrayItemType).toBe('string'); - expect(snapshot.tools[0].usageHints).toContain('调用 github_create_issue 前必须提供:owner, repo, title'); - expect(snapshot.tools[0].usageHints).toContain('priority 只能从枚举值中选择:low / medium / high'); + expect(snapshot.tools[0].usageHints).toContain('Before calling github_create_issue, provide: owner, repo, title'); + expect(snapshot.tools[0].usageHints).toContain('priority must be one of: low / medium / high'); expect(snapshot.tools[0].inputSchema).toBeUndefined(); }); @@ -93,7 +93,47 @@ describe('aiMCPToolSchemaInsights', () => { }); expect(snapshot.matchedToolCount).toBe(0); - expect(snapshot.warnings).toContain('没有找到匹配的 MCP 工具。'); + expect(snapshot.warnings).toContain('No matching MCP tool was found.'); expect(snapshot.nextActions[0]).toContain('inspect_mcp_setup'); }); + + it('localizes tool schema wrapper copy while preserving aliases, schema paths, and enum values', () => { + const translate = (key: string, params?: Record) => ({ + 'ai_chat.inspection.mcp_tool_schema.usage.required_params': `T_REQUIRED_${params?.alias}_${params?.parameters}`, + 'ai_chat.inspection.mcp_tool_schema.usage.enum_values': `T_ENUM_${params?.path}_${params?.values}`, + 'ai_chat.inspection.mcp_tool_schema.usage.schema_fields_only': 'T_SCHEMA_ONLY', + 'ai_chat.inspection.mcp_tool_schema.message.with_matches': `T_MESSAGE_${params?.matched}_${params?.returned}`, + }[key] || key); + + const snapshot = buildMCPToolSchemaSnapshot({ + alias: 'github_create_issue', + mcpTools: [ + { + alias: 'github_create_issue', + originalName: 'create_issue', + serverId: 'github-server', + serverName: 'GitHub', + inputSchema: { + type: 'object', + required: ['owner'], + properties: { + owner: { type: 'string', description: '仓库 owner' }, + state: { type: 'string', enum: ['open', 'closed'] }, + }, + }, + }, + ], + translate, + } as Parameters[0] & { translate: typeof translate }); + + expect(snapshot.tools[0].usageHints).toEqual([ + 'T_REQUIRED_github_create_issue_owner', + 'T_ENUM_state_open / closed', + 'T_SCHEMA_ONLY', + ]); + expect(snapshot.message).toBe('T_MESSAGE_1_1'); + expect(snapshot.tools[0].alias).toBe('github_create_issue'); + expect(snapshot.tools[0].parameters.find((item) => item.path === 'state')?.enumValues).toEqual(['open', 'closed']); + expect(snapshot.tools[0].parameters.find((item) => item.path === 'owner')?.description).toBe('仓库 owner'); + }); }); diff --git a/frontend/src/components/ai/aiMCPToolSchemaInsights.ts b/frontend/src/components/ai/aiMCPToolSchemaInsights.ts index 0be6631..65777cc 100644 --- a/frontend/src/components/ai/aiMCPToolSchemaInsights.ts +++ b/frontend/src/components/ai/aiMCPToolSchemaInsights.ts @@ -1,4 +1,6 @@ import type { AIMCPToolDescriptor } from '../../types'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; const DEFAULT_TOOL_LIMIT = 8; const MAX_TOOL_LIMIT = 30; @@ -6,6 +8,13 @@ const MAX_PARAMETER_HINTS = 40; const MAX_ENUM_VALUES = 12; const MAX_SCHEMA_DEPTH = 2; +const translateMCPToolSchemaCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: Parameters[1], +): string => translateInspectionCopy(translate, key, fallback, params); + interface JSONSchemaRecord { [key: string]: any; } @@ -158,8 +167,9 @@ const buildUsageHints = (params: { tool: AIMCPToolDescriptor; hasInputSchema: boolean; parameterHints: MCPToolSchemaParameterHint[]; + translate?: AIInspectionTranslator; }) => { - const { tool, hasInputSchema, parameterHints } = params; + const { tool, hasInputSchema, parameterHints, translate } = params; const hints: string[] = []; const requiredTopLevel = parameterHints .filter((item) => item.required && !item.path.includes('.')) @@ -168,19 +178,41 @@ const buildUsageHints = (params: { const nestedHint = parameterHints.find((item) => item.nestedPropertyCount > 0 || item.path.includes('.')); if (!hasInputSchema) { - hints.push('这个 MCP 工具没有声明 inputSchema;调用前优先查看服务 README 或先用空对象试探。'); + hints.push(translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.usage.no_input_schema', + 'This MCP tool does not declare inputSchema; check the service README first or probe with an empty object.', + )); } if (requiredTopLevel.length > 0) { - hints.push(`调用 ${tool.alias} 前必须提供:${requiredTopLevel.join(', ')}`); + hints.push(translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.usage.required_params', + 'Before calling {{alias}}, provide: {{parameters}}', + { alias: tool.alias, parameters: requiredTopLevel.join(', ') }, + )); } if (enumHint) { - hints.push(`${enumHint.path} 只能从枚举值中选择:${enumHint.enumValues.join(' / ')}`); + hints.push(translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.usage.enum_values', + '{{path}} must be one of: {{values}}', + { path: enumHint.path, values: enumHint.enumValues.join(' / ') }, + )); } if (nestedHint) { - hints.push('嵌套对象和数组参数必须按 JSON 结构传入,不要把对象整体写成字符串。'); + hints.push(translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.usage.nested_json', + 'Nested object and array parameters must follow the JSON structure; do not pass the whole object as a string.', + )); } if (parameterHints.length > 0) { - hints.push('调用前只传 schema 中声明的字段;不确定字段含义时先向用户确认,而不是猜测。'); + hints.push(translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.usage.schema_fields_only', + 'Only pass fields declared in the schema; if a field meaning is unclear, ask the user instead of guessing.', + )); } return hints; }; @@ -192,6 +224,7 @@ export const buildMCPToolSchemaSnapshot = (params: { keyword?: string; includeSchema?: boolean; limit?: number; + translate?: AIInspectionTranslator; }) => { const { mcpTools = [], @@ -199,6 +232,7 @@ export const buildMCPToolSchemaSnapshot = (params: { serverId = '', keyword = '', includeSchema = false, + translate, } = params; const normalizedAlias = normalizeSearchText(alias); const normalizedServerId = normalizeSearchText(serverId); @@ -254,7 +288,7 @@ export const buildMCPToolSchemaSnapshot = (params: { requiredParameterCount: requiredParameters.length, requiredParameters, parameters: parameterHints, - usageHints: buildUsageHints({ tool, hasInputSchema, parameterHints }), + usageHints: buildUsageHints({ tool, hasInputSchema, parameterHints, translate }), inputSchema: includeSchema ? inputSchema : undefined, }; }); @@ -262,14 +296,38 @@ export const buildMCPToolSchemaSnapshot = (params: { const warnings: string[] = []; const nextActions: string[] = []; if (allTools.length === 0) { - warnings.push('当前没有发现任何 MCP 工具,可能还没有配置 MCP 服务,或服务测试/发现失败。'); - nextActions.push('先调用 inspect_mcp_setup 查看 MCP 服务是否启用并已发现工具。'); + warnings.push(translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.warning.no_tools', + 'No MCP tools were discovered; MCP services may not be configured, or service testing/discovery may have failed.', + )); + nextActions.push(translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.next_action.inspect_setup', + 'Call inspect_mcp_setup first to check whether MCP services are enabled and tools have been discovered.', + )); } else if (matchedTools.length === 0) { - warnings.push('没有找到匹配的 MCP 工具。'); - nextActions.push('先调用 inspect_mcp_setup 查看当前实际发现到的 MCP 工具 alias,再用 alias 精确查询。'); + warnings.push(translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.warning.no_matches', + 'No matching MCP tool was found.', + )); + nextActions.push(translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.next_action.lookup_alias', + 'Call inspect_mcp_setup first to check the MCP tool aliases actually discovered, then query by exact alias.', + )); } else if (tools.some((tool) => !tool.hasInputSchema)) { - warnings.push('部分 MCP 工具没有声明 inputSchema,参数说明可能不完整。'); - nextActions.push('没有 schema 的工具需要回到 MCP 服务 README 或工具返回错误继续确认参数。'); + warnings.push(translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.warning.missing_schema', + 'Some MCP tools do not declare inputSchema, so parameter documentation may be incomplete.', + )); + nextActions.push(translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.next_action.read_readme', + 'For tools without schema, go back to the MCP service README or use tool errors to confirm parameters.', + )); } return { @@ -288,9 +346,22 @@ export const buildMCPToolSchemaSnapshot = (params: { warnings, nextActions, message: matchedTools.length > 0 - ? `已找到 ${matchedTools.length} 个 MCP 工具,返回 ${tools.length} 个参数 schema 摘要` + ? translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.message.with_matches', + 'Found {{matched}} MCP tools and returned {{returned}} parameter schema summaries', + { matched: matchedTools.length, returned: tools.length }, + ) : allTools.length > 0 - ? '没有找到匹配的 MCP 工具' - : '当前还没有可用 MCP 工具 schema', + ? translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.message.no_matches', + 'No matching MCP tool was found', + ) + : translateMCPToolSchemaCopy( + translate, + 'ai_chat.inspection.mcp_tool_schema.message.empty', + 'No MCP tool schema is available yet', + ), }; }; diff --git a/frontend/src/components/ai/aiPromptInsights.test.ts b/frontend/src/components/ai/aiPromptInsights.test.ts index cd3d07b..c4edb7c 100644 --- a/frontend/src/components/ai/aiPromptInsights.test.ts +++ b/frontend/src/components/ai/aiPromptInsights.test.ts @@ -1,8 +1,53 @@ +import { readFileSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; import { buildAIGuidanceSnapshot } from './aiPromptInsights'; describe('aiPromptInsights', () => { + it('localizes prompt guidance wrappers while keeping prompt and skill content raw', () => { + const snapshot = buildAIGuidanceSnapshot({ + userPromptSettings: { + global: '回答前先核对上下文。', + database: '', + jvm: '', + jvmDiagnostic: '', + }, + skills: [ + { + id: 'skill-1', + name: '结构审查', + description: '优先核对字段和索引', + systemPrompt: '先看字段,再给结论。', + enabled: true, + scopes: ['database'], + requiredTools: ['get_columns'], + }, + ], + translate: (key, params) => { + const suffix = params + ? ` ${Object.entries(params).map(([paramKey, value]) => `${paramKey}=${value}`).join(',')}` + : ''; + return `T:${key}${suffix}`; + }, + }); + + expect(snapshot.customPrompts[0].label).toBe('T:ai_chat.inspection.guidance.scope.global'); + expect(snapshot.customPrompts[0].content).toBe('回答前先核对上下文。'); + expect(snapshot.enabledSkills[0].name).toBe('结构审查'); + expect(snapshot.enabledSkills[0].systemPrompt).toBe('先看字段,再给结论。'); + expect(snapshot.message).toBe('T:ai_chat.inspection.guidance.message.configured promptCount=1,skillCount=1'); + }); + + it('keeps prompt guidance production source free of legacy Chinese wrappers', () => { + const source = readFileSync('src/components/ai/aiPromptInsights.ts', 'utf8'); + + expect(source).not.toContain('全局'); + expect(source).not.toContain('数据库会话'); + expect(source).not.toContain('当前已启用'); + expect(source).not.toContain('当前没有启用自定义提示词或 Skills'); + }); + it('summarizes active custom prompts and enabled skills for runtime inspection', () => { const snapshot = buildAIGuidanceSnapshot({ userPromptSettings: { diff --git a/frontend/src/components/ai/aiPromptInsights.ts b/frontend/src/components/ai/aiPromptInsights.ts index 9f32547..9bec312 100644 --- a/frontend/src/components/ai/aiPromptInsights.ts +++ b/frontend/src/components/ai/aiPromptInsights.ts @@ -1,16 +1,20 @@ import type { AISkillConfig, AIUserPromptSettings } from '../../types'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; -const PROMPT_SCOPE_LABELS: Record = { - global: '全局', - database: '数据库会话', - jvm: 'JVM 资源分析', - jvmDiagnostic: 'JVM 诊断', +const PROMPT_SCOPE_FALLBACKS: Record = { + global: 'Global', + database: 'Database session', + jvm: 'JVM resource analysis', + jvmDiagnostic: 'JVM diagnostics', }; export const buildAIGuidanceSnapshot = (params: { userPromptSettings?: AIUserPromptSettings; skills?: AISkillConfig[]; + translate?: AIInspectionTranslator; }) => { + const translate = params.translate; const userPromptSettings = params.userPromptSettings || { global: '', database: '', @@ -19,11 +23,15 @@ export const buildAIGuidanceSnapshot = (params: { }; const skills = Array.isArray(params.skills) ? params.skills : []; - const customPrompts = (Object.keys(PROMPT_SCOPE_LABELS) as Array).map((scope) => { + const customPrompts = (Object.keys(PROMPT_SCOPE_FALLBACKS) as Array).map((scope) => { const content = String(userPromptSettings[scope] || '').trim(); return { scope, - label: PROMPT_SCOPE_LABELS[scope], + label: translateInspectionCopy( + translate, + `ai_chat.inspection.guidance.scope.${scope}`, + PROMPT_SCOPE_FALLBACKS[scope], + ), enabled: content.length > 0, charCount: content.length, content, @@ -54,7 +62,16 @@ export const buildAIGuidanceSnapshot = (params: { disabledSkillCount, enabledSkills, message: enabledPromptCount > 0 || enabledSkills.length > 0 - ? `当前已启用 ${enabledPromptCount} 条自定义提示词、${enabledSkills.length} 个 Skills` - : '当前没有启用自定义提示词或 Skills', + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.guidance.message.configured', + `${enabledPromptCount} custom prompts and ${enabledSkills.length} Skills are enabled`, + { promptCount: enabledPromptCount, skillCount: enabledSkills.length }, + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.guidance.message.empty', + 'No custom prompts or Skills are enabled', + ), }; }; diff --git a/frontend/src/components/ai/aiProviderInsights.test.ts b/frontend/src/components/ai/aiProviderInsights.test.ts index cbf3456..b23e126 100644 --- a/frontend/src/components/ai/aiProviderInsights.test.ts +++ b/frontend/src/components/ai/aiProviderInsights.test.ts @@ -1,9 +1,49 @@ +import { readFileSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; import type { AIProviderConfig } from '../../types'; import { buildAIProviderSnapshot } from './aiProviderInsights'; describe('aiProviderInsights', () => { + it('localizes provider summary wrappers while keeping provider names and hosts raw', () => { + const snapshot = buildAIProviderSnapshot({ + providers: [{ + id: 'provider-1', + type: 'openai', + name: 'OpenAI 主账号', + apiKey: '', + hasSecret: true, + baseUrl: 'https://api.openai.com/v1', + model: 'gpt-5.4', + models: ['gpt-5.4'], + maxTokens: 32000, + temperature: 0.2, + }], + activeProviderId: 'provider-1', + dynamicModels: ['gpt-5.4'], + translate: (key, params) => { + const suffix = params + ? ` ${Object.entries(params).map(([paramKey, value]) => `${paramKey}=${value}`).join(',')}` + : ''; + return `T:${key}${suffix}`; + }, + }); + + expect(snapshot.activeProvider?.name).toBe('OpenAI 主账号'); + expect(snapshot.activeProvider?.baseUrlHost).toBe('api.openai.com'); + expect(snapshot.message).toBe('T:ai_chat.inspection.provider.message.active_ready count=1,provider=OpenAI 主账号'); + }); + + it('keeps provider production source free of legacy Chinese wrappers', () => { + const source = readFileSync('src/components/ai/aiProviderInsights.ts', 'utf8'); + + expect(source).not.toContain('当前没有配置 AI 供应商'); + expect(source).not.toContain('当前正在使用'); + expect(source).not.toContain('当前共配置'); + expect(source).not.toContain('尚未选择活动供应商'); + }); + it('returns a sanitized provider snapshot with missing-secret and missing-model diagnostics', () => { const providers: AIProviderConfig[] = [ { @@ -66,7 +106,7 @@ describe('aiProviderInsights', () => { issues: ['missing_secret', 'missing_base_url', 'missing_selected_model', 'missing_declared_models'], status: 'needs_attention', }); - expect(snapshot.message).toContain('正在使用 OpenAI 主账号'); + expect(snapshot.message).toContain('using OpenAI 主账号'); expect(JSON.stringify(snapshot)).not.toContain('apiKey'); expect(JSON.stringify(snapshot)).not.toContain('secret-token'); }); diff --git a/frontend/src/components/ai/aiProviderInsights.ts b/frontend/src/components/ai/aiProviderInsights.ts index 999f49b..6e73a13 100644 --- a/frontend/src/components/ai/aiProviderInsights.ts +++ b/frontend/src/components/ai/aiProviderInsights.ts @@ -1,4 +1,6 @@ import type { AIProviderConfig } from '../../types'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; const DECLARED_MODEL_PREVIEW_LIMIT = 12; const DYNAMIC_MODEL_PREVIEW_LIMIT = 20; @@ -58,7 +60,9 @@ export const buildAIProviderSnapshot = (params: { providers?: AIProviderConfig[]; activeProviderId?: string | null; dynamicModels?: string[]; + translate?: AIInspectionTranslator; }) => { + const translate = params.translate; const providers = Array.isArray(params.providers) ? params.providers : []; const activeProviderId = trimText(params.activeProviderId); const dynamicModelPreview = sliceList( @@ -130,11 +134,30 @@ export const buildAIProviderSnapshot = (params: { dynamicModels: dynamicModelPreview.items, dynamicModelsTruncated: dynamicModelPreview.truncated, message: providerSummaries.length === 0 - ? '当前没有配置 AI 供应商' + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.provider.message.empty', + 'No AI providers are configured', + ) : activeProvider ? activeProvider.issueCount > 0 - ? `当前正在使用 ${activeProvider.name || activeProvider.id},但还有 ${activeProvider.issueCount} 项待检查` - : `当前共配置 ${providerSummaries.length} 个供应商,正在使用 ${activeProvider.name || activeProvider.id}` - : `当前已配置 ${providerSummaries.length} 个供应商,但尚未选择活动供应商`, + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.provider.message.active_needs_attention', + `Using ${activeProvider.name || activeProvider.id}, but ${activeProvider.issueCount} items still need checking`, + { provider: activeProvider.name || activeProvider.id, issueCount: activeProvider.issueCount }, + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.provider.message.active_ready', + `${providerSummaries.length} providers are configured; using ${activeProvider.name || activeProvider.id}`, + { count: providerSummaries.length, provider: activeProvider.name || activeProvider.id }, + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.provider.message.unselected', + `${providerSummaries.length} providers are configured, but no active provider is selected`, + { count: providerSummaries.length }, + ), }; }; diff --git a/frontend/src/components/ai/aiRedisTopologyInsights.test.ts b/frontend/src/components/ai/aiRedisTopologyInsights.test.ts index d551285..3d4370c 100644 --- a/frontend/src/components/ai/aiRedisTopologyInsights.test.ts +++ b/frontend/src/components/ai/aiRedisTopologyInsights.test.ts @@ -1,8 +1,49 @@ +import { readFileSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; import type { SavedConnection } from '../../types'; +import { catalogs } from '../../i18n/catalog'; import { buildRedisTopologySnapshot } from './aiRedisTopologyInsights'; +const source = readFileSync(new URL('./aiRedisTopologyInsights.ts', import.meta.url), 'utf8'); + +const REDIS_TOPOLOGY_I18N_KEYS = [ + 'ai_chat.inspection.redis_topology.label.single', + 'ai_chat.inspection.redis_topology.label.cluster', + 'ai_chat.inspection.redis_topology.label.sentinel', + 'ai_chat.inspection.redis_topology.warning.missing_host', + 'ai_chat.inspection.redis_topology.warning.ssh_unsupported', + 'ai_chat.inspection.redis_topology.warning.missing_sentinel_master', + 'ai_chat.inspection.redis_topology.warning.single_sentinel_node', + 'ai_chat.inspection.redis_topology.warning.sentinel_default_redis_port', + 'ai_chat.inspection.redis_topology.warning.cluster_single_seed', + 'ai_chat.inspection.redis_topology.warning.cluster_logical_db', + 'ai_chat.inspection.redis_topology.warning.cluster_sentinel_fields_ignored', + 'ai_chat.inspection.redis_topology.warning.single_multiple_nodes', + 'ai_chat.inspection.redis_topology.warning.single_sentinel_fields_ignored', + 'ai_chat.inspection.redis_topology.db_note.cluster_logical_namespace', + 'ai_chat.inspection.redis_topology.db_note.sentinel_selected_db', + 'ai_chat.inspection.redis_topology.db_note.single_selected_db', + 'ai_chat.inspection.redis_topology.next_action.fill_host', + 'ai_chat.inspection.redis_topology.next_action.fill_sentinel_master', + 'ai_chat.inspection.redis_topology.next_action.disable_ssh', + 'ai_chat.inspection.redis_topology.next_action.check_sentinel_port', + 'ai_chat.inspection.redis_topology.next_action.review_cluster_logical_db', + 'ai_chat.inspection.redis_topology.next_action.align_single_topology', + 'ai_chat.inspection.redis_topology.next_action.test_connection', + 'ai_chat.inspection.redis_topology.recommendation.sentinel_addresses', + 'ai_chat.inspection.redis_topology.recommendation.separate_auth', + 'ai_chat.inspection.redis_topology.recommendation.cluster_multiple_seeds', + 'ai_chat.inspection.redis_topology.recommendation.cluster_namespace', + 'ai_chat.inspection.redis_topology.recommendation.single_one_address', + 'ai_chat.inspection.redis_topology.recommendation.network_for_cluster_sentinel', + 'ai_chat.inspection.redis_topology.recommendation.check_tls', +] as const; + +const translateWithKeyEcho = (key: string, params?: Record) => + `T:${key}${params ? `:${JSON.stringify(params)}` : ''}`; + const buildRedisConnection = ( id: string, name: string, @@ -25,7 +66,7 @@ describe('buildRedisTopologySnapshot', () => { it('summarizes Redis Sentinel settings without exposing passwords', () => { const snapshot = buildRedisTopologySnapshot({ connections: [ - buildRedisConnection('redis-sentinel', '生产 Redis Sentinel', { + buildRedisConnection('redis-sentinel', 'Production Redis Sentinel', { host: 'sentinel-a.local', port: 6379, hosts: ['sentinel-b.local:26379'], @@ -41,7 +82,7 @@ describe('buildRedisTopologySnapshot', () => { hasRedisSentinelPassword: true, }), ], - keyword: '生产', + keyword: 'production', }); expect(snapshot.totalRedisConnections).toBe(1); @@ -63,10 +104,13 @@ describe('buildRedisTopologySnapshot', () => { physicalDb: 2, mode: 'failover_selected_db', }); - expect(snapshot.connections[0].warnings).toContain('Sentinel master 名称为空,go-redis FailoverClient 无法发现主节点'); - expect(snapshot.connections[0].warnings).toContain('Sentinel 主地址端口是 6379,请确认这里填写的是 Sentinel 端口,常见默认值是 26379'); - expect(snapshot.connections[0].blockingReasons).toContain('Sentinel master 名称为空,go-redis FailoverClient 无法发现主节点'); - expect(snapshot.connections[0].nextActions.join('\n')).toContain('补充 Sentinel master 名称'); + expect(snapshot.connections[0].warnings.length).toBeGreaterThan(0); + expect(snapshot.connections[0].warnings.join('\n')).toContain('Sentinel'); + expect(snapshot.connections[0].warnings.join('\n')).toContain('go-redis FailoverClient'); + expect(snapshot.connections[0].warnings.join('\n')).toContain('26379'); + expect(snapshot.connections[0].blockingReasons.length).toBeGreaterThan(0); + expect(snapshot.connections[0].blockingReasons.join('\n')).toContain('go-redis FailoverClient'); + expect(snapshot.connections[0].nextActions.join('\n')).toContain('Sentinel'); expect(JSON.stringify(snapshot)).not.toContain('redis-secret'); expect(JSON.stringify(snapshot)).not.toContain('sentinel-secret'); }); @@ -74,7 +118,7 @@ describe('buildRedisTopologySnapshot', () => { it('reports cluster logical-db and SSH risks', () => { const snapshot = buildRedisTopologySnapshot({ connections: [ - buildRedisConnection('redis-cluster', '订单 Redis Cluster', { + buildRedisConnection('redis-cluster', 'Orders Redis Cluster', { host: '10.10.1.10', port: 6379, hosts: ['10.10.1.11:6379', '10.10.1.12:6379'], @@ -100,16 +144,17 @@ describe('buildRedisTopologySnapshot', () => { selectedDb: 4, mode: 'cluster_logical_namespace', }); - expect(snapshot.connections[0].warnings).toContain('Redis Cluster 当前后端不支持 SSH 隧道,请改用直连、代理或远程 MCP HTTP 方案'); - expect(snapshot.connections[0].warnings).toContain('Redis Cluster 物理上只支持 db0;GoNavi 会用 __gonavi_db_N__: 前缀模拟逻辑库隔离'); - expect(snapshot.connections[0].nextActions.join('\n')).toContain('关闭 SSH 隧道'); - expect(snapshot.connections[0].recommendations?.join('\n')).toContain('种子节点'); + expect(snapshot.connections[0].warnings.join('\n')).toContain('Redis Cluster'); + expect(snapshot.connections[0].warnings.join('\n')).toContain('SSH'); + expect(snapshot.connections[0].warnings.join('\n')).toContain('__gonavi_db_N__'); + expect(snapshot.connections[0].nextActions.join('\n')).toContain('SSH'); + expect(snapshot.connections[0].recommendations?.join('\n')).toContain('Redis Cluster'); }); it('filters out non-Redis connections and warns about multi-host single mode', () => { const snapshot = buildRedisTopologySnapshot({ connections: [ - buildRedisConnection('redis-single', '缓存单机', { + buildRedisConnection('redis-single', 'Cache Standalone', { host: 'redis.local', port: 6379, topology: 'single', @@ -117,7 +162,7 @@ describe('buildRedisTopologySnapshot', () => { }), { id: 'mysql-1', - name: '业务库', + name: 'Business Database', config: { type: 'mysql', host: 'mysql.local', @@ -140,7 +185,61 @@ describe('buildRedisTopologySnapshot', () => { physicalDb: 0, mode: 'cluster_logical_namespace', }); - expect(snapshot.connections[0].warnings).toContain('单机模式下存在多个节点地址,后端会按多节点集群路径处理,建议显式改为 Cluster 模式'); - expect(snapshot.connections[0].nextActions.join('\n')).toContain('显式切换为 Cluster 模式'); + expect(snapshot.connections[0].warnings.join('\n')).toContain('Cluster'); + expect(snapshot.connections[0].nextActions.join('\n')).toContain('Cluster'); + }); + + it('localizes controlled Redis topology diagnostics while preserving raw Redis values', () => { + const snapshot = buildRedisTopologySnapshot({ + connections: [ + buildRedisConnection('redis-cluster', 'Orders Redis Cluster', { + host: '10.10.1.10', + port: 6379, + hosts: ['10.10.1.11:6379'], + topology: 'cluster', + redisDB: 3, + useSSH: true, + useSSL: true, + }, { + includeRedisDatabases: [0, 3], + }), + ], + connectionId: 'redis-cluster', + translate: translateWithKeyEcho, + }); + + const connection = snapshot.connections[0]; + expect(connection.topologyLabel).toBe('T:ai_chat.inspection.redis_topology.label.cluster'); + expect(connection.effectiveTopologyLabel).toBe('T:ai_chat.inspection.redis_topology.label.cluster'); + expect(connection.warnings).toContain('T:ai_chat.inspection.redis_topology.warning.ssh_unsupported:{"topologyLabel":"T:ai_chat.inspection.redis_topology.label.cluster"}'); + expect(connection.warnings).toContain('T:ai_chat.inspection.redis_topology.warning.cluster_logical_db'); + expect(connection.blockingReasons).toContain('T:ai_chat.inspection.redis_topology.warning.ssh_unsupported:{"topologyLabel":"T:ai_chat.inspection.redis_topology.label.cluster"}'); + expect(connection.dbSemantics.note).toBe('T:ai_chat.inspection.redis_topology.db_note.cluster_logical_namespace'); + expect(connection.nextActions).toContain('T:ai_chat.inspection.redis_topology.next_action.disable_ssh'); + expect(connection.recommendations).toContain('T:ai_chat.inspection.redis_topology.recommendation.check_tls'); + expect(connection.safeUriExample).toBe('rediss://10.10.1.10:6379,10.10.1.11:6379/0?topology=cluster&skip_verify=true'); + expect(JSON.stringify(connection)).toContain('Redis Cluster'); + expect(JSON.stringify(connection)).toContain('go-redis ClusterClient'); + }); + + it('keeps Redis topology snapshot copy behind six-language catalog keys', () => { + for (const [language, catalog] of Object.entries(catalogs)) { + const missing = REDIS_TOPOLOGY_I18N_KEYS.filter((key) => !(key in catalog)); + expect(missing, language).toEqual([]); + } + }); + + it('keeps Redis topology source free of hardcoded Chinese diagnostics', () => { + [ + '\u4e3b\u673a\u5730\u5740\u4e3a\u7a7a', + '\u5355\u673a\u6a21\u5f0f\u76f4\u63a5\u4f7f\u7528', + '\u8865\u5145 Sentinel master \u540d\u79f0', + '\u786e\u8ba4\u4e3b\u673a\u548c\u9644\u52a0\u8282\u70b9', + 'Redis Cluster \u7269\u7406', + '\u4e0d\u652f\u6301 SSH \u96a7\u9053', + 'Sentinel master \u540d\u79f0\u4e3a\u7a7a', + ].forEach((literal) => { + expect(source).not.toContain(literal); + }); }); }); diff --git a/frontend/src/components/ai/aiRedisTopologyInsights.ts b/frontend/src/components/ai/aiRedisTopologyInsights.ts index 14dccbb..2b722dc 100644 --- a/frontend/src/components/ai/aiRedisTopologyInsights.ts +++ b/frontend/src/components/ai/aiRedisTopologyInsights.ts @@ -1,7 +1,34 @@ import type { SavedConnection } from '../../types'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; type RedisTopology = 'single' | 'cluster' | 'sentinel'; +type RedisTopologyWarningCode = + | 'missing_host' + | 'ssh_unsupported' + | 'missing_sentinel_master' + | 'single_sentinel_node' + | 'sentinel_default_redis_port' + | 'cluster_single_seed' + | 'cluster_logical_db' + | 'cluster_sentinel_fields_ignored' + | 'single_multiple_nodes' + | 'single_sentinel_fields_ignored'; + +interface RedisTopologyWarning { + code: RedisTopologyWarningCode; + message: string; + blocking: boolean; +} + +const redisTopologyCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: Record, +): string => translateInspectionCopy(translate, `ai_chat.inspection.redis_topology.${key}`, fallback, params); + const normalizeText = (input: unknown): string => String(input || '').trim(); const normalizeLowerText = (input: unknown): string => normalizeText(input).toLowerCase(); @@ -35,10 +62,17 @@ const topologyAdapterName = (topology: RedisTopology): string => { return 'go-redis Client'; }; -const topologyModeLabel = (topology: RedisTopology): string => { - if (topology === 'sentinel') return 'Redis Sentinel'; - if (topology === 'cluster') return 'Redis Cluster'; - return 'Redis 单机'; +const topologyModeLabel = ( + topology: RedisTopology, + translate?: AIInspectionTranslator, +): string => { + if (topology === 'sentinel') { + return redisTopologyCopy(translate, 'label.sentinel', 'Redis Sentinel'); + } + if (topology === 'cluster') { + return redisTopologyCopy(translate, 'label.cluster', 'Redis Cluster'); + } + return redisTopologyCopy(translate, 'label.single', 'Standalone Redis'); }; const buildSeedAddresses = (connection: SavedConnection): string[] => { @@ -70,61 +104,111 @@ const matchesKeyword = (keyword: string, connection: SavedConnection, seedAddres ].some((field) => normalizeLowerText(field).includes(keyword)); }; -const buildRedisTopologyWarnings = (connection: SavedConnection, seedAddresses: string[]): string[] => { +const buildRedisTopologyWarnings = ( + connection: SavedConnection, + seedAddresses: string[], + translate?: AIInspectionTranslator, +): RedisTopologyWarning[] => { const config = connection.config || {}; const configuredTopology = normalizeRedisTopology(config.topology); const effectiveTopology = resolveEffectiveRedisTopology(configuredTopology, seedAddresses); - const warnings: string[] = []; + const warnings: RedisTopologyWarning[] = []; + const pushWarning = ( + code: RedisTopologyWarningCode, + fallback: string, + options: { + blocking?: boolean; + params?: Record; + } = {}, + ) => { + warnings.push({ + code, + blocking: options.blocking === true, + message: redisTopologyCopy(translate, `warning.${code}`, fallback, options.params), + }); + }; if (!normalizeText(config.host)) { - warnings.push('主机地址为空,连接前需要填写 Redis 节点或 Sentinel 地址'); + pushWarning( + 'missing_host', + 'Host is empty; fill in a Redis node or Sentinel address before connecting', + { blocking: true }, + ); } if ((effectiveTopology === 'cluster' || effectiveTopology === 'sentinel') && config.useSSH === true) { - warnings.push(`${effectiveTopology === 'cluster' ? 'Redis Cluster' : 'Redis Sentinel'} 当前后端不支持 SSH 隧道,请改用直连、代理或远程 MCP HTTP 方案`); + const topologyLabel = topologyModeLabel(effectiveTopology, translate); + pushWarning( + 'ssh_unsupported', + `${topologyLabel} is not supported with SSH tunnels by the current backend; use direct access, a proxy, or remote MCP HTTP instead`, + { + blocking: true, + params: { topologyLabel }, + }, + ); } if (configuredTopology === 'sentinel') { if (!normalizeText(config.redisSentinelMaster)) { - warnings.push('Sentinel master 名称为空,go-redis FailoverClient 无法发现主节点'); + pushWarning( + 'missing_sentinel_master', + 'Sentinel master name is empty; go-redis FailoverClient cannot discover the primary node', + { blocking: true }, + ); } if (seedAddresses.length < 2) { - warnings.push('Sentinel 只配置了一个节点,建议至少填写 2-3 个 Sentinel 地址以避免单点失败'); + pushWarning( + 'single_sentinel_node', + 'Only one Sentinel node is configured; provide at least 2-3 Sentinel addresses to avoid a single point of failure', + ); } if (Number(config.port) === 6379) { - warnings.push('Sentinel 主地址端口是 6379,请确认这里填写的是 Sentinel 端口,常见默认值是 26379'); + pushWarning( + 'sentinel_default_redis_port', + 'The primary Sentinel address uses port 6379; confirm this is a Sentinel port, commonly 26379 by default', + ); } } if (effectiveTopology === 'cluster') { if (seedAddresses.length < 2) { - warnings.push('Cluster 只配置了一个种子节点,建议填写多个 master/replica 节点提高发现成功率'); + pushWarning( + 'cluster_single_seed', + 'Only one Cluster seed node is configured; add multiple master/replica nodes to improve discovery reliability', + ); } const redisDB = Number(config.redisDB || 0); const includeRedisDatabases = Array.isArray(connection.includeRedisDatabases) ? connection.includeRedisDatabases.filter((item) => typeof item === 'number') : []; if (redisDB > 0 || includeRedisDatabases.some((item) => item > 0)) { - warnings.push('Redis Cluster 物理上只支持 db0;GoNavi 会用 __gonavi_db_N__: 前缀模拟逻辑库隔离'); + pushWarning( + 'cluster_logical_db', + 'Redis Cluster physically supports only db0; GoNavi uses the __gonavi_db_N__: prefix to emulate logical DB isolation', + ); } if (normalizeText(config.redisSentinelMaster) || normalizeText(config.redisSentinelUser)) { - warnings.push('Cluster 模式下 Sentinel master / Sentinel 用户字段不会生效'); + pushWarning( + 'cluster_sentinel_fields_ignored', + 'Sentinel master and Sentinel user fields do not take effect in Cluster mode', + ); } } if (configuredTopology === 'single') { if (seedAddresses.length > 1) { - warnings.push('单机模式下存在多个节点地址,后端会按多节点集群路径处理,建议显式改为 Cluster 模式'); + pushWarning( + 'single_multiple_nodes', + 'Standalone mode has multiple node addresses; the backend will use the multi-node Cluster path, so explicitly switch to Cluster mode', + ); } if (normalizeText(config.redisSentinelMaster) || normalizeText(config.redisSentinelUser)) { - warnings.push('单机模式下 Sentinel 字段不会生效,如需哨兵发现请切换为 Sentinel 模式'); + pushWarning( + 'single_sentinel_fields_ignored', + 'Sentinel fields do not take effect in Standalone mode; switch to Sentinel mode if Sentinel discovery is required', + ); } } return warnings; }; -const isBlockingRedisWarning = (warning: string): boolean => - warning.includes('主机地址为空') || - warning.includes('master 名称为空') || - warning.includes('不支持 SSH 隧道'); - const buildSafeRedisUriExample = ( connection: SavedConnection, seedAddresses: string[], @@ -173,6 +257,7 @@ const buildSafeRedisUriExample = ( const buildRedisDBSemantics = ( connection: SavedConnection, effectiveTopology: RedisTopology, + translate?: AIInspectionTranslator, ) => { const config = connection.config || {}; const redisDB = typeof config.redisDB === 'number' ? config.redisDB : 0; @@ -185,7 +270,11 @@ const buildRedisDBSemantics = ( selectedDb: redisDB, includeRedisDatabases, mode: 'cluster_logical_namespace', - note: 'Redis Cluster 物理只支持 db0;GoNavi 用 __gonavi_db_N__: 前缀模拟多库视图。', + note: redisTopologyCopy( + translate, + 'db_note.cluster_logical_namespace', + 'Redis Cluster physically supports only db0; GoNavi uses the __gonavi_db_N__: prefix to emulate a multi-DB view.', + ), }; } return { @@ -194,43 +283,86 @@ const buildRedisDBSemantics = ( includeRedisDatabases, mode: effectiveTopology === 'sentinel' ? 'failover_selected_db' : 'selected_db', note: effectiveTopology === 'sentinel' - ? 'Sentinel 发现 master 后连接指定 DB,切库会保留 Sentinel 配置重连。' - : '单机模式直接使用 Redis SELECT DB。', + ? redisTopologyCopy( + translate, + 'db_note.sentinel_selected_db', + 'After Sentinel discovers the master, GoNavi connects to the selected DB and keeps the Sentinel settings when reconnecting.', + ) + : redisTopologyCopy( + translate, + 'db_note.single_selected_db', + 'Standalone mode uses Redis SELECT DB directly.', + ), }; }; const buildRedisNextActions = ( connection: SavedConnection, - warnings: string[], + warnings: RedisTopologyWarning[], + translate?: AIInspectionTranslator, ): string[] => { const config = connection.config || {}; const topology = normalizeRedisTopology(config.topology); const actions: string[] = []; if (!normalizeText(config.host)) { - actions.push('先填写主机地址;Sentinel 模式填写 Sentinel 地址,Cluster 模式填写 Redis Cluster 种子节点。'); + actions.push(redisTopologyCopy( + translate, + 'next_action.fill_host', + 'Fill in the host first; use Sentinel addresses for Sentinel mode and Redis Cluster seed nodes for Cluster mode.', + )); } if (topology === 'sentinel' && !normalizeText(config.redisSentinelMaster)) { - actions.push('补充 Sentinel master 名称,例如 mymaster。'); + actions.push(redisTopologyCopy( + translate, + 'next_action.fill_sentinel_master', + 'Fill in the Sentinel master name, for example mymaster.', + )); } - if (warnings.some((warning) => warning.includes('不支持 SSH 隧道'))) { - actions.push('关闭 SSH 隧道,改用直连、代理/VPN,或使用 GoNavi MCP HTTP 让远端 Agent 通过本机 GoNavi 访问。'); + if (warnings.some((warning) => warning.code === 'ssh_unsupported')) { + actions.push(redisTopologyCopy( + translate, + 'next_action.disable_ssh', + 'Disable the SSH tunnel and use direct access, proxy/VPN, or GoNavi MCP HTTP so the remote Agent can access Redis through local GoNavi.', + )); } - if (warnings.some((warning) => warning.includes('26379'))) { - actions.push('把 Sentinel 主地址端口改为 26379,除非你的 Sentinel 明确监听其他端口。'); + if (warnings.some((warning) => warning.code === 'sentinel_default_redis_port')) { + actions.push(redisTopologyCopy( + translate, + 'next_action.check_sentinel_port', + 'Change the primary Sentinel address port to 26379 unless your Sentinel explicitly listens on another port.', + )); } - if (warnings.some((warning) => warning.includes('db0'))) { - actions.push('确认业务是否真的需要 Redis Cluster 多库视图;如果只是 key 分组,优先使用业务命名空间。'); + if (warnings.some((warning) => warning.code === 'cluster_logical_db')) { + actions.push(redisTopologyCopy( + translate, + 'next_action.review_cluster_logical_db', + 'Confirm whether the workload really needs a Redis Cluster multi-DB view; if this is only key grouping, prefer an application namespace.', + )); } - if (warnings.some((warning) => warning.includes('多节点集群路径'))) { - actions.push('显式切换为 Cluster 模式,或删除附加节点只保留一个单机地址,避免配置拓扑和后端实际拓扑不一致。'); + if (warnings.some((warning) => warning.code === 'single_multiple_nodes')) { + actions.push(redisTopologyCopy( + translate, + 'next_action.align_single_topology', + 'Explicitly switch to Cluster mode, or remove extra nodes and keep one Standalone address to avoid mismatch between configured and backend topology.', + )); } if (actions.length === 0) { - actions.push(`配置看起来可用于 ${topologyModeLabel(topology)},下一步可以测试连接并查看 Redis DB/Key 树。`); + const topologyLabel = topologyModeLabel(topology, translate); + actions.push(redisTopologyCopy( + translate, + 'next_action.test_connection', + `The configuration looks usable for ${topologyLabel}; next, test the connection and inspect the Redis DB/key tree.`, + { topologyLabel }, + )); } return actions; }; -const buildRedisTopologyRecommendations = (connection: SavedConnection, warnings: string[]): string[] => { +const buildRedisTopologyRecommendations = ( + connection: SavedConnection, + warnings: RedisTopologyWarning[], + translate?: AIInspectionTranslator, +): string[] => { const config = connection.config || {}; const configuredTopology = normalizeRedisTopology(config.topology); const seedAddresses = buildSeedAddresses(connection); @@ -238,20 +370,48 @@ const buildRedisTopologyRecommendations = (connection: SavedConnection, warnings const recommendations: string[] = []; if (configuredTopology === 'sentinel') { - recommendations.push('确认主机和附加节点填写的是 Sentinel 地址,不是 Redis master 地址'); - recommendations.push('分别填写 Redis 数据节点账号密码和 Sentinel 自身账号密码,二者不要混用'); + recommendations.push(redisTopologyCopy( + translate, + 'recommendation.sentinel_addresses', + 'Confirm that host and extra nodes are Sentinel addresses, not Redis master addresses.', + )); + recommendations.push(redisTopologyCopy( + translate, + 'recommendation.separate_auth', + 'Fill Redis data-node credentials and Sentinel credentials separately; do not mix them.', + )); } else if (effectiveTopology === 'cluster') { - recommendations.push('优先配置 2 个以上种子节点,并确认这些节点属于同一个 Redis Cluster'); - recommendations.push('如果需要多库视图,优先在业务 key 上显式使用命名空间,避免误解 Cluster 的物理 db0 限制'); + recommendations.push(redisTopologyCopy( + translate, + 'recommendation.cluster_multiple_seeds', + 'Prefer at least two seed nodes, and confirm these nodes belong to the same Redis Cluster.', + )); + recommendations.push(redisTopologyCopy( + translate, + 'recommendation.cluster_namespace', + 'If a multi-DB view is needed, prefer explicit namespaces in business keys to avoid misunderstanding the physical db0 limit of Cluster.', + )); } else { - recommendations.push('单机模式只填写一个 Redis 地址;如果有多个节点,请改用 Cluster 或 Sentinel 模式'); + recommendations.push(redisTopologyCopy( + translate, + 'recommendation.single_one_address', + 'Standalone mode should contain one Redis address; if there are multiple nodes, use Cluster or Sentinel mode instead.', + )); } - if (warnings.some((warning) => warning.includes('SSH'))) { - recommendations.push('跨网络访问 Redis Cluster/Sentinel 时,优先使用网络代理、VPN 或 GoNavi MCP HTTP,而不是单端口 SSH 隧道'); + if (warnings.some((warning) => warning.code === 'ssh_unsupported')) { + recommendations.push(redisTopologyCopy( + translate, + 'recommendation.network_for_cluster_sentinel', + 'For cross-network Redis Cluster/Sentinel access, prefer a network proxy, VPN, or GoNavi MCP HTTP instead of a single-port SSH tunnel.', + )); } if (config.useSSL === true) { - recommendations.push('已启用 TLS,连接失败时优先核对 sslMode、CA/证书路径和服务端 SNI'); + recommendations.push(redisTopologyCopy( + translate, + 'recommendation.check_tls', + 'TLS is enabled; if connection fails, first check sslMode, CA/certificate paths, and server SNI.', + )); } return recommendations; }; @@ -262,6 +422,7 @@ export const buildRedisTopologySnapshot = (params: { keyword?: unknown; limit?: unknown; includeRecommendations?: unknown; + translate?: AIInspectionTranslator; }) => { const { connections, @@ -269,6 +430,7 @@ export const buildRedisTopologySnapshot = (params: { keyword, limit, includeRecommendations, + translate, } = params; const safeConnectionId = normalizeText(connectionId); const safeKeyword = normalizeLowerText(keyword); @@ -296,11 +458,14 @@ export const buildRedisTopologySnapshot = (params: { const topology = normalizeRedisTopology(config.topology); const seedAddresses = buildSeedAddresses(connection); const effectiveTopology = resolveEffectiveRedisTopology(topology, seedAddresses); - const warnings = buildRedisTopologyWarnings(connection, seedAddresses); + const warningDetails = buildRedisTopologyWarnings(connection, seedAddresses, translate); + const warnings = warningDetails.map((warning) => warning.message); const includeRedisDatabases = Array.isArray(connection.includeRedisDatabases) ? connection.includeRedisDatabases.filter((item) => typeof item === 'number') : []; - const blockingReasons = warnings.filter(isBlockingRedisWarning); + const blockingReasons = warningDetails + .filter((warning) => warning.blocking) + .map((warning) => warning.message); const status = blockingReasons.length > 0 ? 'blocked' : warnings.length > 0 @@ -310,9 +475,9 @@ export const buildRedisTopologySnapshot = (params: { id: connection.id, name: connection.name, topology, - topologyLabel: topologyModeLabel(topology), + topologyLabel: topologyModeLabel(topology, translate), effectiveTopology, - effectiveTopologyLabel: topologyModeLabel(effectiveTopology), + effectiveTopologyLabel: topologyModeLabel(effectiveTopology, translate), topologyMismatch: topology !== effectiveTopology, status, blockingReasons, @@ -334,11 +499,11 @@ export const buildRedisTopologySnapshot = (params: { ? Boolean(normalizeText(config.redisSentinelUser) || normalizeText(config.redisSentinelPassword) || connection.hasRedisSentinelPassword === true) : false, safeUriExample: buildSafeRedisUriExample(connection, seedAddresses), - dbSemantics: buildRedisDBSemantics(connection, effectiveTopology), + dbSemantics: buildRedisDBSemantics(connection, effectiveTopology, translate), warnings, - nextActions: buildRedisNextActions(connection, warnings), + nextActions: buildRedisNextActions(connection, warningDetails, translate), recommendations: shouldIncludeRecommendations - ? buildRedisTopologyRecommendations(connection, warnings) + ? buildRedisTopologyRecommendations(connection, warningDetails, translate) : undefined, }; }); diff --git a/frontend/src/components/ai/aiRuntimeInsights.test.ts b/frontend/src/components/ai/aiRuntimeInsights.test.ts index 1518f0b..a1634dd 100644 --- a/frontend/src/components/ai/aiRuntimeInsights.test.ts +++ b/frontend/src/components/ai/aiRuntimeInsights.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; import type { AIMCPToolDescriptor, AIProviderConfig, AISkillConfig } from '../../types'; @@ -44,6 +46,40 @@ const mcpTools: AIMCPToolDescriptor[] = [{ }]; describe('buildAIRuntimeSnapshot', () => { + it('localizes runtime labels and summary while keeping provider and tool names raw', () => { + const snapshot = buildAIRuntimeSnapshot({ + providers, + activeProviderId: 'provider-1', + safetyLevel: 'readonly', + contextLevel: 'with_samples', + skills, + mcpTools, + dynamicModels: ['gpt-5.4'], + builtinToolNames: ['inspect_ai_runtime'], + translate: (key, params) => { + const suffix = params + ? ` ${Object.entries(params).map(([paramKey, value]) => `${paramKey}=${value}`).join(',')}` + : ''; + return `T:${key}${suffix}`; + }, + }); + + expect(snapshot.safetyLabel).toBe('T:ai_chat.inspection.runtime.safety.readonly'); + expect(snapshot.contextLabel).toBe('T:ai_chat.inspection.runtime.context.with_samples'); + expect(snapshot.activeProvider?.name).toBe('OpenAI 主账号'); + expect(snapshot.mcpTools[0].title).toBe('打开浏览器'); + expect(snapshot.message).toBe('T:ai_chat.inspection.runtime.message.active provider=OpenAI 主账号,toolCount=2'); + }); + + it('keeps runtime production source free of legacy Chinese wrappers', () => { + const source = readFileSync('src/components/ai/aiRuntimeInsights.ts', 'utf8'); + + expect(source).not.toContain('只读'); + expect(source).not.toContain('结构+样例'); + expect(source).not.toContain('当前 AI 正在使用'); + expect(source).not.toContain('当前未启用 AI 供应商'); + }); + it('returns a sanitized runtime snapshot for the active provider, tools, and skills', () => { const snapshot = buildAIRuntimeSnapshot({ providers, @@ -60,9 +96,9 @@ describe('buildAIRuntimeSnapshot', () => { hasActiveProvider: true, providerCount: 1, safetyLevel: 'readonly', - safetyLabel: '只读', + safetyLabel: 'Read-only', contextLevel: 'with_samples', - contextLabel: '结构+样例', + contextLabel: 'Schema + samples', dynamicModelCount: 2, enabledSkillCount: 1, builtinToolCount: 3, @@ -113,7 +149,7 @@ describe('buildAIRuntimeSnapshot', () => { expect(snapshot).toMatchObject({ hasActiveProvider: false, activeProvider: null, - message: '当前未启用 AI 供应商', + message: 'No AI provider is currently active', }); }); }); diff --git a/frontend/src/components/ai/aiRuntimeInsights.ts b/frontend/src/components/ai/aiRuntimeInsights.ts index 6be7f1a..c142c67 100644 --- a/frontend/src/components/ai/aiRuntimeInsights.ts +++ b/frontend/src/components/ai/aiRuntimeInsights.ts @@ -5,17 +5,19 @@ import type { AISafetyLevel, AISkillConfig, } from '../../types'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; -const SAFETY_LEVEL_LABELS: Record = { - readonly: '只读', - readwrite: '读写', - full: '完全开放', +const SAFETY_LEVEL_FALLBACKS: Record = { + readonly: 'Read-only', + readwrite: 'Read/write', + full: 'Full access', }; -const CONTEXT_LEVEL_LABELS: Record = { - schema_only: '仅结构', - with_samples: '结构+样例', - with_results: '结构+结果', +const CONTEXT_LEVEL_FALLBACKS: Record = { + schema_only: 'Schema only', + with_samples: 'Schema + samples', + with_results: 'Schema + results', }; const BUILTIN_TOOL_PREVIEW_LIMIT = 30; @@ -46,6 +48,7 @@ export const buildAIRuntimeSnapshot = (params: { mcpTools?: AIMCPToolDescriptor[]; dynamicModels?: string[]; builtinToolNames?: string[]; + translate?: AIInspectionTranslator; }) => { const { providers = [], @@ -56,6 +59,7 @@ export const buildAIRuntimeSnapshot = (params: { mcpTools = [], dynamicModels = [], builtinToolNames = [], + translate, } = params; const activeProvider = providers.find((provider) => provider.id === activeProviderId) || null; @@ -105,9 +109,17 @@ export const buildAIRuntimeSnapshot = (params: { model: provider.model || '', })), safetyLevel: normalizedSafetyLevel, - safetyLabel: SAFETY_LEVEL_LABELS[normalizedSafetyLevel] || normalizedSafetyLevel, + safetyLabel: translateInspectionCopy( + translate, + `ai_chat.inspection.runtime.safety.${normalizedSafetyLevel}`, + SAFETY_LEVEL_FALLBACKS[normalizedSafetyLevel] || normalizedSafetyLevel, + ), contextLevel: normalizedContextLevel, - contextLabel: CONTEXT_LEVEL_LABELS[normalizedContextLevel] || normalizedContextLevel, + contextLabel: translateInspectionCopy( + translate, + `ai_chat.inspection.runtime.context.${normalizedContextLevel}`, + CONTEXT_LEVEL_FALLBACKS[normalizedContextLevel] || normalizedContextLevel, + ), dynamicModelCount: dynamicModelPreview.total, dynamicModels: dynamicModelPreview.items, dynamicModelsTruncated: dynamicModelPreview.truncated, @@ -134,7 +146,16 @@ export const buildAIRuntimeSnapshot = (params: { hasDynamicModelsLoaded: dynamicModelPreview.total > 0, }, message: activeProvider - ? `当前 AI 正在使用 ${activeProvider.name || activeProvider.id},共暴露 ${builtinPreview.total + mcpPreview.total} 个工具` - : '当前未启用 AI 供应商', + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.runtime.message.active', + `AI is using ${activeProvider.name || activeProvider.id} with ${builtinPreview.total + mcpPreview.total} tools available`, + { provider: activeProvider.name || activeProvider.id, toolCount: builtinPreview.total + mcpPreview.total }, + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.runtime.message.no_provider', + 'No AI provider is currently active', + ), }; }; diff --git a/frontend/src/components/ai/aiSafetyInsights.test.ts b/frontend/src/components/ai/aiSafetyInsights.test.ts index d954c07..a4292c9 100644 --- a/frontend/src/components/ai/aiSafetyInsights.test.ts +++ b/frontend/src/components/ai/aiSafetyInsights.test.ts @@ -1,8 +1,92 @@ +import { readFileSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; import { buildAISafetySnapshot } from './aiSafetyInsights'; +const source = readFileSync(new URL('./aiSafetyInsights.ts', import.meta.url), 'utf8'); +const executorSource = readFileSync(new URL('./aiSnapshotInspectionAIConfigToolExecutor.ts', import.meta.url), 'utf8'); +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; +const requiredSafetyKeys = [ + 'ai_chat.inspection.safety.rule.readonly', + 'ai_chat.inspection.safety.rule.readwrite', + 'ai_chat.inspection.safety.rule.full', + 'ai_chat.inspection.safety.restriction.readonly_blocks_mutating', + 'ai_chat.inspection.safety.restriction.non_query_confirmation', + 'ai_chat.inspection.safety.restriction.mcp_allow_mutating', + 'ai_chat.inspection.safety.restriction.active_result_readonly', + 'ai_chat.inspection.safety.restriction.jvm_readonly', + 'ai_chat.inspection.safety.restriction.jvm_mutating_disabled', + 'ai_chat.inspection.safety.recommendation.enable_readwrite_for_dml', + 'ai_chat.inspection.safety.recommendation.enable_full_for_ddl', + 'ai_chat.inspection.safety.recommendation.full_required_for_schema', + 'ai_chat.inspection.safety.recommendation.open_editable_grid', + 'ai_chat.inspection.safety.recommendation.confirm_jvm_policy', + 'ai_chat.inspection.safety.recommendation.enable_jvm_mutating', + 'ai_chat.inspection.safety.message.active', + 'ai_chat.inspection.safety.message.no_connection', +] as const; + describe('buildAISafetySnapshot', () => { + it('localizes safety labels, restrictions, recommendations and summary while preserving raw connection names', () => { + const snapshot = buildAISafetySnapshot({ + safetyLevel: 'readonly', + connections: [ + { + id: 'conn-1', + name: '生产主库', + config: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + user: 'root', + }, + }, + ], + activeContext: { connectionId: 'conn-1', dbName: 'orders' }, + translate: (key, params) => { + const suffix = params + ? ` ${Object.entries(params).map(([paramKey, value]) => `${paramKey}=${value}`).join(',')}` + : ''; + return `T:${key}${suffix}`; + }, + }); + + expect(snapshot.safetyLabel).toBe('T:ai_chat.inspection.runtime.safety.readonly'); + expect(snapshot.sqlRuleText).toBe('T:ai_chat.inspection.safety.rule.readonly'); + expect(snapshot.effectiveRestrictions).toContain('T:ai_chat.inspection.safety.rule.readonly'); + expect(snapshot.effectiveRestrictions).toContain('T:ai_chat.inspection.safety.restriction.readonly_blocks_mutating'); + expect(snapshot.recommendations).toContain('T:ai_chat.inspection.safety.recommendation.enable_readwrite_for_dml'); + expect(snapshot.message).toBe('T:ai_chat.inspection.safety.message.active safety=T:ai_chat.inspection.runtime.safety.readonly,connection=生产主库'); + expect(snapshot.activeConnection?.connectionName).toBe('生产主库'); + }); + + it('keeps safety production source free of legacy Chinese wrappers and threads translate from the executor', () => { + expect(executorSource).toContain('buildAISafetySnapshot({'); + expect(executorSource).toMatch(/buildAISafetySnapshot\(\{[\s\S]*translate,/); + + [ + '只读模式仅允许查询语句。', + '当前安全级别下,任何 DML/DDL 都会被直接阻止。', + '任何允许通过的非查询语句都仍然需要人工确认。', + '当前 JVM 诊断明确禁止 mutating 命令', + '如需执行 INSERT/UPDATE/DELETE', + '当前 AI 安全级别为', + '当前没有活动连接', + ].forEach((legacyCopy) => { + expect(source).not.toContain(legacyCopy); + }); + }); + + it('keeps safety inspection catalog keys available in every locale', () => { + locales.forEach((locale) => { + const catalog = JSON.parse(readFileSync(new URL(`../../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record; + requiredSafetyKeys.forEach((key) => { + expect(catalog[key], `${locale}:${key}`).toBeTruthy(); + }); + }); + }); + it('describes readonly ai safety when no active connection is selected', () => { const snapshot = buildAISafetySnapshot({ safetyLevel: 'readonly', @@ -14,8 +98,8 @@ describe('buildAISafetySnapshot', () => { expect(snapshot.permissionMatrix.allowDML).toBe(false); expect(snapshot.permissionMatrix.allowDDL).toBe(false); expect(snapshot.hasActiveConnection).toBe(false); - expect(snapshot.effectiveRestrictions).toContain('只读模式仅允许查询语句。'); - expect(snapshot.recommendations).toContain('如需执行 INSERT/UPDATE/DELETE,请先把 AI 安全级别切到读写模式。'); + expect(snapshot.effectiveRestrictions).toContain('Read-only mode only allows query statements.'); + expect(snapshot.recommendations).toContain('Switch AI safety level to read/write mode before executing INSERT/UPDATE/DELETE.'); }); it('includes jvm connection restrictions and MCP write confirmation hints', () => { @@ -60,7 +144,7 @@ describe('buildAISafetySnapshot', () => { expect(snapshot.activeConnection?.readOnly).toBe(true); expect(snapshot.jvmGuards?.allowMutatingCommands).toBe(false); expect(snapshot.effectiveRestrictions.join('\n')).toContain('allowMutating=true'); - expect(snapshot.effectiveRestrictions.join('\n')).toContain('当前 JVM 诊断明确禁止 mutating 命令'); + expect(snapshot.effectiveRestrictions.join('\n')).toContain('Current JVM diagnostics explicitly disallow mutating commands'); }); it('describes full safety mode as allowing other statements with confirmation', () => { @@ -72,7 +156,7 @@ describe('buildAISafetySnapshot', () => { expect(snapshot.safetyLevel).toBe('full'); expect(snapshot.permissionMatrix.allowDML).toBe(true); expect(snapshot.permissionMatrix.allowDDL).toBe(true); - expect(snapshot.sqlRuleText).toContain('允许所有 SQL 操作'); - expect(snapshot.effectiveRestrictions.join('\n')).toContain('高风险或未识别语句仍会要求确认'); + expect(snapshot.sqlRuleText).toContain('allows all SQL operations'); + expect(snapshot.effectiveRestrictions.join('\n')).toContain('high-risk or unrecognized statements still require confirmation'); }); }); diff --git a/frontend/src/components/ai/aiSafetyInsights.ts b/frontend/src/components/ai/aiSafetyInsights.ts index 6f1a958..f79e85c 100644 --- a/frontend/src/components/ai/aiSafetyInsights.ts +++ b/frontend/src/components/ai/aiSafetyInsights.ts @@ -1,15 +1,17 @@ import type { AISafetyLevel, SavedConnection, TabData } from '../../types'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; -const SAFETY_LEVEL_LABELS: Record = { - readonly: '只读', - readwrite: '读写', - full: '完全开放', +const SAFETY_LEVEL_FALLBACKS: Record = { + readonly: 'Read-only', + readwrite: 'Read/write', + full: 'Full access', }; -const SAFETY_RULE_TEXTS: Record = { - readonly: '只读模式仅允许查询语句。', - readwrite: '读写模式允许查询和 DML,DDL 仍会被阻止。', - full: '完全开放模式允许所有 SQL 操作;高风险或未识别语句仍会要求确认。', +const SAFETY_RULE_FALLBACKS: Record = { + readonly: 'Read-only mode only allows query statements.', + readwrite: 'Read/write mode allows queries and DML; DDL is still blocked.', + full: 'Full access mode allows all SQL operations; high-risk or unrecognized statements still require confirmation.', }; const normalizeSafetyLevel = (value: AISafetyLevel | string | undefined): string => { @@ -34,6 +36,7 @@ export const buildAISafetySnapshot = (params: { tabs?: TabData[]; activeTabId?: string | null; connections: SavedConnection[]; + translate?: AIInspectionTranslator; }) => { const { safetyLevel, @@ -41,6 +44,7 @@ export const buildAISafetySnapshot = (params: { tabs = [], activeTabId = null, connections, + translate, } = params; const normalizedSafetyLevel = normalizeSafetyLevel(safetyLevel); @@ -56,47 +60,105 @@ export const buildAISafetySnapshot = (params: { const isJVMSession = config?.type === 'jvm'; const activeResultReadOnly = activeTab?.readOnly === true; const jvmReadOnly = isJVMSession && jvm?.readOnly !== false; + const safetyLabel = translateInspectionCopy( + translate, + `ai_chat.inspection.runtime.safety.${normalizedSafetyLevel}`, + SAFETY_LEVEL_FALLBACKS[normalizedSafetyLevel] || normalizedSafetyLevel, + ); + const sqlRuleText = translateInspectionCopy( + translate, + `ai_chat.inspection.safety.rule.${normalizedSafetyLevel}`, + SAFETY_RULE_FALLBACKS[normalizedSafetyLevel] || SAFETY_RULE_FALLBACKS.readonly, + ); const effectiveRestrictions = [ - SAFETY_RULE_TEXTS[normalizedSafetyLevel] || SAFETY_RULE_TEXTS.readonly, + sqlRuleText, ]; if (normalizedSafetyLevel === 'readonly') { - effectiveRestrictions.push('当前安全级别下,任何 DML/DDL 都会被直接阻止。'); + effectiveRestrictions.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.restriction.readonly_blocks_mutating', + 'At the current safety level, all DML/DDL is blocked directly.', + )); } else { - effectiveRestrictions.push('任何允许通过的非查询语句都仍然需要人工确认。'); - effectiveRestrictions.push('如果通过 GoNavi MCP 的 execute_sql 执行非查询语句,还必须显式传 allowMutating=true。'); + effectiveRestrictions.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.restriction.non_query_confirmation', + 'Any allowed non-query statement still requires human confirmation.', + )); + effectiveRestrictions.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.restriction.mcp_allow_mutating', + 'When executing non-query statements through GoNavi MCP execute_sql, allowMutating=true must also be passed explicitly.', + )); } if (activeResultReadOnly) { - effectiveRestrictions.push('当前活动页签结果集是只读的,不能把它当成可直接回写的数据网格。'); + effectiveRestrictions.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.restriction.active_result_readonly', + 'The current active tab result set is read-only and cannot be treated as a directly writable data grid.', + )); } if (jvmReadOnly) { - effectiveRestrictions.push('当前 JVM 连接本身是只读连接,默认只能按观察/排障思路生成诊断计划。'); + effectiveRestrictions.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.restriction.jvm_readonly', + 'The current JVM connection is read-only, so diagnostic plans should default to observation and troubleshooting.', + )); } if (isJVMSession && diagnostic?.allowMutatingCommands !== true) { - effectiveRestrictions.push('当前 JVM 诊断明确禁止 mutating 命令,即使 AI 安全级别允许写入,也不能假设这类命令能执行。'); + effectiveRestrictions.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.restriction.jvm_mutating_disabled', + 'Current JVM diagnostics explicitly disallow mutating commands, even if the AI safety level allows writes.', + )); } const recommendations: string[] = []; if (normalizedSafetyLevel === 'readonly') { - recommendations.push('如需执行 INSERT/UPDATE/DELETE,请先把 AI 安全级别切到读写模式。'); - recommendations.push('如需执行 CREATE/ALTER/DROP/TRUNCATE 等结构变更,请切到完全开放模式。'); + recommendations.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.recommendation.enable_readwrite_for_dml', + 'Switch AI safety level to read/write mode before executing INSERT/UPDATE/DELETE.', + )); + recommendations.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.recommendation.enable_full_for_ddl', + 'Switch to full access mode before executing CREATE/ALTER/DROP/TRUNCATE schema changes.', + )); } else if (normalizedSafetyLevel === 'readwrite') { - recommendations.push('当前已经允许 DML;如果目标是改表结构,仍需要切到完全开放模式。'); + recommendations.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.recommendation.full_required_for_schema', + 'DML is already allowed; schema changes still require full access mode.', + )); } if (activeResultReadOnly) { - recommendations.push('如果目标是编辑结果网格,请重新打开可编辑的表或查询结果,不要只看当前只读页签。'); + recommendations.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.recommendation.open_editable_grid', + 'If the goal is editing a result grid, reopen an editable table or query result instead of the current read-only tab.', + )); } if (jvmReadOnly) { - recommendations.push('当前 JVM 连接按只读策略回答;需要变更类诊断前先确认连接策略是否应调整。'); + recommendations.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.recommendation.confirm_jvm_policy', + 'The current JVM connection should be treated as read-only; confirm whether its policy should change before mutating diagnostics.', + )); } if (isJVMSession && diagnostic?.allowMutatingCommands !== true) { - recommendations.push('当前 JVM 诊断禁止 mutating 命令;如需执行高风险命令,应先调整诊断权限。'); + recommendations.push(translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.recommendation.enable_jvm_mutating', + 'JVM diagnostics currently disallow mutating commands; adjust diagnostic permissions before high-risk commands.', + )); } return { safetyLevel: normalizedSafetyLevel, - safetyLabel: SAFETY_LEVEL_LABELS[normalizedSafetyLevel] || normalizedSafetyLevel, - sqlRuleText: SAFETY_RULE_TEXTS[normalizedSafetyLevel] || SAFETY_RULE_TEXTS.readonly, + safetyLabel, + sqlRuleText, permissionMatrix: buildPermissionMatrix(normalizedSafetyLevel), hasActiveConnection: Boolean(connection), activeConnection: connection @@ -129,7 +191,17 @@ export const buildAISafetySnapshot = (params: { effectiveRestrictions, recommendations, message: connection - ? `当前 AI 安全级别为 ${SAFETY_LEVEL_LABELS[normalizedSafetyLevel] || normalizedSafetyLevel},活动连接为 ${connection.name}` - : `当前 AI 安全级别为 ${SAFETY_LEVEL_LABELS[normalizedSafetyLevel] || normalizedSafetyLevel},当前没有活动连接`, + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.message.active', + `AI safety level is ${safetyLabel}; active connection is ${connection.name}`, + { safety: safetyLabel, connection: connection.name }, + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.safety.message.no_connection', + `AI safety level is ${safetyLabel}; no active connection is selected`, + { safety: safetyLabel }, + ), }; }; diff --git a/frontend/src/components/ai/aiSettingsModalConfig.test.tsx b/frontend/src/components/ai/aiSettingsModalConfig.test.tsx index 8b6f95e..00515ef 100644 --- a/frontend/src/components/ai/aiSettingsModalConfig.test.tsx +++ b/frontend/src/components/ai/aiSettingsModalConfig.test.tsx @@ -1,15 +1,20 @@ +import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { QWEN_CODING_PLAN_ANTHROPIC_BASE_URL } from '../../utils/aiProviderPresets'; +import { t as translateCatalog } from '../../i18n/catalog'; import { EMPTY_MCP_SERVER, EMPTY_SKILL, PROVIDER_PRESETS, findPreset, + localizeProviderPresets, matchProviderPreset, } from './aiSettingsModalConfig'; describe('aiSettingsModalConfig', () => { + const source = readFileSync(new URL('./aiSettingsModalConfig.tsx', import.meta.url), 'utf8'); + it('finds the matching preset and falls back to custom when the key is unknown', () => { expect(findPreset('openai').label).toBe('OpenAI'); expect(findPreset('missing-preset').key).toBe('custom'); @@ -41,4 +46,49 @@ describe('aiSettingsModalConfig', () => { expect(PROVIDER_PRESETS.some((item) => item.key === 'openai')).toBe(true); expect(PROVIDER_PRESETS.some((item) => item.key === 'custom')).toBe(true); }); + + it('localizes provider preset card copy through existing catalog keys', () => { + const localized = localizeProviderPresets(PROVIDER_PRESETS, (key) => translateCatalog('en-US', key)); + const qwen = localized.find((item) => item.key === 'qwen-bailian'); + const custom = localized.find((item) => item.key === 'custom'); + + expect(qwen).toMatchObject({ + label: 'Qwen (Bailian General)', + desc: 'Bailian Anthropic-compatible endpoint / remote model list', + }); + expect(custom).toMatchObject({ + label: 'Custom', + desc: 'Custom API endpoint', + }); + expect(localized.find((item) => item.key === 'minimax')).toMatchObject({ + desc: 'M3 / M2.7 series (Anthropic-compatible)', + }); + }); + + it('keeps provider preset source copy behind catalog keys', () => { + for (const preset of PROVIDER_PRESETS) { + expect(preset.labelKey).toMatch(/^ai_settings\.provider_preset\.[a-z0-9_]+\.label$/); + expect(preset.descKey).toMatch(/^ai_settings\.provider_preset\.[a-z0-9_]+\.desc$/); + expect(source).toContain(preset.labelKey); + expect(source).toContain(preset.descKey); + } + + [ + '通义千问(百炼通用)', + '百炼 Anthropic 兼容 / 模型从远端拉取', + '通义千问(Coding Plan)', + 'Claude Code CLI 代理链路 / 使用官方支持模型清单', + '智谱 GLM', + 'Kimi K2.5 (Anthropic 兼容)', + 'Gemini 3.1 / 2.5 系列', + '火山方舟', + 'Ark 通用推理 / 豆包模型', + '火山 Coding Plan', + '本地部署开源模型', + '自定义', + '自定义 API 端点', + ].forEach((legacyCopy) => { + expect(source).not.toContain(legacyCopy); + }); + }); }); diff --git a/frontend/src/components/ai/aiSettingsModalConfig.tsx b/frontend/src/components/ai/aiSettingsModalConfig.tsx index a806d61..25f4a33 100644 --- a/frontend/src/components/ai/aiSettingsModalConfig.tsx +++ b/frontend/src/components/ai/aiSettingsModalConfig.tsx @@ -24,8 +24,10 @@ import { export interface ProviderPreset { key: string; label: string; + labelKey: string; icon: React.ReactNode; desc: string; + descKey: string; color: string; backendType: AIProviderType; fixedApiFormat?: string; @@ -35,21 +37,41 @@ export interface ProviderPreset { } export 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: 'M3 / M2.7 系列 (Anthropic 兼容)', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M3', models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'] }, - { 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', label: 'OpenAI', labelKey: 'ai_settings.provider_preset.openai.label', icon: , desc: 'GPT-5.4 / 5.3 series', descKey: 'ai_settings.provider_preset.openai.desc', color: '#10b981', backendType: 'openai', defaultBaseUrl: 'https://api.openai.com/v1', defaultModel: 'gpt-4o', models: [] }, + { key: 'deepseek', label: 'DeepSeek', labelKey: 'ai_settings.provider_preset.deepseek.label', icon: , desc: 'DeepSeek-V4 / R1', descKey: 'ai_settings.provider_preset.deepseek.desc', color: '#3b82f6', backendType: 'openai', defaultBaseUrl: 'https://api.deepseek.com/v1', defaultModel: 'deepseek-chat', models: [] }, + { key: 'qwen-bailian', label: 'Qwen (Bailian General)', labelKey: 'ai_settings.provider_preset.qwen_bailian.label', icon: , desc: 'Bailian Anthropic-compatible endpoint / remote model list', descKey: 'ai_settings.provider_preset.qwen_bailian.desc', color: '#6366f1', backendType: 'anthropic', defaultBaseUrl: QWEN_BAILIAN_ANTHROPIC_BASE_URL, defaultModel: '', models: [] }, + { key: 'qwen-coding-plan', label: 'Qwen (Coding Plan)', labelKey: 'ai_settings.provider_preset.qwen_coding_plan.label', icon: , desc: 'Claude Code CLI proxy chain / official supported model list', descKey: 'ai_settings.provider_preset.qwen_coding_plan.desc', color: '#4f46e5', backendType: 'custom', fixedApiFormat: 'claude-cli', defaultBaseUrl: QWEN_CODING_PLAN_ANTHROPIC_BASE_URL, defaultModel: '', models: QWEN_CODING_PLAN_MODELS }, + { key: 'zhipu', label: 'Zhipu GLM', labelKey: 'ai_settings.provider_preset.zhipu.label', icon: , desc: 'GLM-5 / GLM-5-Turbo', descKey: 'ai_settings.provider_preset.zhipu.desc', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4', defaultModel: 'glm-4', models: [] }, + { key: 'moonshot', label: 'Kimi', labelKey: 'ai_settings.provider_preset.moonshot.label', icon: , desc: 'Kimi K2.5 (Anthropic-compatible)', descKey: 'ai_settings.provider_preset.moonshot.desc', color: '#0d9488', backendType: 'anthropic', defaultBaseUrl: 'https://api.moonshot.cn/anthropic', defaultModel: 'moonshot-v1-8k', models: [] }, + { key: 'anthropic', label: 'Claude', labelKey: 'ai_settings.provider_preset.anthropic.label', icon: , desc: 'Claude Opus/Sonnet', descKey: 'ai_settings.provider_preset.anthropic.desc', color: '#d97706', backendType: 'anthropic', defaultBaseUrl: 'https://api.anthropic.com', defaultModel: 'claude-3-5-sonnet-20241022', models: [] }, + { key: 'gemini', label: 'Gemini', labelKey: 'ai_settings.provider_preset.gemini.label', icon: , desc: 'Gemini 3.1 / 2.5 series', descKey: 'ai_settings.provider_preset.gemini.desc', color: '#059669', backendType: 'gemini', defaultBaseUrl: 'https://generativelanguage.googleapis.com', defaultModel: 'gemini-2.5-flash', models: [] }, + { key: 'volcengine-ark', label: 'Volcengine Ark', labelKey: 'ai_settings.provider_preset.volcengine_ark.label', icon: , desc: 'Ark general inference / Doubao models', descKey: 'ai_settings.provider_preset.volcengine_ark.desc', color: '#0ea5e9', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/v3', defaultModel: '', models: [] }, + { key: 'volcengine-coding', label: 'Volcengine Coding Plan', labelKey: 'ai_settings.provider_preset.volcengine_coding.label', icon: , desc: 'Ark Code / Coding Plan', descKey: 'ai_settings.provider_preset.volcengine_coding.desc', color: '#0284c7', backendType: 'openai', defaultBaseUrl: 'https://ark.cn-beijing.volces.com/api/coding/v3', defaultModel: '', models: [] }, + { key: 'minimax', label: 'MiniMax', labelKey: 'ai_settings.provider_preset.minimax.label', icon: , desc: 'M3 / M2.7 series (Anthropic-compatible)', descKey: 'ai_settings.provider_preset.minimax.desc', color: '#e11d48', backendType: 'anthropic', defaultBaseUrl: 'https://api.minimaxi.com/anthropic', defaultModel: 'MiniMax-M3', models: ['MiniMax-M3', 'MiniMax-M2.7', 'MiniMax-M2.7-highspeed'] }, + { key: 'ollama', label: 'Ollama', labelKey: 'ai_settings.provider_preset.ollama.label', icon: , desc: 'Locally deployed open-source models', descKey: 'ai_settings.provider_preset.ollama.desc', color: '#78716c', backendType: 'openai', defaultBaseUrl: 'http://localhost:11434/v1', defaultModel: 'llama3', models: [] }, + { key: 'custom', label: 'Custom', labelKey: 'ai_settings.provider_preset.custom.label', icon: , desc: 'Custom API endpoint', descKey: 'ai_settings.provider_preset.custom.desc', color: '#64748b', backendType: 'custom', defaultBaseUrl: '', defaultModel: '', models: [] }, ]; +type ProviderPresetTranslator = (key: string) => string; + +export const localizeProviderPreset = ( + preset: ProviderPreset, + translate: ProviderPresetTranslator, +): ProviderPreset => { + const label = translate(preset.labelKey); + const desc = translate(preset.descKey); + return { + ...preset, + label: label && label !== preset.labelKey ? label : preset.label, + desc: desc && desc !== preset.descKey ? desc : preset.desc, + }; +}; + +export const localizeProviderPresets = ( + presets: ProviderPreset[], + translate: ProviderPresetTranslator, +): ProviderPreset[] => presets.map((preset) => localizeProviderPreset(preset, translate)); + export const findPreset = (key: string): ProviderPreset => PROVIDER_PRESETS.find((preset) => preset.key === key) || PROVIDER_PRESETS[PROVIDER_PRESETS.length - 1]; diff --git a/frontend/src/components/ai/aiSetupHealthInsights.test.ts b/frontend/src/components/ai/aiSetupHealthInsights.test.ts index d0454a0..7b81344 100644 --- a/frontend/src/components/ai/aiSetupHealthInsights.test.ts +++ b/frontend/src/components/ai/aiSetupHealthInsights.test.ts @@ -1,8 +1,67 @@ +import { readFileSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; import { buildAISetupHealthSnapshot } from './aiSetupHealthInsights'; describe('buildAISetupHealthSnapshot', () => { + it('localizes setup health wrappers while keeping provider and skill names raw', () => { + const snapshot = buildAISetupHealthSnapshot({ + providers: [{ + id: 'provider-1', + type: 'openai', + name: 'OpenAI 主账号', + apiKey: '', + hasSecret: false, + baseUrl: '', + model: '', + models: [], + maxTokens: 32000, + temperature: 0.2, + }], + activeProviderId: 'provider-1', + builtinToolNames: [], + mcpServers: [], + mcpClientStatuses: [], + mcpTools: [], + skills: [], + dynamicModels: [], + userPromptSettings: { + global: '', + database: '', + jvm: '', + jvmDiagnostic: '', + }, + activeContext: { + connectionId: 'conn-1', + dbName: 'crm', + }, + activeContextItems: [], + translate: (key, params) => { + const suffix = params + ? ` ${Object.entries(params).map(([paramKey, value]) => `${paramKey}=${value}`).join(',')}` + : ''; + return `T:${key}${suffix}`; + }, + }); + + expect(snapshot.blockers).toContain('T:ai_chat.inspection.setup.blocker.missing_secret'); + expect(snapshot.blockers).toContain('T:ai_chat.inspection.setup.blocker.missing_base_url'); + expect(snapshot.nextActions).toContain('T:ai_chat.inspection.setup.next_action.fill_secret'); + expect(snapshot.warnings).toContain('T:ai_chat.inspection.setup.warning.no_mcp_servers'); + expect(snapshot.message).toBe('T:ai_chat.inspection.setup.message.blocked count=3'); + expect(snapshot.summary.activeProviderName).toBe('OpenAI 主账号'); + }); + + it('keeps setup health production source free of legacy Chinese wrappers', () => { + const source = readFileSync('src/components/ai/aiSetupHealthInsights.ts', 'utf8'); + + expect(source).not.toContain('当前没有活动 AI 供应商'); + expect(source).not.toContain('当前活动供应商缺少 API Key / Secret'); + expect(source).not.toContain('当前还没有配置任何 MCP 服务'); + expect(source).not.toContain('当前 AI 配置体检通过'); + }); + it('marks the setup as blocked when the active provider is missing critical pieces', () => { const snapshot = buildAISetupHealthSnapshot({ providers: [{ @@ -38,11 +97,11 @@ describe('buildAISetupHealthSnapshot', () => { }); expect(snapshot.status).toBe('blocked'); - expect(snapshot.blockers).toContain('当前活动供应商缺少 API Key / Secret'); - expect(snapshot.blockers).toContain('当前活动供应商缺少接口地址'); - expect(snapshot.blockers).toContain('当前活动供应商还没有选中模型'); - expect(snapshot.nextActions).toContain('补齐当前活动供应商的密钥'); - expect(snapshot.nextActions).toContain('为当前活动供应商选择一个可用模型'); + expect(snapshot.blockers).toContain('The active provider is missing an API Key / Secret'); + expect(snapshot.blockers).toContain('The active provider is missing a base URL'); + expect(snapshot.blockers).toContain('The active provider has no selected model'); + expect(snapshot.nextActions).toContain('Fill in the active provider secret'); + expect(snapshot.nextActions).toContain('Select an available model for the active provider'); expect(snapshot.summary.chatReady).toBe(false); }); @@ -128,7 +187,7 @@ describe('buildAISetupHealthSnapshot', () => { expect(snapshot.summary.activeProviderName).toBe('OpenAI 主账号'); expect(snapshot.summary.currentExternalClientCount).toBe(1); expect(snapshot.summary.enabledSkillCount).toBe(1); - expect(snapshot.mcp.message).toContain('当前共配置 1 个 MCP 服务'); + expect(snapshot.mcp.message).toContain('1 MCP server is configured'); expect(snapshot.guidance.enabledSkillPreview).toContain('结构审查'); }); @@ -203,8 +262,8 @@ describe('buildAISetupHealthSnapshot', () => { expect(snapshot.status).toBe('needs_attention'); expect(snapshot.summary.mcpServerConfigurationIssueCount).toBe(2); expect(snapshot.summary.mcpServersWithConfigurationErrors).toBe(1); - expect(snapshot.warnings).toContain('有 1 个 MCP 服务存在启动配置错误,测试和工具发现可能失败'); - expect(snapshot.nextActions).toContain('先修复 MCP 服务配置检查里的错误项,再重新测试服务'); + expect(snapshot.warnings).toContain('1 MCP server has launch configuration errors; testing and tool discovery may fail'); + expect(snapshot.nextActions).toContain('Fix the MCP server configuration errors first, then test the server again'); expect(snapshot.mcp.servers[0].configurationIssues.map((issue) => issue.key)).toContain('command-missing'); }); }); diff --git a/frontend/src/components/ai/aiSetupHealthInsights.ts b/frontend/src/components/ai/aiSetupHealthInsights.ts index 05e6c7c..b90e873 100644 --- a/frontend/src/components/ai/aiSetupHealthInsights.ts +++ b/frontend/src/components/ai/aiSetupHealthInsights.ts @@ -13,6 +13,8 @@ import { buildAIGuidanceSnapshot } from './aiPromptInsights'; import { buildAIProviderSnapshot } from './aiProviderInsights'; import { buildAIRuntimeSnapshot } from './aiRuntimeInsights'; import { buildMCPSetupSnapshot } from './aiMCPInsights'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; type AISetupHealthStatus = 'ready' | 'needs_attention' | 'blocked'; @@ -44,7 +46,9 @@ export const buildAISetupHealthSnapshot = (params: { userPromptSettings?: AIUserPromptSettings; activeContext?: { connectionId?: string | null; dbName?: string | null } | null; activeContextItems?: AIContextItem[]; + translate?: AIInspectionTranslator; }) => { + const translate = params.translate; const activeContextItems = Array.isArray(params.activeContextItems) ? params.activeContextItems : []; const runtimeSnapshot = buildAIRuntimeSnapshot({ providers: params.providers, @@ -55,11 +59,13 @@ export const buildAISetupHealthSnapshot = (params: { mcpTools: params.mcpTools, dynamicModels: params.dynamicModels, builtinToolNames: params.builtinToolNames, + translate, }); const providerSnapshot = buildAIProviderSnapshot({ providers: params.providers, activeProviderId: params.activeProviderId, dynamicModels: params.dynamicModels, + translate, }); const chatReadiness = buildAIChatReadinessSnapshot({ providers: params.providers, @@ -67,15 +73,18 @@ export const buildAISetupHealthSnapshot = (params: { dynamicModels: params.dynamicModels, activeContext: params.activeContext, activeContextItems, + translate, }); const mcpSnapshot = buildMCPSetupSnapshot({ mcpServers: params.mcpServers, mcpClientStatuses: params.mcpClientStatuses, mcpTools: params.mcpTools, + translate, }); const guidanceSnapshot = buildAIGuidanceSnapshot({ userPromptSettings: params.userPromptSettings, skills: params.skills, + translate, }); const blockers: string[] = []; @@ -83,49 +92,129 @@ export const buildAISetupHealthSnapshot = (params: { const nextActions: string[] = []; if (!providerSnapshot.hasActiveProvider) { - appendUnique(blockers, '当前没有活动 AI 供应商'); - appendUnique(nextActions, '先在 AI 设置里添加并选中一个活动供应商'); + appendUnique(blockers, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.blocker.no_active_provider', + 'No active AI provider is selected', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.next_action.select_provider', + 'Add and select an active provider in AI settings first', + )); } const activeProviderIssues = providerSnapshot.activeProvider?.issues || []; if (activeProviderIssues.includes('missing_secret')) { - appendUnique(blockers, '当前活动供应商缺少 API Key / Secret'); - appendUnique(nextActions, '补齐当前活动供应商的密钥'); + appendUnique(blockers, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.blocker.missing_secret', + 'The active provider is missing an API Key / Secret', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.next_action.fill_secret', + 'Fill in the active provider secret', + )); } if (activeProviderIssues.includes('missing_base_url')) { - appendUnique(blockers, '当前活动供应商缺少接口地址'); - appendUnique(nextActions, '补齐当前活动供应商的 baseUrl'); + appendUnique(blockers, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.blocker.missing_base_url', + 'The active provider is missing a base URL', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.next_action.fill_base_url', + 'Fill in the active provider baseUrl', + )); } if (activeProviderIssues.includes('missing_selected_model') || chatReadiness.status === 'missing_model') { - appendUnique(blockers, '当前活动供应商还没有选中模型'); - appendUnique(nextActions, '为当前活动供应商选择一个可用模型'); + appendUnique(blockers, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.blocker.missing_model', + 'The active provider has no selected model', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.next_action.select_model', + 'Select an available model for the active provider', + )); } if (chatReadiness.status === 'loading_models') { - appendUnique(warnings, '当前正在加载模型列表,模型选择尚未完成'); - appendUnique(nextActions, '等待模型列表加载完成后重新确认活动模型'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.warning.loading_models', + 'The model list is still loading, so model selection is not complete yet', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.next_action.wait_models', + 'Wait for the model list to finish loading, then confirm the active model again', + )); } if (mcpSnapshot.serverCount === 0) { - appendUnique(warnings, '当前还没有配置任何 MCP 服务'); - appendUnique(nextActions, '如需扩展 AI 工具能力,可新增并测试至少 1 个 MCP 服务'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.warning.no_mcp_servers', + 'No MCP servers are configured yet', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.next_action.add_mcp_server', + 'To extend AI tool capabilities, add and test at least one MCP server', + )); } mcpSnapshot.warnings.forEach((warning) => appendUnique(warnings, warning)); mcpSnapshot.nextActions.forEach((action) => appendUnique(nextActions, action)); if (mcpSnapshot.currentClientCount === 0) { - appendUnique(warnings, 'Claude Code / Codex 还没有本机客户端接入当前 GoNavi MCP,OpenClaw/Hermans 需要远程桥接'); - appendUnique(nextActions, '如需让外部 Agent 使用 GoNavi MCP,本机客户端可接入 Claude Code/Codex,云端 Agent 先配置远程 MCP 桥接'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.warning.external_client_not_connected', + 'Claude Code / Codex is not connected to the current GoNavi MCP as a local client yet; OpenClaw/Hermans need a remote bridge', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.next_action.connect_external_client', + 'To let external Agents use GoNavi MCP, connect local clients such as Claude Code/Codex or configure a remote MCP bridge for cloud Agents', + )); } if (mcpSnapshot.enabledServerCount > 0 && runtimeSnapshot.mcpToolCount === 0) { - appendUnique(warnings, '已启用 MCP 服务,但当前还没有发现可用 MCP 工具'); - appendUnique(nextActions, '逐条测试已启用的 MCP 服务,确认命令、参数和环境变量能正确发现工具'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.warning.no_mcp_tools', + 'MCP servers are enabled, but no available MCP tools have been discovered yet', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.next_action.test_mcp_servers', + 'Test each enabled MCP server and confirm its command, arguments, and environment variables can discover tools correctly', + )); } if (guidanceSnapshot.customPromptCount === 0 && guidanceSnapshot.enabledSkillCount === 0) { - appendUnique(warnings, '当前没有自定义提示词或 Skills'); - appendUnique(nextActions, '如需固定回答风格或工作流,可补充自定义提示词或启用 Skills'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.warning.no_guidance', + 'No custom prompts or Skills are configured', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.next_action.add_guidance', + 'To pin response style or workflow, add custom prompts or enable Skills', + )); } if (chatReadiness.ready && params.activeContext?.connectionId && activeContextItems.length === 0) { - appendUnique(warnings, '当前聊天已就绪,但还没有挂载任何表结构上下文'); - appendUnique(nextActions, '如需更准的 SQL / 结构建议,可先把目标表结构关联到 AI 上下文'); + appendUnique(warnings, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.warning.no_schema_context', + 'Chat is ready, but no table schema context is attached yet', + )); + appendUnique(nextActions, translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.next_action.attach_schema_context', + 'For more accurate SQL or schema suggestions, attach the target table schema to AI context first', + )); } const status: AISetupHealthStatus = blockers.length > 0 @@ -135,10 +224,24 @@ export const buildAISetupHealthSnapshot = (params: { : 'ready'; const message = status === 'ready' - ? '当前 AI 配置体检通过,供应商、聊天前置和 MCP 运行链路都处于可用状态' + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.message.ready', + 'AI setup health passed; provider, chat prerequisites, and MCP runtime path are available', + ) : status === 'blocked' - ? `当前 AI 配置存在 ${blockers.length} 个阻塞项,优先修复活动供应商和聊天前置条件` - : `当前 AI 配置整体可用,但还有 ${warnings.length} 个建议项可以继续优化`; + ? translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.message.blocked', + `AI setup has ${blockers.length} blockers; fix the active provider and chat prerequisites first`, + { count: blockers.length }, + ) + : translateInspectionCopy( + translate, + 'ai_chat.inspection.setup.message.needs_attention', + `AI setup is usable overall, but ${warnings.length} recommendations can still be optimized`, + { count: warnings.length }, + ); return { status, diff --git a/frontend/src/components/ai/aiShortcutInsights.test.ts b/frontend/src/components/ai/aiShortcutInsights.test.ts index b21c286..994bec7 100644 --- a/frontend/src/components/ai/aiShortcutInsights.test.ts +++ b/frontend/src/components/ai/aiShortcutInsights.test.ts @@ -1,12 +1,17 @@ -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { buildShortcutSnapshot } from './aiShortcutInsights'; +import { setCurrentLanguage } from '../../i18n'; import { cloneShortcutOptions, DEFAULT_SHORTCUT_OPTIONS, } from '../../utils/shortcuts'; describe('aiShortcutInsights', () => { + afterEach(() => { + setCurrentLanguage('zh-CN'); + }); + it('returns current-platform and cross-platform shortcut bindings with customization markers', () => { const shortcutOptions = cloneShortcutOptions(DEFAULT_SHORTCUT_OPTIONS); shortcutOptions.toggleQueryResultsPanel.windows = { @@ -31,6 +36,8 @@ describe('aiShortcutInsights', () => { }); it('supports filtering by action key or shortcut-related keywords', () => { + setCurrentLanguage('en-US'); + const byAction = buildShortcutSnapshot({ currentPlatform: 'windows', action: 'toggleQueryResultsPanel', diff --git a/frontend/src/components/ai/aiShortcutInsights.ts b/frontend/src/components/ai/aiShortcutInsights.ts index dc2a139..7733cd1 100644 --- a/frontend/src/components/ai/aiShortcutInsights.ts +++ b/frontend/src/components/ai/aiShortcutInsights.ts @@ -1,3 +1,4 @@ +import { SUPPORTED_LANGUAGES, t } from "../../i18n"; import { isMacLikePlatform } from "../../utils/appearance"; import { DEFAULT_SHORTCUT_OPTIONS, @@ -64,6 +65,25 @@ const matchesKeywordFilter = ( filter: string, ): boolean => !filter || searchText.includes(filter); +const buildLocalizedShortcutSearchTerms = ( + action: ShortcutAction, +): string[] => { + const terms = new Set(); + for (const language of SUPPORTED_LANGUAGES) { + const labelKey = `app.shortcuts.action.${action}.label`; + const descriptionKey = `app.shortcuts.action.${action}.description`; + const label = t(labelKey, undefined, language); + const description = t(descriptionKey, undefined, language); + if (label && label !== labelKey) { + terms.add(label); + } + if (description && description !== descriptionKey) { + terms.add(description); + } + } + return [...terms]; +}; + export const buildShortcutSnapshot = ({ shortcutOptions, currentPlatform = getShortcutPlatform(isMacLikePlatform()), @@ -102,6 +122,7 @@ export const buildShortcutSnapshot = ({ shortcutAction, meta.label, meta.description, + ...buildLocalizedShortcutSearchTerms(shortcutAction), meta.scope || "global", currentBinding.combo, currentBinding.defaultCombo, diff --git a/frontend/src/components/ai/aiSlashCommands.test.ts b/frontend/src/components/ai/aiSlashCommands.test.ts index 3e3fae3..bbc3864 100644 --- a/frontend/src/components/ai/aiSlashCommands.test.ts +++ b/frontend/src/components/ai/aiSlashCommands.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { @@ -6,11 +7,111 @@ import { groupAISlashCommands, } from './aiSlashCommands'; +const source = readFileSync(new URL('./aiSlashCommands.ts', import.meta.url), 'utf8'); +const zhCnCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-CN.json', import.meta.url), 'utf8')); +const zhTwCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-TW.json', import.meta.url), 'utf8')); +const enUsCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/en-US.json', import.meta.url), 'utf8')); +const jaJpCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ja-JP.json', import.meta.url), 'utf8')); +const deDeCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/de-DE.json', import.meta.url), 'utf8')); +const ruRuCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ru-RU.json', import.meta.url), 'utf8')); + +const zhCnTranslate = (key: string) => zhCnCatalog[key] || key; + +const diagnosticSlashCommandIds = [ + 'health', + 'tools', + 'budget', + 'hotspots', + 'mcp', + 'mcpfail', + 'mcpadd', + 'mcpdraft', + 'mcptool', + 'connfail', + 'shortcuts', + 'applog', + 'airender', + 'safety', + 'activity', + 'tx', +] as const; + describe('aiSlashCommands', () => { + it('uses i18n keys and english fallback instead of legacy Chinese slash metadata literals', () => { + expect(source).toContain("catalogTranslate('en-US', key, params)"); + expect(source).toContain('ai_chat.input.slash.category.generate.title'); + expect(source).toContain('ai_chat.input.slash.health.label'); + expect(source).toContain('ai_chat.input.slash.tx.prompt'); + expect(source).not.toContain("title: 'SQL 生成'"); + expect(source).not.toContain("description: '直接产出 SQL、测试数据或迁移草稿。'"); + expect(source).not.toContain("label: '🩺 AI 配置体检'"); + expect(source).not.toContain("prompt: '请先调用 inspect_ai_setup_health"); + }); + + it('keeps slash keywords behind localized catalog keys instead of production Chinese literals', () => { + expect(source).toContain('keywordKey:'); + expect(source).not.toContain("keywords: ['查询'"); + expect(source).not.toContain("'工具目录'"); + expect(source).not.toContain("'自动提交'"); + }); + + it('keeps slash category, empty-state, command, and keyword keys present in all six catalogs', () => { + const slashCommandIds = [ + 'query', + 'sql', + 'mock', + 'diff', + 'explain', + 'optimize', + 'schema', + 'index', + ...diagnosticSlashCommandIds, + ] as const; + const requiredKeys = [ + 'ai_chat.input.slash.category.generate.title', + 'ai_chat.input.slash.category.generate.description', + 'ai_chat.input.slash.category.review.title', + 'ai_chat.input.slash.category.review.description', + 'ai_chat.input.slash.category.diagnose.title', + 'ai_chat.input.slash.category.diagnose.description', + 'ai_chat.input.slash.empty.title', + 'ai_chat.input.slash.empty.description', + 'ai_chat.input.slash.empty.summary', + ...slashCommandIds.flatMap((id) => ([ + `ai_chat.input.slash.${id}.label`, + `ai_chat.input.slash.${id}.desc`, + `ai_chat.input.slash.${id}.prompt`, + `ai_chat.input.slash.${id}.keywords`, + ])), + ]; + + for (const key of requiredKeys) { + expect(zhCnCatalog[key]).toBeTruthy(); + expect(zhTwCatalog[key]).toBeTruthy(); + expect(enUsCatalog[key]).toBeTruthy(); + expect(jaJpCatalog[key]).toBeTruthy(); + expect(deDeCatalog[key]).toBeTruthy(); + expect(ruRuCatalog[key]).toBeTruthy(); + } + }); + it('returns all default commands when only slash is present', () => { const commands = filterAISlashCommands('/'); + const sql = commands.find((command) => command.cmd === '/sql'); + const health = commands.find((command) => command.cmd === '/health'); + const groups = groupAISlashCommands(commands); expect(commands.length).toBeGreaterThan(8); + expect(sql).toMatchObject({ + label: '📝 Generate SQL', + desc: 'Describe requirements and generate statements', + prompt: 'Generate SQL from the following requirements:', + }); + expect(health).toMatchObject({ + label: '🩺 AI health check', + desc: 'Run health probes for the current AI setup', + prompt: 'Call inspect_ai_setup_health first. Run a full health check of the current GoNavi AI setup, then summarize blockers, warnings, and nextActions.', + }); expect(commands.some((command) => command.cmd === '/health')).toBe(true); expect(commands.some((command) => command.cmd === '/tools')).toBe(true); expect(commands.some((command) => command.cmd === '/budget')).toBe(true); @@ -25,75 +126,78 @@ describe('aiSlashCommands', () => { expect(commands.some((command) => command.cmd === '/applog')).toBe(true); expect(commands.some((command) => command.cmd === '/airender')).toBe(true); expect(commands.some((command) => command.cmd === '/tx')).toBe(true); + expect(groups[0]).toMatchObject({ key: 'generate', title: 'SQL generation' }); + expect(groups[1]).toMatchObject({ key: 'review', title: 'Structure review' }); + expect(groups[2]).toMatchObject({ key: 'diagnose', title: 'Diagnostic probes' }); }); it('supports filtering by chinese keywords in addition to command prefix', () => { - const commands = filterAISlashCommands('体检'); + const commands = filterAISlashCommands('体检', zhCnTranslate); expect(commands.map((command) => command.cmd)).toContain('/health'); expect(commands.map((command) => command.cmd)).not.toContain('/mcpadd'); }); it('supports filtering builtin tool catalog diagnostics by keyword and command prefix', () => { - expect(filterAISlashCommands('工具目录').map((command) => command.cmd)).toContain('/tools'); - expect(filterAISlashCommands('参数提示').map((command) => command.cmd)).toContain('/tools'); + expect(filterAISlashCommands('工具目录', zhCnTranslate).map((command) => command.cmd)).toContain('/tools'); + expect(filterAISlashCommands('参数提示', zhCnTranslate).map((command) => command.cmd)).toContain('/tools'); expect(filterAISlashCommands('/too').map((command) => command.cmd)).toContain('/tools'); }); it('supports filtering context budget diagnostics by keyword and command prefix', () => { - expect(filterAISlashCommands('上下文').map((command) => command.cmd)).toContain('/budget'); - expect(filterAISlashCommands('变慢').map((command) => command.cmd)).toContain('/budget'); + expect(filterAISlashCommands('上下文', zhCnTranslate).map((command) => command.cmd)).toContain('/budget'); + expect(filterAISlashCommands('变慢', zhCnTranslate).map((command) => command.cmd)).toContain('/budget'); expect(filterAISlashCommands('/bud').map((command) => command.cmd)).toContain('/budget'); }); it('supports filtering code hotspot diagnostics by keyword and command prefix', () => { - expect(filterAISlashCommands('大文件').map((command) => command.cmd)).toContain('/hotspots'); - expect(filterAISlashCommands('拆分').map((command) => command.cmd)).toContain('/hotspots'); + expect(filterAISlashCommands('大文件', zhCnTranslate).map((command) => command.cmd)).toContain('/hotspots'); + expect(filterAISlashCommands('拆分', zhCnTranslate).map((command) => command.cmd)).toContain('/hotspots'); expect(filterAISlashCommands('/hot').map((command) => command.cmd)).toContain('/hotspots'); }); it('supports filtering shortcut diagnostics by chinese keyword and command prefix', () => { - expect(filterAISlashCommands('快捷键').map((command) => command.cmd)).toContain('/shortcuts'); + expect(filterAISlashCommands('快捷键', zhCnTranslate).map((command) => command.cmd)).toContain('/shortcuts'); expect(filterAISlashCommands('/sho').map((command) => command.cmd)).toContain('/shortcuts'); }); it('supports filtering connection-failure diagnostics by chinese keyword and command prefix', () => { - expect(filterAISlashCommands('连接失败').map((command) => command.cmd)).toContain('/connfail'); + expect(filterAISlashCommands('连接失败', zhCnTranslate).map((command) => command.cmd)).toContain('/connfail'); expect(filterAISlashCommands('/conn').map((command) => command.cmd)).toContain('/connfail'); }); it('supports filtering app-log diagnostics by chinese keyword and command prefix', () => { - expect(filterAISlashCommands('日志').map((command) => command.cmd)).toContain('/applog'); + expect(filterAISlashCommands('日志', zhCnTranslate).map((command) => command.cmd)).toContain('/applog'); expect(filterAISlashCommands('/app').map((command) => command.cmd)).toContain('/applog'); }); it('supports filtering ai-render diagnostics by chinese keyword and command prefix', () => { - expect(filterAISlashCommands('气泡空白').map((command) => command.cmd)).toContain('/airender'); + expect(filterAISlashCommands('气泡空白', zhCnTranslate).map((command) => command.cmd)).toContain('/airender'); expect(filterAISlashCommands('/air').map((command) => command.cmd)).toContain('/airender'); }); it('supports filtering sql editor transaction diagnostics by keyword and command prefix', () => { - expect(filterAISlashCommands('自动提交').map((command) => command.cmd)).toContain('/tx'); - expect(filterAISlashCommands('未提交').map((command) => command.cmd)).toContain('/tx'); + expect(filterAISlashCommands('自动提交', zhCnTranslate).map((command) => command.cmd)).toContain('/tx'); + expect(filterAISlashCommands('未提交', zhCnTranslate).map((command) => command.cmd)).toContain('/tx'); expect(filterAISlashCommands('/tx').map((command) => command.cmd)).toContain('/tx'); }); it('supports filtering mcp tool schema diagnostics by keyword and command prefix', () => { expect(filterAISlashCommands('arguments').map((command) => command.cmd)).toContain('/mcptool'); - expect(filterAISlashCommands('MCP工具参数').map((command) => command.cmd)).toContain('/mcptool'); + expect(filterAISlashCommands('MCP工具参数', zhCnTranslate).map((command) => command.cmd)).toContain('/mcptool'); expect(filterAISlashCommands('/mcpt').map((command) => command.cmd)).toContain('/mcptool'); }); it('supports filtering mcp runtime failure diagnostics by keyword and command prefix', () => { - expect(filterAISlashCommands('运行期失败').map((command) => command.cmd)).toContain('/mcpfail'); - expect(filterAISlashCommands('工具发现0个').map((command) => command.cmd)).toContain('/mcpfail'); + expect(filterAISlashCommands('运行期失败', zhCnTranslate).map((command) => command.cmd)).toContain('/mcpfail'); + expect(filterAISlashCommands('工具发现0个', zhCnTranslate).map((command) => command.cmd)).toContain('/mcpfail'); expect(filterAISlashCommands('stdio').map((command) => command.cmd)).toContain('/mcpfail'); expect(filterAISlashCommands('/mcpf').map((command) => command.cmd)).toContain('/mcpfail'); }); it('supports filtering mcp draft validation diagnostics by keyword and command prefix', () => { - expect(filterAISlashCommands('MCP草稿').map((command) => command.cmd)).toContain('/mcpdraft'); - expect(filterAISlashCommands('启动命令').map((command) => command.cmd)).toContain('/mcpdraft'); + expect(filterAISlashCommands('MCP草稿', zhCnTranslate).map((command) => command.cmd)).toContain('/mcpdraft'); + expect(filterAISlashCommands('启动命令', zhCnTranslate).map((command) => command.cmd)).toContain('/mcpdraft'); expect(filterAISlashCommands('/mcpd').map((command) => command.cmd)).toContain('/mcpdraft'); }); diff --git a/frontend/src/components/ai/aiSlashCommands.ts b/frontend/src/components/ai/aiSlashCommands.ts index 86c1c72..f80af19 100644 --- a/frontend/src/components/ai/aiSlashCommands.ts +++ b/frontend/src/components/ai/aiSlashCommands.ts @@ -1,5 +1,10 @@ +import { t as catalogTranslate } from '../../i18n/catalog'; +import type { I18nParams } from '../../i18n/types'; + export type AISlashCommandCategory = 'generate' | 'review' | 'diagnose'; +export type AISlashCommandTranslate = (key: string, params?: I18nParams) => string; + export interface AISlashCommandDefinition { cmd: string; label: string; @@ -20,51 +25,104 @@ export interface AISlashCommandGroup extends AISlashCommandCategoryMeta { commands: AISlashCommandDefinition[]; } -export const AI_SLASH_COMMAND_CATEGORIES: AISlashCommandCategoryMeta[] = [ +interface AISlashCommandCategoryTemplate { + key: AISlashCommandCategory; + titleKey: string; + descriptionKey: string; +} + +interface AISlashCommandTemplate { + cmd: string; + labelKey: string; + descKey: string; + promptKey: string; + category: AISlashCommandCategory; + keywordKey: string; + featured?: boolean; +} + +const defaultTranslate: AISlashCommandTranslate = (key, params) => + catalogTranslate('en-US', key, params); + +const AI_SLASH_COMMAND_CATEGORIES: AISlashCommandCategoryTemplate[] = [ { key: 'generate', - title: 'SQL 生成', - description: '直接产出 SQL、测试数据或迁移草稿。', + titleKey: 'ai_chat.input.slash.category.generate.title', + descriptionKey: 'ai_chat.input.slash.category.generate.description', }, { key: 'review', - title: '结构评审', - description: '解释 SQL、评审表设计和索引策略。', + titleKey: 'ai_chat.input.slash.category.review.title', + descriptionKey: 'ai_chat.input.slash.category.review.description', }, { key: 'diagnose', - title: '诊断探针', - description: '优先调用内置探针看 AI、MCP 和最近 SQL 活动的真实状态。', + titleKey: 'ai_chat.input.slash.category.diagnose.title', + descriptionKey: 'ai_chat.input.slash.category.diagnose.description', }, ]; -export const DEFAULT_AI_SLASH_COMMANDS: AISlashCommandDefinition[] = [ - { cmd: '/query', label: '🔍 自然语言查询', desc: '用中文描述你想查什么', prompt: '帮我写一条 SQL 查询:', category: 'generate', featured: true, keywords: ['查询', '自然语言', '查数据'] }, - { cmd: '/sql', label: '📝 生成 SQL', desc: '描述需求自动生成语句', prompt: '请根据以下需求生成 SQL:', category: 'generate', featured: true, keywords: ['sql', '生成', '查询语句'] }, - { cmd: '/mock', label: '🎲 造测试数据', desc: '生成 INSERT 测试数据', prompt: '请为当前关联的表生成 10 条符合业务语义的测试数据 INSERT 语句:', category: 'generate', keywords: ['mock', '测试数据', 'insert'] }, - { cmd: '/diff', label: '🔄 表对比', desc: '对比两表差异生成变更', prompt: '请对比以下两张表的结构差异,并生成从旧版本迁移到新版本的 ALTER 语句:', category: 'generate', keywords: ['diff', '迁移', 'alter'] }, - { cmd: '/explain', label: '💡 解释 SQL', desc: '解释选中 SQL 的逻辑', prompt: '请解释以下 SQL 的执行逻辑和每一步的作用:\n```sql\n\n```', category: 'review', featured: true, keywords: ['解释', 'sql', '逻辑'] }, - { cmd: '/optimize', label: '⚡ 优化分析', desc: '分析 SQL 性能瓶颈', prompt: '请分析以下 SQL 的性能问题,并给出优化后的版本:\n```sql\n\n```', category: 'review', keywords: ['优化', '索引', '性能'] }, - { cmd: '/schema', label: '🏗️ 表设计评审', desc: '评审表结构设计质量', prompt: '请全面评审当前关联表的设计,包括字段类型、范式、索引策略等方面的改进建议:', category: 'review', keywords: ['schema', '表结构', '设计'] }, - { cmd: '/index', label: '📊 索引建议', desc: '推荐最优索引方案', prompt: '请基于当前表结构和常见查询场景,推荐最优的索引方案并给出建表语句:', category: 'review', keywords: ['index', '索引', '慢查询'] }, - { cmd: '/health', label: '🩺 AI 配置体检', desc: '调用体检探针总览当前 AI 配置', prompt: '请先调用 inspect_ai_setup_health,对当前 GoNavi AI 配置做一次完整体检,然后总结 blockers、warnings 和 nextActions。', category: 'diagnose', featured: true, keywords: ['health', '体检', 'ai配置', '探针'] }, - { cmd: '/tools', label: '🧰 工具目录', desc: '按关键词选择该用哪个内置探针', prompt: '请先调用 inspect_ai_tool_catalog,按我的问题关键词筛选推荐流程、内置工具参数提示和当前 MCP 工具摘要,再告诉我下一步应该调用哪个工具。关键词:', category: 'diagnose', keywords: ['工具目录', '内置工具', 'toolcatalog', '参数提示', 'arguments', '探针路线'] }, - { cmd: '/budget', label: '🧠 上下文体量', desc: '诊断消息、DDL、MCP schema 和 Skills 体量', prompt: '请先调用 inspect_ai_context_budget,检查当前会话消息、工具结果、DDL、MCP schema、提示词和 Skills 的体量风险,并给出应该收窄哪些上下文。', category: 'diagnose', keywords: ['上下文', 'context', '体量', '预算', '变慢', '乱答', 'schema太大', '工具结果'] }, - { cmd: '/hotspots', label: '🧱 代码热点', desc: '查看大文件拆分候选和测试范围', prompt: '请先调用 inspect_codebase_hotspots,读取当前 GoNavi 前端大文件热点、建议拆分切片和测试目标,再告诉我下一步最适合拆哪个文件、拆到什么边界,以及需要跑哪些验证。关键词:', category: 'diagnose', keywords: ['大文件', '臃肿', '拆分', '重构', 'hotspots', '代码热点', '几千行'] }, - { cmd: '/mcp', label: '🪛 排查 MCP 接入', desc: '检查 MCP 服务和外部客户端状态', prompt: '请先调用 inspect_mcp_setup,帮我盘点当前 MCP 服务、工具发现结果,以及 Claude Code / Codex 本机客户端和 OpenClaw / Hermans 远程 Agent 的接入状态。', category: 'diagnose', featured: true, keywords: ['mcp', 'codex', 'claude', 'openclaw', 'hermans', '外部客户端'] }, - { cmd: '/mcpfail', label: '🧯 MCP 运行失败', desc: '读取 MCP 启动、发现和调用失败日志', prompt: '请先调用 inspect_mcp_runtime_failures,读取最近 MCP 启动、工具发现、工具调用、stdio、Docker 或 HTTP MCP 失败日志,结合当前 MCP 服务配置判断原因和 nextActions。关键词或服务名:', category: 'diagnose', keywords: ['mcpfail', 'mcp失败', '运行期失败', '工具发现0个', 'stdio', 'docker mcp', 'http mcp', '启动失败', '调用失败'] }, - { cmd: '/mcpadd', label: '🧭 新增 MCP 指引', desc: '查看 command、args、env 和模板怎么填', prompt: '请先调用 inspect_mcp_authoring_guide;如果我贴了完整启动命令或草稿,再调用 inspect_mcp_draft 试算字段和校验问题;最后结合 inspect_mcp_setup,告诉我新增 GoNavi MCP 服务时 command、args、env、timeout 应该怎么填,以及最接近的模板应该选哪个。', category: 'diagnose', featured: true, keywords: ['mcp新增', 'command', 'args', 'env', '模板'] }, - { cmd: '/mcpdraft', label: '🧪 MCP 草稿校验', desc: '校验一条 MCP 启动命令怎么拆', prompt: '请先调用 inspect_mcp_draft 校验我提供的 MCP fullCommand 或 command/args/env/timeout 草稿,返回自动拆分结果、启动预览、suggestedServerSeed、错误/告警和 nextActions;如果还缺字段说明,再补充调用 inspect_mcp_authoring_guide。', category: 'diagnose', keywords: ['mcp草稿', 'mcp校验', 'fullcommand', '启动命令', '参数拆分', 'command', 'args', 'env'] }, - { cmd: '/mcptool', label: '🧩 MCP 工具参数', desc: '查看 MCP 工具 schema 和 arguments 写法', prompt: '请先调用 inspect_mcp_setup 找到当前已发现的 MCP 工具 alias;如果我已经给了工具名或关键词,再调用 inspect_mcp_tool_schema 读取对应 inputSchema,告诉我必填参数、字段类型、枚举值、嵌套路径,以及 arguments JSON 应该怎么写。', category: 'diagnose', keywords: ['mcp工具', 'mcp工具参数', 'schema', 'arguments', '参数', '工具调用', 'inputschema'] }, - { cmd: '/connfail', label: '🧯 连接失败探针', desc: '总结最近连接失败、冷却和验证异常', prompt: '请先调用 inspect_recent_connection_failures,帮我总结最近数据库连接失败、连接冷却、验证失败和 SSH 隧道异常的真实日志结论;如果已经有明确地址或类型,再结合 inspect_current_connection 或 inspect_saved_connections 继续缩小范围。', category: 'diagnose', featured: true, keywords: ['连接失败', '冷却', '验证失败', 'ssh', 'mysql'] }, - { cmd: '/shortcuts', label: '⌨️ 快捷键探针', desc: '读取当前 Win/Mac 快捷键配置', prompt: '请先调用 inspect_shortcuts,告诉我当前 GoNavi 的快捷键配置,尤其是执行 SQL、切换结果区、打开 AI 面板和 AI 发送消息这些动作在当前平台和另一平台分别怎么按,是否改过默认值。', category: 'diagnose', keywords: ['快捷键', 'shortcuts', '结果区', 'mac', 'windows'] }, - { cmd: '/applog', label: '🪵 应用日志', desc: '回看最近 GoNavi 应用日志', prompt: '请先调用 inspect_app_logs,帮我看最近 GoNavi 应用日志里的错误和警告;如果我提到连接失败、MCP 拉起失败、启动异常或 gonavi.log,就优先结合关键词继续筛。', category: 'diagnose', keywords: ['日志', 'gonavi.log', 'mcp报错', '连接失败', '启动异常'] }, - { cmd: '/airender', label: '🧯 AI 渲染异常', desc: '读取最近一次 AI 消息渲染失败记录', prompt: '请先调用 inspect_ai_last_render_error,告诉我最近一次 AI 消息渲染失败记录里是哪条消息、报错摘要是什么,以及下一步该怎么排查。', category: 'diagnose', keywords: ['渲染失败', '气泡空白', 'ai消息', 'render', '白块'] }, - { cmd: '/safety', label: '🛡️ 查看写入安全', desc: '确认只读/写入边界和 allowMutating', prompt: '请先调用 inspect_ai_safety,告诉我当前 AI 和 GoNavi MCP 的写入边界、是否只读,以及 execute_sql 是否需要 allowMutating。', category: 'diagnose', keywords: ['安全', '只读', 'allowmutating', 'ddl', 'dml'] }, - { cmd: '/activity', label: '🕘 最近 SQL 活动', desc: '总结最近执行、报错和热点', prompt: '请先调用 inspect_recent_sql_activity,帮我总结最近 SQL 活动、错误热点和主要读写类型。', category: 'diagnose', keywords: ['activity', 'sql日志', '最近执行', '报错'] }, - { cmd: '/tx', label: '🔁 SQL 事务状态', desc: '查看 SQL 编辑器提交模式和待提交事务', prompt: '请先调用 inspect_sql_editor_transaction,告诉我 SQL 编辑器当前 DML 托管事务语义、手动/自动提交设置、活动 SQL 页签是否会进入事务、是否有待提交事务,以及下一步应该提交、回滚还是继续执行。', category: 'diagnose', featured: true, keywords: ['事务', 'transaction', '提交', '自动提交', '手动提交', '未提交', 'dml'] }, +const AI_SLASH_COMMAND_TEMPLATES: AISlashCommandTemplate[] = [ + { cmd: '/query', labelKey: 'ai_chat.input.slash.query.label', descKey: 'ai_chat.input.slash.query.desc', promptKey: 'ai_chat.input.slash.query.prompt', keywordKey: 'ai_chat.input.slash.query.keywords', category: 'generate', featured: true }, + { cmd: '/sql', labelKey: 'ai_chat.input.slash.sql.label', descKey: 'ai_chat.input.slash.sql.desc', promptKey: 'ai_chat.input.slash.sql.prompt', keywordKey: 'ai_chat.input.slash.sql.keywords', category: 'generate', featured: true }, + { cmd: '/mock', labelKey: 'ai_chat.input.slash.mock.label', descKey: 'ai_chat.input.slash.mock.desc', promptKey: 'ai_chat.input.slash.mock.prompt', keywordKey: 'ai_chat.input.slash.mock.keywords', category: 'generate' }, + { cmd: '/diff', labelKey: 'ai_chat.input.slash.diff.label', descKey: 'ai_chat.input.slash.diff.desc', promptKey: 'ai_chat.input.slash.diff.prompt', keywordKey: 'ai_chat.input.slash.diff.keywords', category: 'generate' }, + { cmd: '/explain', labelKey: 'ai_chat.input.slash.explain.label', descKey: 'ai_chat.input.slash.explain.desc', promptKey: 'ai_chat.input.slash.explain.prompt', keywordKey: 'ai_chat.input.slash.explain.keywords', category: 'review', featured: true }, + { cmd: '/optimize', labelKey: 'ai_chat.input.slash.optimize.label', descKey: 'ai_chat.input.slash.optimize.desc', promptKey: 'ai_chat.input.slash.optimize.prompt', keywordKey: 'ai_chat.input.slash.optimize.keywords', category: 'review' }, + { cmd: '/schema', labelKey: 'ai_chat.input.slash.schema.label', descKey: 'ai_chat.input.slash.schema.desc', promptKey: 'ai_chat.input.slash.schema.prompt', keywordKey: 'ai_chat.input.slash.schema.keywords', category: 'review' }, + { cmd: '/index', labelKey: 'ai_chat.input.slash.index.label', descKey: 'ai_chat.input.slash.index.desc', promptKey: 'ai_chat.input.slash.index.prompt', keywordKey: 'ai_chat.input.slash.index.keywords', category: 'review' }, + { cmd: '/health', labelKey: 'ai_chat.input.slash.health.label', descKey: 'ai_chat.input.slash.health.desc', promptKey: 'ai_chat.input.slash.health.prompt', keywordKey: 'ai_chat.input.slash.health.keywords', category: 'diagnose', featured: true }, + { cmd: '/tools', labelKey: 'ai_chat.input.slash.tools.label', descKey: 'ai_chat.input.slash.tools.desc', promptKey: 'ai_chat.input.slash.tools.prompt', keywordKey: 'ai_chat.input.slash.tools.keywords', category: 'diagnose' }, + { cmd: '/budget', labelKey: 'ai_chat.input.slash.budget.label', descKey: 'ai_chat.input.slash.budget.desc', promptKey: 'ai_chat.input.slash.budget.prompt', keywordKey: 'ai_chat.input.slash.budget.keywords', category: 'diagnose' }, + { cmd: '/hotspots', labelKey: 'ai_chat.input.slash.hotspots.label', descKey: 'ai_chat.input.slash.hotspots.desc', promptKey: 'ai_chat.input.slash.hotspots.prompt', keywordKey: 'ai_chat.input.slash.hotspots.keywords', category: 'diagnose' }, + { cmd: '/mcp', labelKey: 'ai_chat.input.slash.mcp.label', descKey: 'ai_chat.input.slash.mcp.desc', promptKey: 'ai_chat.input.slash.mcp.prompt', keywordKey: 'ai_chat.input.slash.mcp.keywords', category: 'diagnose', featured: true }, + { cmd: '/mcpfail', labelKey: 'ai_chat.input.slash.mcpfail.label', descKey: 'ai_chat.input.slash.mcpfail.desc', promptKey: 'ai_chat.input.slash.mcpfail.prompt', keywordKey: 'ai_chat.input.slash.mcpfail.keywords', category: 'diagnose' }, + { cmd: '/mcpadd', labelKey: 'ai_chat.input.slash.mcpadd.label', descKey: 'ai_chat.input.slash.mcpadd.desc', promptKey: 'ai_chat.input.slash.mcpadd.prompt', keywordKey: 'ai_chat.input.slash.mcpadd.keywords', category: 'diagnose', featured: true }, + { cmd: '/mcpdraft', labelKey: 'ai_chat.input.slash.mcpdraft.label', descKey: 'ai_chat.input.slash.mcpdraft.desc', promptKey: 'ai_chat.input.slash.mcpdraft.prompt', keywordKey: 'ai_chat.input.slash.mcpdraft.keywords', category: 'diagnose' }, + { cmd: '/mcptool', labelKey: 'ai_chat.input.slash.mcptool.label', descKey: 'ai_chat.input.slash.mcptool.desc', promptKey: 'ai_chat.input.slash.mcptool.prompt', keywordKey: 'ai_chat.input.slash.mcptool.keywords', category: 'diagnose' }, + { cmd: '/connfail', labelKey: 'ai_chat.input.slash.connfail.label', descKey: 'ai_chat.input.slash.connfail.desc', promptKey: 'ai_chat.input.slash.connfail.prompt', keywordKey: 'ai_chat.input.slash.connfail.keywords', category: 'diagnose', featured: true }, + { cmd: '/shortcuts', labelKey: 'ai_chat.input.slash.shortcuts.label', descKey: 'ai_chat.input.slash.shortcuts.desc', promptKey: 'ai_chat.input.slash.shortcuts.prompt', keywordKey: 'ai_chat.input.slash.shortcuts.keywords', category: 'diagnose' }, + { cmd: '/applog', labelKey: 'ai_chat.input.slash.applog.label', descKey: 'ai_chat.input.slash.applog.desc', promptKey: 'ai_chat.input.slash.applog.prompt', keywordKey: 'ai_chat.input.slash.applog.keywords', category: 'diagnose' }, + { cmd: '/airender', labelKey: 'ai_chat.input.slash.airender.label', descKey: 'ai_chat.input.slash.airender.desc', promptKey: 'ai_chat.input.slash.airender.prompt', keywordKey: 'ai_chat.input.slash.airender.keywords', category: 'diagnose' }, + { cmd: '/safety', labelKey: 'ai_chat.input.slash.safety.label', descKey: 'ai_chat.input.slash.safety.desc', promptKey: 'ai_chat.input.slash.safety.prompt', keywordKey: 'ai_chat.input.slash.safety.keywords', category: 'diagnose' }, + { cmd: '/activity', labelKey: 'ai_chat.input.slash.activity.label', descKey: 'ai_chat.input.slash.activity.desc', promptKey: 'ai_chat.input.slash.activity.prompt', keywordKey: 'ai_chat.input.slash.activity.keywords', category: 'diagnose' }, + { cmd: '/tx', labelKey: 'ai_chat.input.slash.tx.label', descKey: 'ai_chat.input.slash.tx.desc', promptKey: 'ai_chat.input.slash.tx.prompt', keywordKey: 'ai_chat.input.slash.tx.keywords', category: 'diagnose', featured: true }, ]; +const splitAISlashCommandKeywords = (keywords: string): string[] => + String(keywords || '') + .split('|') + .map((keyword) => keyword.trim()) + .filter(Boolean); + +const localizeAISlashCommand = ( + command: AISlashCommandTemplate, + translate: AISlashCommandTranslate, +): AISlashCommandDefinition => ({ + cmd: command.cmd, + label: translate(command.labelKey), + desc: translate(command.descKey), + prompt: translate(command.promptKey), + category: command.category, + keywords: splitAISlashCommandKeywords(translate(command.keywordKey)), + featured: command.featured, +}); + +const localizeAISlashCommandCategories = ( + translate: AISlashCommandTranslate, +): AISlashCommandCategoryMeta[] => AI_SLASH_COMMAND_CATEGORIES.map((meta) => ({ + key: meta.key, + title: translate(meta.titleKey), + description: translate(meta.descriptionKey), +})); + +const buildAISlashCommands = ( + translate: AISlashCommandTranslate = defaultTranslate, +): AISlashCommandDefinition[] => AI_SLASH_COMMAND_TEMPLATES.map((command) => + localizeAISlashCommand(command, translate)); + +export const DEFAULT_AI_SLASH_COMMANDS: AISlashCommandDefinition[] = buildAISlashCommands(); + const buildCommandSearchText = (command: AISlashCommandDefinition): string => [ command.cmd, command.label, @@ -72,28 +130,37 @@ const buildCommandSearchText = (command: AISlashCommandDefinition): string => [ ...(command.keywords || []), ].join(' ').toLowerCase(); -export const filterAISlashCommands = (filter: string): AISlashCommandDefinition[] => { +export const filterAISlashCommands = ( + filter: string, + translate: AISlashCommandTranslate = defaultTranslate, +): AISlashCommandDefinition[] => { + const commands = buildAISlashCommands(translate); const normalized = String(filter || '').trim().toLowerCase(); if (!normalized || normalized === '/') { - return DEFAULT_AI_SLASH_COMMANDS; + return commands; } const slashSearch = normalized.startsWith('/') ? normalized : `/${normalized}`; const keywordSearch = normalized.startsWith('/') ? normalized.slice(1) : normalized; - return DEFAULT_AI_SLASH_COMMANDS.filter((command) => { + return commands.filter((command) => { const searchText = buildCommandSearchText(command); return command.cmd.startsWith(slashSearch) || searchText.includes(keywordSearch); }); }; -export const groupAISlashCommands = (commands: AISlashCommandDefinition[]): AISlashCommandGroup[] => - AI_SLASH_COMMAND_CATEGORIES +export const groupAISlashCommands = ( + commands: AISlashCommandDefinition[], + translate: AISlashCommandTranslate = defaultTranslate, +): AISlashCommandGroup[] => + localizeAISlashCommandCategories(translate) .map((meta) => ({ ...meta, commands: commands.filter((command) => command.category === meta.key), })) .filter((group) => group.commands.length > 0); -export const getFeaturedAISlashCommands = (): AISlashCommandDefinition[] => - DEFAULT_AI_SLASH_COMMANDS.filter((command) => command.featured); +export const getFeaturedAISlashCommands = ( + translate: AISlashCommandTranslate = defaultTranslate, +): AISlashCommandDefinition[] => + buildAISlashCommands(translate).filter((command) => command.featured); diff --git a/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts index 9da2bf7..39eff49 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionAIConfigToolExecutor.ts @@ -6,7 +6,8 @@ import type { SavedConnection, TabData, } from '../../types'; -import { BUILTIN_AI_TOOL_INFO } from '../../utils/aiToolRegistry'; +import { t as translateCatalog, type I18nParams } from '../../i18n'; +import { BUILTIN_AI_TOOL_INFO, localizeBuiltinAIToolInfo } from '../../utils/aiToolRegistry'; import { buildAIChatReadinessSnapshot } from './aiChatReadiness'; import { buildAIGuidanceSnapshot } from './aiPromptInsights'; import { buildAIProviderSnapshot } from './aiProviderInsights'; @@ -20,6 +21,7 @@ import { buildAISetupHealthSnapshot } from './aiSetupHealthInsights'; import { buildMCPSetupSnapshot } from './aiMCPInsights'; import { buildMCPRemoteAccessSnapshot } from './aiMCPRemoteAccessInsights'; import { buildMCPToolSchemaSnapshot } from './aiMCPToolSchemaInsights'; +import { translateInspectionCopy } from './aiInspectionI18n'; import type { AISnapshotInspectionRuntime, AISnapshotInspectionRuntimeState, @@ -40,9 +42,15 @@ interface ExecuteAIConfigSnapshotToolCallOptions { skills?: AISkillConfig[]; userPromptSettings?: AIUserPromptSettings; dynamicModels?: string[]; + translate?: (key: string, params?: I18nParams) => string; runtime?: AISnapshotInspectionRuntime; } +const translateInspectionZhCN = ( + key: string, + params?: I18nParams, +) => translateCatalog(key, params, 'zh-CN'); + const loadRuntimeState = async ( runtime: AISnapshotInspectionRuntime | undefined, ): Promise => @@ -83,6 +91,7 @@ export async function executeAIConfigSnapshotToolCall( skills = [], userPromptSettings, dynamicModels = [], + translate, runtime, } = options; @@ -109,6 +118,7 @@ export async function executeAIConfigSnapshotToolCall( userPromptSettings, activeContext, activeContextItems: aiContexts[activeContextKey] || [], + translate, })), success: true, }; @@ -125,6 +135,7 @@ export async function executeAIConfigSnapshotToolCall( mcpTools, dynamicModels, builtinToolNames: BUILTIN_AI_TOOL_NAMES, + translate, })), success: true, }; @@ -138,6 +149,7 @@ export async function executeAIConfigSnapshotToolCall( tabs, activeTabId, connections, + translate, })), success: true, }; @@ -149,6 +161,7 @@ export async function executeAIConfigSnapshotToolCall( providers: Array.isArray(runtimeState?.providers) ? runtimeState.providers : [], activeProviderId: runtimeState?.activeProviderId || '', dynamicModels, + translate, })), success: true, }; @@ -165,6 +178,7 @@ export async function executeAIConfigSnapshotToolCall( dynamicModels, activeContext, activeContextItems: aiContexts[activeContextKey] || [], + translate, })), success: true, }; @@ -172,12 +186,13 @@ export async function executeAIConfigSnapshotToolCall( case 'inspect_ai_tool_catalog': return { content: JSON.stringify(buildAIToolCatalogSnapshot({ - builtinTools: BUILTIN_AI_TOOL_INFO, + builtinTools: localizeBuiltinAIToolInfo(translate), mcpTools, keyword: args.keyword, toolName: args.toolName, includeMCPTools: args.includeMCPTools !== false, limit: args.limit, + translate, })), success: true, }; @@ -188,6 +203,7 @@ export async function executeAIConfigSnapshotToolCall( mcpServers: Array.isArray(mcpServers) ? mcpServers : [], mcpClientStatuses: Array.isArray(mcpClientInstallStatuses) ? mcpClientInstallStatuses : [], mcpTools, + translate, })), success: true, }; @@ -202,18 +218,22 @@ export async function executeAIConfigSnapshotToolCall( path: args.path, exposeStrategy: args.exposeStrategy, tokenConfigured: args.tokenConfigured, + translate, })), success: true, }; } case 'inspect_mcp_authoring_guide': return { - content: JSON.stringify(buildMCPAuthoringGuideSnapshot()), + content: JSON.stringify(buildMCPAuthoringGuideSnapshot(translate)), success: true, }; case 'inspect_mcp_draft': return { - content: JSON.stringify(buildMCPDraftInspectionSnapshot(args)), + content: JSON.stringify(buildMCPDraftInspectionSnapshot({ + ...args, + translate: translate || translateInspectionZhCN, + })), success: true, }; case 'inspect_mcp_docker_setup': { @@ -224,6 +244,7 @@ export async function executeAIConfigSnapshotToolCall( mcpTools, serverId: args.serverId, includeDisabled: args.includeDisabled !== false, + translate, })), success: true, }; @@ -237,6 +258,7 @@ export async function executeAIConfigSnapshotToolCall( keyword: args.keyword, includeSchema: args.includeSchema === true, limit: args.limit, + translate, })), success: true, }; @@ -245,6 +267,7 @@ export async function executeAIConfigSnapshotToolCall( content: JSON.stringify(buildAIGuidanceSnapshot({ userPromptSettings, skills, + translate, })), success: true, }; @@ -252,21 +275,25 @@ export async function executeAIConfigSnapshotToolCall( return null; } } catch (error: any) { - const label = { - inspect_ai_setup_health: '体检当前 AI 配置失败', - inspect_ai_runtime: '读取当前 AI 运行状态失败', - inspect_ai_safety: '读取当前 AI 安全边界失败', - inspect_ai_providers: '读取当前 AI 供应商配置失败', - inspect_ai_chat_readiness: '读取 AI 聊天发送前置状态失败', - inspect_ai_tool_catalog: '读取 AI 工具目录失败', - inspect_mcp_setup: '读取 MCP 配置状态失败', - inspect_mcp_remote_access: '读取 MCP 远程接入指引失败', - inspect_mcp_authoring_guide: '读取 MCP 新增填写指引失败', - inspect_mcp_draft: '校验 MCP 新增草稿失败', - inspect_mcp_docker_setup: '检查 Docker MCP 配置失败', - inspect_mcp_tool_schema: '读取 MCP 工具参数 schema 失败', - inspect_ai_guidance: '读取当前 AI 提示与技能配置失败', - }[toolName] || '读取 AI 配置探针失败'; + const label = translateInspectionCopy( + translate, + `ai_chat.inspection.ai_config.error.${toolName}`, + { + inspect_ai_setup_health: 'Failed to inspect current AI setup', + inspect_ai_runtime: 'Failed to read current AI runtime state', + inspect_ai_safety: 'Failed to read current AI safety boundary', + inspect_ai_providers: 'Failed to read current AI provider configuration', + inspect_ai_chat_readiness: 'Failed to read AI chat prerequisites', + inspect_ai_tool_catalog: 'Failed to read AI tool catalog', + inspect_mcp_setup: 'Failed to read MCP setup state', + inspect_mcp_remote_access: 'Failed to read MCP remote access guidance', + inspect_mcp_authoring_guide: 'Failed to read MCP authoring guide', + inspect_mcp_draft: 'Failed to validate MCP draft', + inspect_mcp_docker_setup: 'Failed to inspect Docker MCP setup', + inspect_mcp_tool_schema: 'Failed to read MCP tool parameter schema', + inspect_ai_guidance: 'Failed to read current AI prompts and Skills configuration', + }[toolName] || 'Failed to read AI configuration inspection', + ); return { content: `${label}: ${error?.message || error}`, success: false, diff --git a/frontend/src/components/ai/aiSnapshotInspectionAppHealthToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionAppHealthToolExecutor.ts index f104977..424dabb 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionAppHealthToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionAppHealthToolExecutor.ts @@ -7,7 +7,8 @@ import type { SavedConnection, TabData, } from '../../types'; -import { BUILTIN_AI_TOOL_INFO } from '../../utils/aiToolRegistry'; +import type { I18nParams } from '../../i18n'; +import { BUILTIN_AI_TOOL_INFO, localizeBuiltinAIToolInfo } from '../../utils/aiToolRegistry'; import { buildAIAppHealthSnapshot } from './aiAppHealthInsights'; import { buildAILastRenderErrorSnapshot } from './aiLastRenderErrorInsights'; import { buildAISupportBundleSnapshot } from './aiSupportBundleInsights'; @@ -36,6 +37,7 @@ interface ExecuteAppHealthSnapshotToolCallOptions { skills?: AISkillConfig[]; userPromptSettings?: AIUserPromptSettings; dynamicModels?: string[]; + translate?: (key: string, params?: I18nParams) => string; runtime?: AISnapshotInspectionRuntime; } @@ -58,9 +60,14 @@ const readLogTail = async ( runtime: AISnapshotInspectionRuntime | undefined, lineLimit: number, keyword: string, + translate: ((key: string, params?: I18nParams) => string) | undefined, ) => { if (typeof runtime?.readAppLogTail !== 'function') { - return { success: false, message: '当前环境暂不支持读取 GoNavi 应用日志' }; + return { + success: false, + message: translate?.('ai_chat.inspection.app_health.log_reading_unavailable') + || 'The current runtime does not support reading GoNavi application logs', + }; } return runtime.readAppLogTail(lineLimit, keyword); }; @@ -90,6 +97,7 @@ export async function executeAppHealthSnapshotToolCall( skills = [], userPromptSettings, dynamicModels = [], + translate, runtime, } = options; @@ -104,8 +112,8 @@ export async function executeAppHealthSnapshotToolCall( const [runtimeState, mcpState, appLogReadResult, connectionFailureReadResult] = await Promise.all([ loadRuntimeState(runtime), loadMCPSetupState(runtime), - readLogTail(runtime, lineLimit, keyword), - readLogTail(runtime, lineLimit, connectionKeyword), + readLogTail(runtime, lineLimit, keyword, translate), + readLogTail(runtime, lineLimit, connectionKeyword, translate), ]); const [mcpServers, mcpClientInstallStatuses] = mcpState; @@ -121,7 +129,7 @@ export async function executeAppHealthSnapshotToolCall( mcpClientStatuses: Array.isArray(mcpClientInstallStatuses) ? mcpClientInstallStatuses : [], mcpTools, dynamicModels, - builtinTools: BUILTIN_AI_TOOL_INFO, + builtinTools: localizeBuiltinAIToolInfo(translate), builtinToolNames: BUILTIN_AI_TOOL_NAMES, userPromptSettings, activeContext, @@ -135,7 +143,7 @@ export async function executeAppHealthSnapshotToolCall( activeTabId, appLogReadResult, connectionFailureReadResult, - lastRenderErrorSnapshot: buildAILastRenderErrorSnapshot(), + lastRenderErrorSnapshot: buildAILastRenderErrorSnapshot(translate), keyword, connectionKeyword, lineLimit, @@ -147,6 +155,7 @@ export async function executeAppHealthSnapshotToolCall( path: args.path, exposeStrategy: args.exposeStrategy, tokenConfigured: args.tokenConfigured, + translate, })), success: true, }; @@ -172,17 +181,25 @@ export async function executeAppHealthSnapshotToolCall( activeTabId, appLogReadResult, connectionFailureReadResult, - lastRenderErrorSnapshot: buildAILastRenderErrorSnapshot(), + lastRenderErrorSnapshot: buildAILastRenderErrorSnapshot(translate), keyword, connectionKeyword, lineLimit, includeLogLines: args.includeLogLines === true, + translate, })), success: true, }; } catch (error: any) { + const errorLabel = translate?.( + toolName === 'inspect_ai_support_bundle' + ? 'ai_chat.inspection.app_health.error.support_bundle_failed' + : 'ai_chat.inspection.app_health.error.app_health_failed', + ) || (toolName === 'inspect_ai_support_bundle' + ? 'Failed to generate AI support bundle' + : 'Failed to read AI application health overview'); return { - content: `${toolName === 'inspect_ai_support_bundle' ? '生成 AI 支持包失败' : '读取 AI 应用健康总览失败'}: ${error?.message || error}`, + content: `${errorLabel}: ${error?.message || error}`, success: false, }; } diff --git a/frontend/src/components/ai/aiSnapshotInspectionConnectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionConnectionToolExecutor.ts index 5d78e40..a667ed9 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionConnectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionConnectionToolExecutor.ts @@ -4,6 +4,7 @@ import type { SavedConnection, TabData, } from '../../types'; +import type { I18nParams } from '../../i18n'; import { buildAIContextSnapshot } from './aiContextInsights'; import { buildConnectionCapabilitiesSnapshot } from './aiConnectionCapabilitiesInsights'; import { buildCurrentConnectionSnapshot } from './aiConnectionInsights'; @@ -30,9 +31,17 @@ interface ExecuteConnectionWorkspaceSnapshotToolCallOptions { tabs?: TabData[]; activeTabId?: string | null; externalSQLDirectories?: ExternalSQLDirectory[]; + translate?: (key: string, params?: I18nParams) => string; runtime?: AISnapshotInspectionRuntime; } +const translateInspectionMessage = ( + translate: ((key: string, params?: I18nParams) => string) | undefined, + key: string, + fallback: string, + params?: I18nParams, +) => translate?.(key, params) || fallback; + export async function executeConnectionWorkspaceSnapshotToolCall({ toolName, args, @@ -42,6 +51,7 @@ export async function executeConnectionWorkspaceSnapshotToolCall({ tabs = [], activeTabId = null, externalSQLDirectories = [], + translate, runtime, }: ExecuteConnectionWorkspaceSnapshotToolCallOptions): Promise { switch (toolName) { @@ -52,6 +62,7 @@ export async function executeConnectionWorkspaceSnapshotToolCall({ tabs, activeTabId, connections, + translate, })), success: true, }; @@ -63,6 +74,7 @@ export async function executeConnectionWorkspaceSnapshotToolCall({ tabs, activeTabId, connections, + translate, })), success: true, }; @@ -84,6 +96,7 @@ export async function executeConnectionWorkspaceSnapshotToolCall({ keyword: args.keyword, limit: args.limit, includeRecommendations: args.includeRecommendations, + translate, })), success: true, }; @@ -104,22 +117,71 @@ export async function executeConnectionWorkspaceSnapshotToolCall({ const requestedFilePath = String(args.filePath || '').trim(); if (!requestedFilePath) { return { - content: '读取外部 SQL 文件失败: filePath 不能为空', + content: translateInspectionMessage( + translate, + 'ai_chat.inspection.external_sql_file.error.read_failed', + 'Failed to read external SQL file: filePath is required', + { + detail: translateInspectionMessage( + translate, + 'ai_chat.inspection.external_sql_file.error.file_path_required', + 'filePath is required', + ), + }, + ), success: false, }; } if (!findBestMatchingExternalSQLDirectory(requestedFilePath, externalSQLDirectories)) { return { - content: '读取外部 SQL 文件失败: 目标文件不在已配置的外部 SQL 目录中', + content: translateInspectionMessage( + translate, + 'ai_chat.inspection.external_sql_file.error.read_failed', + 'Failed to read external SQL file: The target file is outside configured external SQL directories', + { + detail: translateInspectionMessage( + translate, + 'ai_chat.inspection.external_sql_file.error.outside_configured_directory', + 'The target file is outside configured external SQL directories', + ), + }, + ), success: false, }; } - const readResult = typeof runtime?.readSQLFile === 'function' - ? await runtime.readSQLFile(requestedFilePath) - : { success: false, message: '当前环境暂不支持读取本地 SQL 文件' }; + let readResult; + if (typeof runtime?.readSQLFile === 'function') { + try { + readResult = await runtime.readSQLFile(requestedFilePath); + } catch (error: any) { + readResult = { + success: false, + message: error?.message || String(error), + }; + } + } else { + readResult = { + success: false, + message: translateInspectionMessage( + translate, + 'ai_chat.inspection.external_sql_file.error.unsupported_runtime', + 'The current runtime does not support reading local SQL files yet', + ), + }; + } if (!readResult?.success) { + const detail = readResult?.message || translateInspectionMessage( + translate, + 'ai_chat.inspection.external_sql_file.error.unknown', + 'Unknown error', + ); return { - content: `读取外部 SQL 文件失败: ${readResult?.message || '未知错误'}`, + content: translateInspectionMessage( + translate, + 'ai_chat.inspection.external_sql_file.error.read_failed', + `Failed to read external SQL file: ${detail}`, + { detail }, + ), success: false, }; } @@ -142,6 +204,7 @@ export async function executeConnectionWorkspaceSnapshotToolCall({ activeTabId, connections, includeContent: args.includeContent !== false, + translate, })), success: true, }; @@ -164,6 +227,7 @@ export async function executeConnectionWorkspaceSnapshotToolCall({ connections, includeDDL: args.includeDDL === true, ddlLimit: args.ddlLimit, + translate, })), success: true, }; diff --git a/frontend/src/components/ai/aiSnapshotInspectionDiagnosticsToolExecutor.i18n.test.ts b/frontend/src/components/ai/aiSnapshotInspectionDiagnosticsToolExecutor.i18n.test.ts new file mode 100644 index 0000000..705da68 --- /dev/null +++ b/frontend/src/components/ai/aiSnapshotInspectionDiagnosticsToolExecutor.i18n.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { executeDiagnosticsSnapshotToolCall } from './aiSnapshotInspectionDiagnosticsToolExecutor'; + +const translate = (key: string, params?: Record) => { + const messages: Record = { + 'ai_chat.inspection.diagnostics.error.read_app_logs_unsupported': 'APP_UNSUPPORTED', + 'ai_chat.inspection.diagnostics.error.read_app_logs_failed': `APP_FAILED :: ${params?.detail}`, + 'ai_chat.inspection.diagnostics.error.read_ai_upstream_logs_failed': `UPSTREAM_FAILED :: ${params?.detail}`, + 'ai_chat.inspection.diagnostics.error.read_recent_connection_failures_failed': `RECENT_FAILED :: ${params?.detail}`, + }; + return messages[key] || key; +}; + +type DiagnosticsOverrides = Partial[0]> & { + toolName: string; +}; + +const execute = (overrides: DiagnosticsOverrides) => + executeDiagnosticsSnapshotToolCall({ + args: {}, + connections: [], + mcpTools: [], + translate, + ...overrides, + }); + +describe('aiSnapshotInspectionDiagnosticsToolExecutor i18n', () => { + it('localizes unsupported app-log reads through the diagnostics wrapper', async () => { + const result = await execute({ + toolName: 'inspect_app_logs', + runtime: {}, + }); + + expect(result?.success).toBe(false); + expect(result?.content).toBe('APP_FAILED :: APP_UNSUPPORTED'); + }); + + it('localizes upstream-log read failures while preserving raw detail', async () => { + const result = await execute({ + toolName: 'inspect_ai_upstream_logs', + runtime: { + readAppLogTail: vi.fn().mockResolvedValue({ + success: false, + message: 'ENOENT: C:/Users/demo/.GoNavi/Logs/gonavi.log', + }), + }, + }); + + expect(result?.success).toBe(false); + expect(result?.content).toBe('UPSTREAM_FAILED :: ENOENT: C:/Users/demo/.GoNavi/Logs/gonavi.log'); + }); + + it('localizes recent connection failure read failures while preserving raw detail', async () => { + const result = await execute({ + toolName: 'inspect_recent_connection_failures', + runtime: { + readAppLogTail: vi.fn().mockResolvedValue({ + success: false, + message: 'dial tcp 127.0.0.1:3306: connect: connection refused', + }), + }, + }); + + expect(result?.success).toBe(false); + expect(result?.content).toBe('RECENT_FAILED :: dial tcp 127.0.0.1:3306: connect: connection refused'); + }); +}); diff --git a/frontend/src/components/ai/aiSnapshotInspectionDiagnosticsToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionDiagnosticsToolExecutor.ts index 45daed5..98455c1 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionDiagnosticsToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionDiagnosticsToolExecutor.ts @@ -31,10 +31,12 @@ import { buildShortcutSnapshot } from './aiShortcutInsights'; import { buildAILastRenderErrorSnapshot } from './aiLastRenderErrorInsights'; import { buildRecentConnectionFailureSnapshot } from './aiConnectionFailureInsights'; import { buildMCPRuntimeFailureSnapshot } from './aiMCPRuntimeFailureInsights'; +import { translateInspectionCopy } from './aiInspectionI18n'; import type { AISnapshotInspectionRuntime, SnapshotInspectionResult, } from './aiSnapshotInspectionToolTypes'; +import type { I18nParams } from '../../i18n'; interface ExecuteDiagnosticsSnapshotToolCallOptions { toolName: string; @@ -52,9 +54,21 @@ interface ExecuteDiagnosticsSnapshotToolCallOptions { sqlSnippets?: SqlSnippet[]; skills?: AISkillConfig[]; userPromptSettings?: AIUserPromptSettings; + translate?: (key: string, params?: I18nParams) => string; runtime?: AISnapshotInspectionRuntime; } +const translateDiagnosticsCopy = ( + translate: ExecuteDiagnosticsSnapshotToolCallOptions['translate'], + key: string, + fallback: string, + params?: I18nParams, +): string => ( + translate + ? translateInspectionCopy(translate, key, fallback, params) + : fallback +); + export async function executeDiagnosticsSnapshotToolCall({ toolName, args, @@ -71,6 +85,7 @@ export async function executeDiagnosticsSnapshotToolCall({ sqlSnippets = [], skills = [], userPromptSettings, + translate, runtime, }: ExecuteDiagnosticsSnapshotToolCallOptions): Promise { switch (toolName) { @@ -83,6 +98,7 @@ export async function executeDiagnosticsSnapshotToolCall({ keyword: args.keyword, limit: args.limit, includePreview: args.includePreview !== false, + translate, })), success: true, }; @@ -96,6 +112,7 @@ export async function executeDiagnosticsSnapshotToolCall({ limit: args.limit, includeContent: args.includeContent !== false, previewLimit: args.previewLimit, + translate, })), success: true, }; @@ -112,6 +129,7 @@ export async function executeDiagnosticsSnapshotToolCall({ mcpTools, skills, userPromptSettings, + translate, })), success: true, }; @@ -122,6 +140,7 @@ export async function executeDiagnosticsSnapshotToolCall({ minLines: args.minLines, limit: args.limit, includeRecommendations: args.includeRecommendations !== false, + translate, })), success: true, }; @@ -143,6 +162,7 @@ export async function executeDiagnosticsSnapshotToolCall({ keyword: args.keyword, dbName: args.dbName, activityKind: args.activityKind, + translate, })), success: true, }; @@ -158,6 +178,7 @@ export async function executeDiagnosticsSnapshotToolCall({ connections, sqlLogs, includeSqlPreview: args.includeSqlPreview !== false, + translate, })), success: true, }; @@ -165,10 +186,27 @@ export async function executeDiagnosticsSnapshotToolCall({ case 'inspect_app_logs': { const readResult = typeof runtime?.readAppLogTail === 'function' ? await runtime.readAppLogTail(Number(args.lineLimit) || 80, String(args.keyword || '')) - : { success: false, message: '当前环境暂不支持读取 GoNavi 应用日志' }; + : { + success: false, + message: translateDiagnosticsCopy( + translate, + 'ai_chat.inspection.diagnostics.error.read_app_logs_unsupported', + 'The current environment does not support reading GoNavi app logs', + ), + }; if (!readResult?.success) { + const detail = String(readResult?.message || translateDiagnosticsCopy( + translate, + 'ai_chat.inspection.diagnostics.error.unknown', + 'unknown error', + )); return { - content: `读取 GoNavi 应用日志失败: ${readResult?.message || '未知错误'}`, + content: translateDiagnosticsCopy( + translate, + 'ai_chat.inspection.diagnostics.error.read_app_logs_failed', + `Failed to read GoNavi app logs: ${detail}`, + { detail }, + ), success: false, }; } @@ -177,24 +215,52 @@ export async function executeDiagnosticsSnapshotToolCall({ readResult, keyword: args.keyword, lineLimit: args.lineLimit, + translate, })), success: true, }; } case 'inspect_ai_upstream_logs': { - const keyword = String(args.requestId || args.provider || args.keyword || 'AI 上游请求').trim(); + const keyword = String(args.requestId || args.provider || args.keyword || '').trim(); + const readKeyword = keyword || 'requestId='; const readResult = typeof runtime?.readAppLogTail === 'function' - ? await runtime.readAppLogTail(Number(args.lineLimit) || 160, keyword) - : { success: false, message: '当前环境暂不支持读取 GoNavi 应用日志' }; + ? await runtime.readAppLogTail(Number(args.lineLimit) || 160, readKeyword) + : { + success: false, + message: translateDiagnosticsCopy( + translate, + 'ai_chat.inspection.diagnostics.error.read_app_logs_unsupported', + 'The current environment does not support reading GoNavi app logs', + ), + }; if (!readResult?.success) { + const detail = String(readResult?.message || translateDiagnosticsCopy( + translate, + 'ai_chat.inspection.diagnostics.error.unknown', + 'unknown error', + )); return { - content: `读取 AI 上游请求日志失败: ${readResult?.message || '未知错误'}`, + content: translateDiagnosticsCopy( + translate, + 'ai_chat.inspection.diagnostics.error.read_ai_upstream_logs_failed', + `Failed to read AI upstream request logs: ${detail}`, + { detail }, + ), success: false, }; } + const snapshotReadResult = keyword || !readResult?.data || typeof readResult.data !== 'object' + ? readResult + : { + ...readResult, + data: { + ...readResult.data, + keyword, + }, + }; return { content: JSON.stringify(buildAIUpstreamLogSnapshot({ - readResult, + readResult: snapshotReadResult, provider: args.provider, requestId: args.requestId, keyword: args.keyword, @@ -204,6 +270,7 @@ export async function executeDiagnosticsSnapshotToolCall({ includeLines: args.includeLines === true, bodyPreviewLimit: args.bodyPreviewLimit, includePayloadSummary: args.includePayloadSummary !== false, + translate, })), success: true, }; @@ -211,10 +278,27 @@ export async function executeDiagnosticsSnapshotToolCall({ case 'inspect_recent_connection_failures': { const readResult = typeof runtime?.readAppLogTail === 'function' ? await runtime.readAppLogTail(Number(args.lineLimit) || 120, String(args.keyword || '')) - : { success: false, message: '当前环境暂不支持读取 GoNavi 应用日志' }; + : { + success: false, + message: translateDiagnosticsCopy( + translate, + 'ai_chat.inspection.diagnostics.error.read_app_logs_unsupported', + 'The current environment does not support reading GoNavi app logs', + ), + }; if (!readResult?.success) { + const detail = String(readResult?.message || translateDiagnosticsCopy( + translate, + 'ai_chat.inspection.diagnostics.error.unknown', + 'unknown error', + )); return { - content: `读取最近连接失败记录失败: ${readResult?.message || '未知错误'}`, + content: translateDiagnosticsCopy( + translate, + 'ai_chat.inspection.diagnostics.error.read_recent_connection_failures_failed', + `Failed to read recent connection failure records: ${detail}`, + { detail }, + ), success: false, }; } @@ -223,6 +307,7 @@ export async function executeDiagnosticsSnapshotToolCall({ readResult, keyword: args.keyword, lineLimit: args.lineLimit, + translate, })), success: true, }; @@ -231,10 +316,23 @@ export async function executeDiagnosticsSnapshotToolCall({ const keyword = String(args.serverName || args.keyword || 'MCP').trim(); const readResult = typeof runtime?.readAppLogTail === 'function' ? await runtime.readAppLogTail(Number(args.lineLimit) || 160, keyword) - : { success: false, message: '当前环境暂不支持读取 GoNavi 应用日志' }; + : { + success: false, + message: translateDiagnosticsCopy( + translate, + 'ai_chat.inspection.mcp_runtime.error.read_logs_unsupported', + 'The current environment does not support reading GoNavi app logs', + ), + }; if (!readResult?.success) { + const detail = String(readResult?.message || 'unknown error'); return { - content: `读取 MCP 运行期失败日志失败: ${readResult?.message || '未知错误'}`, + content: translateDiagnosticsCopy( + translate, + 'ai_chat.inspection.mcp_runtime.error.read_logs_failed', + `Failed to read MCP runtime failure logs: ${detail}`, + { detail }, + ), success: false, }; } @@ -250,13 +348,14 @@ export async function executeDiagnosticsSnapshotToolCall({ serverName: args.serverName, lineLimit: args.lineLimit, includeLines: args.includeLines === true, + translate, })), success: true, }; } case 'inspect_ai_last_render_error': return { - content: JSON.stringify(buildAILastRenderErrorSnapshot()), + content: JSON.stringify(buildAILastRenderErrorSnapshot(translate)), success: true, }; case 'inspect_saved_queries': diff --git a/frontend/src/components/ai/aiSnapshotInspectionSqlRiskToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionSqlRiskToolExecutor.ts index aa34c3c..4b00207 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionSqlRiskToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionSqlRiskToolExecutor.ts @@ -1,5 +1,6 @@ import type { SavedConnection, TabData } from '../../types'; import { buildSqlRiskSnapshot } from './aiSqlRiskInsights'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; import type { AISnapshotInspectionRuntime, SnapshotInspectionResult, @@ -12,6 +13,7 @@ interface ExecuteSqlRiskInspectionToolCallOptions { tabs?: TabData[]; activeTabId?: string | null; runtime?: AISnapshotInspectionRuntime; + translate?: AIInspectionTranslator; } export async function executeSqlRiskInspectionToolCall({ @@ -21,6 +23,7 @@ export async function executeSqlRiskInspectionToolCall({ tabs = [], activeTabId = null, runtime, + translate, }: ExecuteSqlRiskInspectionToolCallOptions): Promise { if (toolName !== 'inspect_sql_risk') { return null; @@ -42,6 +45,7 @@ export async function executeSqlRiskInspectionToolCall({ activeTabId, connections, safetyCheck, + translate, })), success: true, }; diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.i18n.test.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.i18n.test.ts new file mode 100644 index 0000000..77c5ca6 --- /dev/null +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.i18n.test.ts @@ -0,0 +1,125 @@ +import { readFileSync } from 'node:fs'; + +import { describe, expect, it, vi } from 'vitest'; + +import { executeSnapshotInspectionToolCall } from './aiSnapshotInspectionToolExecutor'; + +const source = readFileSync(new URL('./aiSnapshotInspectionToolExecutor.ts', import.meta.url), 'utf8'); +const locales = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const; +const localInspectionErrorKeys = [ + 'ai_chat.inspection.snapshot.error.inspect_current_connection', + 'ai_chat.inspection.snapshot.error.inspect_connection_capabilities', + 'ai_chat.inspection.snapshot.error.inspect_saved_connections', + 'ai_chat.inspection.snapshot.error.inspect_redis_topology', + 'ai_chat.inspection.snapshot.error.inspect_external_sql_directories', + 'ai_chat.inspection.snapshot.error.inspect_external_sql_file', + 'ai_chat.inspection.snapshot.error.inspect_ai_sessions', + 'ai_chat.inspection.snapshot.error.inspect_active_tab', + 'ai_chat.inspection.snapshot.error.inspect_workspace_tabs', + 'ai_chat.inspection.snapshot.error.inspect_ai_context', + 'ai_chat.inspection.snapshot.error.inspect_recent_sql_logs', + 'ai_chat.inspection.snapshot.error.inspect_recent_sql_activity', + 'ai_chat.inspection.snapshot.error.inspect_sql_editor_transaction', + 'ai_chat.inspection.snapshot.error.inspect_mcp_runtime_failures', + 'ai_chat.inspection.snapshot.error.inspect_ai_last_render_error', + 'ai_chat.inspection.snapshot.error.inspect_ai_message_flow', + 'ai_chat.inspection.snapshot.error.inspect_ai_context_budget', + 'ai_chat.inspection.snapshot.error.inspect_codebase_hotspots', + 'ai_chat.inspection.snapshot.error.inspect_saved_queries', + 'ai_chat.inspection.snapshot.error.inspect_sql_snippets', + 'ai_chat.inspection.snapshot.error.inspect_shortcuts', + 'ai_chat.inspection.snapshot.error.inspect_app_health', + 'ai_chat.inspection.snapshot.error.default', +] as const; + +const translate = (key: string, params?: Record) => { + const messages: Record = { + 'ai_chat.inspection.diagnostics.error.read_app_logs_failed': `APP_FAILED :: ${params?.detail}`, + 'ai_chat.inspection.diagnostics.error.read_ai_upstream_logs_failed': `UPSTREAM_FAILED :: ${params?.detail}`, + 'ai_chat.inspection.diagnostics.error.read_recent_connection_failures_failed': `RECENT_FAILED :: ${params?.detail}`, + 'ai_chat.inspection.snapshot.error.inspect_saved_connections': `SAVED_FAILED :: ${params?.detail}`, + }; + return messages[key] || key; +}; + +const execute = (toolName: string) => + executeSnapshotInspectionToolCall({ + toolName, + args: {}, + connections: [], + mcpTools: [], + translate, + runtime: { + readAppLogTail: vi.fn().mockRejectedValue(new Error('raw log read failure')), + }, + }); + +describe('aiSnapshotInspectionToolExecutor diagnostics i18n fallback', () => { + it('localizes diagnostics log-read exception wrappers while preserving raw detail', async () => { + await expect(execute('inspect_app_logs')).resolves.toMatchObject({ + success: false, + content: 'APP_FAILED :: raw log read failure', + }); + await expect(execute('inspect_ai_upstream_logs')).resolves.toMatchObject({ + success: false, + content: 'UPSTREAM_FAILED :: raw log read failure', + }); + await expect(execute('inspect_recent_connection_failures')).resolves.toMatchObject({ + success: false, + content: 'RECENT_FAILED :: raw log read failure', + }); + }); + + it('localizes generic local inspection exception wrappers while preserving raw detail', async () => { + const result = await executeSnapshotInspectionToolCall({ + toolName: 'inspect_saved_connections', + args: {}, + connections: null as any, + mcpTools: [], + translate, + }); + + expect(result).toMatchObject({ + success: false, + content: expect.stringContaining('SAVED_FAILED ::'), + }); + expect(result?.content).toContain('Cannot read'); + }); + + it('keeps generic local inspection wrapper keys in every locale and removes legacy Chinese labels from production source', () => { + locales.forEach((locale) => { + const catalog = JSON.parse(readFileSync(new URL(`../../../../shared/i18n/${locale}.json`, import.meta.url), 'utf8')) as Record; + localInspectionErrorKeys.forEach((key) => { + expect(catalog[key], `${locale}:${key}`).toBeTruthy(); + }); + }); + + [ + '读取当前连接失败', + '读取当前连接能力矩阵失败', + '读取本地连接清单失败', + '读取 Redis 拓扑配置失败', + '读取外部 SQL 目录失败', + '读取外部 SQL 文件失败', + '读取本地 AI 会话清单失败', + '读取当前活动页签失败', + '读取当前工作区页签失败', + '读取当前 AI 上下文失败', + '获取最近 SQL 日志失败', + '汇总最近 SQL 活动失败', + '读取 SQL 编辑器事务状态失败', + '读取 MCP 运行期失败诊断失败', + '读取最近一次 AI 渲染异常失败', + '读取 AI 消息流诊断失败', + '读取 AI 上下文体量诊断失败', + '读取代码热点诊断失败', + '读取已保存查询失败', + '读取 SQL 片段失败', + '读取快捷键配置失败', + '读取 AI 应用健康总览失败', + '读取本地探针快照失败', + ].forEach((legacyCopy) => { + expect(source).not.toContain(legacyCopy); + }); + }); +}); diff --git a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts index a43eac0..25b9f95 100644 --- a/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts +++ b/frontend/src/components/ai/aiSnapshotInspectionToolExecutor.ts @@ -11,10 +11,12 @@ import type { TabData, } from '../../types'; import type { SqlLog } from '../../store'; +import type { I18nParams } from '../../i18n'; import { executeConnectionWorkspaceSnapshotToolCall } from './aiSnapshotInspectionConnectionToolExecutor'; import { executeDiagnosticsSnapshotToolCall } from './aiSnapshotInspectionDiagnosticsToolExecutor'; import { executeAIConfigSnapshotToolCall } from './aiSnapshotInspectionAIConfigToolExecutor'; import { executeAppHealthSnapshotToolCall } from './aiSnapshotInspectionAppHealthToolExecutor'; +import { translateInspectionCopy } from './aiInspectionI18n'; import type { AISnapshotInspectionRuntime, SnapshotInspectionResult, @@ -40,6 +42,7 @@ interface ExecuteSnapshotInspectionToolCallOptions { skills?: AISkillConfig[]; userPromptSettings?: AIUserPromptSettings; dynamicModels?: string[]; + translate?: (key: string, params?: I18nParams) => string; runtime?: AISnapshotInspectionRuntime; } @@ -65,6 +68,7 @@ export async function executeSnapshotInspectionToolCall( skills = [], userPromptSettings, dynamicModels = [], + translate, runtime, } = options; @@ -84,6 +88,7 @@ export async function executeSnapshotInspectionToolCall( skills, userPromptSettings, dynamicModels, + translate, runtime, }); if (appHealthResult) { @@ -102,6 +107,7 @@ export async function executeSnapshotInspectionToolCall( skills, userPromptSettings, dynamicModels, + translate, runtime, }); if (aiConfigResult) { @@ -114,6 +120,7 @@ export async function executeSnapshotInspectionToolCall( connections, tabs, activeTabId, + translate, runtime, }); if (sqlRiskResult) { @@ -129,6 +136,7 @@ export async function executeSnapshotInspectionToolCall( tabs, activeTabId, externalSQLDirectories, + translate, runtime, }); if (connectionWorkspaceResult) { @@ -151,6 +159,7 @@ export async function executeSnapshotInspectionToolCall( sqlSnippets, skills, userPromptSettings, + translate, runtime, }); if (diagnosticsResult) { @@ -159,36 +168,78 @@ export async function executeSnapshotInspectionToolCall( return null; } catch (error: any) { - const label = { - inspect_current_connection: '读取当前连接失败', - inspect_connection_capabilities: '读取当前连接能力矩阵失败', - inspect_saved_connections: '读取本地连接清单失败', - inspect_redis_topology: '读取 Redis 拓扑配置失败', - inspect_external_sql_directories: '读取外部 SQL 目录失败', - inspect_external_sql_file: '读取外部 SQL 文件失败', - inspect_ai_sessions: '读取本地 AI 会话清单失败', - inspect_active_tab: '读取当前活动页签失败', - inspect_workspace_tabs: '读取当前工作区页签失败', - inspect_ai_context: '读取当前 AI 上下文失败', - inspect_recent_sql_logs: '获取最近 SQL 日志失败', - inspect_recent_sql_activity: '汇总最近 SQL 活动失败', - inspect_sql_editor_transaction: '读取 SQL 编辑器事务状态失败', - inspect_sql_risk: '检查 SQL 风险失败', - inspect_app_logs: '读取 GoNavi 应用日志失败', - inspect_ai_upstream_logs: '读取 AI 上游请求日志失败', - inspect_recent_connection_failures: '汇总最近连接失败记录失败', - inspect_mcp_runtime_failures: '读取 MCP 运行期失败诊断失败', - inspect_ai_last_render_error: '读取最近一次 AI 渲染异常失败', - inspect_ai_message_flow: '读取 AI 消息流诊断失败', - inspect_ai_context_budget: '读取 AI 上下文体量诊断失败', - inspect_codebase_hotspots: '读取代码热点诊断失败', - inspect_saved_queries: '读取已保存查询失败', - inspect_sql_snippets: '读取 SQL 片段失败', - inspect_shortcuts: '读取快捷键配置失败', - inspect_app_health: '读取 AI 应用健康总览失败', - }[toolName] || '读取本地探针快照失败'; + const detail = String(error?.message || error); + const diagnosticsReadError = { + inspect_app_logs: { + key: 'ai_chat.inspection.diagnostics.error.read_app_logs_failed', + fallback: `Failed to read GoNavi app logs: ${detail}`, + }, + inspect_ai_upstream_logs: { + key: 'ai_chat.inspection.diagnostics.error.read_ai_upstream_logs_failed', + fallback: `Failed to read AI upstream request logs: ${detail}`, + }, + inspect_recent_connection_failures: { + key: 'ai_chat.inspection.diagnostics.error.read_recent_connection_failures_failed', + fallback: `Failed to read recent connection failure records: ${detail}`, + }, + }[toolName]; + if (diagnosticsReadError) { + return { + content: translateInspectionCopy( + translate, + diagnosticsReadError.key, + diagnosticsReadError.fallback, + { detail }, + ), + success: false, + }; + } + if (toolName === 'inspect_sql_risk') { + return { + content: translateInspectionCopy( + translate, + 'ai_chat.inspection.sql_risk.error.inspect_failed', + `Failed to inspect SQL risk: ${detail}`, + { detail }, + ), + success: false, + }; + } + const fallbackByToolName: Record = { + inspect_current_connection: `Failed to read current connection: ${detail}`, + inspect_connection_capabilities: `Failed to read current connection capability matrix: ${detail}`, + inspect_saved_connections: `Failed to read saved connection list: ${detail}`, + inspect_redis_topology: `Failed to read Redis topology configuration: ${detail}`, + inspect_external_sql_directories: `Failed to read external SQL directories: ${detail}`, + inspect_external_sql_file: `Failed to read external SQL file: ${detail}`, + inspect_ai_sessions: `Failed to read local AI session list: ${detail}`, + inspect_active_tab: `Failed to read current active tab: ${detail}`, + inspect_workspace_tabs: `Failed to read current workspace tabs: ${detail}`, + inspect_ai_context: `Failed to read current AI context: ${detail}`, + inspect_recent_sql_logs: `Failed to fetch recent SQL logs: ${detail}`, + inspect_recent_sql_activity: `Failed to summarize recent SQL activity: ${detail}`, + inspect_sql_editor_transaction: `Failed to read SQL editor transaction state: ${detail}`, + inspect_mcp_runtime_failures: `Failed to read MCP runtime failure diagnostics: ${detail}`, + inspect_ai_last_render_error: `Failed to read the latest AI render error: ${detail}`, + inspect_ai_message_flow: `Failed to read AI message flow diagnostics: ${detail}`, + inspect_ai_context_budget: `Failed to read AI context budget diagnostics: ${detail}`, + inspect_codebase_hotspots: `Failed to read code hotspot diagnostics: ${detail}`, + inspect_saved_queries: `Failed to read saved queries: ${detail}`, + inspect_sql_snippets: `Failed to read SQL snippets: ${detail}`, + inspect_shortcuts: `Failed to read shortcut configuration: ${detail}`, + inspect_app_health: `Failed to read AI app health overview: ${detail}`, + }; + const fallback = fallbackByToolName[toolName] || `Failed to read local inspection snapshot: ${detail}`; + const errorKey = fallbackByToolName[toolName] + ? `ai_chat.inspection.snapshot.error.${toolName}` + : 'ai_chat.inspection.snapshot.error.default'; return { - content: `${label}: ${error?.message || error}`, + content: translateInspectionCopy( + translate, + errorKey, + fallback, + { detail }, + ), success: false, }; } diff --git a/frontend/src/components/ai/aiSqlEditorTransactionInsights.ts b/frontend/src/components/ai/aiSqlEditorTransactionInsights.ts index ade1bd4..d06501b 100644 --- a/frontend/src/components/ai/aiSqlEditorTransactionInsights.ts +++ b/frontend/src/components/ai/aiSqlEditorTransactionInsights.ts @@ -1,4 +1,5 @@ import type { SqlLog } from '../../store'; +import type { I18nParams } from '../../i18n'; import type { SavedConnection, TabData } from '../../types'; import { findSqlStatementRanges } from '../../utils/sqlStatementSelection'; import { shouldUseSqlEditorManagedTransaction } from '../../utils/sqlEditorTransaction'; @@ -8,9 +9,21 @@ import type { } from './aiSnapshotInspectionToolTypes'; type SqlEditorCommitMode = 'manual' | 'auto'; +type Translate = (key: string, params?: I18nParams) => string; const DEFAULT_AUTO_COMMIT_DELAY_MS = 5000; const SQL_PREVIEW_LIMIT = 1200; +const LOG_TRANSACTION_KEYWORD_PATTERN = new RegExp('\\u4e8b\\u52a1|\\u63d0\\u4ea4|\\u56de\\u6eda|transaction|commit|rollback', 'i'); + +const translateOrFallback = ( + translate: Translate | undefined, + key: string, + params: I18nParams | undefined, + fallback: string, +) => { + const translated = translate?.(key, params); + return translated && translated !== key ? translated : fallback; +}; const normalizeCommitMode = (value: unknown): SqlEditorCommitMode => String(value || '').trim().toLowerCase() === 'auto' ? 'auto' : 'manual'; @@ -54,21 +67,22 @@ const buildActiveSqlTabSnapshot = (params: { activeTabId?: string | null; connections: SavedConnection[]; includeSqlPreview?: boolean; + translate?: Translate; }) => { - const { tabs = [], activeTabId = null, connections, includeSqlPreview = true } = params; + const { tabs = [], activeTabId = null, connections, includeSqlPreview = true, translate } = params; const activeTab = tabs.find((tab) => tab.id === activeTabId); if (!activeTab) { return { hasActiveTab: false, hasSql: false, - message: '当前没有活动页签', + message: translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.no_active_tab', undefined, 'No active tab is currently selected'), }; } if (activeTab.type !== 'query') { return { hasActiveTab: true, hasSql: false, - message: '当前活动页签不是 SQL 编辑器页签', + message: translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.not_sql_tab', undefined, 'The current active tab is not a SQL editor tab'), tab: buildTabSummary(activeTab, connections), }; } @@ -89,10 +103,10 @@ const buildActiveSqlTabSnapshot = (params: { hasExplicitTransactionControl, usesManagedTransaction, transactionSemantics: usesManagedTransaction - ? '执行 INSERT/UPDATE/DELETE/MERGE/REPLACE 等 DML 时会先进入 SQL 编辑器托管事务;提交设置只决定事务执行成功后何时 COMMIT。' + ? translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.semantics.managed_dml', undefined, 'When the SQL editor runs INSERT/UPDATE/DELETE/MERGE/REPLACE DML, it first enters a managed transaction. The commit setting only decides when COMMIT happens after successful execution.') : hasExplicitTransactionControl - ? '检测到用户显式事务控制语句,GoNavi 不会再包一层 SQL 编辑器托管事务。' - : '当前 SQL 不会触发 SQL 编辑器托管事务;只读查询仍走普通查询路径。', + ? translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.semantics.explicit_transaction', undefined, 'Explicit transaction control statements were detected, so GoNavi will not wrap another SQL editor managed transaction around them.') + : translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.semantics.no_managed_transaction', undefined, 'The current SQL does not trigger a SQL editor managed transaction. Read-only queries still use the normal query path.'), }; }; @@ -128,7 +142,7 @@ const isRelevantSqlEditorTransactionLog = (log: SqlLog): boolean => { if (shouldUseSqlEditorManagedTransaction(statements)) return true; if (statements.some(hasTransactionControlStatement)) return true; return /\b(transaction|commit|rollback)\b/i.test(sql) - || /事务|提交|回滚|transaction|commit|rollback/i.test(String(log.message || '')); + || LOG_TRANSACTION_KEYWORD_PATTERN.test(String(log.message || '')); }; const buildRecentLogPreview = (log: SqlLog) => ({ @@ -150,6 +164,7 @@ export const buildSqlEditorTransactionSnapshot = (params: { sqlLogs?: SqlLog[]; includeSqlPreview?: boolean; now?: number; + translate?: Translate; }) => { const { transactionState, @@ -159,6 +174,7 @@ export const buildSqlEditorTransactionSnapshot = (params: { sqlLogs = [], includeSqlPreview = true, now = Date.now(), + translate, } = params; const commitMode = normalizeCommitMode(transactionState?.commitMode); const autoCommitDelayMs = normalizeDelayMs(transactionState?.autoCommitDelayMs); @@ -171,6 +187,7 @@ export const buildSqlEditorTransactionSnapshot = (params: { activeTabId, connections, includeSqlPreview, + translate, }); const activeUsesManagedTransaction = activeSqlTab.hasActiveTab && activeSqlTab.hasSql @@ -187,34 +204,44 @@ export const buildSqlEditorTransactionSnapshot = (params: { const warnings: string[] = []; if (pendingTransactions.length > 0) { - warnings.push(`当前有 ${pendingTransactions.length} 个 SQL 编辑器托管事务待提交或回滚`); + warnings.push(translateOrFallback( + translate, + 'ai_chat.inspection.sql_editor_transaction.warning.pending_transactions', + { count: pendingTransactions.length }, + `There are ${pendingTransactions.length} SQL editor managed transactions pending commit or rollback`, + )); } if (activePendingTransaction) { - warnings.push('当前活动 SQL 页签已有待处理事务,继续执行新的 DML 前应先提交或回滚'); + warnings.push(translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.warning.active_pending_transaction', undefined, 'The active SQL tab already has a pending transaction. Commit or roll it back before running another DML statement.')); } if (activeUsesManagedTransaction && commitMode === 'auto') { - warnings.push('当前设置为自动提交,但 DML 仍会先进入托管事务,只是在延迟到期后自动 COMMIT'); + warnings.push(translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.warning.auto_commit_managed_dml', undefined, 'Auto commit is enabled, but DML still enters a managed transaction and only runs COMMIT after the delay expires.')); } if (activeHasExplicitTransactionControl) { - warnings.push('当前 SQL 已包含显式事务控制,SQL 编辑器不会再接管提交/回滚'); + warnings.push(translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.warning.explicit_transaction_control', undefined, 'The current SQL already contains explicit transaction control, so the SQL editor will not take over commit or rollback.')); } const nextActions: string[] = []; if (activePendingTransaction) { - nextActions.push('先让用户在结果区事务条点击“提交”或“回滚”,或等待自动提交倒计时结束'); + nextActions.push(translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.next_action.resolve_active_pending', undefined, 'Ask the user to click "Commit" or "Rollback" in the result transaction bar, or wait for the auto-commit countdown to finish.')); } else if (pendingTransactions.length > 0) { - nextActions.push('如要继续执行 DML,先切回对应 SQL 页签处理待提交事务'); + nextActions.push(translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.next_action.switch_to_pending_tab', undefined, 'Before continuing with DML, switch back to the matching SQL tab and resolve the pending transaction.')); } if (activeUsesManagedTransaction) { nextActions.push(commitMode === 'auto' - ? `说明当前 DML 会先开启托管事务,执行成功后约 ${Math.round(autoCommitDelayMs / 1000)} 秒自动提交` - : '说明当前 DML 会先开启托管事务,执行成功后需要手动点击提交或回滚'); + ? translateOrFallback( + translate, + 'ai_chat.inspection.sql_editor_transaction.next_action.explain_auto_commit', + { seconds: Math.round(autoCommitDelayMs / 1000) }, + `Explain that the current DML opens a managed transaction first and auto-commits about ${Math.round(autoCommitDelayMs / 1000)} seconds after successful execution.`, + ) + : translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.next_action.explain_manual_commit', undefined, 'Explain that the current DML opens a managed transaction first and requires a manual Commit or Rollback after successful execution.')); } if (!activeSqlTab.hasActiveTab || !activeSqlTab.hasSql) { - nextActions.push('先切换到包含 SQL 草稿的查询页签,或让用户贴出要执行的 SQL'); + nextActions.push(translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.next_action.switch_to_sql_tab', undefined, 'Switch to a query tab with a SQL draft first, or ask the user to paste the SQL they want to run.')); } if (recentRelevantLogs.length > 0) { - nextActions.push('结合 recentRelevantLogs 回看最近写入/事务执行结果,必要时再调用 inspect_recent_sql_activity 下钻'); + nextActions.push(translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.next_action.inspect_recent_activity', undefined, 'Review recent write or transaction execution results in recentRelevantLogs, and call inspect_recent_sql_activity for deeper inspection if needed.')); } return { @@ -222,7 +249,7 @@ export const buildSqlEditorTransactionSnapshot = (params: { commitMode, autoCommitDelayMs, transactionAlwaysOnForDML: true, - semantics: 'SQL 编辑器执行 INSERT/UPDATE/DELETE/MERGE/REPLACE 等 DML 时始终先进入托管事务;“手动/自动”只控制执行成功后的 COMMIT 时机,不控制是否开启事务。', + semantics: translateOrFallback(translate, 'ai_chat.inspection.sql_editor_transaction.commit_policy.semantics', undefined, 'SQL editor runs INSERT/UPDATE/DELETE/MERGE/REPLACE DML inside a managed transaction. Manual or auto mode only controls when COMMIT happens after successful execution, not whether a transaction is opened.'), }, activeSqlTab, pendingTransactionCount: pendingTransactions.length, diff --git a/frontend/src/components/ai/aiSqlLogInsights.test.ts b/frontend/src/components/ai/aiSqlLogInsights.test.ts index 40b6fbe..0130adc 100644 --- a/frontend/src/components/ai/aiSqlLogInsights.test.ts +++ b/frontend/src/components/ai/aiSqlLogInsights.test.ts @@ -3,6 +3,10 @@ import { describe, expect, it } from 'vitest'; import { buildRecentSqlActivitySnapshot, buildRecentSqlLogsSnapshot } from './aiSqlLogInsights'; describe('aiSqlLogInsights', () => { + const translate = (key: string): string => ({ + 'ai_chat.inspection.sql_log.unspecified_database': '(Unspecified database)', + }[key] ?? key); + it('keeps recent sql logs as structured previews with inferred statement metadata', () => { const snapshot = buildRecentSqlLogsSnapshot({ status: 'all', @@ -117,4 +121,32 @@ describe('aiSqlLogInsights', () => { activityKind: 'write', }); }); + + it('localizes generated database placeholders while preserving raw log fields', () => { + const snapshot = buildRecentSqlActivitySnapshot({ + status: 'all', + activityKind: 'all', + limit: 5, + translate, + sqlLogs: [ + { + id: 'log-1', + timestamp: 2, + sql: 'SELECT * FROM raw_table', + status: 'error', + duration: 10, + dbName: '', + message: '原始错误', + }, + ], + }); + + expect(snapshot.dbBreakdown).toEqual({ + '(Unspecified database)': 1, + }); + expect(snapshot.recentExamples[0]).toMatchObject({ + sql: 'SELECT * FROM raw_table', + message: '原始错误', + }); + }); }); diff --git a/frontend/src/components/ai/aiSqlLogInsights.ts b/frontend/src/components/ai/aiSqlLogInsights.ts index 8322866..a713e12 100644 --- a/frontend/src/components/ai/aiSqlLogInsights.ts +++ b/frontend/src/components/ai/aiSqlLogInsights.ts @@ -1,5 +1,6 @@ import type { SqlLog } from '../../store'; +type SqlLogInsightTranslate = (key: string) => string; type SqlLogStatusFilter = 'all' | 'success' | 'error'; type SqlActivityKind = 'read' | 'write' | 'ddl' | 'transaction' | 'session' | 'other'; type SqlActivityKindFilter = 'all' | SqlActivityKind; @@ -235,8 +236,12 @@ export const buildRecentSqlActivitySnapshot = (params: { keyword?: unknown; dbName?: unknown; activityKind?: unknown; + translate?: SqlLogInsightTranslate; }) => { - const { sqlLogs = [], limit, status, keyword, dbName, activityKind } = params; + const { sqlLogs = [], limit, status, keyword, dbName, activityKind, translate } = params; + const unspecifiedDatabaseLabel = translate + ? translate('ai_chat.inspection.sql_log.unspecified_database') + : '(Unspecified database)'; const safeLimit = normalizeSqlLogLimit(limit, DEFAULT_SQL_ACTIVITY_LIMIT); const safeStatus = normalizeSqlLogStatus(status); const safeKeyword = String(keyword || '').trim().toLowerCase(); @@ -270,7 +275,7 @@ export const buildRecentSqlActivitySnapshot = (params: { }); const statementTypeBreakdown = buildCountBreakdown(filteredLogs.map((log) => log.statementType)); - const dbBreakdown = buildCountBreakdown(filteredLogs.map((log) => log.dbName || '(未指定数据库)')); + const dbBreakdown = buildCountBreakdown(filteredLogs.map((log) => log.dbName || unspecifiedDatabaseLabel)); const errorMessageBreakdown = buildCountBreakdown( filteredLogs .filter((log) => log.status === 'error' && String(log.message || '').trim()) diff --git a/frontend/src/components/ai/aiSqlRiskInsights.test.ts b/frontend/src/components/ai/aiSqlRiskInsights.test.ts index 4e34d95..bbf9b48 100644 --- a/frontend/src/components/ai/aiSqlRiskInsights.test.ts +++ b/frontend/src/components/ai/aiSqlRiskInsights.test.ts @@ -27,8 +27,51 @@ describe('aiSqlRiskInsights', () => { expect(snapshot.requiresUserConfirmation).toBe(true); expect(snapshot.activityKinds).toContain('write'); expect(snapshot.activityKinds).toContain('read'); - expect(snapshot.warnings).toContain('DELETE 缺少 WHERE 条件,可能删除整表数据'); - expect(snapshot.warnings.join('\n')).toContain('批量执行前应逐条确认影响范围'); + expect(snapshot.warnings).toContain('DELETE is missing a WHERE clause and may delete the entire table.'); + expect(snapshot.warnings.join('\n')).toContain('Confirm the impact scope of each statement before batch execution'); + }); + + it('uses the provided translator for SQL risk warnings and guidance', () => { + const snapshot = buildSqlRiskSnapshot({ + sql: 'DELETE FROM accounts; SELECT * FROM accounts;', + connections, + safetyCheck: { + allowed: false, + operationType: 'DELETE', + }, + translate: (key, params) => ({ + 'ai_chat.inspection.sql_risk.warning.multi_statement': `translated multi statement ${params?.count}`, + 'ai_chat.inspection.sql_risk.warning.data_change': 'translated data change', + 'ai_chat.inspection.sql_risk.warning.delete_missing_where': 'translated delete missing where', + 'ai_chat.inspection.sql_risk.warning.safety_blocked': `translated safety blocked ${params?.operationType}`, + 'ai_chat.inspection.sql_risk.next_action.explain_and_confirm': 'translated explain and confirm', + 'ai_chat.inspection.sql_risk.next_action.confirm_write_scope': 'translated confirm write scope', + })[key] || key, + }); + + expect(snapshot.warnings).toContain('translated multi statement 2'); + expect(snapshot.warnings).toContain('translated data change'); + expect(snapshot.warnings).toContain('translated delete missing where'); + expect(snapshot.warnings).toContain('translated safety blocked DELETE'); + expect(snapshot.nextActions).toEqual([ + 'translated explain and confirm', + 'translated confirm write scope', + ]); + expect(snapshot.sqlPreview).toBe('DELETE FROM accounts; SELECT * FROM accounts;'); + }); + + it('uses the provided translator for empty SQL state', () => { + const snapshot = buildSqlRiskSnapshot({ + connections, + translate: (key) => ({ + 'ai_chat.inspection.sql_risk.message.no_sql': 'translated no SQL message', + 'ai_chat.inspection.sql_risk.next_action.provide_sql': 'translated provide SQL', + })[key] || key, + }); + + expect(snapshot.hasSql).toBe(false); + expect(snapshot.message).toBe('translated no SQL message'); + expect(snapshot.nextActions).toEqual(['translated provide SQL']); }); it('reads SQL from the active query tab and includes safety check result', () => { @@ -60,7 +103,7 @@ describe('aiSqlRiskInsights', () => { }); expect(snapshot.riskLevel).toBe('critical'); expect(snapshot.safetyCheck).toMatchObject({ allowed: false, operationType: 'UPDATE' }); - expect(snapshot.warnings).toContain('UPDATE 缺少 WHERE 条件,可能更新整表数据'); - expect(snapshot.warnings).toContain('当前 AI 安全策略不允许执行 UPDATE 类型 SQL'); + expect(snapshot.warnings).toContain('UPDATE is missing a WHERE clause and may update the entire table.'); + expect(snapshot.warnings).toContain('The current AI safety policy does not allow UPDATE SQL.'); }); }); diff --git a/frontend/src/components/ai/aiSqlRiskInsights.ts b/frontend/src/components/ai/aiSqlRiskInsights.ts index 61e408e..b30a7e5 100644 --- a/frontend/src/components/ai/aiSqlRiskInsights.ts +++ b/frontend/src/components/ai/aiSqlRiskInsights.ts @@ -1,5 +1,8 @@ import type { SavedConnection, TabData } from '../../types'; +import type { I18nParams } from '../../i18n'; import { findSqlStatementRanges } from '../../utils/sqlStatementSelection'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; type SqlRiskLevel = 'none' | 'low' | 'medium' | 'high' | 'critical'; type SqlActivityKind = 'read' | 'write' | 'ddl' | 'transaction' | 'session' | 'routine' | 'other'; @@ -9,6 +12,11 @@ interface SqlSafetyCheckResult { operationType?: string; } +interface LocalizableText { + key: string; + fallback: string; +} + const SQL_PREVIEW_LIMIT = 12000; const READ_TOKENS = new Set(['select', 'show', 'describe', 'desc', 'explain', 'with']); @@ -105,6 +113,12 @@ const escalateRisk = (current: SqlRiskLevel, next: SqlRiskLevel): SqlRiskLevel = const hasWhereClause = (statement: string): boolean => /\bwhere\b/i.test(stripCommentsAndLiterals(statement)); +const translateText = ( + translate: AIInspectionTranslator | undefined, + { key, fallback }: LocalizableText, + params?: I18nParams, +): string => translateInspectionCopy(translate, key, fallback, params); + const normalizeLimit = (limit: unknown): number => { const value = Math.floor(Number(limit) || SQL_PREVIEW_LIMIT); if (value < 200) return 200; @@ -112,7 +126,7 @@ const normalizeLimit = (limit: unknown): number => { return value; }; -const buildStatementRisk = (statement: string) => { +const buildStatementRisk = (statement: string, translate?: AIInspectionTranslator) => { const token = resolveFirstToken(statement); const activityKind = classifySqlActivity(token); const normalized = stripCommentsAndLiterals(statement); @@ -121,39 +135,66 @@ const buildStatementRisk = (statement: string) => { if (!token) { riskLevel = 'none'; - warnings.push('未识别到有效 SQL 操作关键字'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.sql_risk.warning.unrecognized_operation', + fallback: 'No valid SQL operation keyword was recognized.', + })); } if (activityKind === 'write') { riskLevel = escalateRisk(riskLevel, 'high'); - warnings.push('该语句会修改数据,执行前应确认目标库、条件和影响范围'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.sql_risk.warning.data_change', + fallback: 'This statement modifies data. Confirm the target database, conditions, and impact scope before execution.', + })); } if (activityKind === 'ddl') { riskLevel = escalateRisk(riskLevel, 'high'); - warnings.push('该语句会修改数据库结构或对象,建议先备份并确认回滚方案'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.sql_risk.warning.ddl_change', + fallback: 'This statement modifies database structures or objects. Back up first and confirm the rollback plan.', + })); } if (activityKind === 'routine') { riskLevel = escalateRisk(riskLevel, 'medium'); - warnings.push('该语句会调用例程或过程,可能存在隐式写入或副作用'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.sql_risk.warning.routine_side_effect', + fallback: 'This statement calls a routine or procedure and may have implicit writes or side effects.', + })); } if (/^\s*delete\b/i.test(normalized) && !hasWhereClause(statement)) { riskLevel = escalateRisk(riskLevel, 'critical'); - warnings.push('DELETE 缺少 WHERE 条件,可能删除整表数据'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.sql_risk.warning.delete_missing_where', + fallback: 'DELETE is missing a WHERE clause and may delete the entire table.', + })); } if (/^\s*update\b/i.test(normalized) && !hasWhereClause(statement)) { riskLevel = escalateRisk(riskLevel, 'critical'); - warnings.push('UPDATE 缺少 WHERE 条件,可能更新整表数据'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.sql_risk.warning.update_missing_where', + fallback: 'UPDATE is missing a WHERE clause and may update the entire table.', + })); } if (/\btruncate\s+(?:table\s+)?[A-Za-z0-9_`"[\].]+/i.test(normalized)) { riskLevel = escalateRisk(riskLevel, 'critical'); - warnings.push('TRUNCATE 会快速清空表数据,通常不可按行回滚'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.sql_risk.warning.truncate', + fallback: 'TRUNCATE quickly clears table data and usually cannot be rolled back row by row.', + })); } if (/\bdrop\s+(database|schema|table|view|materialized\s+view)\b/i.test(normalized)) { riskLevel = escalateRisk(riskLevel, 'critical'); - warnings.push('DROP 会删除数据库对象,执行前必须确认对象和备份'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.sql_risk.warning.drop_object', + fallback: 'DROP deletes database objects. Confirm the object and backup before execution.', + })); } if (/\bgrant\b|\brevoke\b/i.test(normalized)) { riskLevel = escalateRisk(riskLevel, 'high'); - warnings.push('GRANT / REVOKE 会改变权限边界,应确认授权对象和范围'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.sql_risk.warning.permission_change', + fallback: 'GRANT / REVOKE changes permission boundaries. Confirm the grantee and scope.', + })); } return { @@ -196,6 +237,7 @@ export const buildSqlRiskSnapshot = (params: { activeTabId?: string | null; connections: SavedConnection[]; safetyCheck?: SqlSafetyCheckResult; + translate?: AIInspectionTranslator; }) => { const { source, sql, activeTab } = resolveActiveSqlSource({ sql: params.sql, @@ -212,8 +254,14 @@ export const buildSqlRiskSnapshot = (params: { hasSql: false, source, message: activeTab - ? '当前活动页签不是 SQL 查询页签,或编辑区没有 SQL 内容' - : '未传入 SQL,且当前没有可读取的活动 SQL 查询页签', + ? translateText(params.translate, { + key: 'ai_chat.inspection.sql_risk.message.no_active_query_sql', + fallback: 'The current active tab is not a SQL query tab, or the editor has no SQL content.', + }) + : translateText(params.translate, { + key: 'ai_chat.inspection.sql_risk.message.no_sql', + fallback: 'No SQL was provided, and there is no readable active SQL query tab.', + }), activeTab: activeTab ? { id: activeTab.id, title: activeTab.title, @@ -222,18 +270,24 @@ export const buildSqlRiskSnapshot = (params: { safetyCheck: params.safetyCheck || null, riskLevel: 'none' as SqlRiskLevel, warnings: [], - nextActions: ['先传入 sql 参数,或切换到包含 SQL 草稿的查询页签'], + nextActions: [translateText(params.translate, { + key: 'ai_chat.inspection.sql_risk.next_action.provide_sql', + fallback: 'Pass the sql argument first, or switch to a query tab that contains a SQL draft.', + })], }; } const statements = findSqlStatementRanges(sql).map((range) => range.text.trim()).filter(Boolean); - const statementRisks = statements.map(buildStatementRisk); + const statementRisks = statements.map((statement) => buildStatementRisk(statement, params.translate)); let riskLevel: SqlRiskLevel = statements.length > 0 ? 'low' : 'none'; const warnings: string[] = []; if (statements.length > 1) { riskLevel = escalateRisk(riskLevel, 'medium'); - warnings.push(`检测到 ${statements.length} 条 SQL 语句,批量执行前应逐条确认影响范围`); + warnings.push(translateText(params.translate, { + key: 'ai_chat.inspection.sql_risk.warning.multi_statement', + fallback: '{{count}} SQL statements were detected. Confirm the impact scope of each statement before batch execution.', + }, { count: statements.length })); } for (const statementRisk of statementRisks) { @@ -245,7 +299,16 @@ export const buildSqlRiskSnapshot = (params: { if (params.safetyCheck?.allowed === false) { riskLevel = escalateRisk(riskLevel, 'high'); - warnings.push(`当前 AI 安全策略不允许执行 ${params.safetyCheck.operationType || '该'} 类型 SQL`); + const operationType = String(params.safetyCheck.operationType || '').trim(); + warnings.push(operationType + ? translateText(params.translate, { + key: 'ai_chat.inspection.sql_risk.warning.safety_blocked', + fallback: 'The current AI safety policy does not allow {{operationType}} SQL.', + }, { operationType }) + : translateText(params.translate, { + key: 'ai_chat.inspection.sql_risk.warning.safety_blocked_unknown', + fallback: 'The current AI safety policy does not allow this SQL operation type.', + })); } const activityKinds = Array.from(new Set(statementRisks.map((item) => item.activityKind))); @@ -277,7 +340,19 @@ export const buildSqlRiskSnapshot = (params: { statements: statementRisks, warnings, nextActions: warnings.length > 0 - ? ['先向用户说明风险点,再要求用户确认是否继续', '写入或 DDL 语句应先确认 WHERE、备份、目标库和影响范围'] - : ['只读查询风险较低,仍建议先核对目标连接和库名'], + ? [ + translateText(params.translate, { + key: 'ai_chat.inspection.sql_risk.next_action.explain_and_confirm', + fallback: 'Explain the risk points to the user first, then ask the user to confirm whether to continue.', + }), + translateText(params.translate, { + key: 'ai_chat.inspection.sql_risk.next_action.confirm_write_scope', + fallback: 'For write or DDL statements, confirm WHERE clauses, backups, target database, and impact scope first.', + }), + ] + : [translateText(params.translate, { + key: 'ai_chat.inspection.sql_risk.next_action.read_only_check_target', + fallback: 'Read-only queries are lower risk, but still confirm the target connection and database name first.', + })], }; }; diff --git a/frontend/src/components/ai/aiSupportBundleInsights.test.ts b/frontend/src/components/ai/aiSupportBundleInsights.test.ts new file mode 100644 index 0000000..610dbdc --- /dev/null +++ b/frontend/src/components/ai/aiSupportBundleInsights.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAISupportBundleSnapshot } from './aiSupportBundleInsights'; + +describe('aiSupportBundleInsights', () => { + it('uses the provided translator for support bundle wrapper copy', () => { + const snapshot = buildAISupportBundleSnapshot({ + translate: (key) => ({ + 'ai_chat.inspection.support_bundle.message.ready': 'translated support bundle ready', + 'ai_chat.inspection.support_bundle.privacy.note': 'translated privacy note', + })[key] || key, + }); + + expect(snapshot.message).toBe('translated support bundle ready'); + expect(snapshot.privacy.note).toBe('translated privacy note'); + }); +}); diff --git a/frontend/src/components/ai/aiSupportBundleInsights.ts b/frontend/src/components/ai/aiSupportBundleInsights.ts index 9b7584e..3176ad1 100644 --- a/frontend/src/components/ai/aiSupportBundleInsights.ts +++ b/frontend/src/components/ai/aiSupportBundleInsights.ts @@ -12,9 +12,11 @@ import type { TabData, } from '../../types'; import type { AIBuiltinToolInfo } from '../../utils/aiBuiltinToolInfo.types'; +import type { I18nParams } from '../../i18n'; import { buildAIAppHealthSnapshot } from './aiAppHealthInsights'; import { buildAIMessageFlowSnapshot } from './aiChatSessionInsights'; import { buildAIContextBudgetSnapshot } from './aiContextBudgetInsights'; +import { translateInspectionCopy } from './aiInspectionI18n'; import { buildMCPRemoteAccessSnapshot } from './aiMCPRemoteAccessInsights'; import { buildAIToolCatalogSnapshot } from './aiToolCatalogInsights'; @@ -62,6 +64,7 @@ export const buildAISupportBundleSnapshot = (params: { path?: string; exposeStrategy?: string; tokenConfigured?: boolean; + translate?: (key: string, params?: I18nParams) => string; }) => { const aiChatHistory = params.aiChatHistory || {}; const aiChatSessions = params.aiChatSessions || []; @@ -90,6 +93,7 @@ export const buildAISupportBundleSnapshot = (params: { connectionKeyword: params.connectionKeyword, lineLimit: params.lineLimit, includeLogLines: params.includeLogLines === true, + translate: params.translate, }); const messageFlow = buildAIMessageFlowSnapshot({ aiChatSessions, @@ -99,6 +103,7 @@ export const buildAISupportBundleSnapshot = (params: { limit: 32, includeContent: params.includeMessageContent === true, previewLimit: 240, + translate: params.translate, }); const contextBudget = buildAIContextBudgetSnapshot({ aiContexts: params.aiContexts, @@ -111,6 +116,7 @@ export const buildAISupportBundleSnapshot = (params: { mcpTools: params.mcpTools, skills: params.skills, userPromptSettings: params.userPromptSettings, + translate: params.translate, }); const remoteAccess = buildMCPRemoteAccessSnapshot({ mcpClientStatuses: params.mcpClientStatuses, @@ -119,14 +125,26 @@ export const buildAISupportBundleSnapshot = (params: { path: params.path, exposeStrategy: params.exposeStrategy, tokenConfigured: params.tokenConfigured, + translate: params.translate, }); const toolCatalog = buildAIToolCatalogSnapshot({ builtinTools: params.builtinTools || [], mcpTools: params.mcpTools, - keyword: String(params.keyword || 'ai mcp 日志 连接 上下文').trim(), + keyword: String(params.keyword || 'ai mcp logs connections context').trim(), includeMCPTools: true, limit: 10, + translate: params.translate, }); + const supportBundleMessage = translateInspectionCopy( + params.translate, + 'ai_chat.inspection.support_bundle.message.ready', + 'Generated a GoNavi AI support bundle snapshot for diagnosing AI, MCP, logs, connections, and context size issues', + ); + const privacyNote = translateInspectionCopy( + params.translate, + 'ai_chat.inspection.support_bundle.privacy.note', + 'By default, only summaries and structured counts are returned; log lines or message previews are included only when includeLogLines/includeMessageContent is explicitly enabled.', + ); const warnings: string[] = []; const nextActions: string[] = []; @@ -141,14 +159,14 @@ export const buildAISupportBundleSnapshot = (params: { return { kind: 'ai_support_bundle', - message: '已生成 GoNavi AI 支持包快照,可用于排查 AI、MCP、日志、连接和上下文体量问题', + message: supportBundleMessage, privacy: { databasePasswordsIncluded: false, providerSecretsIncluded: false, mcpEnvValuesIncluded: false, logLinesIncluded: params.includeLogLines === true, messageContentIncluded: params.includeMessageContent === true, - note: '默认只返回摘要和结构化计数;只有显式开启 includeLogLines/includeMessageContent 时才附带日志或消息内容预览。', + note: privacyNote, }, summary: { appHealthStatus: appHealth.status, diff --git a/frontend/src/components/ai/aiSystemContextMessages.test.ts b/frontend/src/components/ai/aiSystemContextMessages.test.ts index f6eb013..c80c355 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.test.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; import type { @@ -6,8 +8,60 @@ import type { SavedConnection, TabData, } from '../../types'; +import { catalogs } from '../../i18n/catalog'; import { buildAISystemContextMessages } from './aiSystemContextMessages'; +const AI_SYSTEM_INSPECTION_GUIDANCE_KEYS = [ + 'ai_chat.system.inspection_guidance.inspect_ai_runtime', + 'ai_chat.system.inspection_guidance.inspect_ai_safety', + 'ai_chat.system.inspection_guidance.inspect_ai_context', + 'ai_chat.system.inspection_guidance.inspect_app_health', + 'ai_chat.system.inspection_guidance.inspect_ai_support_bundle', + 'ai_chat.system.inspection_guidance.inspect_ai_tool_catalog', + 'ai_chat.system.inspection_guidance.inspect_ai_setup_health', + 'ai_chat.system.inspection_guidance.inspect_ai_chat_readiness', + 'ai_chat.system.inspection_guidance.inspect_ai_upstream_logs', + 'ai_chat.system.inspection_guidance.inspect_ai_providers', + 'ai_chat.system.inspection_guidance.inspect_mcp_setup', + 'ai_chat.system.inspection_guidance.inspect_mcp_runtime_failures', + 'ai_chat.system.inspection_guidance.inspect_mcp_authoring_guide', + 'ai_chat.system.inspection_guidance.inspect_mcp_draft', + 'ai_chat.system.inspection_guidance.inspect_mcp_tool_schema', + 'ai_chat.system.inspection_guidance.inspect_ai_guidance', + 'ai_chat.system.inspection_guidance.inspect_shortcuts', + 'ai_chat.system.inspection_guidance.inspect_recent_connection_failures', + 'ai_chat.system.inspection_guidance.inspect_app_logs', + 'ai_chat.system.inspection_guidance.inspect_ai_last_render_error', + 'ai_chat.system.inspection_guidance.inspect_ai_message_flow', + 'ai_chat.system.inspection_guidance.inspect_ai_context_budget', + 'ai_chat.system.inspection_guidance.inspect_codebase_hotspots', + 'ai_chat.system.inspection_guidance.inspect_current_connection', + 'ai_chat.system.inspection_guidance.inspect_connection_capabilities', + 'ai_chat.system.inspection_guidance.inspect_saved_connections', + 'ai_chat.system.inspection_guidance.inspect_redis_topology', + 'ai_chat.system.inspection_guidance.inspect_external_sql_directories', + 'ai_chat.system.inspection_guidance.inspect_external_sql_file', + 'ai_chat.system.inspection_guidance.inspect_recent_sql_activity', + 'ai_chat.system.inspection_guidance.inspect_sql_editor_transaction', + 'ai_chat.system.inspection_guidance.inspect_sql_risk', + 'ai_chat.system.inspection_guidance.inspect_saved_queries', + 'ai_chat.system.inspection_guidance.inspect_ai_sessions', + 'ai_chat.system.inspection_guidance.inspect_sql_snippets', +] as const; + +const AI_SYSTEM_CONTEXT_KEYS = [ + 'ai_chat.system.context.custom_prompt.global', + 'ai_chat.system.context.custom_prompt.database', + 'ai_chat.system.context.custom_prompt.jvm', + 'ai_chat.system.context.custom_prompt.jvm_diagnostic', + 'ai_chat.system.context.skill_prompt', + 'ai_chat.system.context.database_with_schema', + 'ai_chat.system.context.database_with_target', + 'ai_chat.system.context.database_without_context', + 'ai_chat.system.context.jvm_diagnostic_prompt', + 'ai_chat.system.context.jvm_runtime_prompt', +] as const; + const userPromptSettings: AIUserPromptSettings = { global: '回答前先核对上下文。', database: '生成 SQL 时保持只读优先。', @@ -49,6 +103,200 @@ const connections: SavedConnection[] = [ ]; describe('buildAISystemContextMessages', () => { + it('uses the provided translator for fixed system inspection guidance', () => { + const translate = (key: string) => `T:${key}`; + + const messages = buildAISystemContextMessages({ + activeContext: null, + aiContexts: {}, + connections: [connections[0]], + tabs: [], + activeTabId: null, + availableToolNames: ['inspect_ai_runtime', 'inspect_sql_risk'], + skills: [], + userPromptSettings, + translate, + }); + + const joined = messages.map((message) => message.content).join('\n'); + expect(joined).toContain('T:ai_chat.system.inspection_guidance.inspect_ai_runtime'); + expect(joined).toContain('T:ai_chat.system.inspection_guidance.inspect_sql_risk'); + expect(joined).not.toContain('优先调用 inspect_ai_runtime 读取当前 AI 运行状态'); + expect(joined).not.toContain('优先调用 inspect_sql_risk 检查当前编辑区或传入 SQL'); + }); + + it('keeps fixed system inspection guidance keys in all six catalogs', () => { + for (const key of AI_SYSTEM_INSPECTION_GUIDANCE_KEYS) { + for (const language of Object.keys(catalogs) as Array) { + expect(catalogs[language]).toHaveProperty(key); + expect(catalogs[language][key as keyof (typeof catalogs)[typeof language]]).toBeTruthy(); + } + } + }); + + it('keeps fixed system inspection guidance production source free of legacy Chinese wrappers', () => { + const source = readFileSync(new URL('./aiSystemInspectionGuidance.ts', import.meta.url), 'utf8'); + expect(source).not.toMatch(/[\u4e00-\u9fff]/); + }); + + it('uses the provided translator for fixed system context prompts and wrappers', () => { + const translate = (key: string, params?: Record) => { + if (key === 'ai_chat.system.context.custom_prompt.global') { + return `global prompt wrapper -> ${params?.content}`; + } + if (key === 'ai_chat.system.context.custom_prompt.database') { + return `database prompt wrapper -> ${params?.content}`; + } + if (key === 'ai_chat.system.context.custom_prompt.jvm') { + return `jvm prompt wrapper -> ${params?.content}`; + } + if (key === 'ai_chat.system.context.custom_prompt.jvm_diagnostic') { + return `diagnostic prompt wrapper -> ${params?.content}`; + } + if (key === 'ai_chat.system.context.skill_prompt') { + return `skill wrapper -> ${params?.skillName} / ${params?.skillDescription} / ${params?.requiredTools} / ${params?.content}`; + } + if (key === 'ai_chat.system.context.database_with_schema') { + return `database schema prompt -> ${params?.dbType} / ${params?.ddlChunks}`; + } + if (key === 'ai_chat.system.context.database_with_target') { + return `database target prompt -> ${params?.dbType} / ${params?.dbName}`; + } + if (key === 'ai_chat.system.context.database_without_context') { + return `database no context prompt -> ${params?.connList}`; + } + if (key === 'ai_chat.system.context.jvm_runtime_prompt') { + return `jvm runtime prompt -> ${params?.connectionName} / ${params?.host} / ${params?.providerMode} / ${params?.environment} / ${params?.resourcePath}`; + } + if (key === 'ai_chat.system.context.jvm_diagnostic_prompt') { + return `jvm diagnostic prompt -> ${params?.connectionName} / ${params?.host} / ${params?.transport} / ${params?.environment} / ${params?.observeAllowed}`; + } + return key; + }; + + const databaseWithSchemaMessages = buildAISystemContextMessages({ + activeContext: { connectionId: 'conn-1', dbName: 'app_db' }, + aiContexts: { + 'conn-1:app_db': [ + { + dbName: 'app_db', + tableName: 'orders', + ddl: 'CREATE TABLE orders (id bigint);', + }, + ], + }, + connections: [connections[0]], + tabs: [], + activeTabId: null, + availableToolNames: ['inspect_workspace_tabs', 'get_columns'], + skills: [ + { + id: 'skill-1', + name: '结构审查', + description: '优先核对结构', + systemPrompt: '先看字段和索引,再给结论。', + enabled: true, + scopes: ['database'], + requiredTools: ['inspect_workspace_tabs', 'get_columns'], + }, + ], + userPromptSettings, + translate, + }); + const schemaJoined = databaseWithSchemaMessages.map((message) => message.content).join('\n'); + expect(schemaJoined).toContain('database schema prompt -> MySQL / -- Table: app_db.orders'); + expect(schemaJoined).toContain('CREATE TABLE orders (id bigint);'); + expect(schemaJoined).toContain('global prompt wrapper -> 回答前先核对上下文。'); + expect(schemaJoined).toContain('database prompt wrapper -> 生成 SQL 时保持只读优先。'); + expect(schemaJoined).toContain('skill wrapper -> 结构审查 / 优先核对结构 /'); + expect(schemaJoined).toContain('inspect_workspace_tabs, get_columns'); + expect(schemaJoined).toContain('先看字段和索引,再给结论。'); + expect(schemaJoined).not.toContain('以下是当前用户的自定义补充提示词'); + expect(schemaJoined).not.toContain('以下是当前启用的 Skill'); + expect(schemaJoined).not.toContain('你是一个专业的数据库助手'); + + const databaseTargetMessages = buildAISystemContextMessages({ + activeContext: { connectionId: 'conn-1', dbName: 'app_db' }, + aiContexts: {}, + connections: [connections[0]], + tabs: [], + activeTabId: null, + availableToolNames: [], + skills: [], + userPromptSettings, + translate, + }); + expect(databaseTargetMessages[0].content).toContain('database target prompt -> MySQL / app_db'); + + const databaseNoContextMessages = buildAISystemContextMessages({ + activeContext: null, + aiContexts: {}, + connections: [connections[0]], + tabs: [], + activeTabId: null, + availableToolNames: [], + skills: [], + userPromptSettings, + translate, + }); + expect(databaseNoContextMessages[0].content).toContain('database no context prompt -> {id: "conn-1", name: "本地开发库", type: "mysql"}'); + + const runtimeMessages = buildAISystemContextMessages({ + activeContext: null, + aiContexts: {}, + connections, + tabs: [ + { + id: 'jvm-tab-1', + title: 'JVM Resource', + type: 'jvm-resource', + connectionId: 'jvm-1', + providerMode: 'jmx', + resourcePath: 'java.lang:type=Memory', + }, + ], + activeTabId: 'jvm-tab-1', + availableToolNames: [], + skills: [], + userPromptSettings, + translate, + }); + expect(runtimeMessages[0].content).toContain('jvm runtime prompt -> JVM 诊断环境 / 10.0.0.8 / jmx / uat / java.lang:type=Memory'); + expect(runtimeMessages[1].content).toContain('global prompt wrapper -> 回答前先核对上下文。'); + expect(runtimeMessages[2].content).toContain('jvm prompt wrapper -> 解释 JVM 资源时先说风险。'); + + const diagnosticMessages = buildAISystemContextMessages({ + activeContext: null, + aiContexts: {}, + connections, + tabs: [ + { + id: 'diag-tab-1', + title: 'JVM Diagnostic', + type: 'jvm-diagnostic', + connectionId: 'jvm-1', + }, + ], + activeTabId: 'diag-tab-1', + availableToolNames: [], + skills: [], + userPromptSettings, + translate, + }); + expect(diagnosticMessages[0].content).toContain('jvm diagnostic prompt -> JVM 诊断环境 / 10.0.0.8 / agent-bridge / uat / allowed'); + expect(diagnosticMessages[1].content).toContain('global prompt wrapper -> 回答前先核对上下文。'); + expect(diagnosticMessages[2].content).toContain('diagnostic prompt wrapper -> 诊断命令必须说明预期信号。'); + }); + + it('keeps fixed system context keys in all six catalogs', () => { + for (const key of AI_SYSTEM_CONTEXT_KEYS) { + for (const language of Object.keys(catalogs) as Array) { + expect(catalogs[language]).toHaveProperty(key); + expect(catalogs[language][key as keyof (typeof catalogs)[typeof language]]).toBeTruthy(); + } + } + }); + it('adds database workspace inspection guidance plus custom prompts and eligible skills', () => { const skills: AISkillConfig[] = [ { @@ -68,51 +316,26 @@ describe('buildAISystemContextMessages', () => { connections: [connections[0]], tabs: [], activeTabId: null, - availableToolNames: ['inspect_workspace_tabs', 'inspect_app_health', 'inspect_ai_support_bundle', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_ai_upstream_logs', 'inspect_ai_tool_catalog', 'inspect_mcp_setup', 'inspect_mcp_runtime_failures', 'inspect_mcp_authoring_guide', 'inspect_mcp_draft', 'inspect_mcp_tool_schema', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_sql_editor_transaction', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_ai_message_flow', 'inspect_ai_context_budget', 'inspect_codebase_hotspots', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], + availableToolNames: ['inspect_workspace_tabs', 'inspect_app_health', 'inspect_ai_support_bundle', 'inspect_ai_setup_health', 'inspect_ai_runtime', 'inspect_ai_safety', 'inspect_ai_providers', 'inspect_ai_chat_readiness', 'inspect_ai_upstream_logs', 'inspect_ai_tool_catalog', 'inspect_mcp_setup', 'inspect_mcp_runtime_failures', 'inspect_mcp_authoring_guide', 'inspect_mcp_draft', 'inspect_mcp_tool_schema', 'inspect_ai_guidance', 'inspect_ai_context', 'inspect_current_connection', 'inspect_connection_capabilities', 'inspect_saved_connections', 'inspect_redis_topology', 'inspect_external_sql_directories', 'inspect_external_sql_file', 'inspect_recent_sql_activity', 'inspect_sql_editor_transaction', 'inspect_sql_risk', 'inspect_recent_connection_failures', 'inspect_app_logs', 'inspect_ai_last_render_error', 'inspect_ai_message_flow', 'inspect_ai_context_budget', 'inspect_codebase_hotspots', 'inspect_saved_queries', 'inspect_ai_sessions', 'inspect_sql_snippets', 'inspect_shortcuts', 'get_columns'], skills, userPromptSettings, }); const joined = messages.map((message) => message.content).join('\n'); - expect(joined).toContain('inspect_workspace_tabs 盘点当前工作区'); - expect(joined).toContain('inspect_app_health 获取 AI 配置、应用日志、连接失败、回复气泡渲染异常和工作区页签的全局健康总览'); - expect(joined).toContain('inspect_ai_support_bundle 生成不含密钥和数据库密码的支持包'); - expect(joined).toContain('inspect_ai_setup_health 先拿到整体现状'); - expect(joined).toContain('inspect_ai_runtime 读取当前 AI 运行状态'); - expect(joined).toContain('inspect_ai_safety 读取真实安全边界'); - expect(joined).toContain('inspect_ai_providers 读取真实供应商配置'); - expect(joined).toContain('inspect_ai_chat_readiness 读取真实发送前置状态'); - expect(joined).toContain('inspect_ai_upstream_logs 读取脱敏后的真实请求日志'); - expect(joined).toContain('inspect_ai_tool_catalog 按关键词读取真实工具目录'); - expect(joined).toContain('inspect_mcp_setup 读取真实 MCP 配置'); - expect(joined).toContain('inspect_mcp_runtime_failures 读取真实 MCP 运行期失败日志'); - expect(joined).toContain('inspect_mcp_authoring_guide 读取真实新增指引和模板'); - expect(joined).toContain('inspect_mcp_draft 返回自动拆分、启动预览、suggestedServerSeed、配置错误/告警和 nextActions'); - expect(joined).toContain('inspect_mcp_tool_schema 读取真实 inputSchema'); - expect(joined).toContain('inspect_ai_guidance 读取真实提示与技能配置'); - expect(joined).toContain('inspect_ai_context 读取当前挂载的表结构上下文'); - expect(joined).toContain('inspect_current_connection'); - expect(joined).toContain('inspect_connection_capabilities'); - expect(joined).toContain('inspect_saved_connections'); - expect(joined).toContain('inspect_external_sql_directories'); - expect(joined).toContain('inspect_external_sql_file'); - expect(joined).toContain('inspect_recent_sql_activity'); - expect(joined).toContain('inspect_sql_editor_transaction 读取真实提交设置'); - expect(joined).toContain('inspect_sql_risk'); - expect(joined).toContain('inspect_recent_connection_failures 读取真实连接失败总结'); - expect(joined).toContain('inspect_app_logs 读取真实应用日志尾部'); - expect(joined).toContain('inspect_ai_last_render_error 读取最近一次被隔离的前端渲染异常记录'); - expect(joined).toContain('inspect_ai_message_flow 读取当前会话的真实消息结构'); - expect(joined).toContain('inspect_ai_context_budget 读取消息、DDL、MCP schema、提示词和 Skills 的体量风险'); - expect(joined).toContain('inspect_codebase_hotspots 读取大文件热点'); - expect(joined).toContain('inspect_saved_queries'); - expect(joined).toContain('inspect_ai_sessions'); - expect(joined).toContain('inspect_sql_snippets'); - expect(joined).toContain('inspect_shortcuts 读取真实快捷键配置和平台差异'); - expect(joined).toContain('当前连接'); - expect(joined).toContain('以下是当前用户的自定义补充提示词(全局)'); - expect(joined).toContain('以下是当前用户的自定义补充提示词(数据库会话)'); - expect(joined).toContain('以下是当前启用的 Skill「结构审查」'); + expect(joined).toContain('inspect_workspace_tabs'); + for (const key of AI_SYSTEM_INSPECTION_GUIDANCE_KEYS) { + const toolName = key.slice('ai_chat.system.inspection_guidance.'.length); + expect(joined).toContain(toolName); + } + expect(joined).toContain('call inspect_app_health first'); + expect(joined).toContain('call inspect_sql_risk first'); + expect(joined).toContain('Existing connections'); + expect(joined).toContain('The user has provided an additional global prompt'); + expect(joined).toContain('回答前先核对上下文。'); + expect(joined).toContain('The user has provided an additional database-session prompt'); + expect(joined).toContain('生成 SQL 时保持只读优先。'); + expect(joined).toContain('The active Skill "结构审查" (优先核对结构) applies to this response'); + expect(joined).toContain('先看字段和索引,再给结论。'); }); it('builds the JVM diagnostic prompt when the active tab is a diagnostic workspace', () => { @@ -137,9 +360,10 @@ describe('buildAISystemContextMessages', () => { }); expect(messages).toHaveLength(3); - expect(messages[0].content).toContain('你是 GoNavi 的 JVM 诊断助手'); - expect(messages[0].content).toContain('transport 必须填写当前值 agent-bridge'); - expect(messages[1].content).toContain('以下是当前用户的自定义补充提示词(全局)'); - expect(messages[2].content).toContain('以下是当前用户的自定义补充提示词(JVM 诊断)'); + expect(messages[0].content).toContain("You are GoNavi's JVM diagnostic assistant"); + expect(messages[0].content).toContain('transport must be the current value agent-bridge'); + expect(messages[1].content).toContain('The user has provided an additional global prompt'); + expect(messages[2].content).toContain('The user has provided an additional JVM diagnostic prompt'); + expect(messages[2].content).toContain('诊断命令必须说明预期信号。'); }); }); diff --git a/frontend/src/components/ai/aiSystemContextMessages.ts b/frontend/src/components/ai/aiSystemContextMessages.ts index 43d28ba..33f1f4b 100644 --- a/frontend/src/components/ai/aiSystemContextMessages.ts +++ b/frontend/src/components/ai/aiSystemContextMessages.ts @@ -12,6 +12,10 @@ import { appendDatabaseInspectionGuidanceMessages, appendJVMInspectionGuidanceMessages, } from './aiSystemInspectionGuidance'; +import { + translateInspectionCopy, + type AIInspectionTranslator, +} from './aiInspectionI18n'; export interface AISystemContextMessage { role: 'system'; @@ -30,12 +34,14 @@ interface BuildAISystemContextMessagesOptions { userPromptSettings: AIUserPromptSettings; overrideJVMPlanContext?: JVMAIPlanContext; overrideJVMDiagnosticPlanContext?: JVMDiagnosticPlanContext; + translate?: AIInspectionTranslator; } const appendCustomPrompt = ( messages: AISystemContextMessage[], - label: string, + key: string, content: string, + translate?: AIInspectionTranslator, ) => { const trimmed = String(content || '').trim(); if (!trimmed) { @@ -43,7 +49,12 @@ const appendCustomPrompt = ( } messages.push({ role: 'system', - content: `以下是当前用户的自定义补充提示词(${label})。在不违反安全规则和事实约束的前提下,请优先遵循:\n${trimmed}`, + content: translateInspectionCopy( + translate, + key, + 'The user has provided an additional prompt for this context. Follow it when it does not conflict with safety rules or factual constraints:\n{{content}}', + { content: trimmed }, + ), }); }; @@ -51,15 +62,16 @@ const appendCustomPromptGroup = ( messages: AISystemContextMessage[], prompts: string[], userPromptSettings: AIUserPromptSettings, + translate?: AIInspectionTranslator, ) => { - appendCustomPrompt(messages, '全局', userPromptSettings.global); + appendCustomPrompt(messages, 'ai_chat.system.context.custom_prompt.global', userPromptSettings.global, translate); prompts.forEach((prompt) => { if (prompt === 'database') { - appendCustomPrompt(messages, '数据库会话', userPromptSettings.database); + appendCustomPrompt(messages, 'ai_chat.system.context.custom_prompt.database', userPromptSettings.database, translate); } else if (prompt === 'jvm') { - appendCustomPrompt(messages, 'JVM 资源分析', userPromptSettings.jvm); + appendCustomPrompt(messages, 'ai_chat.system.context.custom_prompt.jvm', userPromptSettings.jvm, translate); } else if (prompt === 'jvmDiagnostic') { - appendCustomPrompt(messages, 'JVM 诊断', userPromptSettings.jvmDiagnostic); + appendCustomPrompt(messages, 'ai_chat.system.context.custom_prompt.jvm_diagnostic', userPromptSettings.jvmDiagnostic, translate); } }); }; @@ -69,6 +81,7 @@ const appendSkillPromptGroup = ( scopes: string[], skills: AISkillConfig[], availableToolNames: string[], + translate?: AIInspectionTranslator, ) => { const wantedScopes = new Set(['global', ...scopes]); const availableToolNameSet = new Set(availableToolNames); @@ -89,21 +102,60 @@ const appendSkillPromptGroup = ( if (!promptText) { return; } + const requiredTools = Array.isArray(skill.requiredTools) && skill.requiredTools.length > 0 + ? skill.requiredTools.join(', ') + : ''; const requiredToolText = Array.isArray(skill.requiredTools) && skill.requiredTools.length > 0 - ? `\n依赖工具:${skill.requiredTools.join(', ')}` + ? translateInspectionCopy( + translate, + 'ai_chat.system.context.skill_prompt.required_tools', + `\nRequired tools: ${requiredTools}`, + { requiredTools }, + ) : ''; messages.push({ role: 'system', - content: `以下是当前启用的 Skill「${skill.name}」${skill.description ? `(${skill.description})` : ''}。请在本次回答中遵循它的约束和工作方式:${requiredToolText}\n${promptText}`, + content: translateInspectionCopy( + translate, + skill.description ? 'ai_chat.system.context.skill_prompt' : 'ai_chat.system.context.skill_prompt_without_description', + skill.description + ? 'The active Skill "{{skillName}}" ({{skillDescription}}) applies to this response. Follow its constraints and workflow:{{requiredTools}}\n{{content}}' + : 'The active Skill "{{skillName}}" applies to this response. Follow its constraints and workflow:{{requiredTools}}\n{{content}}', + { + skillName: skill.name, + skillDescription: skill.description || '', + requiredTools: requiredToolText, + content: promptText, + }, + ), }); }); }; const resolveDatabaseDisplayType = (config: ConnectionConfig | undefined): string => { const dbType = config?.type || 'unknown'; - return dbType === 'diros' ? 'Doris' : dbType.charAt(0).toUpperCase() + dbType.slice(1); + const displayTypes: Record = { + clickhouse: 'ClickHouse', + diros: 'Doris', + duckdb: 'DuckDB', + mongodb: 'MongoDB', + mysql: 'MySQL', + postgresql: 'PostgreSQL', + redis: 'Redis', + sqlite: 'SQLite', + sqlserver: 'SQL Server', + tdengine: 'TDengine', + }; + return displayTypes[dbType] || dbType.charAt(0).toUpperCase() + dbType.slice(1); }; +const contextCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: Record, +) => translateInspectionCopy(translate, key, fallback, params); + const resolveActiveTab = (params: { tabs: TabData[]; connections: SavedConnection[]; @@ -165,6 +217,7 @@ export function buildAISystemContextMessages({ userPromptSettings, overrideJVMPlanContext, overrideJVMDiagnosticPlanContext, + translate, }: BuildAISystemContextMessagesOptions): AISystemContextMessage[] { const connectionKey = activeContext?.connectionId ? `${activeContext.connectionId}:${activeContext.dbName || ''}` : 'default'; const activeContextItems = aiContexts[connectionKey] || []; @@ -189,29 +242,60 @@ export function buildAISystemContextMessages({ const diagnosticTransport = overrideJVMDiagnosticPlanContext?.transport || diagnostic?.transport || 'agent-bridge'; const readOnly = activeConnection.config.jvm?.readOnly !== false; const environment = activeConnection.config.jvm?.environment || 'unknown'; + const diagnosticPolicy = contextCopy( + translate, + readOnly ? 'ai_chat.system.context.jvm_diagnostic_policy.read_only' : 'ai_chat.system.context.jvm_diagnostic_policy.writable', + readOnly + ? 'Default to read-only diagnostic reasoning: only generate observe, trace, and troubleshooting commands, and never assume anything has already executed.' + : 'Diagnostic commands may be generated, but provide a plan first and let the user decide whether to run it.', + ); + const permissionAllowed = contextCopy( + translate, + 'ai_chat.system.context.permission.allowed', + 'allowed', + ); + const permissionDenied = contextCopy( + translate, + 'ai_chat.system.context.permission.denied', + 'denied', + ); systemMessages.push({ role: 'system', - content: `你是 GoNavi 的 JVM 诊断助手。当前页签是 Arthas 兼容诊断工作台,目标是输出可回填到诊断控制台的结构化诊断计划。 + content: contextCopy( + translate, + 'ai_chat.system.context.jvm_diagnostic_prompt', + `You are GoNavi's JVM diagnostic assistant. The active tab is an Arthas-compatible diagnostic workspace. Produce a structured diagnostic plan that can be filled back into the diagnostic console. -当前连接:${activeConnection.name} -目标主机:${activeConnection.config.host || '-'} -诊断 transport:${diagnosticTransport} -运行环境:${environment} -连接策略:${readOnly ? '默认按只读诊断思路回答,只生成观察、trace、排障命令,不要假设已经执行。' : '允许生成诊断命令,但仍然必须先给计划,再由用户决定是否执行。'} -命令权限:observe=${diagnostic?.allowObserveCommands !== false ? '允许' : '禁止'},trace=${diagnostic?.allowTraceCommands === true ? '允许' : '禁止'},mutating=${diagnostic?.allowMutatingCommands === true ? '允许' : '禁止'} +Current connection: {{connectionName}} +Target host: {{host}} +Diagnostic transport: {{transport}} +Runtime environment: {{environment}} +Connection policy: {{connectionPolicy}} +Command permissions: observe={{observeAllowed}}, trace={{traceAllowed}}, mutating={{mutatingAllowed}} -回答规则: -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. 如果命令权限不允许某类操作,就不要输出该类命令;无法满足时直接说明限制。`, +Response rules: +1. You may include a short analysis first, but the response must contain exactly one \`\`\`json code block. +2. JSON fields are strictly limited to intent, transport, command, riskLevel, reason, and expectedSignals. +3. transport must be the current value {{transport}}; do not invent another transport. +4. command must be a single diagnostic command without a shell prompt, line-joined commands, multiple commands, or a code fence. +5. riskLevel must be low, medium, or high. +6. expectedSignals must be an array of strings describing the signals to observe after execution. +7. If permissions do not allow a class of operation, do not output that class of command; if the request cannot be satisfied, state the limitation directly.`, + { + connectionName: activeConnection.name, + host: activeConnection.config.host || '-', + transport: diagnosticTransport, + environment, + connectionPolicy: diagnosticPolicy, + observeAllowed: diagnostic?.allowObserveCommands !== false ? permissionAllowed : permissionDenied, + traceAllowed: diagnostic?.allowTraceCommands === true ? permissionAllowed : permissionDenied, + mutatingAllowed: diagnostic?.allowMutatingCommands === true ? permissionAllowed : permissionDenied, + }, + ), }); - appendJVMInspectionGuidanceMessages(systemMessages, availableToolNames); - appendCustomPromptGroup(systemMessages, ['jvmDiagnostic'], userPromptSettings); - appendSkillPromptGroup(systemMessages, ['jvmDiagnostic'], skills, availableToolNames); + appendJVMInspectionGuidanceMessages(systemMessages, availableToolNames, translate); + appendCustomPromptGroup(systemMessages, ['jvmDiagnostic'], userPromptSettings, translate); + appendSkillPromptGroup(systemMessages, ['jvmDiagnostic'], skills, availableToolNames, translate); return systemMessages; } @@ -224,28 +308,60 @@ export function buildAISystemContextMessages({ const resourcePath = activeTab.resourcePath || ''; const readOnly = activeConnection.config.jvm?.readOnly !== false; const environment = activeConnection.config.jvm?.environment || 'unknown'; + const connectionPolicy = contextCopy( + translate, + readOnly ? 'ai_chat.system.context.jvm_runtime_policy.read_only' : 'ai_chat.system.context.jvm_runtime_policy.writable', + readOnly + ? 'This is a read-only connection. Only analyze and generate change plans; never assume writes have already executed.' + : 'This is a writable connection, but every modification must first produce a preview and wait for human confirmation.', + ); + const resourcePathLine = resourcePath + ? contextCopy( + translate, + 'ai_chat.system.context.jvm_runtime_resource_path', + 'Current resource path: {{resourcePath}}', + { resourcePath }, + ) + : contextCopy( + translate, + 'ai_chat.system.context.jvm_runtime_resource_path_unselected', + 'No specific resource path is currently selected.', + ); systemMessages.push({ role: 'system', - content: `你是 GoNavi 的 JVM 运行时分析助手。当前上下文不是 SQL,而是 JVM 资源工作台。 + content: contextCopy( + translate, + 'ai_chat.system.context.jvm_runtime_prompt', + `You are GoNavi's JVM runtime analysis assistant. The current context is not SQL; it is the JVM resource workspace. -当前连接:${activeConnection.name} -目标主机:${activeConnection.config.host || '-'} -Provider 模式:${providerMode} -运行环境:${environment} -连接策略:${readOnly ? '只读连接,只能分析和生成变更计划,绝不能假设已执行写入。' : '可写连接,但任何修改都必须先生成预览并等待人工确认。'} -${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体资源路径。'} +Current connection: {{connectionName}} +Target host: {{host}} +Provider mode: {{providerMode}} +Runtime environment: {{environment}} +Connection policy: {{connectionPolicy}} +{{resourcePathLine}} -回答规则: -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. 不要输出脚本、命令或“已经执行成功”之类的表述。`, +Response rules: +1. You may explain resource structure, risk, modification suggestions, and rollback suggestions. +2. If the user asks for a JVM change plan, output exactly one \`\`\`json code block, and limit JSON fields to targetType, selector, action, payload, and reason. +3. Prefer actions declared by the current resource snapshot or metadata in supportedActions. If none are declared, infer cautiously from the snapshot. +4. Prefer the current resource path for selector.resourcePath. If the path is unknown, state that exact targeting is unavailable instead of inventing a path. +5. payload may only use {"format":"json","value":{...}} or {"format":"text","value":"..."}; do not output scripts, commands, or bare values. +6. Do not output scripts, commands, or statements such as "already executed successfully".`, + { + connectionName: activeConnection.name, + host: activeConnection.config.host || '-', + providerMode, + environment, + connectionPolicy, + resourcePath, + resourcePathLine, + }, + ), }); - appendJVMInspectionGuidanceMessages(systemMessages, availableToolNames); - appendCustomPromptGroup(systemMessages, ['jvm'], userPromptSettings); - appendSkillPromptGroup(systemMessages, ['jvm'], skills, availableToolNames); + appendJVMInspectionGuidanceMessages(systemMessages, availableToolNames, translate); + appendCustomPromptGroup(systemMessages, ['jvm'], userPromptSettings, translate); + appendSkillPromptGroup(systemMessages, ['jvm'], skills, availableToolNames, translate); return systemMessages; } @@ -264,54 +380,74 @@ ${resourcePath ? `当前资源路径:${resourcePath}` : '当前未选中具体 const ddlChunks = activeContextItems.map((item) => `-- Table: ${item.dbName}.${item.tableName}\n${item.ddl}`).join('\n\n'); systemMessages.push({ role: 'system', - content: `你是一个专业的数据库助手。当前连接的数据库类型是 ${dbDisplayType}。请使用 ${dbDisplayType} 方言生成 SQL。以下是用户关联的表结构信息,请在回答时优先参考:\n\n${ddlChunks}`, + content: contextCopy( + translate, + 'ai_chat.system.context.database_with_schema', + 'You are a professional database assistant. The current connection database type is {{dbType}}. Generate SQL using the {{dbType}} dialect. The user has attached table schema information; prioritize it when answering:\n\n{{ddlChunks}}', + { dbType: dbDisplayType, ddlChunks }, + ), }); } else if (targetConnId && targetDbName) { const connection = connections.find((item) => item.id === targetConnId); const dbDisplayType = resolveDatabaseDisplayType(connection?.config); systemMessages.push({ role: 'system', - content: `你是一个专业的数据库助手。当前连接的数据库类型是 ${dbDisplayType},当前数据库名为 ${targetDbName}。如果用户需要查询特定的表或者有关当前库的信息,你可以调用提供的 get_tables 工具来主动获取数据表信息。`, + content: contextCopy( + translate, + 'ai_chat.system.context.database_with_target', + 'You are a professional database assistant. The current connection database type is {{dbType}}, and the current database name is {{dbName}}. If the user needs a specific table or information about the current database, call the provided get_tables tool to actively fetch table information.', + { dbType: dbDisplayType, dbName: targetDbName }, + ), }); } else { const connList = connections.map((connection) => `{id: "${connection.id}", name: "${connection.name}", type: "${connection.config?.type || 'unknown'}"}`).join(', '); + const renderedConnList = connList || contextCopy( + translate, + 'ai_chat.system.context.no_connections', + 'no connections', + ); systemMessages.push({ role: 'system', - content: `你是一个专业的数据库助手。用户目前在界面上没有选中任何具体的数据库或数据表用于充当上下文。 + content: contextCopy( + translate, + 'ai_chat.system.context.database_without_context', + `You are a professional database assistant. The user has not selected any specific database or table in the interface as context. -重要规则: -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. 如果是常规问答(不涉及数据库查询)则正常作答即可。 +Important rules: +1. If you need to help the user find a target table, never guess the table name. Call tools to fetch real data. +2. Complete workflow: get_connections -> get_databases -> get_tables -> get_columns -> generate SQL. Do not skip any step. +3. Connection priority is critical. After retrieving connections, check them in this order: + - First priority: host is localhost or 127.0.0.1, or the connection name indicates a local environment. + - Second priority: name or host indicates a development/local environment, or host is a private-network IP such as 10.x, 192.168.x, or 172.16-31.x. + - Third priority: other connections such as testing or production. + If the target table is found in a higher-priority connection, use that connection directly and do not search lower-priority connections. +4. If the target table is not found in the current database, continue querying other databases instead of giving up. +5. Stop only when every possible database has been checked or the target table has clearly been found. +6. For ordinary questions that do not involve database queries, answer normally. -SQL 生成规则(极重要,必须严格遵守): -7. 如果用户提到“当前页签”“当前 SQL”“当前编辑器”“这条语句”,但消息里没有贴出具体内容,优先调用 inspect_active_tab 读取当前活动页签上下文,不要猜测当前工作区里打开的内容。 -8. 如果用户提到“当前开了哪些页签”“工作区里有哪些 tab”“我现在打开了哪些查询”,优先调用 inspect_workspace_tabs 盘点当前工作区,再决定深入哪个页签。 -9. 【字段精确性 - 绝对红线】生成 SQL 之前,必须先调用 get_columns 获取目标表的真实字段列表。SQL 中的每一个字段名必须与 get_columns 返回的 field 字段完全一致(区分大小写)。不得自行拼凑、缩写或联想字段名(例如字段是 channel 就必须写 channel,不得写成 pay_channel)。 -10. 如果用户在问索引优化、联表关系、触发器副作用、约束或 DDL 细节,在 get_columns 之后继续按需调用 get_indexes、get_foreign_keys、get_triggers、get_table_ddl,再给结论。 -11. 生成 SQL 时禁止使用 "database.table" 格式的限定前缀,只写表名本身。 -12. 报告结果时,连接名/ID 和数据库名必须严格来自同一个 get_tables 调用的实际参数。禁止将 A 连接的 connectionId 与 B 连接的 dbName 混搭。 -13. 如果有多个名称相似的数据库,请明确告诉用户目标表具体位于哪个数据库。 -14. 【关键】每个 SQL 代码块的第一行必须添加上下文声明注释,格式严格为:-- @context connectionId=<连接ID> dbName=<数据库名>。connectionId 和 dbName 必须来自同一个成功的 get_tables 调用(即你在该调用中传入的实际参数值)。示例: +SQL generation rules: +7. If the user mentions the current tab, current SQL, current editor, or this statement without pasting the content, call inspect_active_tab first to read the active tab context instead of guessing what is open. +8. If the user asks which tabs are open or what queries are currently in the workspace, call inspect_workspace_tabs first, then decide which tab to inspect further. +9. Field accuracy is an absolute rule. Before generating SQL, call get_columns to get the real target-table field list. Every field name in SQL must exactly match the field value returned by get_columns, including case. Do not compose, abbreviate, or infer field names. +10. If the user asks about index optimization, join relationships, trigger side effects, constraints, or DDL details, call get_indexes, get_foreign_keys, get_triggers, and get_table_ddl as needed after get_columns, then provide the conclusion. +11. When generating SQL, do not use a "database.table" qualified prefix; write only the table name. +12. When reporting results, the connection name/ID and database name must come from the same actual get_tables call parameters. Do not mix connectionId from one connection with dbName from another. +13. If multiple databases have similar names, clearly tell the user which database contains the target table. +14. The first line of each SQL code block must be a context declaration comment in this exact format: -- @context connectionId= dbName=. connectionId and dbName must come from the same successful get_tables call parameters. Example: \`\`\`sql -- @context connectionId=1770778676549 dbName=mkefu_test SELECT * FROM users WHERE status = 1; \`\`\` -当前存在的连接:[${connList || '无连接'}]`, +Existing connections: [{{connList}}]`, + { connList: renderedConnList }, + ), }); } - appendDatabaseInspectionGuidanceMessages(systemMessages, availableToolNames); + appendDatabaseInspectionGuidanceMessages(systemMessages, availableToolNames, translate); - appendCustomPromptGroup(systemMessages, ['database'], userPromptSettings); - appendSkillPromptGroup(systemMessages, ['database'], skills, availableToolNames); + appendCustomPromptGroup(systemMessages, ['database'], userPromptSettings, translate); + appendSkillPromptGroup(systemMessages, ['database'], skills, availableToolNames, translate); return systemMessages; } diff --git a/frontend/src/components/ai/aiSystemInspectionGuidance.ts b/frontend/src/components/ai/aiSystemInspectionGuidance.ts index 86420ab..a14d0a9 100644 --- a/frontend/src/components/ai/aiSystemInspectionGuidance.ts +++ b/frontend/src/components/ai/aiSystemInspectionGuidance.ts @@ -1,251 +1,177 @@ import type { AISystemContextMessage } from './aiSystemContextMessages'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; + +const INSPECTION_GUIDANCE_KEY_PREFIX = 'ai_chat.system.inspection_guidance'; + +const INSPECTION_GUIDANCE_FALLBACKS = { + inspect_ai_runtime: + 'If the user asks which model is active, what the current safety level is, which tools are available, or which Skills / MCP tools are enabled, call inspect_ai_runtime first to read the current AI runtime state instead of answering from memory or assumptions.', + inspect_ai_safety: + 'If the user asks why writing is blocked, whether the current mode is read-only, whether DDL can run, or whether allowMutating should be passed, call inspect_ai_safety first to read the real safety boundary instead of guessing from UI state or memory.', + inspect_ai_context: + 'If the user asks about the current AI context, associated tables, or table schemas attached to the session, call inspect_ai_context first to read the mounted table-schema context instead of repeating from memory.', + inspect_app_health: + 'If the user reports unstable AI behavior, asks for an overall check, asks about obvious GoNavi AI issues, wants connection, MCP, and log diagnostics together, or mentions abnormal AI reply bubbles, call inspect_app_health first to get the global health overview, then decide whether to drill into inspect_ai_setup_health, inspect_app_logs, inspect_recent_connection_failures, or inspect_ai_last_render_error.', + inspect_ai_support_bundle: + 'If the user says the AI is immature or unstable, asks to export troubleshooting material, wants MCP, connection, logs, and context inspected together, or is preparing to hand the issue to development, call inspect_ai_support_bundle first to create a support bundle without secrets or database passwords, then drill down based on warnings and nextActions.', + inspect_ai_tool_catalog: + 'If the user question spans multiple features, you are unsure which built-in tool to call first, or the user asks what tools exist, how to fill tool parameters, or which probe fits a problem type, call inspect_ai_tool_catalog first to read the real tool catalog, recommended workflow, and parameter hints before choosing a concrete probe.', + inspect_ai_setup_health: + 'If the user asks why AI is hard to use, asks for a health check of the current AI configuration, or asks about obvious current AI issues, call inspect_ai_setup_health first to get the overall state, then drill into inspect_ai_providers, inspect_ai_chat_readiness, inspect_mcp_setup, or inspect_ai_guidance as needed.', + inspect_ai_chat_readiness: + 'If the user asks why sending is unavailable, what configuration the current AI chat is missing, or whether the input area is ready, call inspect_ai_chat_readiness first to read the real pre-send state instead of judging only from UI state or memory.', + inspect_ai_upstream_logs: + 'If the user mentions upstream AI requests, request parameters, request body, requestId, model payloads, tools not triggering, or the exact upstream error payload, call inspect_ai_upstream_logs first to read the redacted request log and payload-structure summary, then continue with inspect_ai_providers or inspect_ai_message_flow as needed.', + inspect_ai_providers: + 'If the user asks which providers are configured, why the model list is empty, whether an API Key is configured, or why sending is unavailable / no model is selected, call inspect_ai_providers first to read the real provider configuration instead of guessing from memory.', + inspect_mcp_setup: + 'If the user asks which MCP servers are configured, whether Claude / Codex is connected to the GoNavi MCP, why an external client cannot use it, or which MCP services are enabled, call inspect_mcp_setup first to read the real MCP configuration and external-client access state instead of guessing from memory.', + inspect_mcp_runtime_failures: + 'If the user mentions a failed new MCP test, zero discovered tools, MCP tool-call failures, stdio disconnects, Docker MCP exits, or HTTP MCP startup failures, call inspect_mcp_runtime_failures first to read real MCP runtime failure logs and current service discovery state, then decide whether to drill into inspect_mcp_draft, inspect_mcp_docker_setup, or inspect_mcp_setup.', + inspect_mcp_authoring_guide: + 'If the user does not know how to fill command / args / env / timeout for a new MCP server, asks for a node / uvx / python template, or asks why a startup command cannot be entered as one full line, call inspect_mcp_authoring_guide first to read the real authoring guide and templates. If the user has already pasted a command or draft, call inspect_mcp_draft to evaluate it with the real validator instead of explaining from memory.', + inspect_mcp_draft: + 'If the user pastes an MCP README startup command, a command / args / env / timeout draft, or asks how to fill that MCP command in GoNavi, call inspect_mcp_draft first to return automatic splitting, launch preview, suggestedServerSeed, configuration errors / warnings, and nextActions, then give the user concrete values.', + inspect_mcp_tool_schema: + 'If the user asks how to fill parameters for an MCP tool, reports an MCP tool argument error, or asks how to write the arguments JSON for an MCP tool, call inspect_mcp_tool_schema first to read the real inputSchema. If the alias is unknown, call inspect_mcp_setup first to find the currently discovered tool alias.', + inspect_ai_guidance: + 'If the user asks which prompts are currently attached, which Skills are active, why you answered in a certain way, or what the current database / JVM prompt is, call inspect_ai_guidance first to read the real prompt and Skill configuration instead of summarizing from memory.', + inspect_shortcuts: + 'If the user asks what a shortcut is, how it differs between Win and Mac, what the result-area / AI panel / SQL execution shortcut is, or whether they changed the default shortcut, call inspect_shortcuts first to read the real shortcut configuration and platform differences instead of answering default values from memory.', + inspect_recent_connection_failures: + 'If the user asks why a connection cannot be established, mentions recent connection failure cooldown, validation failure, SSH tunnel issues, or multiStatements / parameter compatibility exceptions, call inspect_recent_connection_failures first to read the real connection failure summary, then decide whether to drill into inspect_current_connection, inspect_saved_connections, or inspect_app_logs.', + inspect_app_logs: + 'If the user mentions gonavi.log, recent logs, startup errors, MCP startup failures, or database connection failures, call inspect_app_logs first to read the real application log tail. Continue filtering by keyword if needed instead of guessing only from a dialog or toast.', + inspect_ai_last_render_error: + 'If the user says an AI message is blank, a bubble failed to render, or a message block error was isolated without breaking the whole panel, call inspect_ai_last_render_error first to read the most recent isolated frontend render exception instead of guessing from a screenshot.', + inspect_ai_message_flow: + 'If the user says an AI reply was split into multiple bubbles, the model did not continue after tool calls, message stream state is wrong, or one turn was not appended to the same bubble, call inspect_ai_message_flow first to read the real current-session message structure, consecutive assistant messages, and unresolved tool calls instead of guessing from UI state.', + inspect_ai_context_budget: + 'If the user says AI is slow, context is too large, too many table schemas are mounted, tool results are too long, the model starts answering unreliably, or a complex task needs context sizing before execution, call inspect_ai_context_budget first to read the size risks for messages, DDL, MCP schema, prompts, and Skills, then decide whether to narrow context or split the task.', + inspect_codebase_hotspots: + 'If the user mentions bloated multi-thousand-line files, continuing to split large components, which file should be split next, or whether AI / MCP / UI changes are risky, call inspect_codebase_hotspots first to read large-file hotspots, suggested split slices, and test targets before defining the change scope.', + inspect_current_connection: + 'If the user asks about the current connection, current data source, which database or address is connected, or whether the connection uses SSH / proxy, call inspect_current_connection first to read the active connection summary instead of guessing from UI state or memory.', + inspect_connection_capabilities: + 'If the user asks why database creation / deletion / renaming is unavailable, why result editing is unavailable, or which frontend actions this data source supports, call inspect_connection_capabilities first to read the real connection capability matrix instead of relying on database common knowledge or memory.', + inspect_saved_connections: + 'If the user asks which connections are stored locally, asks to find a MySQL / PostgreSQL / Redis connection, or asks which connection uses SSH / proxy, call inspect_saved_connections first to read the real local connection list, then decide which connection to inspect next.', + inspect_redis_topology: + 'If the user mentions Redis Sentinel / cluster, Sentinel master, Redis Cluster multi-database behavior, Redis DB switching failures, or how to fill multiple Redis nodes, call inspect_redis_topology first to read the real Redis topology, nodes, authentication state, and risk hints instead of guessing from default ports or experience.', + inspect_external_sql_directories: + 'If the user mentions external SQL directories, scripts inside a directory, where a specific SQL file is stored, or where the currently opened SQL file came from, call inspect_external_sql_directories first to read the real external SQL directory assets, then decide whether to read the active tab or locate a concrete script.', + inspect_external_sql_file: + 'If the user provides an external SQL file path or explicitly asks to inspect report.sql / job.sql in a directory, call inspect_external_sql_file first to read the real file content. If the file is already open in the editor, combine it with inspect_active_tab to check the current draft.', + inspect_recent_sql_activity: + 'If the user asks what was run recently, whether data was just deleted, whether recent work was mostly reads or writes, or which database recently had the most errors, call inspect_recent_sql_activity first to read the recent SQL activity summary, then decide whether to drill into inspect_recent_sql_logs for concrete statements.', + inspect_sql_editor_transaction: + 'If the user asks about manual commit / autocommit in the SQL editor, whether there is an uncommitted transaction, whether update / insert / delete will auto-commit, or whether transaction semantics were misunderstood, call inspect_sql_editor_transaction first to read the real commit settings, pending transactions, and whether the current SQL tab will enter a managed transaction instead of explaining from memory.', + inspect_sql_risk: + 'If the user asks you to execute, delete, update, run DDL, run bulk SQL, or asks whether a SQL statement can run / is dangerous, call inspect_sql_risk first to check the current editor or supplied SQL for statement count, write / DDL risk, WHERE conditions, and safety policy result. When high / critical risk is found, explain the risk and ask for confirmation before proceeding.', + inspect_saved_queries: + 'If the user mentions saved queries, SQL history, a previously written statement, or asks to find an earlier script, call inspect_saved_queries first to read locally saved queries, then decide whether to verify fields or reuse SQL.', + inspect_ai_sessions: + 'If the user mentions a previous AI conversation, a prior discussion, or asks which recent session talked about this problem, call inspect_ai_sessions first to read the local AI session list and previews, then decide whether to inspect the current tab or reuse historical SQL.', + inspect_sql_snippets: + 'If the user mentions SQL snippets, snippet templates, template prefixes, or common templates, call inspect_sql_snippets first to read the local SQL snippet library instead of inventing existing templates from memory.', +} as const; + +type InspectionGuidanceToolName = keyof typeof INSPECTION_GUIDANCE_FALLBACKS; + +const guidanceKey = (toolName: InspectionGuidanceToolName): string => + `${INSPECTION_GUIDANCE_KEY_PREFIX}.${toolName}`; + +const buildGuidanceContent = ( + toolName: InspectionGuidanceToolName, + translate?: AIInspectionTranslator, +): string => translateInspectionCopy( + translate, + guidanceKey(toolName), + INSPECTION_GUIDANCE_FALLBACKS[toolName], +); const appendGuidanceIfToolAvailable = ( messages: AISystemContextMessage[], availableToolNames: string[], - toolName: string, - content: string, + toolName: InspectionGuidanceToolName, + translate?: AIInspectionTranslator, ) => { if (!availableToolNames.includes(toolName)) { return; } - messages.push({ role: 'system', content }); + messages.push({ role: 'system', content: buildGuidanceContent(toolName, translate) }); }; const appendAIRuntimeInspectionGuidance = ( messages: AISystemContextMessage[], availableToolNames: string[], + translate?: AIInspectionTranslator, ) => { - appendGuidanceIfToolAvailable( - messages, - availableToolNames, - 'inspect_ai_runtime', - '如果用户提到“你现在用的哪个模型”“当前安全级别”“你现在能调用什么工具”“当前启用了哪些 skills / MCP 工具”,优先调用 inspect_ai_runtime 读取当前 AI 运行状态,不要凭记忆或假设回答。', - ); + appendGuidanceIfToolAvailable(messages, availableToolNames, 'inspect_ai_runtime', translate); }; const appendAISafetyInspectionGuidance = ( messages: AISystemContextMessage[], availableToolNames: string[], + translate?: AIInspectionTranslator, ) => { - appendGuidanceIfToolAvailable( - messages, - availableToolNames, - 'inspect_ai_safety', - '如果用户提到“为什么现在不能写”“当前是不是只读”“DDL 能不能执行”“allowMutating 要不要传”,优先调用 inspect_ai_safety 读取真实安全边界,不要只凭界面现象或记忆猜测。', - ); + appendGuidanceIfToolAvailable(messages, availableToolNames, 'inspect_ai_safety', translate); }; export const appendJVMInspectionGuidanceMessages = ( messages: AISystemContextMessage[], availableToolNames: string[], + translate?: AIInspectionTranslator, ) => { - appendAIRuntimeInspectionGuidance(messages, availableToolNames); - appendAISafetyInspectionGuidance(messages, availableToolNames); + appendAIRuntimeInspectionGuidance(messages, availableToolNames, translate); + appendAISafetyInspectionGuidance(messages, availableToolNames, translate); }; export const appendDatabaseInspectionGuidanceMessages = ( messages: AISystemContextMessage[], availableToolNames: string[], + translate?: AIInspectionTranslator, ) => { - appendGuidanceIfToolAvailable( - messages, - availableToolNames, + const databaseGuidanceTools: InspectionGuidanceToolName[] = [ 'inspect_ai_context', - '如果用户提到“当前 AI 上下文”“当前关联了哪些表”“现在带了哪些表结构”,优先调用 inspect_ai_context 读取当前挂载的表结构上下文,不要凭记忆复述。', - ); - appendAIRuntimeInspectionGuidance(messages, availableToolNames); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, + 'inspect_ai_runtime', 'inspect_app_health', - '如果用户提到“AI 不稳定”“整体帮我看看”“GoNavi AI 现在还有哪些明显问题”“连接、MCP、日志一起排查”或“AI 回复气泡显示异常”,优先调用 inspect_app_health 获取 AI 配置、应用日志、连接失败、回复气泡渲染异常和工作区页签的全局健康总览,再决定下钻 inspect_ai_setup_health、inspect_app_logs、inspect_recent_connection_failures 或 inspect_ai_last_render_error。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_ai_support_bundle', - '如果用户提到“AI 不成熟/不稳定”“帮我导出排障材料”“MCP、连接、日志、上下文一起看”或准备把问题交给开发定位,优先调用 inspect_ai_support_bundle 生成不含密钥和数据库密码的支持包,再根据 warnings 和 nextActions 下钻。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_ai_tool_catalog', - '如果用户问题横跨多个功能、你不确定该先调用哪个内置工具,或用户问“你有哪些工具/这个工具参数怎么填/某类问题该用哪个探针”,优先调用 inspect_ai_tool_catalog 按关键词读取真实工具目录、推荐流程和参数提示,再选择具体探针。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_ai_setup_health', - '如果用户提到“AI 为什么不好用”“帮我体检一下当前 AI 配置”“当前 AI 整体还有哪些明显问题”,优先调用 inspect_ai_setup_health 先拿到整体现状,再按需下钻 inspect_ai_providers、inspect_ai_chat_readiness、inspect_mcp_setup 或 inspect_ai_guidance。', - ); - appendAISafetyInspectionGuidance(messages, availableToolNames); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, + 'inspect_ai_safety', 'inspect_ai_chat_readiness', - '如果用户提到“为什么现在不能发送”“当前 AI 聊天到底缺什么配置”“输入区准备好了没有”,优先调用 inspect_ai_chat_readiness 读取真实发送前置状态,不要只凭界面现象或记忆判断。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_ai_upstream_logs', - '如果用户提到“AI 上游请求”“请求入参/请求体”“requestId”“发给模型的 payload”“工具调用没触发”“上游接口报错具体传了什么”,优先调用 inspect_ai_upstream_logs 读取脱敏后的真实请求日志和 payload 结构摘要,再结合 inspect_ai_providers 或 inspect_ai_message_flow 继续定位。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_ai_providers', - '如果用户提到“当前配了哪些供应商”“为什么模型列表为空”“API Key 有没有配”“为什么现在不能发送/没选中模型”,优先调用 inspect_ai_providers 读取真实供应商配置,不要凭记忆猜测。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_mcp_setup', - '如果用户提到“我现在配了哪些 MCP”“Claude/Codex 有没有接入 GoNavi MCP”“为什么外部客户端用不了”“当前 MCP 服务启用了哪些”,优先调用 inspect_mcp_setup 读取真实 MCP 配置和外部客户端接入状态,不要凭记忆猜测。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_mcp_runtime_failures', - '如果用户提到“新增 MCP 测试失败”“工具发现 0 个”“MCP 工具调用失败”“stdio 断开”“Docker MCP 退出”或“HTTP MCP 启动失败”,优先调用 inspect_mcp_runtime_failures 读取真实 MCP 运行期失败日志和当前服务发现状态,再决定是否下钻 inspect_mcp_draft、inspect_mcp_docker_setup 或 inspect_mcp_setup。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_mcp_authoring_guide', - '如果用户提到“新增 MCP 不知道 command/args/env/timeout 怎么填”“给我一个 node / uvx / python 模板”“为什么启动命令不能直接填整行”,优先调用 inspect_mcp_authoring_guide 读取真实新增指引和模板;如果用户已经贴出命令或草稿,再调用 inspect_mcp_draft 用真实校验器试算,不要凭记忆口述。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_mcp_draft', - '如果用户贴出 MCP README 启动命令、command/args/env/timeout 草稿,或问“这条 MCP 命令在 GoNavi 里怎么填”,优先调用 inspect_mcp_draft 返回自动拆分、启动预览、suggestedServerSeed、配置错误/告警和 nextActions,再给用户具体填写结果。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_mcp_tool_schema', - '如果用户提到“某个 MCP 工具参数怎么填”“MCP 工具调用报参数错误”“这个 MCP tool 的 arguments JSON 怎么写”,优先调用 inspect_mcp_tool_schema 读取真实 inputSchema;如果不知道 alias,先调用 inspect_mcp_setup 找到当前发现的工具 alias。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_ai_guidance', - '如果用户提到“你现在带了哪些提示词”“当前生效的是哪些 Skills”“为什么你会这样回答”“当前数据库/JVM prompt 是什么”,优先调用 inspect_ai_guidance 读取真实提示与技能配置,不要凭记忆概括。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_shortcuts', - '如果用户提到“快捷键是什么”“Win 和 Mac 分别怎么按”“结果区/AI 面板/执行 SQL 的组合键”“我是不是改过默认快捷键”,优先调用 inspect_shortcuts 读取真实快捷键配置和平台差异,不要凭记忆回答默认值。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_recent_connection_failures', - '如果用户提到“为什么连接不上”“连接最近失败,正在冷却中”“验证失败”“SSH 隧道是不是有问题”“multiStatements / 参数兼容异常”,优先调用 inspect_recent_connection_failures 读取真实连接失败总结,再决定是否继续下钻 inspect_current_connection、inspect_saved_connections 或 inspect_app_logs。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_app_logs', - '如果用户提到“gonavi.log”“最近日志”“启动报错”“MCP 拉不起来”“数据库连接为什么失败”,优先调用 inspect_app_logs 读取真实应用日志尾部;必要时再结合关键词继续筛选,不要只凭弹窗或提示文案猜测。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_ai_last_render_error', - '如果用户提到“AI 某条消息空白了”“某个气泡渲染失败”“消息块局部报错但面板没全挂”,优先调用 inspect_ai_last_render_error 读取最近一次被隔离的前端渲染异常记录,不要只凭截图现象猜测。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_ai_message_flow', - '如果用户提到“AI 回复被拆成多个气泡”“工具调用后没继续回答”“消息流状态不对”“同一轮回答没有追加到同一个气泡”,优先调用 inspect_ai_message_flow 读取当前会话的真实消息结构、连续 assistant 消息和未闭环工具调用,不要只凭界面现象猜测。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_ai_context_budget', - '如果用户提到“AI 变慢”“上下文太大”“表结构挂太多”“工具结果太长”“模型开始乱答”或复杂任务前需要判断是否该拆小上下文,优先调用 inspect_ai_context_budget 读取消息、DDL、MCP schema、提示词和 Skills 的体量风险,再决定收窄上下文或拆任务。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_codebase_hotspots', - '如果用户提到“几千行文件太臃肿”“继续拆分大组件”“下一步该拆哪个文件”“AI/MCP/UI 改动风险大不大”,优先调用 inspect_codebase_hotspots 读取大文件热点、建议拆分切片和测试目标,再制定改动范围。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_current_connection', - '如果用户提到“当前连接”“当前数据源”“我现在连的是哪个库/地址”“这个连接走没走 SSH/代理”,优先调用 inspect_current_connection 读取当前活动连接摘要,不要凭界面或记忆猜测。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_connection_capabilities', - '如果用户提到“为什么这里不能建库/删库/改库名”“为什么结果不能编辑”“这个数据源支持哪些前端动作”,优先调用 inspect_connection_capabilities 读取真实连接能力矩阵,不要凭数据库常识或记忆猜测。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_saved_connections', - '如果用户提到“本地存了哪些连接”“帮我找 mysql / postgres / redis 连接”“哪条连接配了 SSH/代理”,优先调用 inspect_saved_connections 读取真实本地连接清单,再决定继续查看哪条连接。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_redis_topology', - '如果用户提到“Redis 哨兵/集群”“Sentinel master”“Redis Cluster 多库”“切换 Redis DB 失败”“Redis 多节点怎么填”,优先调用 inspect_redis_topology 读取真实 Redis 拓扑、节点、认证状态和风险提示,不要凭默认端口或经验猜测。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_external_sql_directories', - '如果用户提到“外部 SQL 目录”“目录里的脚本”“某个 SQL 文件放在哪个目录”“当前打开的 SQL 文件来自哪里”,优先调用 inspect_external_sql_directories 读取真实外部 SQL 目录资产,再决定继续读取活动页签还是定位具体脚本。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_external_sql_file', - '如果用户已经给出了某个外部 SQL 文件路径,或明确提到“帮我看看这个目录里的 report.sql / job.sql 在写什么”,优先调用 inspect_external_sql_file 读取真实文件内容;如果这个文件已经在编辑器中打开,再结合 inspect_active_tab 看当前草稿。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_recent_sql_activity', - '如果用户提到“最近都执行了什么”“是不是刚删过数据”“最近主要在查还是在改”“哪个库最近报错最多”,优先调用 inspect_recent_sql_activity 先读最近 SQL 活动总结,再决定是否继续下钻 inspect_recent_sql_logs 看具体语句。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_sql_editor_transaction', - '如果用户提到“SQL 编辑器手动提交/自动提交”“当前有没有未提交事务”“执行 update/insert/delete 会不会自动提交”“事务语义是不是理解错了”,优先调用 inspect_sql_editor_transaction 读取真实提交设置、待提交事务和当前 SQL 页签是否会进入托管事务,不要凭记忆解释。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_sql_risk', - '如果用户要求你执行、删除、更新、DDL、批量 SQL,或问“这条 SQL 能不能跑/危险不危险”,优先调用 inspect_sql_risk 检查当前编辑区或传入 SQL 的语句数量、写入/DDL 风险、WHERE 条件和安全策略结果;发现 high/critical 风险时先解释风险并让用户确认,不要直接推进执行。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_saved_queries', - '如果用户提到“保存过的查询”“历史 SQL”“之前写过的语句”“帮我找以前那条脚本”,优先调用 inspect_saved_queries 读取本地已保存查询,再决定是否继续核对字段或复用 SQL。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_ai_sessions', - '如果用户提到“之前那条 AI 对话”“上次聊过的记录”“最近哪个会话说过这个问题”,优先调用 inspect_ai_sessions 读取本地 AI 会话清单和预览,再决定继续查看当前页签还是复用历史 SQL。', - ); - appendGuidanceIfToolAvailable( - messages, - availableToolNames, 'inspect_sql_snippets', - '如果用户提到“SQL 片段”“snippet”“模板前缀”“常用模板”,优先调用 inspect_sql_snippets 读取本地 SQL 片段库,不要凭记忆编造现有模板。', - ); + ]; + + databaseGuidanceTools.forEach((toolName) => { + appendGuidanceIfToolAvailable(messages, availableToolNames, toolName, translate); + }); }; diff --git a/frontend/src/components/ai/aiToolCatalogInsights.test.ts b/frontend/src/components/ai/aiToolCatalogInsights.test.ts new file mode 100644 index 0000000..f1e7ba8 --- /dev/null +++ b/frontend/src/components/ai/aiToolCatalogInsights.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAIToolCatalogSnapshot } from './aiToolCatalogInsights'; + +const builtinTool = { + name: 'inspect_demo_tool', + icon: 'tool', + desc: 'Demo inspection tool', + detail: 'Reads demo state', + params: 'Requires demoId', + tool: { + type: 'function' as const, + function: { + name: 'inspect_demo_tool', + description: 'Demo inspection tool', + parameters: { + type: 'object', + properties: { + demoId: { type: 'string', description: 'Demo id' }, + }, + required: ['demoId'], + }, + }, + }, +}; + +describe('aiToolCatalogInsights', () => { + it('falls back to zh-CN builtin flow copy when no translator is provided', () => { + const snapshot = buildAIToolCatalogSnapshot({ + builtinTools: [builtinTool], + mcpTools: [], + keyword: 'mcp', + includeMCPTools: false, + }); + + expect(snapshot.flows.some((flow) => flow.title === '新增 MCP 填写指引')).toBe(true); + expect(snapshot.message).toBe('Returned tool catalog suggestions for keyword mcp'); + }); + + it('localizes controlled catalog messages while preserving raw tool identifiers', () => { + const translate = (key: string, params?: Record) => { + const messages: Record = { + 'ai_chat.inspection.tool_catalog.next_action.filter_by_keyword': 'FILTER FIRST', + 'ai_chat.inspection.tool_catalog.warning.no_mcp_tools': 'NO MCP TOOLS', + 'ai_chat.inspection.tool_catalog.next_action.inspect_mcp_setup': 'CHECK MCP SETUP', + 'ai_chat.inspection.tool_catalog.next_action.use_parameter_descriptions': 'USE PARAM DESCRIPTIONS', + 'ai_chat.inspection.tool_catalog.message.by_keyword': `KEYWORD ${params?.keyword || ''}`, + }; + return messages[key] || key; + }; + + const snapshot = buildAIToolCatalogSnapshot({ + builtinTools: [builtinTool], + mcpTools: [], + keyword: 'demo', + includeMCPTools: true, + translate, + }); + + expect(snapshot.message).toBe('KEYWORD demo'); + expect(snapshot.warnings).toContain('NO MCP TOOLS'); + expect(snapshot.nextActions).toContain('CHECK MCP SETUP'); + expect(snapshot.nextActions).toContain('USE PARAM DESCRIPTIONS'); + expect(snapshot.builtinTools[0]?.name).toBe('inspect_demo_tool'); + expect(snapshot.builtinTools[0]?.parameters[0]?.name).toBe('demoId'); + }); + + it('localizes no-match guidance and keeps requested tool names raw', () => { + const translate = (key: string, params?: Record) => { + const messages: Record = { + 'ai_chat.inspection.tool_catalog.warning.no_matches': 'NO MATCHES', + 'ai_chat.inspection.tool_catalog.next_action.broaden_keyword': 'BROADEN', + 'ai_chat.inspection.tool_catalog.message.by_tool_name': `TOOL ${params?.toolName || ''}`, + }; + return messages[key] || key; + }; + + const snapshot = buildAIToolCatalogSnapshot({ + builtinTools: [builtinTool], + mcpTools: [{ + alias: 'github_create_issue', + originalName: 'create_issue', + serverId: 'github-server', + serverName: 'GitHub', + title: 'Create Issue', + description: 'Create a GitHub issue', + }], + keyword: 'missing-keyword', + toolName: 'github_create_issue', + includeMCPTools: true, + translate, + }); + + expect(snapshot.message).toBe('TOOL github_create_issue'); + expect(snapshot.warnings).toContain('NO MATCHES'); + expect(snapshot.nextActions).toContain('BROADEN'); + expect(snapshot.query.toolName).toBe('github_create_issue'); + }); +}); diff --git a/frontend/src/components/ai/aiToolCatalogInsights.ts b/frontend/src/components/ai/aiToolCatalogInsights.ts index c1d60ce..b6e533e 100644 --- a/frontend/src/components/ai/aiToolCatalogInsights.ts +++ b/frontend/src/components/ai/aiToolCatalogInsights.ts @@ -1,9 +1,13 @@ import type { AIMCPToolDescriptor } from '../../types'; +import type { I18nParams } from '../../i18n'; +import { t as translateCatalog } from '../../i18n'; import { - BUILTIN_TOOL_FLOWS, describeBuiltinToolParameters, + localizeBuiltinToolFlows, } from '../../utils/aiBuiltinToolCatalog'; import type { AIBuiltinToolInfo } from '../../utils/aiBuiltinToolInfo.types'; +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; const DEFAULT_LIMIT = 12; const MAX_LIMIT = 40; @@ -42,6 +46,21 @@ const readMCPToolParameterSummary = (tool: AIMCPToolDescriptor) => { }; }; +const translateToolCatalogCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, + params?: I18nParams, +): string => translateInspectionCopy( + translate, + `ai_chat.inspection.tool_catalog.${key}`, + fallback, + params, +); + +const defaultBuiltinFlowTranslator = (key: string) => + translateCatalog(key, undefined, 'zh-CN'); + export const buildAIToolCatalogSnapshot = (params: { builtinTools: AIBuiltinToolInfo[]; mcpTools?: AIMCPToolDescriptor[]; @@ -49,20 +68,25 @@ export const buildAIToolCatalogSnapshot = (params: { toolName?: string; includeMCPTools?: boolean; limit?: number; + translate?: AIInspectionTranslator; }) => { const { builtinTools, mcpTools = [], toolName = '', includeMCPTools = true, + translate, } = params; const keyword = normalizeText(params.keyword); const normalizedToolName = normalizeText(toolName); const limit = normalizeLimit(params.limit); const mcpKeyword = keyword || normalizedToolName; - const shouldReturnAllMCPTools = !mcpKeyword || mcpKeyword.includes('mcp') || mcpKeyword.includes('工具'); + const shouldReturnAllMCPTools = !mcpKeyword || mcpKeyword.includes('mcp') || mcpKeyword.includes('\u5de5\u5177'); + const builtinToolFlows = localizeBuiltinToolFlows( + translate || defaultBuiltinFlowTranslator, + ); - const matchedFlows = BUILTIN_TOOL_FLOWS + const matchedFlows = builtinToolFlows .filter((flow) => matchesAnyText(keyword, [flow.title, flow.steps, flow.description])) .map((flow, index) => ({ flow, @@ -148,18 +172,42 @@ export const buildAIToolCatalogSnapshot = (params: { const nextActions: string[] = []; if (!keyword && !normalizedToolName) { - nextActions.push('先按用户问题关键词过滤,例如 mcp、连接失败、事务、快捷键、schema 或日志。'); + nextActions.push(translateToolCatalogCopy( + translate, + 'next_action.filter_by_keyword', + 'Filter by user-question keywords first, such as mcp, connection failure, transaction, shortcut, schema, or logs', + )); } if (includeMCPTools && mcpTools.length === 0) { - warnings.push('当前没有发现外部 MCP 工具;如果用户需要外部能力,先检查 MCP 服务配置和工具发现状态。'); - nextActions.push('调用 inspect_mcp_setup 查看 MCP 服务和外部客户端接入状态。'); + warnings.push(translateToolCatalogCopy( + translate, + 'warning.no_mcp_tools', + 'No external MCP tools were discovered; if the user needs external capabilities, check MCP service configuration and tool discovery status first', + )); + nextActions.push(translateToolCatalogCopy( + translate, + 'next_action.inspect_mcp_setup', + 'Call inspect_mcp_setup to inspect MCP services and external client access status', + )); } if (keyword && matchedFlows.length === 0 && matchedBuiltinTools.length === 0 && matchedMCPTools.length === 0) { - warnings.push('没有找到匹配的工具或推荐流程。'); - nextActions.push('改用更宽泛关键词,或先调用 inspect_ai_runtime 查看当前完整工具清单。'); + warnings.push(translateToolCatalogCopy( + translate, + 'warning.no_matches', + 'No matching tools or recommended flows were found', + )); + nextActions.push(translateToolCatalogCopy( + translate, + 'next_action.broaden_keyword', + 'Use broader keywords, or call inspect_ai_runtime first to view the complete current tool list', + )); } if (matchedBuiltinTools.some((tool) => tool.parameters.length > 0)) { - nextActions.push('调用带参数工具前,优先按 parameters.description 组装 arguments;缺少上下文时先向用户确认。'); + nextActions.push(translateToolCatalogCopy( + translate, + 'next_action.use_parameter_descriptions', + 'Before calling tools with parameters, build arguments from parameters.description first; confirm with the user when context is missing', + )); } return { @@ -171,7 +219,7 @@ export const buildAIToolCatalogSnapshot = (params: { }, totals: { builtinToolCount: builtinTools.length, - flowCount: BUILTIN_TOOL_FLOWS.length, + flowCount: builtinToolFlows.length, mcpToolCount: Array.isArray(mcpTools) ? mcpTools.length : 0, }, returned: { @@ -185,9 +233,23 @@ export const buildAIToolCatalogSnapshot = (params: { warnings, nextActions, message: normalizedToolName - ? `已按工具名 ${toolName} 返回目录信息` + ? translateToolCatalogCopy( + translate, + 'message.by_tool_name', + `Returned catalog information for tool ${toolName}`, + { toolName }, + ) : keyword - ? `已按关键词 ${params.keyword} 返回工具目录建议` - : '已返回 GoNavi AI 工具目录摘要', + ? translateToolCatalogCopy( + translate, + 'message.by_keyword', + `Returned tool catalog suggestions for keyword ${params.keyword}`, + { keyword: params.keyword || '' }, + ) + : translateToolCatalogCopy( + translate, + 'message.summary', + 'Returned the GoNavi AI tool catalog summary', + ), }; }; diff --git a/frontend/src/components/ai/aiUpstreamLogInsights.test.ts b/frontend/src/components/ai/aiUpstreamLogInsights.test.ts new file mode 100644 index 0000000..87d4f01 --- /dev/null +++ b/frontend/src/components/ai/aiUpstreamLogInsights.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { buildAIUpstreamLogSnapshot } from './aiUpstreamLogInsights'; + +describe('aiUpstreamLogInsights', () => { + it('uses the provided translator for user-facing warnings and guidance', () => { + const snapshot = buildAIUpstreamLogSnapshot({ + readResult: { + data: { + lines: [ + '2026/06/11 11:20:00.000000 [INFO] AI 上游请求开始:requestId=openai-123 provider=openai method=POST endpoint=https://api.example.com/v1/chat/completions body={not-json', + ], + }, + }, + includePayloadSummary: true, + translate: (key) => ({ + 'ai_chat.inspection.upstream_logs.warning.invalid_json': 'translated invalid JSON warning', + 'ai_chat.inspection.upstream_logs.next_action.filter_request_body': 'translated filter request body', + 'ai_chat.inspection.upstream_logs.next_action.inspect_timeout': 'translated inspect timeout', + })[key] || key, + }); + + expect(snapshot.requests[0]?.bodySummary?.warnings).toContain('translated invalid JSON warning'); + expect(snapshot.nextActions).toContain('translated filter request body'); + expect(snapshot.nextActions).toContain('translated inspect timeout'); + }); + + it('uses the provided translator for the empty upstream-log state', () => { + const snapshot = buildAIUpstreamLogSnapshot({ + readResult: { + data: { + lines: [], + }, + }, + translate: (key) => ({ + 'ai_chat.inspection.upstream_logs.message.empty': 'translated empty message', + 'ai_chat.inspection.upstream_logs.next_action.confirm_logging': 'translated confirm logging', + 'ai_chat.inspection.upstream_logs.next_action.send_message': 'translated send message', + 'ai_chat.inspection.upstream_logs.next_action.read_warn_error': 'translated read warn error', + })[key] || key, + }); + + expect(snapshot.message).toBe('translated empty message'); + expect(snapshot.nextActions).toEqual([ + 'translated confirm logging', + 'translated send message', + 'translated read warn error', + ]); + }); +}); diff --git a/frontend/src/components/ai/aiUpstreamLogInsights.ts b/frontend/src/components/ai/aiUpstreamLogInsights.ts index b9661ae..b72d477 100644 --- a/frontend/src/components/ai/aiUpstreamLogInsights.ts +++ b/frontend/src/components/ai/aiUpstreamLogInsights.ts @@ -1,3 +1,6 @@ +import type { AIInspectionTranslator } from './aiInspectionI18n'; +import { translateInspectionCopy } from './aiInspectionI18n'; + const DEFAULT_AI_UPSTREAM_LOG_LIMIT = 160; const MAX_AI_UPSTREAM_LOG_LIMIT = 300; const DEFAULT_AI_UPSTREAM_REQUEST_LIMIT = 12; @@ -44,6 +47,11 @@ interface AIUpstreamPayloadSummary { warnings?: string[]; } +interface LocalizableText { + key: string; + fallback: string; +} + interface AIUpstreamRequestSummary { requestId: string; provider: string; @@ -89,6 +97,11 @@ const truncateText = (value: string, limit: number): string => { const isRecord = (value: unknown): value is Record => !!value && typeof value === 'object' && !Array.isArray(value); +const translateText = ( + translate: AIInspectionTranslator | undefined, + { key, fallback }: LocalizableText, +): string => translateInspectionCopy(translate, key, fallback); + const asRecordArray = (value: unknown): Record[] => Array.isArray(value) ? value.filter(isRecord) : []; @@ -164,7 +177,10 @@ const estimateInputTextChars = (body: Record): number => { + addTextLength(body.input); }; -const summarizeAIUpstreamPayload = (bodyText: string): AIUpstreamPayloadSummary => { +const summarizeAIUpstreamPayload = ( + bodyText: string, + translate?: AIInspectionTranslator, +): AIUpstreamPayloadSummary => { let parsed: unknown; try { parsed = JSON.parse(bodyText); @@ -172,7 +188,10 @@ const summarizeAIUpstreamPayload = (bodyText: string): AIUpstreamPayloadSummary return { parseable: false, parseError: String(error?.message || error || 'invalid JSON'), - warnings: ['请求 body 不是完整 JSON,可能已被日志截断,无法生成结构化摘要'], + warnings: [translateText(translate, { + key: 'ai_chat.inspection.upstream_logs.warning.invalid_json', + fallback: 'The request body is not complete JSON. It may have been truncated by logs, so a structured summary cannot be generated.', + })], }; } @@ -180,7 +199,10 @@ const summarizeAIUpstreamPayload = (bodyText: string): AIUpstreamPayloadSummary return { parseable: false, parseError: 'request body is not a JSON object', - warnings: ['请求 body 不是 JSON object,无法识别模型、消息和工具字段'], + warnings: [translateText(translate, { + key: 'ai_chat.inspection.upstream_logs.warning.not_json_object', + fallback: 'The request body is not a JSON object, so model, message, and tool fields cannot be identified.', + })], }; } @@ -201,13 +223,22 @@ const summarizeAIUpstreamPayload = (bodyText: string): AIUpstreamPayloadSummary const warnings: string[] = []; if (messageCount === 0) { - warnings.push('未识别到 messages、contents、system 或 prompt 字段,请确认上游协议是否符合预期'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.upstream_logs.warning.missing_messages', + fallback: 'No messages, contents, system, or prompt field was found. Confirm whether the upstream protocol matches expectations.', + })); } if (toolCount === 0) { - warnings.push('payload 未携带 tools/functions,模型无法发起工具调用'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.upstream_logs.warning.missing_tools', + fallback: 'The payload does not include tools/functions, so the model cannot initiate tool calls.', + })); } if (inputTextCharCount > 60000) { - warnings.push('输入文本体量较大,必要时先收窄上下文或减少日志/DDL 内容'); + warnings.push(translateText(translate, { + key: 'ai_chat.inspection.upstream_logs.warning.large_input', + fallback: 'The input text is large. Narrow the context or reduce log/DDL content if needed.', + })); } return { @@ -251,6 +282,7 @@ const parseAIUpstreamLogEvent = ( line: string, bodyPreviewLimit: number, includePayloadSummary: boolean, + translate?: AIInspectionTranslator, ): AIUpstreamLogEvent | null => { if (!line.includes('AI 上游请求')) { return null; @@ -275,7 +307,7 @@ const parseAIUpstreamLogEvent = ( status: statusText ? Number(statusText) : undefined, duration, bodyPreview: bodyText ? truncateText(redactAIUpstreamLogPreview(bodyText), bodyPreviewLimit) : undefined, - bodySummary: bodyText && includePayloadSummary ? summarizeAIUpstreamPayload(bodyText) : undefined, + bodySummary: bodyText && includePayloadSummary ? summarizeAIUpstreamPayload(bodyText, translate) : undefined, error: errorText ? truncateText(redactAIUpstreamLogPreview(errorText), 2000) : undefined, line: truncateText(redactAIUpstreamLogPreview(line), 4000), }; @@ -356,6 +388,7 @@ export const buildAIUpstreamLogSnapshot = (params: { includeLines?: unknown; bodyPreviewLimit?: unknown; includePayloadSummary?: unknown; + translate?: AIInspectionTranslator; }) => { const data = params.readResult?.data && typeof params.readResult.data === 'object' ? params.readResult.data as Record @@ -363,12 +396,13 @@ export const buildAIUpstreamLogSnapshot = (params: { const requestedLineLimit = clampNumber(data.requestedLineLimit ?? params.lineLimit, DEFAULT_AI_UPSTREAM_LOG_LIMIT, 1, MAX_AI_UPSTREAM_LOG_LIMIT); const requestLimit = clampNumber(params.requestLimit, DEFAULT_AI_UPSTREAM_REQUEST_LIMIT, 1, MAX_AI_UPSTREAM_REQUEST_LIMIT); const bodyPreviewLimit = clampNumber(params.bodyPreviewLimit, DEFAULT_AI_UPSTREAM_BODY_LIMIT, 200, MAX_AI_UPSTREAM_BODY_LIMIT); + const { translate } = params; const includeBody = params.includeBody !== false; const includeLines = params.includeLines === true; const includePayloadSummary = params.includePayloadSummary !== false; const lines = normalizeLogLines(data.lines); const parsedEvents = lines - .map((line) => parseAIUpstreamLogEvent(line, bodyPreviewLimit, includePayloadSummary)) + .map((line) => parseAIUpstreamLogEvent(line, bodyPreviewLimit, includePayloadSummary, translate)) .filter((event): event is AIUpstreamLogEvent => Boolean(event)) .filter((event) => matchesFilter(event, params)); const eventBreakdown = { @@ -398,16 +432,34 @@ export const buildAIUpstreamLogSnapshot = (params: { lines: includeLines ? parsedEvents.map((event) => event.line) : undefined, message: parsedEvents.length > 0 ? '' - : '最近日志里没有找到 AI 上游请求记录;请先发送一次 AI 消息,或扩大 lineLimit 后重试。', + : translateText(translate, { + key: 'ai_chat.inspection.upstream_logs.message.empty', + fallback: 'No AI upstream request records were found in recent logs. Send one AI message first, or retry with a larger lineLimit.', + }), nextActions: parsedEvents.length > 0 ? [ - '如需核对完整入参,先用 requestId 精确过滤,再查看 bodyPreview 是否已被截断。', - '如果只有开始没有完成/失败,继续查看 inspect_app_logs 或扩大 lineLimit 排查请求是否超时。', + translateText(translate, { + key: 'ai_chat.inspection.upstream_logs.next_action.filter_request_body', + fallback: 'To verify full request inputs, filter precisely by requestId first, then check whether bodyPreview was truncated.', + }), + translateText(translate, { + key: 'ai_chat.inspection.upstream_logs.next_action.inspect_timeout', + fallback: 'If there is only a start event with no completion or failure, continue with inspect_app_logs or increase lineLimit to check for request timeout.', + }), ] : [ - '确认当前构建已包含 AI 上游请求日志能力。', - '发送一次 AI 聊天消息后再调用本工具。', - '如果仍没有记录,调用 inspect_app_logs 读取最近 WARN/ERROR 原文。', + translateText(translate, { + key: 'ai_chat.inspection.upstream_logs.next_action.confirm_logging', + fallback: 'Confirm that the current build includes AI upstream request logging.', + }), + translateText(translate, { + key: 'ai_chat.inspection.upstream_logs.next_action.send_message', + fallback: 'Send one AI chat message, then call this tool again.', + }), + translateText(translate, { + key: 'ai_chat.inspection.upstream_logs.next_action.read_warn_error', + fallback: 'If there are still no records, call inspect_app_logs to read recent WARN/ERROR raw logs.', + }), ], }; }; diff --git a/frontend/src/components/ai/aiWorkspaceInsights.test.ts b/frontend/src/components/ai/aiWorkspaceInsights.test.ts new file mode 100644 index 0000000..9d0b3cf --- /dev/null +++ b/frontend/src/components/ai/aiWorkspaceInsights.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; + +import { t as translateCatalog } from '../../i18n/catalog'; +import { buildActiveTabSnapshot } from './aiWorkspaceInsights'; + +describe('buildActiveTabSnapshot', () => { + it('localizes the empty active-tab message', () => { + const snapshot = buildActiveTabSnapshot({ + tabs: [], + activeTabId: null, + connections: [], + translate: (key, params) => translateCatalog('en-US', key, params), + }); + + expect(snapshot).toEqual({ + hasActiveTab: false, + message: 'No active tab is currently selected', + }); + }); +}); diff --git a/frontend/src/components/ai/aiWorkspaceInsights.ts b/frontend/src/components/ai/aiWorkspaceInsights.ts index a2ff5a5..624b669 100644 --- a/frontend/src/components/ai/aiWorkspaceInsights.ts +++ b/frontend/src/components/ai/aiWorkspaceInsights.ts @@ -1,8 +1,21 @@ import type { SavedConnection, TabData } from '../../types'; +import { t as translateCatalog, type I18nParams } from '../../i18n'; const ACTIVE_TAB_CONTENT_LIMIT = 12000; const WORKSPACE_TAB_CONTENT_LIMIT = 4000; +type AIInspectionTranslator = (key: string, params?: I18nParams) => string; + +const translateInspectionCopy = ( + translate: AIInspectionTranslator | undefined, + key: string, + fallback: string, +): string => { + const t = translate || ((catalogKey, params) => translateCatalog(catalogKey, params, 'en-US')); + const translated = t(key); + return translated && translated !== key ? translated : fallback; +}; + const normalizeWorkspaceTabLimit = (input: unknown): number => { const value = Math.floor(Number(input) || 12); if (value < 1) return 1; @@ -75,18 +88,24 @@ export const buildActiveTabSnapshot = (params: { activeTabId?: string | null; connections: SavedConnection[]; includeContent?: boolean; + translate?: AIInspectionTranslator; }) => { const { tabs = [], activeTabId = null, connections, includeContent = true, + translate, } = params; const activeTab = tabs.find((tab) => tab.id === activeTabId); if (!activeTab) { return { hasActiveTab: false, - message: '当前没有活动页签', + message: translateInspectionCopy( + translate, + 'ai_chat.inspection.workspace.no_active_tab', + 'No active tab is currently selected', + ), }; } diff --git a/frontend/src/components/ai/mcpClientInstallPanelState.test.ts b/frontend/src/components/ai/mcpClientInstallPanelState.test.ts index 333395e..c712c6f 100644 --- a/frontend/src/components/ai/mcpClientInstallPanelState.test.ts +++ b/frontend/src/components/ai/mcpClientInstallPanelState.test.ts @@ -1,5 +1,7 @@ +import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; +import { catalogs } from '../../i18n/catalog'; import type { AIMCPClientInstallStatus } from '../../types'; import { getMCPClientDetectionSummary, @@ -12,6 +14,108 @@ import { resolveMCPClientInstallActionLabel, } from './mcpClientInstallPanelState'; +const source = readFileSync(new URL('./mcpClientInstallPanelState.ts', import.meta.url), 'utf8'); +const installPanelSource = readFileSync(new URL('./AIMCPClientInstallPanel.tsx', import.meta.url), 'utf8'); +const selectorPanelSource = readFileSync(new URL('./AIMCPClientSelectorPanel.tsx', import.meta.url), 'utf8'); +const statusPanelSource = readFileSync(new URL('./AIMCPClientStatusPanel.tsx', import.meta.url), 'utf8'); +const installerSource = readFileSync(new URL('./useAIMCPClientInstaller.ts', import.meta.url), 'utf8'); + +const REQUIRED_MCP_CLIENT_INSTALL_KEYS = [ + 'ai_chat.mcp_client.install.status_tone.connected', + 'ai_chat.mcp_client.install.status_tone.update_required', + 'ai_chat.mcp_client.install.status_tone.remote_bridge', + 'ai_chat.mcp_client.install.status_tone.status_error', + 'ai_chat.mcp_client.install.status_tone.not_connected', + 'ai_chat.mcp_client.install.state.connected', + 'ai_chat.mcp_client.install.state.stale', + 'ai_chat.mcp_client.install.state.error', + 'ai_chat.mcp_client.install.state.remote', + 'ai_chat.mcp_client.install.state.missing', + 'ai_chat.mcp_client.install.summary.connected', + 'ai_chat.mcp_client.install.summary.stale', + 'ai_chat.mcp_client.install.summary.error', + 'ai_chat.mcp_client.install.summary.remote', + 'ai_chat.mcp_client.install.summary.missing', + 'ai_chat.mcp_client.install.option.connected', + 'ai_chat.mcp_client.install.option.stale', + 'ai_chat.mcp_client.install.option.error', + 'ai_chat.mcp_client.install.option.remote', + 'ai_chat.mcp_client.install.option.missing', + 'ai_chat.mcp_client.install.detection.remote', + 'ai_chat.mcp_client.install.detection.detected', + 'ai_chat.mcp_client.install.detection.not_detected', + 'ai_chat.mcp_client.install.selected.connected', + 'ai_chat.mcp_client.install.selected.stale', + 'ai_chat.mcp_client.install.selected.error', + 'ai_chat.mcp_client.install.selected.remote', + 'ai_chat.mcp_client.install.selected.missing', + 'ai_chat.mcp_client.install.action.connected', + 'ai_chat.mcp_client.install.action.update', + 'ai_chat.mcp_client.install.action.copy_remote', + 'ai_chat.mcp_client.install.action.install', + 'ai_chat.mcp_client.install.intro.title', + 'ai_chat.mcp_client.install.intro.description', + 'ai_chat.mcp_client.install.repeat_avoidance', + 'ai_chat.mcp_client.install.selector.title', + 'ai_chat.mcp_client.install.selector.description', + 'ai_chat.mcp_client.install.selector.aria_label', + 'ai_chat.mcp_client.install.selector.choice_title', + 'ai_chat.mcp_client.install.selector.step.target.title', + 'ai_chat.mcp_client.install.selector.step.target.detail', + 'ai_chat.mcp_client.install.selector.step.write.title', + 'ai_chat.mcp_client.install.selector.step.write.detail', + 'ai_chat.mcp_client.install.selector.step.restart.title', + 'ai_chat.mcp_client.install.selector.step.restart.detail', + 'ai_chat.mcp_client.install.selector.hint.active_remote', + 'ai_chat.mcp_client.install.selector.hint.active_local', + 'ai_chat.mcp_client.install.selector.hint.inactive_remote', + 'ai_chat.mcp_client.install.selector.hint.inactive_local', + 'ai_chat.mcp_client.install.status.title', + 'ai_chat.mcp_client.install.status.current_target', + 'ai_chat.mcp_client.install.status.no_client', + 'ai_chat.mcp_client.install.status.current_state', + 'ai_chat.mcp_client.install.status.remote_boundary', + 'ai_chat.mcp_client.install.status.cli_prefix', + 'ai_chat.mcp_client.install.status.cli.remote', + 'ai_chat.mcp_client.install.status.cli.detected', + 'ai_chat.mcp_client.install.status.cli.not_detected', + 'ai_chat.mcp_client.install.status.command_path', + 'ai_chat.mcp_client.install.status.detection_result', + 'ai_chat.mcp_client.install.status.detection_missing', + 'ai_chat.mcp_client.install.status.config_file', + 'ai_chat.mcp_client.install.status.launch_command', + 'ai_chat.mcp_client.install.status.refresh', + 'ai_chat.mcp_client.install.status.copy_config', + 'ai_chat.mcp_client.install.status.copy_command', + 'ai_chat.mcp_client.install.message.refresh_failed', + 'ai_chat.mcp_client.install.message.remote_guide_copied', + 'ai_chat.mcp_client.install.message.remote_guide_copy_failed', + 'ai_chat.mcp_client.install.message.already_connected', + 'ai_chat.mcp_client.install.message.codex_not_supported', + 'ai_chat.mcp_client.install.message.claude_not_supported', + 'ai_chat.mcp_client.install.message.install_success', + 'ai_chat.mcp_client.install.message.install_failed', + 'ai_chat.mcp_client.install.message.config_path_missing', + 'ai_chat.mcp_client.install.message.config_path_copied', + 'ai_chat.mcp_client.install.message.config_path_copy_failed', + 'ai_chat.mcp_client.install.message.launch_command_missing', + 'ai_chat.mcp_client.install.message.launch_command_copied', + 'ai_chat.mcp_client.install.message.launch_command_copy_failed', +]; + +const translatedCopy: Record = { + 'ai_chat.mcp_client.install.status_tone.connected': 'T:connected', + 'ai_chat.mcp_client.install.state.connected': 'T:state-connected', + 'ai_chat.mcp_client.install.selected.connected': 'T:selected-connected', + 'ai_chat.mcp_client.install.action.connected': 'T:action-connected {{label}}', + 'ai_chat.mcp_client.install.summary.connected': 'T:summary-connected {{label}}', +}; + +const translate = ( + key: string, + params?: Record, +) => (translatedCopy[key] || key).replace(/\{\{(\w+)\}\}/g, (_match, name) => String(params?.[name] ?? '')); + const buildStatus = (patch: Partial): AIMCPClientInstallStatus => ({ client: 'claude-code', displayName: 'Claude Code', @@ -24,6 +128,46 @@ const buildStatus = (patch: Partial): AIMCPClientInsta }); describe('mcpClientInstallPanelState', () => { + it('keeps external MCP client install copy behind six-language catalog keys', () => { + for (const [language, catalog] of Object.entries(catalogs)) { + const missing = REQUIRED_MCP_CLIENT_INSTALL_KEYS.filter((key) => !(key in catalog)); + expect(missing, `${language} missing mcp client install keys`).toEqual([]); + } + }); + + it('threads the panel translator through status helper copy', () => { + const status = buildStatus({ + installed: true, + matchesCurrent: true, + clientDetected: true, + }); + + expect((getMCPClientStatusTone as any)(status, false, translate).label).toBe('T:connected'); + expect((getMCPClientInstallStateLabel as any)(status, translate)).toBe('T:state-connected'); + expect((getSelectedMCPClientStateLine as any)(status, translate)).toBe('T:selected-connected'); + expect((resolveMCPClientInstallActionLabel as any)(status, translate)).toBe('T:action-connected Claude Code'); + expect((getMCPClientStatusSummary as any)(status, translate)).toBe('T:summary-connected Claude Code'); + }); + + it('guards MCP client install production sources against direct Chinese UI copy', () => { + const combinedSource = [ + source, + installPanelSource, + selectorPanelSource, + statusPanelSource, + installerSource, + ].join('\n'); + + expect(installPanelSource).toContain('useOptionalI18n'); + expect(statusPanelSource).toContain('useOptionalI18n'); + expect(selectorPanelSource).toContain('useOptionalI18n'); + expect(combinedSource).not.toContain('外部工具接入状态'); + expect(combinedSource).not.toContain('选择目标客户端'); + expect(combinedSource).not.toContain('复制启动命令'); + expect(combinedSource).not.toContain('远程接入边界'); + expect(combinedSource).not.toContain('刷新客户端安装状态失败'); + }); + it('marks a current client as already connected and prevents repeated install wording', () => { const status = buildStatus({ installed: true, @@ -32,11 +176,11 @@ describe('mcpClientInstallPanelState', () => { message: '已检测到 Claude Code 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致', }); - expect(getMCPClientStatusTone(status, false).label).toBe('已接入'); - expect(getMCPClientInstallStateLabel(status)).toBe('外部工具接入状态:已接入当前 GoNavi'); - expect(getSelectedMCPClientStateLine(status)).toBe('已接入当前 GoNavi,无需重复操作'); - expect(resolveMCPClientInstallActionLabel(status)).toBe('Claude Code 已接入,无需重复安装'); - expect(getMCPClientStatusSummary(status)).toContain('可直接在这个客户端里调用'); + expect(getMCPClientStatusTone(status, false).label).toBe('Connected'); + expect(getMCPClientInstallStateLabel(status)).toBe('External tool connection status: connected to this GoNavi'); + expect(getSelectedMCPClientStateLine(status)).toBe('Connected to current GoNavi; no repeated action needed'); + expect(resolveMCPClientInstallActionLabel(status)).toBe('Claude Code is connected; no reinstall needed'); + expect(getMCPClientStatusSummary(status)).toContain('can call it directly'); }); it('asks users to update stale external client records instead of reinstalling blindly', () => { @@ -50,10 +194,10 @@ describe('mcpClientInstallPanelState', () => { message: '已检测到 Codex 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新', }); - expect(getMCPClientStatusTone(status, false).label).toBe('需更新'); - expect(getMCPClientOptionSummary(status)).toContain('建议更新为当前安装路径'); - expect(getSelectedMCPClientStateLine(status)).toBe('已存在旧接入记录,建议更新到当前 GoNavi 路径'); - expect(resolveMCPClientInstallActionLabel(status)).toBe('更新 Codex 接入配置'); + expect(getMCPClientStatusTone(status, false).label).toBe('Update needed'); + expect(getMCPClientOptionSummary(status)).toContain('Update it to the current install path'); + expect(getSelectedMCPClientStateLine(status)).toBe('Old connection record exists; update it to the current GoNavi path'); + expect(resolveMCPClientInstallActionLabel(status)).toBe('Update Codex connection config'); }); it('explains that config can be written before the target CLI is detected in PATH', () => { @@ -63,8 +207,8 @@ describe('mcpClientInstallPanelState', () => { }); expect(resolveMCPClientCommandName(status)).toBe('claude'); - expect(getMCPClientDetectionSummary(status)).toContain('CLI 还没加入 PATH'); - expect(resolveMCPClientInstallActionLabel(status)).toBe('安装到 Claude Code(外部工具)'); + expect(getMCPClientDetectionSummary(status)).toContain('CLI is not in PATH yet'); + expect(resolveMCPClientInstallActionLabel(status)).toBe('Install to Claude Code (external tool)'); }); it('treats OpenClaw as a remote bridge target instead of a local install', () => { @@ -76,12 +220,12 @@ describe('mcpClientInstallPanelState', () => { message: 'OpenClaw 通常部署在云端 Linux;请通过远程 MCP 桥接接入 Windows GoNavi,不要复制数据库密码。', }); - expect(getMCPClientStatusTone(status, false).label).toBe('远程桥接'); - expect(getMCPClientInstallStateLabel(status)).toBe('外部工具接入状态:需配置远程 MCP 桥接'); - expect(getMCPClientOptionSummary(status)).toContain('默认 schema-only'); - expect(getMCPClientOptionSummary(status)).toContain('不复制数据库密码'); - expect(getMCPClientDetectionSummary(status)).toContain('本机无需检测 openclaw 命令'); - expect(getSelectedMCPClientStateLine(status)).toContain('数据库密码仍留在 GoNavi 本机'); - expect(resolveMCPClientInstallActionLabel(status)).toBe('复制 OpenClaw 远程接入说明'); + expect(getMCPClientStatusTone(status, false).label).toBe('Remote bridge'); + expect(getMCPClientInstallStateLabel(status)).toBe('External tool connection status: remote MCP bridge required'); + expect(getMCPClientOptionSummary(status)).toContain('schema-only'); + expect(getMCPClientOptionSummary(status)).toContain('database passwords'); + expect(getMCPClientDetectionSummary(status)).toContain('No local openclaw command detection is needed'); + expect(getSelectedMCPClientStateLine(status)).toContain('database passwords stay on the GoNavi machine'); + expect(resolveMCPClientInstallActionLabel(status)).toBe('Copy OpenClaw remote connection guide'); }); }); diff --git a/frontend/src/components/ai/mcpClientInstallPanelState.ts b/frontend/src/components/ai/mcpClientInstallPanelState.ts index cc9ebe3..a7b37ae 100644 --- a/frontend/src/components/ai/mcpClientInstallPanelState.ts +++ b/frontend/src/components/ai/mcpClientInstallPanelState.ts @@ -1,68 +1,97 @@ import type { AIMCPClientInstallStatus } from '../../types'; import { isRemoteMCPClientStatus } from '../../utils/mcpClientInstallStatus'; +export type MCPClientInstallCopyParams = Record; +export type MCPClientInstallTranslator = (key: string, params?: MCPClientInstallCopyParams) => string; + export interface MCPClientInstallStatusTone { label: string; color: string; bg: string; } -export const hasMCPClientStatusIssue = (status: AIMCPClientInstallStatus | undefined): boolean => - /失败|异常|错误/u.test(String(status?.message || '')); +const interpolateFallback = (text: string, params?: MCPClientInstallCopyParams): string => + text.replace(/\{\{(\w+)\}\}/g, (_match, name) => String(params?.[name] ?? '')); + +export const translateMCPClientInstallCopy = ( + translate: MCPClientInstallTranslator | undefined, + key: string, + fallback: string, + params?: MCPClientInstallCopyParams, +): string => { + const translated = translate?.(key, params); + if (translated && translated !== key) { + return translated; + } + return interpolateFallback(fallback, params); +}; + +export const hasMCPClientStatusIssue = (status: AIMCPClientInstallStatus | undefined): boolean => { + const message = String(status?.message || '').toLowerCase(); + return /fail|failed|error|exception/u.test(message) || + message.includes('\u5931\u8d25') || + message.includes('\u5f02\u5e38') || + message.includes('\u9519\u8bef'); +}; export const getMCPClientStatusTone = ( status: AIMCPClientInstallStatus | undefined, darkMode: boolean, + translate?: MCPClientInstallTranslator, ): MCPClientInstallStatusTone => { + const copy = (key: string, fallback: string) => translateMCPClientInstallCopy(translate, key, fallback); if (status?.matchesCurrent) { return { - label: '已接入', + label: copy('ai_chat.mcp_client.install.status_tone.connected', 'Connected'), color: '#16a34a', bg: darkMode ? 'rgba(34,197,94,0.18)' : 'rgba(34,197,94,0.12)', }; } if (status?.installed) { return { - label: '需更新', + label: copy('ai_chat.mcp_client.install.status_tone.update_required', 'Update needed'), color: '#d97706', bg: darkMode ? 'rgba(245,158,11,0.18)' : 'rgba(245,158,11,0.12)', }; } if (isRemoteMCPClientStatus(status)) { return { - label: '远程桥接', + label: copy('ai_chat.mcp_client.install.status_tone.remote_bridge', 'Remote bridge'), color: '#0284c7', bg: darkMode ? 'rgba(56,189,248,0.16)' : 'rgba(14,165,233,0.10)', }; } if (hasMCPClientStatusIssue(status)) { return { - label: '状态异常', + label: copy('ai_chat.mcp_client.install.status_tone.status_error', 'Status error'), color: '#dc2626', bg: darkMode ? 'rgba(239,68,68,0.18)' : 'rgba(239,68,68,0.1)', }; } return { - label: '未接入', + label: copy('ai_chat.mcp_client.install.status_tone.not_connected', 'Not connected'), color: darkMode ? 'rgba(255,255,255,0.72)' : '#64748b', bg: darkMode ? 'rgba(255,255,255,0.08)' : 'rgba(100,116,139,0.08)', }; }; -export const getMCPClientInstallStateLabel = (status: AIMCPClientInstallStatus | undefined): string => { +export const getMCPClientInstallStateLabel = ( + status: AIMCPClientInstallStatus | undefined, + translate?: MCPClientInstallTranslator, +): string => { if (status?.matchesCurrent) { - return '外部工具接入状态:已接入当前 GoNavi'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.state.connected', 'External tool connection status: connected to this GoNavi'); } if (status?.installed) { - return '外部工具接入状态:已存在旧配置,需更新'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.state.stale', 'External tool connection status: old config found, update needed'); } if (hasMCPClientStatusIssue(status)) { - return '外部工具接入状态:读取失败'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.state.error', 'External tool connection status: failed to read'); } if (isRemoteMCPClientStatus(status)) { - return '外部工具接入状态:需配置远程 MCP 桥接'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.state.remote', 'External tool connection status: remote MCP bridge required'); } - return '外部工具接入状态:未接入'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.state.missing', 'External tool connection status: not connected'); }; export const resolveMCPClientCommandName = (status: AIMCPClientInstallStatus | undefined): string => { @@ -73,77 +102,92 @@ export const resolveMCPClientCommandName = (status: AIMCPClientInstallStatus | u return status?.client === 'codex' ? 'codex' : 'claude'; }; -export const getMCPClientStatusSummary = (status: AIMCPClientInstallStatus | undefined): string => { - const label = status?.displayName || '这个客户端'; +export const getMCPClientStatusSummary = ( + status: AIMCPClientInstallStatus | undefined, + translate?: MCPClientInstallTranslator, +): string => { + const label = status?.displayName || 'this client'; if (status?.matchesCurrent) { - return `${label} 已接入当前这份 GoNavi MCP,可直接在这个客户端里调用。`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.summary.connected', '{{label}} is connected to this GoNavi MCP and can call it directly.', { label }); } if (status?.installed) { - return `${label} 里已经有旧的 GoNavi 接入记录,更新后会切到当前这份 GoNavi。`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.summary.stale', '{{label}} already has an old GoNavi entry. Updating will point it to this GoNavi.', { label }); } if (hasMCPClientStatusIssue(status)) { - return `${label} 的接入状态读取失败,建议先刷新检测。`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.summary.error', 'Failed to read the connection status for {{label}}. Refresh detection first.', { label }); } if (isRemoteMCPClientStatus(status)) { - return `${label} 通常运行在云端或远端机器,需要通过远程 MCP 桥接调用当前 GoNavi。`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.summary.remote', '{{label}} usually runs in the cloud or on another machine and needs a remote MCP bridge to call this GoNavi.', { label }); } - return `当前还没有把这份 GoNavi MCP 接入 ${label}。`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.summary.missing', 'This GoNavi MCP is not connected to {{label}} yet.', { label }); }; -export const getMCPClientOptionSummary = (status: AIMCPClientInstallStatus | undefined): string => { +export const getMCPClientOptionSummary = ( + status: AIMCPClientInstallStatus | undefined, + translate?: MCPClientInstallTranslator, +): string => { if (status?.matchesCurrent) { - return '当前这份 GoNavi MCP 已接入到这个客户端。'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.option.connected', 'This GoNavi MCP is already connected to this client.'); } if (status?.installed) { - return '检测到旧的 GoNavi 接入记录,建议更新为当前安装路径。'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.option.stale', 'An old GoNavi entry was detected. Update it to the current install path.'); } if (hasMCPClientStatusIssue(status)) { - return '接入状态读取异常,建议先刷新再处理。'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.option.error', 'Connection status looks abnormal. Refresh before changing it.'); } if (isRemoteMCPClientStatus(status)) { - return '适合云端 Agent:默认 schema-only 读取 GoNavi 表结构,不复制数据库密码,不暴露 SQL 执行。'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.option.remote', 'For cloud Agents: schema-only reads GoNavi structure by default, without copying database passwords or exposing execute_sql.'); } - return '尚未把当前 GoNavi MCP 接入到这里。'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.option.missing', 'Current GoNavi MCP is not connected here yet.'); }; -export const getMCPClientDetectionSummary = (status: AIMCPClientInstallStatus | undefined): string => { - const label = status?.displayName || '这个客户端'; - const commandName = resolveMCPClientCommandName(status); +export const getMCPClientDetectionSummary = ( + status: AIMCPClientInstallStatus | undefined, + translate?: MCPClientInstallTranslator, +): string => { + const label = status?.displayName || 'this client'; + const command = resolveMCPClientCommandName(status); if (isRemoteMCPClientStatus(status)) { - return `${label} 通常不在这台 Windows 上运行,本机无需检测 ${commandName} 命令;请在云端配置远程 MCP 桥接地址。`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.detection.remote', '{{label}} usually does not run on this Windows machine. No local {{command}} command detection is needed; configure a remote MCP bridge URL in the cloud.', { label, command }); } if (status?.clientDetected) { - return `已检测到本机 ${commandName} 命令,接入或更新后重启 ${label} 即可验证。`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.detection.detected', 'Detected local {{command}} command. After connecting or updating, restart {{label}} to verify.', { label, command }); } - return `未检测到本机 ${commandName} 命令;如果 CLI 还没加入 PATH,也可以先写入 ${label} 的接入配置,稍后再重启验证。`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.detection.not_detected', 'Local {{command}} command was not detected. If the CLI is not in PATH yet, you can still write {{label}} config first and restart later.', { label, command }); }; -export const getSelectedMCPClientStateLine = (status: AIMCPClientInstallStatus | undefined): string => { +export const getSelectedMCPClientStateLine = ( + status: AIMCPClientInstallStatus | undefined, + translate?: MCPClientInstallTranslator, +): string => { if (status?.matchesCurrent) { - return '已接入当前 GoNavi,无需重复操作'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.selected.connected', 'Connected to current GoNavi; no repeated action needed'); } if (status?.installed) { - return '已存在旧接入记录,建议更新到当前 GoNavi 路径'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.selected.stale', 'Old connection record exists; update it to the current GoNavi path'); } if (hasMCPClientStatusIssue(status)) { - return '状态读取异常,建议先刷新检测'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.selected.error', 'Status read is abnormal; refresh detection first'); } if (isRemoteMCPClientStatus(status)) { - return '需要配置远程 MCP 桥接,数据库密码仍留在 GoNavi 本机'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.selected.remote', 'Configure a remote MCP bridge; database passwords stay on the GoNavi machine'); } - return '当前还没有接入 GoNavi MCP'; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.selected.missing', 'GoNavi MCP is not connected yet'); }; -export const resolveMCPClientInstallActionLabel = (status: AIMCPClientInstallStatus | undefined): string => { - const label = status?.displayName || '目标客户端'; +export const resolveMCPClientInstallActionLabel = ( + status: AIMCPClientInstallStatus | undefined, + translate?: MCPClientInstallTranslator, +): string => { + const label = status?.displayName || 'target client'; if (status?.matchesCurrent) { - return `${label} 已接入,无需重复安装`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.action.connected', '{{label}} is connected; no reinstall needed', { label }); } if (status?.installed) { - return `更新 ${label} 接入配置`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.action.update', 'Update {{label}} connection config', { label }); } if (isRemoteMCPClientStatus(status)) { - return `复制 ${label} 远程接入说明`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.action.copy_remote', 'Copy {{label}} remote connection guide', { label }); } - return `安装到 ${label}(外部工具)`; + return translateMCPClientInstallCopy(translate, 'ai_chat.mcp_client.install.action.install', 'Install to {{label}} (external tool)', { label }); }; diff --git a/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx b/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx index 19e475e..eb6a515 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageCodeBlock.tsx @@ -5,6 +5,9 @@ import mermaid from 'mermaid'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus, vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; +import { t as catalogTranslate } from '../../../i18n/catalog'; +import type { I18nParams } from '../../../i18n/types'; +import { useOptionalI18n } from '../../../i18n/provider'; import type { OverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme'; import { buildAIReadonlyPreviewSQL } from '../../../utils/aiSqlLimit'; @@ -30,8 +33,23 @@ interface HighlightedCodeBlockProps { activeDbName?: string; } +const useMessageCopy = () => { + const i18n = useOptionalI18n(); + return (key: string, params?: I18nParams) => ( + i18n?.t ?? ((catalogKey, catalogParams) => catalogTranslate('en-US', catalogKey, catalogParams)) + )(key, params); +}; + +const escapeHtml = (value: string) => value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + const MermaidRenderer: React.FC<{ chart: string; darkMode: boolean }> = ({ chart, darkMode }) => { const containerRef = React.useRef(null); + const copy = useMessageCopy(); React.useEffect(() => { if (!containerRef.current) { @@ -47,21 +65,22 @@ const MermaidRenderer: React.FC<{ chart: string; darkMode: boolean }> = ({ chart } })().catch((error: any) => { if (containerRef.current) { - containerRef.current.innerHTML = `
Mermaid 解析失败: ${error.message}
`; + containerRef.current.innerHTML = `
${escapeHtml(copy('ai_chat.message.mermaid.parse_failed', { detail: error?.message || '' }))}
`; } }); } catch (error: any) { if (containerRef.current) { - containerRef.current.innerHTML = `
Mermaid 渲染异常: ${error.message}
`; + containerRef.current.innerHTML = `
${escapeHtml(copy('ai_chat.message.mermaid.render_failed', { detail: error?.message || '' }))}
`; } } - }, [chart, darkMode]); + }, [chart, copy, darkMode]); return
; }; const CodeCopyButton: React.FC<{ text: string }> = ({ text }) => { const [copied, setCopied] = useState(false); + const copy = useMessageCopy(); return ( = ({ text }) => { onMouseLeave={(event) => { event.currentTarget.style.opacity = copied ? '1' : '0.6'; }} > {copied ? : } - {copied ? '已复制' : '复制代码'} + {copied ? copy('ai_chat.message.code.copied') : copy('ai_chat.message.code.copy')} ); }; const CodeRunButton: React.FC<{ text: string; connectionId?: string; dbName?: string }> = ({ text, connectionId, dbName }) => { + const copy = useMessageCopy(); const contextMatch = text.match(/^--\s*@context\s+connectionId=(\S+)\s+dbName=(\S+)/m); const resolvedConnId = contextMatch?.[1] || connectionId; const resolvedDbName = contextMatch?.[2] || dbName; @@ -105,16 +125,16 @@ const CodeRunButton: React.FC<{ text: string; connectionId?: string; dbName?: st if (Service?.AICheckSQL) { const result = await Service.AICheckSQL(text); if (!result.allowed) { - message.error(`🔒 安全策略拦截:当前安全级别不允许执行 ${result.operationType} 类型的 SQL。请在 AI 设置中调整安全级别。`); + message.error(copy('ai_chat.message.security.blocked', { operationType: result.operationType })); return; } if (result.requiresConfirm) { const { Modal } = await import('antd'); Modal.confirm({ - title: '⚠️ 安全确认', - content: result.warningMessage || `此 SQL 为 ${result.operationType} 操作,确定要执行吗?`, - okText: '确认执行', - cancelText: '取消', + title: copy('ai_chat.message.security.confirm_title'), + content: result.warningMessage || copy('ai_chat.message.security.default_warning', { operationType: result.operationType }), + okText: copy('ai_chat.message.security.confirm_execute'), + cancelText: copy('common.cancel'), okButtonProps: { danger: true }, onOk: () => { window.dispatchEvent(new CustomEvent('gonavi:insert-sql', { detail: sqlDetail(true) })); @@ -131,7 +151,7 @@ const CodeRunButton: React.FC<{ text: string; connectionId?: string; dbName?: st return (
- + { @@ -150,10 +170,10 @@ const CodeRunButton: React.FC<{ text: string; connectionId?: string; dbName?: st onMouseLeave={(event) => { event.currentTarget.style.opacity = '0.6'; }} > - 插入 + {copy('ai_chat.message.code.insert')} - + { event.currentTarget.style.opacity = '0.6'; }} > - 执行 + {copy('ai_chat.message.code.execute')}
@@ -187,6 +207,7 @@ const HighlightedCodeBlock: React.FC = ({ activeConnectionId, activeDbName, }) => { + const copy = useMessageCopy(); const [expanded, setExpanded] = useState(false); const [previewData, setPreviewData] = useState(null); const [previewCols, setPreviewCols] = useState([]); @@ -220,10 +241,10 @@ const HighlightedCodeBlock: React.FC = ({ setPreviewData(rows.slice(0, 20)); setPreviewExpanded(true); } else { - setPreviewError(response.message || '查询无结果'); + setPreviewError(response.message || copy('ai_chat.message.code.query_no_result')); } } catch (error: any) { - setPreviewError(error?.message || '执行失败'); + setPreviewError(error?.message || copy('ai_chat.message.code.execute_failed')); } finally { setPreviewLoading(false); } @@ -247,7 +268,7 @@ const HighlightedCodeBlock: React.FC = ({
{isSql && } {isSelectQuery && activeConnectionConfig && ( - + = ({ }} > {previewLoading ? '⏳' : '👁'} - {previewLoading ? '执行中...' : '预览'} + {previewLoading ? copy('ai_chat.message.code.executing') : copy('ai_chat.message.code.preview')} )} @@ -322,7 +343,7 @@ const HighlightedCodeBlock: React.FC = ({ onClick={() => setExpanded(true)} > - 展开全部代码 + {copy('ai_chat.message.code.expand_all')}
)} @@ -338,7 +359,7 @@ const HighlightedCodeBlock: React.FC = ({ }} onClick={() => setExpanded(false)} > - 收起代码 + {copy('ai_chat.message.code.collapse')}
)}
@@ -351,8 +372,8 @@ const HighlightedCodeBlock: React.FC = ({ {previewExpanded && previewData && previewData.length > 0 && (
- 📊 预览结果({previewData.length} 行 × {previewCols.length} 列) - setPreviewExpanded(false)}>收起 ▴ + 📊 {copy('ai_chat.message.code.preview_result', { rows: previewData.length, columns: previewCols.length })} + setPreviewExpanded(false)}>{copy('ai_chat.message.code.preview_collapse')} ▴
@@ -385,7 +406,7 @@ const HighlightedCodeBlock: React.FC = ({ style={{ padding: '4px 12px', cursor: 'pointer', fontSize: 11, color: overlayTheme.mutedText, background: darkMode ? 'rgba(250,173,20,0.05)' : 'rgba(250,173,20,0.03)', borderTop: `1px solid ${darkMode ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.03)'}` }} onClick={() => setPreviewExpanded(true)} > - 📊 查看结果({previewData.length} 行)▾ + 📊 {copy('ai_chat.message.code.view_result', { rows: previewData.length })} ▾ )} diff --git a/frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx b/frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx index ab3f8de..3b116af 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageMarkdown.test.tsx @@ -4,6 +4,8 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import { describe, expect, it, vi } from 'vitest'; import { AIMessageMarkdown } from './AIMessageMarkdown'; +import { AIThinkingBlock, AIToolCallingBlock } from './AIMessageStatusBlocks'; +import { I18nProvider } from '../../../i18n/provider'; import { buildOverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme'; vi.mock('antd', () => ({ @@ -12,13 +14,16 @@ vi.mock('antd', () => ({ })); vi.mock('@ant-design/icons', () => ({ + ApiOutlined: () => null, + CaretDownOutlined: () => null, + CaretRightOutlined: () => null, CheckOutlined: () => null, CopyOutlined: () => null, PlayCircleOutlined: () => null, })); describe('AIMessageMarkdown', () => { - it('keeps SQL code block actions after extracting markdown rendering', () => { + it('renders SQL code block actions through English fallback copy without an i18n provider', () => { const markup = renderToStaticMarkup( { />, ); + expect(markup).toContain('Copy code'); + expect(markup).toContain('Insert'); + expect(markup).toContain('Execute'); + expect(markup).toContain('Preview'); + }); + + it('renders SQL code block actions in Chinese when an i18n provider is available', () => { + const markup = renderToStaticMarkup( + {}}> + + , + ); + expect(markup).toContain('复制代码'); expect(markup).toContain('插入'); expect(markup).toContain('执行'); @@ -77,3 +102,89 @@ describe('AIMessageMarkdown', () => { } }); }); + +describe('AIMessageStatusBlocks', () => { + it('renders thinking and tool status copy through English fallback without an i18n provider', () => { + const overlayTheme = buildOverlayWorkbenchTheme(false); + const thinkingMarkup = renderToStaticMarkup( + , + ); + const toolMarkup = renderToStaticMarkup( + , + ); + + expect(thinkingMarkup).toContain('Thinking process'); + expect(thinkingMarkup).toContain('(8 chars)'); + expect(toolMarkup).toContain('Data probes completed (1 items)'); + expect(toolMarkup).toContain('Read current AI runtime status'); + expect(toolMarkup).toContain('Probe result'); + expect(toolMarkup).toContain('14 chars'); + }); + + it('renders thinking and tool status copy in Chinese when an i18n provider is available', () => { + const overlayTheme = buildOverlayWorkbenchTheme(false); + const markup = renderToStaticMarkup( + {}}> + + + , + ); + + expect(markup).toContain('思考过程'); + expect(markup).toContain('(8 字)'); + expect(markup).toContain('数据探针执行完毕 (1 项)'); + expect(markup).toContain('读取当前 AI 运行状态'); + expect(markup).toContain('探针执行结果'); + expect(markup).toContain('14 个字符'); + }); +}); diff --git a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx index e0114b6..9b79911 100644 --- a/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx +++ b/frontend/src/components/ai/messageBubble/AIMessageStatusBlocks.tsx @@ -1,6 +1,9 @@ import React, { useEffect, useMemo, useState } from 'react'; import { ApiOutlined, CaretDownOutlined, CaretRightOutlined, CheckOutlined } from '@ant-design/icons'; +import { t as catalogTranslate } from '../../../i18n/catalog'; +import type { I18nParams } from '../../../i18n/types'; +import { useOptionalI18n } from '../../../i18n/provider'; import type { AIChatMessage, AIToolCall } from '../../../types'; import type { OverlayWorkbenchTheme } from '../../../utils/overlayWorkbenchTheme'; @@ -22,58 +25,66 @@ interface AIToolCallingBlockProps { hasContent: boolean; } -const TOOL_ACTION_LABELS: Record = { - inspect_ai_runtime: '读取当前 AI 运行状态', - inspect_ai_safety: '读取当前 AI 安全边界', - inspect_ai_providers: '读取当前 AI 供应商与模型配置', - inspect_ai_chat_readiness: '读取当前 AI 聊天发送前置状态', - inspect_ai_tool_catalog: '读取 AI 工具目录和参数提示', - inspect_ai_support_bundle: '生成 AI 排障支持包', - inspect_mcp_setup: '读取当前 MCP 配置状态', - inspect_mcp_runtime_failures: '诊断 MCP 运行期失败', - inspect_mcp_authoring_guide: '读取 MCP 新增填写指引', - inspect_mcp_draft: '校验 MCP 新增草稿', - inspect_mcp_tool_schema: '读取 MCP 工具参数 schema', - inspect_ai_guidance: '读取当前 AI 提示与技能配置', - get_connections: '获取可用连接信息', - get_databases: '扫描数据库列表', - get_tables: '分析表结构信息', - get_all_columns: '汇总跨表字段摘要', - get_columns: '核对真实字段定义', - get_indexes: '检查索引定义', - get_foreign_keys: '梳理外键关系', - get_triggers: '检查触发器逻辑', - get_table_ddl: '提取建表语句', - inspect_table_bundle: '抓取完整表结构快照', - inspect_database_bundle: '抓取数据库结构总览', - inspect_current_connection: '读取当前连接摘要', - inspect_connection_capabilities: '读取当前连接能力矩阵', - inspect_saved_connections: '盘点本地已保存连接', - inspect_redis_topology: '诊断 Redis 拓扑配置', - inspect_external_sql_directories: '盘点外部 SQL 目录', - inspect_external_sql_file: '读取外部 SQL 文件', - inspect_ai_sessions: '盘点本地 AI 历史会话', - inspect_active_tab: '读取当前活动页签', - inspect_workspace_tabs: '盘点当前工作区页签', - inspect_recent_sql_logs: '回看最近 SQL 执行日志', - inspect_recent_sql_activity: '总结最近 SQL 活动', - inspect_sql_editor_transaction: '读取 SQL 编辑器事务状态', - inspect_app_logs: '回看 GoNavi 应用日志', - inspect_recent_connection_failures: '总结最近连接失败记录', - inspect_ai_last_render_error: '读取最近一次 AI 渲染异常', - inspect_ai_message_flow: '诊断当前 AI 消息流', - inspect_ai_context_budget: '诊断 AI 上下文体量风险', - inspect_codebase_hotspots: '读取代码大文件热点', - inspect_saved_queries: '检索本地已保存查询', - inspect_sql_snippets: '读取 SQL 片段模板', - inspect_shortcuts: '读取当前快捷键配置', - preview_table_rows: '预览真实样例数据', - execute_sql: '执行只读 SQL 验证', +const useMessageCopy = () => { + const i18n = useOptionalI18n(); + return (key: string, params?: I18nParams) => ( + i18n?.t ?? ((catalogKey, catalogParams) => catalogTranslate('en-US', catalogKey, catalogParams)) + )(key, params); +}; + +const TOOL_ACTION_LABEL_KEYS: Record = { + inspect_ai_runtime: 'ai_chat.message.tool_call.inspect_ai_runtime', + inspect_ai_safety: 'ai_chat.message.tool_call.inspect_ai_safety', + inspect_ai_providers: 'ai_chat.message.tool_call.inspect_ai_providers', + inspect_ai_chat_readiness: 'ai_chat.message.tool_call.inspect_ai_chat_readiness', + inspect_ai_tool_catalog: 'ai_chat.message.tool_call.inspect_ai_tool_catalog', + inspect_ai_support_bundle: 'ai_chat.message.tool_call.inspect_ai_support_bundle', + inspect_mcp_setup: 'ai_chat.message.tool_call.inspect_mcp_setup', + inspect_mcp_runtime_failures: 'ai_chat.message.tool_call.inspect_mcp_runtime_failures', + inspect_mcp_authoring_guide: 'ai_chat.message.tool_call.inspect_mcp_authoring_guide', + inspect_mcp_draft: 'ai_chat.message.tool_call.inspect_mcp_draft', + inspect_mcp_tool_schema: 'ai_chat.message.tool_call.inspect_mcp_tool_schema', + inspect_ai_guidance: 'ai_chat.message.tool_call.inspect_ai_guidance', + get_connections: 'ai_chat.message.tool_call.get_connections', + get_databases: 'ai_chat.message.tool_call.get_databases', + get_tables: 'ai_chat.message.tool_call.get_tables', + get_all_columns: 'ai_chat.message.tool_call.get_all_columns', + get_columns: 'ai_chat.message.tool_call.get_columns', + get_indexes: 'ai_chat.message.tool_call.get_indexes', + get_foreign_keys: 'ai_chat.message.tool_call.get_foreign_keys', + get_triggers: 'ai_chat.message.tool_call.get_triggers', + get_table_ddl: 'ai_chat.message.tool_call.get_table_ddl', + inspect_table_bundle: 'ai_chat.message.tool_call.inspect_table_bundle', + inspect_database_bundle: 'ai_chat.message.tool_call.inspect_database_bundle', + inspect_current_connection: 'ai_chat.message.tool_call.inspect_current_connection', + inspect_connection_capabilities: 'ai_chat.message.tool_call.inspect_connection_capabilities', + inspect_saved_connections: 'ai_chat.message.tool_call.inspect_saved_connections', + inspect_redis_topology: 'ai_chat.message.tool_call.inspect_redis_topology', + inspect_external_sql_directories: 'ai_chat.message.tool_call.inspect_external_sql_directories', + inspect_external_sql_file: 'ai_chat.message.tool_call.inspect_external_sql_file', + inspect_ai_sessions: 'ai_chat.message.tool_call.inspect_ai_sessions', + inspect_active_tab: 'ai_chat.message.tool_call.inspect_active_tab', + inspect_workspace_tabs: 'ai_chat.message.tool_call.inspect_workspace_tabs', + inspect_recent_sql_logs: 'ai_chat.message.tool_call.inspect_recent_sql_logs', + inspect_recent_sql_activity: 'ai_chat.message.tool_call.inspect_recent_sql_activity', + inspect_sql_editor_transaction: 'ai_chat.message.tool_call.inspect_sql_editor_transaction', + inspect_app_logs: 'ai_chat.message.tool_call.inspect_app_logs', + inspect_recent_connection_failures: 'ai_chat.message.tool_call.inspect_recent_connection_failures', + inspect_ai_last_render_error: 'ai_chat.message.tool_call.inspect_ai_last_render_error', + inspect_ai_message_flow: 'ai_chat.message.tool_call.inspect_ai_message_flow', + inspect_ai_context_budget: 'ai_chat.message.tool_call.inspect_ai_context_budget', + inspect_codebase_hotspots: 'ai_chat.message.tool_call.inspect_codebase_hotspots', + inspect_saved_queries: 'ai_chat.message.tool_call.inspect_saved_queries', + inspect_sql_snippets: 'ai_chat.message.tool_call.inspect_sql_snippets', + inspect_shortcuts: 'ai_chat.message.tool_call.inspect_shortcuts', + preview_table_rows: 'ai_chat.message.tool_call.preview_table_rows', + execute_sql: 'ai_chat.message.tool_call.execute_sql', }; const AIToolResultItem: React.FC<{ resultMsg: AIChatMessage; darkMode: boolean; overlayTheme: OverlayWorkbenchTheme }> = ({ resultMsg, darkMode, overlayTheme }) => { const [toolExpanded, setToolExpanded] = useState(false); const charCount = resultMsg.content ? resultMsg.content.length : 0; + const copy = useMessageCopy(); return (
{toolExpanded ? : } - 探针执行结果 ({resultMsg.tool_name || 'unknown'}) - {charCount > 0 ? `${charCount} 个字符` : '无数据'} + {copy('ai_chat.message.tool_result.title', { name: resultMsg.tool_name || 'unknown' })} + + {charCount > 0 ? copy('ai_chat.message.tool_result.char_count', { count: charCount }) : copy('ai_chat.message.tool_result.no_data')} +
{toolExpanded && (
@@ -113,6 +126,7 @@ export const AIThinkingBlock: React.FC = ({ const isActivelyThinking = isGlobalLoading && !hasContent; const [expanded, setExpanded] = useState(isActivelyThinking); const contentRef = React.useRef(null); + const copy = useMessageCopy(); useEffect(() => { if (isActivelyThinking) { @@ -154,9 +168,9 @@ export const AIThinkingBlock: React.FC = ({ }} > - 💭 思考过程 - {isActivelyThinking && 思考中...} - {!isActivelyThinking && ({displayThinking.length} 字)} + 💭 {copy('ai_chat.message.thinking.title')} + {isActivelyThinking && {copy('ai_chat.message.thinking.active')}} + {!isActivelyThinking && {copy('ai_chat.message.thinking.count', { count: displayThinking.length })}}
= ({ overlayTheme, hasContent, }) => { + const copy = useMessageCopy(); const toolResultsById = useMemo(() => { return new Map( allMessages @@ -233,7 +248,7 @@ export const AIToolCallingBlock: React.FC = ({ ) : ( )} - {!allDone && loading ? '正在执行数据探针...' : `数据探针执行完毕 (${toolCalls.length} 项)`} + {!allDone && loading ? copy('ai_chat.message.tool_call.running') : copy('ai_chat.message.tool_call.done', { count: toolCalls.length })}
@@ -242,7 +257,9 @@ export const AIToolCallingBlock: React.FC = ({ {toolCalls.map((toolCall) => { const resultMsg = toolResultsById.get(toolCall.id); const isDone = Boolean(resultMsg); - const actionName = TOOL_ACTION_LABELS[toolCall.function.name] || toolCall.function.name; + const actionKey = TOOL_ACTION_LABEL_KEYS[toolCall.function.name]; + const translatedActionName = actionKey ? copy(actionKey) : ''; + const actionName = translatedActionName && translatedActionName !== actionKey ? translatedActionName : toolCall.function.name; return (
({ + error: vi.fn(), + warning: vi.fn(), + success: vi.fn(), + info: vi.fn(), +})); + +const dbGetDatabasesMock = vi.hoisted(() => vi.fn()); +const dbGetTablesMock = vi.hoisted(() => vi.fn()); +const dbGetColumnsMock = vi.hoisted(() => vi.fn()); +const dbShowCreateTableMock = vi.hoisted(() => vi.fn()); + +vi.mock('antd', () => ({ + message: messageApi, +})); + +vi.mock('../../../wailsjs/go/app/App', () => ({ + DBGetDatabases: dbGetDatabasesMock, + DBGetTables: dbGetTablesMock, + DBGetColumns: dbGetColumnsMock, + DBShowCreateTable: dbShowCreateTableMock, +})); + +import { useStore } from '../../store'; +import { useAIChatContextBinding } from './useAIChatContextBinding'; + +const source = readFileSync(new URL('./useAIChatContextBinding.ts', import.meta.url), 'utf8'); +const inputSource = readFileSync(new URL('./AIChatInput.tsx', import.meta.url), 'utf8'); +const zhCnCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-CN.json', import.meta.url), 'utf8')); +const zhTwCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/zh-TW.json', import.meta.url), 'utf8')); +const enUsCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/en-US.json', import.meta.url), 'utf8')); +const jaJpCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ja-JP.json', import.meta.url), 'utf8')); +const deDeCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/de-DE.json', import.meta.url), 'utf8')); +const ruRuCatalog = JSON.parse(readFileSync(new URL('../../../../shared/i18n/ru-RU.json', import.meta.url), 'utf8')); + +type HarnessProps = Parameters[0]; + +let latestHook: ReturnType | undefined; + +const addAIContextMock = vi.fn(); +const removeAIContextMock = vi.fn(); + +const baseProps: HarnessProps = { + activeContext: { connectionId: 'conn-1', dbName: 'analytics' }, + activeContextItems: [], + connectionKey: 'conn-1::analytics', + addAIContext: addAIContextMock, + removeAIContext: removeAIContextMock, +}; + +const HookHarness = (props: Partial) => { + latestHook = useAIChatContextBinding({ + ...baseProps, + ...props, + }); + return null; +}; + +describe('useAIChatContextBinding', () => { + beforeEach(() => { + vi.clearAllMocks(); + latestHook = undefined; + useStore.setState({ + connections: [{ + id: 'conn-1', + name: 'analytics-primary', + config: { + type: 'mysql', + host: '127.0.0.1', + port: 3306, + user: 'root', + }, + }], + } as any); + }); + + afterEach(() => { + useStore.setState({ connections: [] } as any); + }); + + it('wires AIChatInput into hook-level translation and removes legacy Chinese context-binding literals', () => { + expect(source).toContain("catalogTranslate('en-US', key, params)"); + expect(source).toContain("ai_chat.input.message.fetch_tables_failed"); + expect(source).toContain("ai_chat.input.message.select_database_context_first"); + expect(source).toContain("ai_chat.input.message.context_load_failed"); + expect(source).toContain("ai_chat.input.message.fetch_table_schema_failed"); + expect(source).toContain("ai_chat.input.message.context_added"); + expect(source).toContain("ai_chat.input.message.context_removed"); + expect(source).toContain("ai_chat.input.message.context_synced"); + expect(source).toContain("ai_chat.input.message.selection_unchanged"); + expect(source).toContain("ai_chat.input.message.context_sync_failed"); + expect(inputSource).toMatch(/useAIChatContextBinding\(\{\s*[\s\S]*translate:\s*t,/); + 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('上下文已同步更新:新增 '); + expect(source).not.toContain('选中的表未发生变化'); + expect(source).not.toContain('同步 AI 上下文失败'); + }); + + it('keeps required context-binding message keys present in all six catalogs', () => { + const requiredKeys = [ + 'ai_chat.input.message.fetch_tables_failed', + 'ai_chat.input.message.select_database_context_first', + 'ai_chat.input.message.context_load_failed', + 'ai_chat.input.message.fetch_table_schema_failed', + 'ai_chat.input.message.context_added', + 'ai_chat.input.message.context_removed', + 'ai_chat.input.message.context_synced', + 'ai_chat.input.message.selection_unchanged', + 'ai_chat.input.message.context_sync_failed', + ]; + + for (const key of requiredKeys) { + expect(zhCnCatalog[key]).toBeTruthy(); + expect(zhTwCatalog[key]).toBeTruthy(); + expect(enUsCatalog[key]).toBeTruthy(); + expect(jaJpCatalog[key]).toBeTruthy(); + expect(deDeCatalog[key]).toBeTruthy(); + expect(ruRuCatalog[key]).toBeTruthy(); + } + }); + + it('falls back to the English warning when no active database context is selected', async () => { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await latestHook!.handleOpenContext(); + }); + + expect(messageApi.warning).toHaveBeenCalledWith('Select a database on the left before attaching chat context'); + + await act(async () => { + renderer!.unmount(); + }); + }); + + it('surfaces the English table-load failure instead of silently swallowing failed context-table fetches', async () => { + dbGetDatabasesMock.mockResolvedValue({ + success: true, + data: [{ name: 'analytics' }], + }); + dbGetTablesMock.mockResolvedValue({ + success: false, + message: 'permission denied', + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await latestHook!.handleOpenContext(); + }); + + expect(messageApi.error).toHaveBeenCalledWith('Failed to load tables: permission denied'); + + await act(async () => { + renderer!.unmount(); + }); + }); + + it('falls back to the English unchanged-selection info message after a no-op sync', async () => { + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + await latestHook!.handleAppendContext(); + }); + + expect(messageApi.info).toHaveBeenCalledWith('Selected tables did not change'); + + await act(async () => { + renderer!.unmount(); + }); + }); +}); diff --git a/frontend/src/components/ai/useAIChatContextBinding.ts b/frontend/src/components/ai/useAIChatContextBinding.ts index efc9371..63320f0 100644 --- a/frontend/src/components/ai/useAIChatContextBinding.ts +++ b/frontend/src/components/ai/useAIChatContextBinding.ts @@ -1,5 +1,7 @@ import React from 'react'; import { message } from 'antd'; +import { t as catalogTranslate } from '../../i18n/catalog'; +import type { I18nParams } from '../../i18n/types'; import type { AIContextItem } from '../../types'; import { useStore } from '../../store'; @@ -18,14 +20,29 @@ interface UseAIChatContextBindingParams { connectionKey: string; addAIContext: (connectionKey: string, item: AIContextItem) => void; removeAIContext: (connectionKey: string, dbName: string, tableName: string) => void; + translate?: AIChatContextBindingTranslate; } +type AIChatContextBindingTranslate = (key: string, params?: I18nParams) => string; + +const defaultTranslate: AIChatContextBindingTranslate = (key, params) => + catalogTranslate('en-US', key, params); + +const getErrorDetail = (value: unknown): string => { + if (value instanceof Error) { + return value.message || 'unknown error'; + } + const detail = String(value || '').trim(); + return detail || 'unknown error'; +}; + export const useAIChatContextBinding = ({ activeContext, activeContextItems, connectionKey, addAIContext, removeAIContext, + translate = defaultTranslate, }: UseAIChatContextBindingParams) => { const [contextOpen, setContextOpen] = React.useState(false); const [contextLoading, setContextLoading] = React.useState(false); @@ -41,6 +58,14 @@ export const useAIChatContextBinding = ({ () => contextTables.filter((table) => table.name.toLowerCase().includes(searchText.toLowerCase())), [contextTables, searchText], ); + const translateMessage = React.useCallback(( + key: string, + fallback: string, + params?: I18nParams, + ) => { + const translated = translate(key, params); + return translated && translated !== key ? translated : fallback; + }, [translate]); const fetchTablesForDb = React.useCallback(async (dbName: string, connConfig: any) => { setContextLoading(true); @@ -50,20 +75,33 @@ export const useAIChatContextBinding = ({ if (res.success && Array.isArray(res.data)) { setContextTables(res.data.map((row) => ({ name: Object.values(row)[0] as string }))); } else { - message.error(`获取表格失败: ${res.message}`); + const detail = getErrorDetail(res.message); + message.error(translateMessage( + 'ai_chat.input.message.fetch_tables_failed', + `Failed to load tables: ${detail}`, + { detail }, + )); setContextTables([]); } } catch (error: any) { - message.error(error?.message || '获取表格失败'); + const detail = getErrorDetail(error); + message.error(translateMessage( + 'ai_chat.input.message.fetch_tables_failed', + `Failed to load tables: ${detail}`, + { detail }, + )); setContextTables([]); } finally { setContextLoading(false); } - }, []); + }, [translateMessage]); const handleOpenContext = React.useCallback(async () => { if (!activeContext?.connectionId) { - message.warning('请先在左侧选择一个数据库作为所聊上下文'); + message.warning(translateMessage( + 'ai_chat.input.message.select_database_context_first', + 'Select a database on the left before attaching chat context', + )); return; } @@ -89,14 +127,25 @@ export const useAIChatContextBinding = ({ if (tablesRes.success && Array.isArray(tablesRes.data)) { setContextTables(tablesRes.data.map((row: any) => ({ name: Object.values(row)[0] as string }))); } else { + const detail = getErrorDetail(tablesRes.message); + message.error(translateMessage( + 'ai_chat.input.message.fetch_tables_failed', + `Failed to load tables: ${detail}`, + { detail }, + )); setContextTables([]); } } catch (error: any) { - message.error(error?.message || '读取上下文表失败'); + const detail = getErrorDetail(error); + message.error(translateMessage( + 'ai_chat.input.message.context_load_failed', + `Failed to load table context: ${detail}`, + { detail }, + )); } finally { setContextLoading(false); } - }, [activeContext, activeContextItems]); + }, [activeContext, activeContextItems, translateMessage]); const handleAppendContext = React.useCallback(async () => { if (!activeContext?.connectionId) { @@ -139,7 +188,13 @@ export const useAIChatContextBinding = ({ }); if (!schemaResult.success) { - message.error(`获取表 ${dbName}.${tableName} 结构失败: ${schemaResult.content}`); + const table = `${dbName}.${tableName}`; + const detail = getErrorDetail(schemaResult.content); + message.error(translateMessage( + 'ai_chat.input.message.fetch_table_schema_failed', + `Failed to load structure for ${table}: ${detail}`, + { table, detail }, + )); continue; } @@ -155,25 +210,45 @@ export const useAIChatContextBinding = ({ if (addedCount > 0 || removedCount > 0) { if (addedCount > 0 && removedCount === 0) { - message.success(`已添加 ${addedCount} 张表的结构到上下文`); + message.success(translateMessage( + 'ai_chat.input.message.context_added', + `Added ${addedCount} table structures to the context`, + { count: addedCount }, + )); } else if (removedCount > 0 && addedCount === 0) { - message.success(`已从上下文移除 ${removedCount} 张表的结构`); + message.success(translateMessage( + 'ai_chat.input.message.context_removed', + `Removed ${removedCount} table structures from the context`, + { count: removedCount }, + )); } else { - message.success(`上下文已同步更新:新增 ${addedCount},移除 ${removedCount}`); + message.success(translateMessage( + 'ai_chat.input.message.context_synced', + `Context synced: added ${addedCount}, removed ${removedCount}`, + { added: addedCount, removed: removedCount }, + )); } if (addedCount > 0) { setContextExpanded(true); } } else { - message.info('选中的表未发生变化'); + message.info(translateMessage( + 'ai_chat.input.message.selection_unchanged', + 'Selected tables did not change', + )); } setContextOpen(false); } catch (error: any) { - message.error(error?.message || '同步 AI 上下文失败'); + const detail = getErrorDetail(error); + message.error(translateMessage( + 'ai_chat.input.message.context_sync_failed', + `Failed to sync AI context: ${detail}`, + { detail }, + )); } finally { setAppendingContext(false); } - }, [activeContext, activeContextItems, addAIContext, connectionKey, removeAIContext, selectedTableKeys]); + }, [activeContext, activeContextItems, addAIContext, connectionKey, removeAIContext, selectedTableKeys, translateMessage]); const handleDbChange = React.useCallback((value: string) => { const connection = useStore.getState().connections.find((item) => item.id === activeContext?.connectionId); diff --git a/frontend/src/components/ai/useAIChatDraftAttachments.test.tsx b/frontend/src/components/ai/useAIChatDraftAttachments.test.tsx new file mode 100644 index 0000000..a19f322 --- /dev/null +++ b/frontend/src/components/ai/useAIChatDraftAttachments.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const createAttachmentMock = vi.hoisted(() => vi.fn()); +const messageApi = vi.hoisted(() => ({ + warning: vi.fn(), + error: vi.fn(), +})); + +vi.mock('antd', () => ({ + message: messageApi, +})); + +vi.mock('./aiChatAttachments', async () => { + const actual = await vi.importActual('./aiChatAttachments'); + return { + ...actual, + createAIChatAttachmentFromFile: (...args: any[]) => createAttachmentMock(...args), + }; +}); + +import { useAIChatDraftAttachments } from './useAIChatDraftAttachments'; + +const makeFile = (parts: BlobPart[], name: string, type: string): File => { + const blob = new Blob(parts, { type }); + return Object.assign(blob, { name, lastModified: 0 }) as File; +}; + +const translateAttachmentCopy = ( + key: string, + params?: Record, +): string => ({ + 'ai_chat.input.attachment.message.warning': `Warning for ${params?.name}: ${params?.message}`, + 'ai_chat.input.attachment.message.read_failed': `Failed to read attachment ${params?.name}: ${params?.detail}`, +}[key] || key); + +const flushAsyncWork = async () => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +const Probe = ({ + setDraftAttachments, +}: { + setDraftAttachments: React.Dispatch>; +}) => { + const { handleAttachmentUpload } = useAIChatDraftAttachments({ + setDraftAttachments, + translate: translateAttachmentCopy, + }); + return ; +}; + +describe('useAIChatDraftAttachments', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('shows a localized warning wrapper when attachment extraction returns a warning', async () => { + const setDraftAttachments = vi.fn(); + createAttachmentMock.mockResolvedValue({ + id: 'att-1', + name: 'budget.pdf', + mimeType: 'application/pdf', + size: 12, + kind: 'pdf', + extractWarning: 'No readable text was extracted from the PDF; if it is scanned or uses complex encoding, copy the body before sending.', + }); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + renderer!.root.findByType('input').props.onChange({ + target: { + files: [makeFile(['pdf'], 'budget.pdf', 'application/pdf')], + }, + }); + }); + await flushAsyncWork(); + + expect(setDraftAttachments).toHaveBeenCalledTimes(1); + expect(messageApi.warning).toHaveBeenCalledWith( + 'Warning for budget.pdf: No readable text was extracted from the PDF; if it is scanned or uses complex encoding, copy the body before sending.', + ); + }); + + it('shows a localized read failure error instead of leaking an unhandled attachment rejection', async () => { + const setDraftAttachments = vi.fn(); + createAttachmentMock.mockRejectedValue(new Error('file read failed')); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + + await act(async () => { + renderer!.root.findByType('input').props.onChange({ + target: { + files: [makeFile(['img'], 'screen.png', 'image/png')], + }, + }); + await Promise.resolve(); + await Promise.resolve(); + }); + await flushAsyncWork(); + + expect(setDraftAttachments).not.toHaveBeenCalled(); + expect(messageApi.error).toHaveBeenCalledWith('Failed to read attachment screen.png: file read failed'); + }); +}); diff --git a/frontend/src/components/ai/useAIChatDraftAttachments.ts b/frontend/src/components/ai/useAIChatDraftAttachments.ts index 120c9d7..9efd381 100644 --- a/frontend/src/components/ai/useAIChatDraftAttachments.ts +++ b/frontend/src/components/ai/useAIChatDraftAttachments.ts @@ -1,26 +1,59 @@ import React from 'react'; import { message } from 'antd'; import type { AIChatAttachment } from '../../types'; -import { createAIChatAttachmentFromFile } from './aiChatAttachments'; +import { createAIChatAttachmentFromFile, type AIChatAttachmentTranslator } from './aiChatAttachments'; interface UseAIChatDraftAttachmentsParams { setDraftAttachments: React.Dispatch>; + translate?: AIChatAttachmentTranslator; } export const useAIChatDraftAttachments = ({ setDraftAttachments, + translate, }: UseAIChatDraftAttachmentsParams) => { const fileInputRef = React.useRef(null); + const translateAttachmentMessage = React.useCallback(( + key: string, + fallback: string, + params?: Record, + ) => { + if (!translate) { + return fallback; + } + const translated = translate(key, params); + return translated && translated !== key ? translated : fallback; + }, [translate]); const appendDraftFiles = React.useCallback(async (files: File[]) => { for (const file of files) { - const attachment = await createAIChatAttachmentFromFile(file); - setDraftAttachments((prev) => [...prev, attachment]); - if (attachment.extractWarning) { - message.warning(`${attachment.name}: ${attachment.extractWarning}`); + try { + const attachment = await createAIChatAttachmentFromFile(file, translate); + setDraftAttachments((prev) => [...prev, attachment]); + if (attachment.extractWarning) { + message.warning(translateAttachmentMessage( + 'ai_chat.input.attachment.message.warning', + `${attachment.name}: ${attachment.extractWarning}`, + { + name: attachment.name, + message: attachment.extractWarning, + }, + )); + } + } catch (error: any) { + const detail = error?.message || String(error); + const name = file.name || 'unnamed'; + message.error(translateAttachmentMessage( + 'ai_chat.input.attachment.message.read_failed', + `Failed to read attachment ${name}: ${detail}`, + { + name, + detail, + }, + )); } } - }, [setDraftAttachments]); + }, [setDraftAttachments, translate, translateAttachmentMessage]); const handleAttachmentUpload = React.useCallback((event: React.ChangeEvent) => { const files = Array.from(event.target.files || []); diff --git a/frontend/src/components/ai/useAIChatLocalTools.test.tsx b/frontend/src/components/ai/useAIChatLocalTools.test.tsx index 627977b..e383dbe 100644 --- a/frontend/src/components/ai/useAIChatLocalTools.test.tsx +++ b/frontend/src/components/ai/useAIChatLocalTools.test.tsx @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import React, { useRef, useState } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, create, type ReactTestRenderer } from 'react-test-renderer'; @@ -6,6 +7,7 @@ import { useStore } from '../../store'; import type { AIToolCall } from '../../types'; import { useAIChatLocalTools } from './useAIChatLocalTools'; +const compressContextIfNeededMock = vi.hoisted(() => vi.fn<() => Promise>(async () => null)); const dispatchAIChatPayloadMock = vi.hoisted(() => vi.fn(async (_options: any) => 'stream')); const executeLocalAIToolCallMock = vi.hoisted(() => vi.fn(async ({ toolCall }: { toolCall: AIToolCall }) => ({ content: `result:${toolCall.function.name}`, @@ -18,6 +20,14 @@ vi.mock('./aiChatPayloadDispatch', () => ({ dispatchAIChatPayload: dispatchAIChatPayloadMock, })); +vi.mock('../../utils/aiChatRuntime', async () => { + const actual = await vi.importActual('../../utils/aiChatRuntime'); + return { + ...actual, + compressContextIfNeeded: compressContextIfNeededMock, + }; +}); + vi.mock('./aiLocalToolExecutor', () => ({ executeLocalAIToolCall: executeLocalAIToolCallMock, buildToolResultMessage: ({ id, timestamp, toolCall, execution }: any) => ({ @@ -32,6 +42,24 @@ vi.mock('./aiLocalToolExecutor', () => ({ })); const SESSION_ID = 'session-local-tools'; +const source = readFileSync(new URL('./useAIChatLocalTools.ts', import.meta.url), 'utf8'); +const panelSource = readFileSync(new URL('../AIChatPanel.tsx', import.meta.url), 'utf8'); +const translatedCopy: Record = { + 'ai_chat.panel.probe.max_rounds': 'T:max-rounds {{count}}', + 'ai_chat.panel.probe.consecutive_failed': 'T:probe-failed', + 'ai_chat.panel.status.summarizing_probe': 'T:summarizing-probe', + 'ai_chat.panel.status.returning_runtime_data': 'T:returning-runtime-data', + 'ai_chat.panel.status.deep_reasoning': 'T:deep-reasoning', + 'ai_chat.panel.status.waiting_instruction': 'T:waiting-instruction', + 'ai_chat.panel.status.analyzing_chain': 'T:analyzing-chain', + 'ai_chat.panel.status.memory_probe_summary': 'T:memory-summary {{summary}}', + 'ai_chat.panel.model_control.continue_after_summary': 'T:continue-after-summary', +}; + +const translate = ( + key: string, + params?: Record, +) => (translatedCopy[key] || key).replace(/\{\{(\w+)\}\}/g, (_match, name) => String(params?.[name] ?? '')); const buildToolCall = (name: string): AIToolCall => ({ id: `call-${name}`, @@ -74,6 +102,7 @@ const LocalToolsHarness = () => { pendingJVMDiagnosticPlanContextRef, setSending, skills: [], + translate, updateAIChatMessage: updateMessage, userPromptSettings: { global: '', @@ -87,8 +116,36 @@ const LocalToolsHarness = () => { }; describe('useAIChatLocalTools', () => { + it('threads the panel translator through the local-tool resend chain', () => { + expect(panelSource).toContain('translate: t,'); + expect(source).toContain('.map((message) => toAIRequestMessage(message, translate));'); + }); + + it('keeps local-tool status and guard copy behind panel i18n keys', () => { + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.probe\.max_rounds'/); + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.probe\.consecutive_failed'/); + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.status\.summarizing_probe'/); + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.status\.returning_runtime_data'/); + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.status\.deep_reasoning'/); + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.status\.waiting_instruction'/); + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.status\.analyzing_chain'/); + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.status\.memory_probe_summary'/); + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.model_control\.continue_after_summary'/); + expect(source).not.toContain('content: `⚠️ 工具调用已达'); + expect(source).not.toContain("content: '⚠️ 探针连续 3 轮执行失败"); + expect(source).not.toContain("content: '汇总探针执行结果中'"); + expect(source).not.toContain("safeUpdateTransition('向模型回传运行时数据')"); + expect(source).not.toContain("safeUpdateTransition('模型大脑深度推理中')"); + expect(source).not.toContain("safeUpdateTransition('等待下发操作指令')"); + expect(source).not.toContain("safeUpdateTransition('正在深度思考链路与逻辑')"); + expect(source).not.toContain('【自动记忆重塑】'); + expect(source).not.toContain('继续完成你先前未竟的分析或执行下一步'); + }); + beforeEach(() => { vi.useFakeTimers(); + compressContextIfNeededMock.mockReset(); + compressContextIfNeededMock.mockResolvedValue(null); dispatchAIChatPayloadMock.mockClear(); executeLocalAIToolCallMock.mockClear(); latestHook = undefined; @@ -179,13 +236,13 @@ describe('useAIChatLocalTools', () => { success: true, tool_name: 'inspect_active_tab', }); - expect(connecting).toMatchObject({ content: '汇总探针执行结果中', loading: true }); + expect(connecting).toMatchObject({ content: 'T:summarizing-probe', loading: true }); expect(dispatchAIChatPayloadMock).toHaveBeenCalledTimes(1); const dispatchArgs = dispatchAIChatPayloadMock.mock.calls[0][0] as any; expect(dispatchArgs.messages[0]).toEqual({ role: 'system', content: 'system-context' }); expect(JSON.stringify(dispatchArgs.messages)).toContain('result:inspect_active_tab'); - expect(JSON.stringify(dispatchArgs.messages)).not.toContain('汇总探针执行结果中'); + expect(JSON.stringify(dispatchArgs.messages)).not.toContain('T:summarizing-probe'); expect(dispatchArgs.tools).toHaveLength(1); await act(async () => { @@ -193,6 +250,47 @@ describe('useAIChatLocalTools', () => { }); }); + it('shows translated progress updates while waiting for the chained request to continue', async () => { + let renderer: ReactTestRenderer | undefined; + await act(async () => { + renderer = create(); + }); + + expect(latestHook).toBeDefined(); + const run = latestHook!.executeLocalTools([buildToolCall('inspect_active_tab')], 'assistant-1'); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + await run; + }); + + const findConnecting = () => + (useStore.getState().aiChatHistory[SESSION_ID] || []).find((message) => message.phase === 'connecting'); + + await act(async () => { + await vi.advanceTimersByTimeAsync(200); + }); + expect(findConnecting()).toMatchObject({ content: 'T:returning-runtime-data' }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(findConnecting()).toMatchObject({ content: 'T:deep-reasoning' }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(700); + }); + expect(findConnecting()).toMatchObject({ content: 'T:waiting-instruction' }); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1800); + }); + expect(findConnecting()).toMatchObject({ content: 'T:analyzing-chain' }); + + await act(async () => { + renderer?.unmount(); + }); + }); + it('does not auto-stop the probe after three recoverable SQL execution errors', async () => { executeLocalAIToolCallMock.mockResolvedValue({ content: "oceanbase: error 900 (42000): ORA-00900 near '50 OFFSET 0'", @@ -223,4 +321,111 @@ describe('useAIChatLocalTools', () => { renderer?.unmount(); }); }); + + it('shows the localized max-round warning after the tool-call cap is exceeded', async () => { + let renderer: ReactTestRenderer | undefined; + await act(async () => { + renderer = create(); + }); + + expect(latestHook).toBeDefined(); + for (let i = 0; i <= 15; i += 1) { + if (i > 0) { + updateMessage(SESSION_ID, 'assistant-1', { + loading: true, + phase: 'tool_calling', + }); + } + const run = latestHook!.executeLocalTools([buildToolCall('inspect_active_tab')], 'assistant-1'); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + await run; + }); + } + + const messages = useStore.getState().aiChatHistory[SESSION_ID] || []; + const assistant = messages.find((message) => message.id === 'assistant-1'); + const limitWarning = messages.find((message) => message.content === 'T:max-rounds 15'); + + expect(assistant).toMatchObject({ loading: false, phase: 'idle' }); + expect(limitWarning).toMatchObject({ role: 'assistant' }); + expect(dispatchAIChatPayloadMock).toHaveBeenCalledTimes(15); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it('replaces long probe history with localized summary prompts before resending', async () => { + compressContextIfNeededMock.mockResolvedValue('summary-body'); + + let renderer: ReactTestRenderer | undefined; + await act(async () => { + renderer = create(); + }); + + expect(latestHook).toBeDefined(); + const run = latestHook!.executeLocalTools([buildToolCall('inspect_active_tab')], 'assistant-1'); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + await run; + }); + + const messages = useStore.getState().aiChatHistory[SESSION_ID] || []; + expect(messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: 'assistant', content: 'T:memory-summary summary-body' }), + expect.objectContaining({ role: 'user', content: 'T:continue-after-summary' }), + ])); + + const dispatchArgs = dispatchAIChatPayloadMock.mock.calls[0][0] as any; + expect(dispatchArgs.messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ role: 'assistant', content: 'T:memory-summary summary-body' }), + expect.objectContaining({ role: 'user', content: 'T:continue-after-summary' }), + ])); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it('closes the current assistant message when three consecutive probe failures trigger the localized stop warning', async () => { + executeLocalAIToolCallMock.mockResolvedValue({ + content: 'dial tcp 127.0.0.1:3306: connect: connection refused', + success: false, + toolName: 'inspect_active_tab', + countsAsProbeFailure: true, + }); + + let renderer: ReactTestRenderer | undefined; + await act(async () => { + renderer = create(); + }); + + expect(latestHook).toBeDefined(); + for (let i = 0; i < 3; i += 1) { + if (i > 0) { + updateMessage(SESSION_ID, 'assistant-1', { + loading: true, + phase: 'tool_calling', + }); + } + const run = latestHook!.executeLocalTools([buildToolCall('inspect_active_tab')], 'assistant-1'); + await act(async () => { + await vi.advanceTimersByTimeAsync(150); + await run; + }); + } + + const messages = useStore.getState().aiChatHistory[SESSION_ID] || []; + const assistant = messages.find((message) => message.id === 'assistant-1'); + const stopWarning = messages.find((message) => message.content === 'T:probe-failed'); + + expect(assistant).toMatchObject({ loading: false, phase: 'idle' }); + expect(stopWarning).toMatchObject({ role: 'assistant' }); + expect(dispatchAIChatPayloadMock).toHaveBeenCalledTimes(2); + + await act(async () => { + renderer?.unmount(); + }); + }); }); diff --git a/frontend/src/components/ai/useAIChatLocalTools.ts b/frontend/src/components/ai/useAIChatLocalTools.ts index 8f2caf2..e11d86f 100644 --- a/frontend/src/components/ai/useAIChatLocalTools.ts +++ b/frontend/src/components/ai/useAIChatLocalTools.ts @@ -15,6 +15,7 @@ import { compressContextIfNeeded, getDynamicMaxContextChars } from '../../utils/ import { toAIRequestMessage } from '../../utils/aiMessagePayload'; import type { AIChatToolDefinition } from '../../utils/aiToolRegistry'; import { dispatchAIChatPayload } from './aiChatPayloadDispatch'; +import type { AIChatAttachmentTranslator } from './aiChatAttachments'; import { buildToolResultMessage, executeLocalAIToolCall, @@ -37,6 +38,7 @@ interface UseAIChatLocalToolsOptions { pendingJVMDiagnosticPlanContextRef: MutableRefObject; setSending: (sending: boolean) => void; skills: AISkillConfig[]; + translate?: AIChatAttachmentTranslator; updateAIChatMessage: ( sid: string, messageId: string, @@ -48,6 +50,17 @@ interface UseAIChatLocalToolsOptions { const MAX_TOOL_CALL_ROUNDS = 15; const SOFT_LIMIT_ROUNDS = 10; +const translatePanelCopy = ( + t: AIChatAttachmentTranslator | undefined, + key: string, + fallback: string, + params?: Record, +): string => { + if (!t) return fallback; + const translated = t(key, params); + return translated && translated !== key ? translated : fallback; +}; + export const useAIChatLocalTools = ({ sid, activeProviderModel, @@ -60,6 +73,7 @@ export const useAIChatLocalTools = ({ pendingJVMDiagnosticPlanContextRef, setSending, skills, + translate, updateAIChatMessage, userPromptSettings, }: UseAIChatLocalToolsOptions) => { @@ -87,7 +101,12 @@ export const useAIChatLocalTools = ({ useStore.getState().addAIChatMessage(sid, { id: nextMessageId(), role: 'assistant', - content: `⚠️ 工具调用已达 ${MAX_TOOL_CALL_ROUNDS} 轮上限,自动终止循环。如需继续探索,请发送新的消息。`, + content: translatePanelCopy( + translate, + 'ai_chat.panel.probe.max_rounds', + `⚠️ Tool calls reached the ${MAX_TOOL_CALL_ROUNDS} round limit and were stopped. Send a new message to continue exploring.`, + { count: MAX_TOOL_CALL_ROUNDS }, + ), timestamp: Date.now(), jvmPlanContext: inheritedJVMPlanContext, jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, @@ -120,6 +139,7 @@ export const useAIChatLocalTools = ({ skills, userPromptSettings, dynamicModels, + translate, }); executions.push(execution); const toolResultMsg: AIChatMessage = buildToolResultMessage({ @@ -140,10 +160,15 @@ export const useAIChatLocalTools = ({ } else { toolCallRoundRef.current += 1; if (toolCallRoundRef.current >= 3) { + updateAIChatMessage(sid, currentAsstMsgId, { loading: false, phase: 'idle' }); useStore.getState().addAIChatMessage(sid, { id: nextMessageId(), role: 'assistant', - content: '⚠️ 探针连续 3 轮执行失败,自动终止。请检查连接状态后重试。', + content: translatePanelCopy( + translate, + 'ai_chat.panel.probe.consecutive_failed', + '⚠️ Probes failed for 3 consecutive rounds and were stopped. Check the connection status and retry.', + ), timestamp: Date.now(), jvmPlanContext: inheritedJVMPlanContext, jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, @@ -160,7 +185,11 @@ export const useAIChatLocalTools = ({ id: nextMessageId(), role: 'assistant', phase: 'connecting', - content: '汇总探针执行结果中', + content: translatePanelCopy( + translate, + 'ai_chat.panel.status.summarizing_probe', + 'Summarizing probe results', + ), timestamp: Date.now(), loading: true, jvmPlanContext: inheritedJVMPlanContext, @@ -175,16 +204,32 @@ export const useAIChatLocalTools = ({ } }; - setTimeout(() => safeUpdateTransition('向模型回传运行时数据'), 200); - setTimeout(() => safeUpdateTransition('模型大脑深度推理中'), 500); - setTimeout(() => safeUpdateTransition('等待下发操作指令'), 1200); - setTimeout(() => safeUpdateTransition('正在深度思考链路与逻辑'), 3000); + setTimeout(() => safeUpdateTransition(translatePanelCopy( + translate, + 'ai_chat.panel.status.returning_runtime_data', + 'Returning runtime data to the model', + )), 200); + setTimeout(() => safeUpdateTransition(translatePanelCopy( + translate, + 'ai_chat.panel.status.deep_reasoning', + 'Model is reasoning deeply', + )), 500); + setTimeout(() => safeUpdateTransition(translatePanelCopy( + translate, + 'ai_chat.panel.status.waiting_instruction', + 'Waiting for operation instructions', + )), 1200); + setTimeout(() => safeUpdateTransition(translatePanelCopy( + translate, + 'ai_chat.panel.status.analyzing_chain', + 'Analyzing chain and logic deeply', + )), 3000); setSending(true); const currentHistory = useStore.getState().aiChatHistory[sid] || []; const messagesPayload = currentHistory .filter((message) => message.phase !== 'connecting') - .map(toAIRequestMessage); + .map((message) => toAIRequestMessage(message, translate)); const sysMessages = await buildSystemContextMessages( inheritedJVMPlanContext, inheritedJVMDiagnosticPlanContext, @@ -192,18 +237,27 @@ export const useAIChatLocalTools = ({ let finalMessagesPayload = messagesPayload; const dynamicMaxLimit = getDynamicMaxContextChars(activeProviderModel); - const summary = await compressContextIfNeeded(sid, messagesPayload, dynamicMaxLimit); + const summary = await compressContextIfNeeded(sid, messagesPayload, dynamicMaxLimit, translate); if (summary) { const compressedMsg: AIChatMessage = { id: nextMessageId(), role: 'assistant', - content: `【自动记忆重塑】已将超长历史探针数据和对话压缩为摘要:\n\n${summary}`, + content: translatePanelCopy( + translate, + 'ai_chat.panel.status.memory_probe_summary', + `[Automatic memory reshape] Long probe history and chat have been compressed into a summary:\n\n${summary}`, + { summary }, + ), timestamp: Date.now() - 1000, }; const continueMsg: AIChatMessage = { id: nextMessageId(), role: 'user', - content: '请根据上述最新状态与探索结果,继续完成你先前未竟的分析或执行下一步。', + content: translatePanelCopy( + translate, + 'ai_chat.panel.model_control.continue_after_summary', + 'Based on the latest status and exploration results above, continue the analysis you had not finished or perform the next step.', + ), timestamp: Date.now() - 500, }; useStore.getState().replaceAIChatHistory(sid, [compressedMsg, continueMsg, chainConnectingMsg]); @@ -227,6 +281,7 @@ export const useAIChatLocalTools = ({ pendingAssistantMessageId: chainConnectingMsg.id, jvmPlanContext: inheritedJVMPlanContext, jvmDiagnosticPlanContext: inheritedJVMDiagnosticPlanContext, + translate, }); } catch (error) { console.error('Failed to chain tool call', error); @@ -244,6 +299,7 @@ export const useAIChatLocalTools = ({ setSending, sid, skills, + translate, updateAIChatMessage, userPromptSettings, ]); diff --git a/frontend/src/components/ai/useAIChatRuntimeResources.test.tsx b/frontend/src/components/ai/useAIChatRuntimeResources.test.tsx new file mode 100644 index 0000000..d086162 --- /dev/null +++ b/frontend/src/components/ai/useAIChatRuntimeResources.test.tsx @@ -0,0 +1,116 @@ +import { readFileSync } from 'node:fs'; +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useAIChatRuntimeResources } from './useAIChatRuntimeResources'; + +const source = readFileSync(new URL('./useAIChatRuntimeResources.ts', import.meta.url), 'utf8'); +const runtimeService = vi.hoisted(() => ({ + AIListModels: vi.fn(), +})); +const windowStub = vi.hoisted(() => ({ + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + setTimeout, +})); + +let latestHook: ReturnType | undefined; + +const flushAsyncWork = async () => { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +}; + +const Harness = () => { + latestHook = useAIChatRuntimeResources({}); + return null; +}; + +describe('useAIChatRuntimeResources', () => { + let consoleWarnSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + latestHook = undefined; + consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + vi.stubGlobal('window', { + ...windowStub, + go: { + aiservice: { + Service: runtimeService, + }, + }, + }); + }); + + afterEach(() => { + consoleWarnSpy.mockRestore(); + vi.unstubAllGlobals(); + }); + + it('keeps the model-fetch failure path free of legacy Chinese wrapper copy', () => { + expect(source).not.toContain('获取模型列表失败:'); + expect(source).not.toContain("'未知错误'"); + }); + + it('uses English notice chrome for thrown model load failures while preserving raw detail', async () => { + runtimeService.AIListModels.mockRejectedValue(new Error('HTTP 401 raw error')); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushAsyncWork(); + + await act(async () => { + await latestHook!.fetchDynamicModels(); + }); + await flushAsyncWork(); + + expect(latestHook!.composerNotice).toEqual({ + tone: 'error', + title: 'Model list failed to load', + description: 'HTTP 401 raw error', + action: { + key: 'reload-models', + label: 'Reload models', + }, + }); + + await act(async () => { + renderer!.unmount(); + }); + }); + + it('uses the default English description when the thrown model load error has no message', async () => { + runtimeService.AIListModels.mockRejectedValue({}); + + let renderer: ReactTestRenderer; + await act(async () => { + renderer = create(); + }); + await flushAsyncWork(); + + await act(async () => { + await latestHook!.fetchDynamicModels(); + }); + await flushAsyncWork(); + + expect(latestHook!.composerNotice).toEqual({ + tone: 'error', + title: 'Model list failed to load', + description: 'Check the provider endpoint, API Key, or account permissions, then reopen the model dropdown.', + action: { + key: 'reload-models', + label: 'Reload models', + }, + }); + + await act(async () => { + renderer!.unmount(); + }); + }); +}); diff --git a/frontend/src/components/ai/useAIChatRuntimeResources.ts b/frontend/src/components/ai/useAIChatRuntimeResources.ts index 8103871..abf31f7 100644 --- a/frontend/src/components/ai/useAIChatRuntimeResources.ts +++ b/frontend/src/components/ai/useAIChatRuntimeResources.ts @@ -211,7 +211,7 @@ export const useAIChatRuntimeResources = ({ } catch (error: any) { console.warn('Failed to fetch models', error); setDynamicModels([]); - setComposerNotice(buildModelFetchFailedNotice(`获取模型列表失败:${error?.message || '未知错误'}`)); + setComposerNotice(buildModelFetchFailedNotice(error?.message)); } finally { setLoadingModels(false); } diff --git a/frontend/src/components/ai/useAIChatStreamSubscription.test.tsx b/frontend/src/components/ai/useAIChatStreamSubscription.test.tsx index a02a0e0..0c4cc3c 100644 --- a/frontend/src/components/ai/useAIChatStreamSubscription.test.tsx +++ b/frontend/src/components/ai/useAIChatStreamSubscription.test.tsx @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs'; import React, { useRef, useState } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { act, create, type ReactTestRenderer } from 'react-test-renderer'; @@ -5,6 +6,8 @@ import { act, create, type ReactTestRenderer } from 'react-test-renderer'; import { useStore } from '../../store'; import { useAIChatStreamSubscription } from './useAIChatStreamSubscription'; +const aiChatStreamMock = vi.hoisted(() => vi.fn(async (..._args: any[]) => undefined)); +const generateTitleForSessionMock = vi.hoisted(() => vi.fn(async () => undefined)); const runtimeMock = vi.hoisted(() => { const handlers = new Map void>(); return { @@ -24,6 +27,19 @@ vi.mock('../../../wailsjs/runtime', () => ({ })); const SESSION_ID = 'session-stream'; +const source = readFileSync(new URL('./useAIChatStreamSubscription.ts', import.meta.url), 'utf8'); +const panelSource = readFileSync(new URL('../AIChatPanel.tsx', import.meta.url), 'utf8'); +const translatedCopy: Record = { + 'ai_chat.panel.model_control.force_tool_call': 'T:force-tool-call', + 'ai_chat.panel.message.error': 'T:error {{detail}}', + 'ai_chat.panel.message.empty_response': 'T:empty-response', + 'ai_chat.panel.message.request_interrupted': 'T:request-interrupted', +}; + +const translate = ( + key: string, + params?: Record, +) => (translatedCopy[key] || key).replace(/\{\{(\w+)\}\}/g, (_match, name) => String(params?.[name] ?? '')); let nextId = 0; const emitStreamChunk = async (data: any) => { @@ -86,22 +102,50 @@ const StreamHarness = () => { updateAIChatMessage: patchMessage, buildSystemContextMessages: async () => [], executeLocalTools: async () => {}, - generateTitleForSession: async () => {}, + generateTitleForSession: generateTitleForSessionMock, nextMessageId: () => `assistant-created-${++nextId}`, nudgeCountRef, pendingJVMPlanContextRef, pendingJVMDiagnosticPlanContextRef, + translate, }); return null; }; describe('useAIChatStreamSubscription', () => { + it('threads the panel translator through the nudge resend chain', () => { + expect(panelSource).toContain('translate: t,'); + expect(source).toContain('const messagesPayload = currentHistory.map((message) => toAIRequestMessage(message, translate));'); + }); + + it('keeps stream error and empty-response copy behind panel i18n keys', () => { + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.message\.error'/); + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.message\.empty_response'/); + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.message\.request_interrupted'/); + expect(source).toMatch(/translatePanelCopy\(\s*translate,\s*'ai_chat\.panel\.model_control\.force_tool_call'/); + expect(source).not.toContain('content: `❌ 错误: ${cleanErr}`'); + expect(source).not.toContain("content: '❌ 模型未能成功响应任何内容"); + expect(source).not.toContain("content: '❌ 请求中断"); + expect(source).not.toContain('请直接使用 function call 调用工具执行操作,不要只用文字描述计划。'); + }); + beforeEach(() => { nextId = 0; + aiChatStreamMock.mockClear(); + generateTitleForSessionMock.mockClear(); runtimeMock.handlers.clear(); runtimeMock.EventsOn.mockClear(); runtimeMock.EventsOff.mockClear(); + vi.stubGlobal('window', { + go: { + aiservice: { + Service: { + AIChatStream: aiChatStreamMock, + }, + }, + }, + }); vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { callback(0); return 1; @@ -131,6 +175,7 @@ describe('useAIChatStreamSubscription', () => { }); afterEach(() => { + vi.useRealTimers(); vi.unstubAllGlobals(); useStore.setState({ aiChatHistory: {}, @@ -164,4 +209,165 @@ describe('useAIChatStreamSubscription', () => { renderer?.unmount(); }); }); + + it('resends a localized force-tool-call nudge when the model only describes the next action', async () => { + vi.useFakeTimers(); + let renderer: ReactTestRenderer | undefined; + + await act(async () => { + renderer = create(); + }); + + await emitStreamChunk({ content: '我先查询一下相关信息' }); + await emitStreamChunk({ done: true }); + await act(async () => { + await vi.advanceTimersByTimeAsync(60); + }); + + expect(aiChatStreamMock).toHaveBeenCalledTimes(1); + const resentMessages = (aiChatStreamMock.mock.calls[0]?.[1] ?? []) as Array<{ role: string; content: string }>; + expect(resentMessages[resentMessages.length - 1]).toEqual({ role: 'user', content: 'T:force-tool-call' }); + expect(generateTitleForSessionMock).not.toHaveBeenCalled(); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it('resends a localized force-tool-call nudge when the model describes the next action in English', async () => { + vi.useFakeTimers(); + let renderer: ReactTestRenderer | undefined; + + await act(async () => { + renderer = create(); + }); + + await emitStreamChunk({ content: "I'll check the relevant information first" }); + await emitStreamChunk({ done: true }); + await act(async () => { + await vi.advanceTimersByTimeAsync(60); + }); + + expect(aiChatStreamMock).toHaveBeenCalledTimes(1); + const resentMessages = (aiChatStreamMock.mock.calls[0]?.[1] ?? []) as Array<{ role: string; content: string }>; + expect(resentMessages[resentMessages.length - 1]).toEqual({ role: 'user', content: 'T:force-tool-call' }); + expect(generateTitleForSessionMock).not.toHaveBeenCalled(); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it('resends a localized force-tool-call nudge when the model describes the next action in Traditional Chinese', async () => { + vi.useFakeTimers(); + let renderer: ReactTestRenderer | undefined; + + await act(async () => { + renderer = create(); + }); + + await emitStreamChunk({ content: '我先查詢一下相關資料' }); + await emitStreamChunk({ done: true }); + await act(async () => { + await vi.advanceTimersByTimeAsync(60); + }); + + expect(aiChatStreamMock).toHaveBeenCalledTimes(1); + const resentMessages = (aiChatStreamMock.mock.calls[0]?.[1] ?? []) as Array<{ role: string; content: string }>; + expect(resentMessages[resentMessages.length - 1]).toEqual({ role: 'user', content: 'T:force-tool-call' }); + expect(generateTitleForSessionMock).not.toHaveBeenCalled(); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it('localizes stream error copy while preserving the sanitized raw detail', async () => { + let renderer: ReactTestRenderer | undefined; + + await act(async () => { + renderer = create(); + }); + + await emitStreamChunk({ error: 'rpc failure' }); + + const messages = useStore.getState().aiChatHistory[SESSION_ID] || []; + const assistant = messages.find((message) => message.id === 'assistant-connecting'); + expect(assistant).toMatchObject({ + content: 'T:error rpc failure', + phase: 'idle', + loading: false, + }); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it('localizes the empty-response fallback when the stream completes without content', async () => { + vi.useFakeTimers(); + let renderer: ReactTestRenderer | undefined; + + await act(async () => { + renderer = create(); + }); + + await emitStreamChunk({ done: true }); + await act(async () => { + await vi.advanceTimersByTimeAsync(60); + }); + + const messages = useStore.getState().aiChatHistory[SESSION_ID] || []; + const assistant = messages.find((message) => message.id === 'assistant-connecting'); + expect(assistant).toMatchObject({ + content: 'T:empty-response', + phase: 'idle', + loading: false, + }); + + await act(async () => { + renderer?.unmount(); + }); + }); + + it('localizes the interrupted-request fallback when no assistant message was created', async () => { + vi.useFakeTimers(); + useStore.setState({ + aiChatHistory: { + [SESSION_ID]: [ + { + id: 'user-1', + role: 'user', + content: 'hello', + timestamp: 1, + }, + ], + }, + aiChatSessions: [{ id: SESSION_ID, title: 'hello', updatedAt: 1 }], + aiActiveSessionId: SESSION_ID, + }); + + let renderer: ReactTestRenderer | undefined; + await act(async () => { + renderer = create(); + }); + + await emitStreamChunk({ done: true }); + await act(async () => { + await vi.advanceTimersByTimeAsync(60); + }); + + const messages = useStore.getState().aiChatHistory[SESSION_ID] || []; + expect(messages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + role: 'assistant', + content: 'T:request-interrupted', + loading: false, + }), + ])); + + await act(async () => { + renderer?.unmount(); + }); + }); }); diff --git a/frontend/src/components/ai/useAIChatStreamSubscription.ts b/frontend/src/components/ai/useAIChatStreamSubscription.ts index 5c5d90c..b9d8d02 100644 --- a/frontend/src/components/ai/useAIChatStreamSubscription.ts +++ b/frontend/src/components/ai/useAIChatStreamSubscription.ts @@ -12,6 +12,7 @@ import type { import { sanitizeErrorMsg } from '../../utils/aiChatRuntime'; import { toAIRequestMessage } from '../../utils/aiMessagePayload'; import type { AIChatToolDefinition } from '../../utils/aiToolRegistry'; +import type { AIChatAttachmentTranslator } from './aiChatAttachments'; interface AIChatStreamChunk { content?: string; @@ -43,6 +44,7 @@ interface UseAIChatStreamSubscriptionOptions { nudgeCountRef: MutableRefObject; pendingJVMPlanContextRef: MutableRefObject; pendingJVMDiagnosticPlanContextRef: MutableRefObject; + translate?: AIChatAttachmentTranslator; } interface AIChatStreamState { @@ -74,6 +76,29 @@ const resetAIChatStreamProgress = (state: AIChatStreamState) => { state.flushPending = false; }; +const translatePanelCopy = ( + t: AIChatAttachmentTranslator | undefined, + key: string, + fallback: string, + params?: Record, +): string => { + if (!t) return fallback; + const translated = t(key, params); + return translated && translated !== key ? translated : fallback; +}; + +const FORCE_TOOL_CALL_NUDGE_PATTERNS = [ + /(?:let me|i(?:'|’)ll|i will|first|next|now).*(?:query|search|find|fetch|get|check|inspect|review|look up|call)/i, + /(?:query|search|find|fetch|get|check|inspect|review|look up).*(?:info(?:rmation)?|field|fields|column|columns|list|data)\s*[::]?\s*$/i, + /(?:\u8ba9\u6211|\u6211\u5148|\u6211\u6765|\u73b0\u5728|\u63a5\u4e0b\u6765|\u4e0b\u9762).*(?:\u67e5\u8be2|\u67e5\u627e|\u83b7\u53d6|\u67e5\u770b|\u68c0\u67e5|\u8c03\u7528)|(?:\u83b7\u53d6|\u67e5\u8be2|\u67e5\u627e|\u67e5\u770b).*(?:\u4fe1\u606f|\u5b57\u6bb5|\u5217\u8868|\u6570\u636e)[::]?\s*$/u, + /(?:\u8b93\u6211|\u6211\u5148|\u6211\u4f86|\u73fe\u5728|\u63a5\u4e0b\u4f86|\u4e0b\u9762).*(?:\u67e5\u8a62|\u67e5\u627e|\u7372\u53d6|\u67e5\u770b|\u6aa2\u67e5|\u8abf\u7528)|(?:\u7372\u53d6|\u67e5\u8a62|\u67e5\u627e|\u67e5\u770b).*(?:\u8cc7\u8a0a|\u5b57\u6bb5|\u6b04\u4f4d|\u5217\u8868|\u8cc7\u6599)[::]?\s*$/u, +]; + +const shouldResendForceToolCallNudge = (content: string): boolean => { + const text = String(content || '').trim(); + return text !== '' && FORCE_TOOL_CALL_NUDGE_PATTERNS.some((pattern) => pattern.test(text)); +}; + export const useAIChatStreamSubscription = ({ sid, sending, @@ -88,6 +113,7 @@ export const useAIChatStreamSubscription = ({ nudgeCountRef, pendingJVMPlanContextRef, pendingJVMDiagnosticPlanContextRef, + translate, }: UseAIChatStreamSubscriptionOptions) => { const sendingRef = useRef(sending); const streamStateRef = useRef(createAIChatStreamState(sid)); @@ -145,11 +171,16 @@ export const useAIChatStreamSubscription = ({ } if (data.error) { - const cleanErr = sanitizeErrorMsg(data.error); + const cleanErr = sanitizeErrorMsg(data.error, translate); const rawErr = cleanErr !== data.error ? data.error : undefined; if (streamState.assistantMsgId) { updateAIChatMessage(sid, streamState.assistantMsgId, { - content: `❌ 错误: ${cleanErr}`, + content: translatePanelCopy( + translate, + 'ai_chat.panel.message.error', + `❌ Error: ${cleanErr}`, + { detail: cleanErr }, + ), phase: 'idle', loading: false, rawError: rawErr, @@ -159,7 +190,12 @@ export const useAIChatStreamSubscription = ({ id: nextMessageId(), role: 'assistant', phase: 'idle', - content: `❌ 错误: ${cleanErr}`, + content: translatePanelCopy( + translate, + 'ai_chat.panel.message.error', + `❌ Error: ${cleanErr}`, + { detail: cleanErr }, + ), rawError: rawErr, timestamp: Date.now(), jvmPlanContext: pendingJVMPlanContextRef.current, @@ -272,21 +308,25 @@ export const useAIChatStreamSubscription = ({ if ( existing && nudgeCountRef.current < 2 && - /(?:让我|我先|我来|现在|接下来|下面).*(?:查询|查找|获取|查看|检查|调用)|(?:获取|查询|查找|查看).*(?:信息|字段|列表|数据)[::]?\s*$/.test(existing.content || '') + shouldResendForceToolCallNudge(existing.content || '') ) { nudgeCountRef.current += 1; updateAIChatMessage(sid, doneAssistantId, { loading: false, phase: 'idle' }); (async () => { try { const currentHistory = useStore.getState().aiChatHistory[sid] || []; - const messagesPayload = currentHistory.map(toAIRequestMessage); + const messagesPayload = currentHistory.map((message) => toAIRequestMessage(message, translate)); const sysMessages = await buildSystemContextMessages( existing.jvmPlanContext, existing.jvmDiagnosticPlanContext, ); messagesPayload.push({ role: 'user', - content: '请直接使用 function call 调用工具执行操作,不要只用文字描述计划。', + content: translatePanelCopy( + translate, + 'ai_chat.panel.model_control.force_tool_call', + 'Use a function call directly to invoke the tool and perform the operation; do not only describe the plan in text.', + ), }); const allMsg = [...sysMessages, ...messagesPayload]; const service = (window as any).go?.aiservice?.Service; @@ -309,7 +349,11 @@ export const useAIChatStreamSubscription = ({ if (!hasContent && !hasThinking && !hasTools) { updateAIChatMessage(sid, doneAssistantId, { - content: '❌ 模型未能成功响应任何内容,可能遭遇频控、上下文超载或理解拒绝。', + content: translatePanelCopy( + translate, + 'ai_chat.panel.message.empty_response', + '❌ The model did not return any content. It may have hit rate limits, context overload, or a refusal.', + ), loading: false, phase: 'idle', }); @@ -320,7 +364,11 @@ export const useAIChatStreamSubscription = ({ addAIChatMessage(sid, { id: nextMessageId(), role: 'assistant', - content: '❌ 请求中断:未收到任何具体回复。', + content: translatePanelCopy( + translate, + 'ai_chat.panel.message.request_interrupted', + '❌ Request interrupted: no concrete reply was received.', + ), timestamp: Date.now(), loading: false, }); @@ -346,6 +394,7 @@ export const useAIChatStreamSubscription = ({ pendingJVMPlanContextRef, setSending, sid, + translate, updateAIChatMessage, ]); }; diff --git a/frontend/src/components/ai/useAIMCPClientInstaller.ts b/frontend/src/components/ai/useAIMCPClientInstaller.ts index 1a7b057..c4cd6eb 100644 --- a/frontend/src/components/ai/useAIMCPClientInstaller.ts +++ b/frontend/src/components/ai/useAIMCPClientInstaller.ts @@ -11,11 +11,14 @@ import { pickPreferredMCPClient, type MCPClientKey, } from '../../utils/mcpClientInstallStatus'; +import { + translateMCPClientInstallCopy, + type MCPClientInstallTranslator, +} from './mcpClientInstallPanelState'; interface MCPClientInstallResult { success?: boolean; client?: string; - message?: string; configPath?: string; command?: string; args?: string[]; @@ -40,6 +43,7 @@ interface UseAIMCPClientInstallerOptions { onBeforeInstall?: () => void; onConfigChanged?: () => void; resolveAIService: () => Promise; + translate?: MCPClientInstallTranslator; } export const useAIMCPClientInstaller = ({ @@ -49,6 +53,7 @@ export const useAIMCPClientInstaller = ({ onBeforeInstall, onConfigChanged, resolveAIService, + translate, }: UseAIMCPClientInstallerOptions) => { const [mcpClientStatuses, setMCPClientStatuses] = useState(EMPTY_MCP_CLIENT_STATUSES); const [selectedMCPClient, setSelectedMCPClient] = useState('claude-code'); @@ -65,6 +70,11 @@ export const useAIMCPClientInstaller = ({ : formatMCPLaunchCommand(selectedMCPClientStatus), [selectedMCPClientStatus], ); + const copy = useCallback(( + key: string, + fallback: string, + params?: Record, + ) => translateMCPClientInstallCopy(translate, key, fallback, params), [translate]); const syncMCPClientStatuses = useCallback((items?: AIMCPClientInstallStatus[]) => { const normalizedStatuses = normalizeMCPClientStatuses(items); @@ -99,14 +109,14 @@ export const useAIMCPClientInstaller = ({ if (silent) { console.warn('[AI] refresh mcp client statuses failed', error); } else { - void messageApi.error(error?.message || '刷新客户端安装状态失败'); + void messageApi.error(error?.message || copy('ai_chat.mcp_client.install.message.refresh_failed', 'Failed to refresh client installation status')); } } finally { if (!silent) { setMCPClientStatusLoading(false); } } - }, [messageApi, resolveAIService, syncMCPClientStatuses]); + }, [copy, messageApi, resolveAIService, syncMCPClientStatuses]); const handleInstallSelectedMCPClient = useCallback(async () => { const remoteClient = isRemoteMCPClientStatus(selectedMCPClientStatus); @@ -118,69 +128,68 @@ export const useAIMCPClientInstaller = ({ setMCPClientSelectionTouched(true); await copyTextToClipboard( buildRemoteMCPClientGuide(selectedMCPClientStatus), - `${targetLabel} 远程接入说明已复制`, + copy('ai_chat.mcp_client.install.message.remote_guide_copied', '{{label}} remote connection guide copied', { label: targetLabel }), ); } catch (error: any) { - void messageApi.error(error?.message || `复制 ${targetLabel} 远程接入说明失败`); + void messageApi.error(error?.message || copy('ai_chat.mcp_client.install.message.remote_guide_copy_failed', 'Failed to copy {{label}} remote connection guide', { label: targetLabel })); } finally { onAfterInstall?.(); } return; } if (selectedMCPClientStatus?.matchesCurrent) { - void messageApi.success(`${targetLabel} 已接入当前 GoNavi MCP,无需重复写入`); + void messageApi.success(copy('ai_chat.mcp_client.install.message.already_connected', '{{label}} is already connected to current GoNavi MCP. No repeated write is needed.', { label: targetLabel })); return; } try { onBeforeInstall?.(); setMCPClientSelectionTouched(true); const service = await resolveAIService(); - let result: MCPClientInstallResult; if (targetClient === 'codex') { if (typeof service?.AIInstallCodexMCP !== 'function') { - throw new Error('当前版本暂不支持自动安装 Codex MCP'); + throw new Error(copy('ai_chat.mcp_client.install.message.codex_not_supported', 'This version does not support automatic Codex MCP installation yet')); } - result = await service.AIInstallCodexMCP(); + await service.AIInstallCodexMCP(); } else { if (typeof service?.AIInstallClaudeCodeMCP !== 'function') { - throw new Error('当前版本暂不支持自动安装 Claude Code MCP'); + throw new Error(copy('ai_chat.mcp_client.install.message.claude_not_supported', 'This version does not support automatic Claude Code MCP installation yet')); } - result = await service.AIInstallClaudeCodeMCP(); + await service.AIInstallClaudeCodeMCP(); } await loadMCPClientStatuses({ silent: true }); onConfigChanged?.(); - void messageApi.success(result?.message || `已写入 ${targetLabel} 用户级 MCP 配置`); + void messageApi.success(copy('ai_chat.mcp_client.install.message.install_success', 'Wrote {{label}} user-level MCP config', { label: targetLabel })); } catch (error: any) { - void messageApi.error(error?.message || `安装 ${targetLabel} MCP 失败`); + void messageApi.error(error?.message || copy('ai_chat.mcp_client.install.message.install_failed', 'Failed to install {{label}} MCP', { label: targetLabel })); } finally { onAfterInstall?.(); } - }, [copyTextToClipboard, loadMCPClientStatuses, messageApi, onAfterInstall, onBeforeInstall, onConfigChanged, resolveAIService, selectedMCPClientStatus]); + }, [copy, copyTextToClipboard, loadMCPClientStatuses, messageApi, onAfterInstall, onBeforeInstall, onConfigChanged, resolveAIService, selectedMCPClientStatus]); const handleCopySelectedMCPConfigPath = useCallback(async () => { const configPath = String(selectedMCPClientStatus?.configPath || '').trim(); if (!configPath) { - void messageApi.warning('当前没有可复制的配置文件路径'); + void messageApi.warning(copy('ai_chat.mcp_client.install.message.config_path_missing', 'No config file path is available to copy')); return; } try { - await copyTextToClipboard(configPath, '配置文件路径已复制'); + await copyTextToClipboard(configPath, copy('ai_chat.mcp_client.install.message.config_path_copied', 'Config file path copied')); } catch (error: any) { - void messageApi.error(error?.message || '复制配置文件路径失败'); + void messageApi.error(error?.message || copy('ai_chat.mcp_client.install.message.config_path_copy_failed', 'Failed to copy config file path')); } - }, [copyTextToClipboard, messageApi, selectedMCPClientStatus]); + }, [copy, copyTextToClipboard, messageApi, selectedMCPClientStatus]); const handleCopySelectedMCPLaunchCommand = useCallback(async () => { if (!selectedMCPClientCommandText) { - void messageApi.warning('当前没有可复制的启动命令'); + void messageApi.warning(copy('ai_chat.mcp_client.install.message.launch_command_missing', 'No launch command is available to copy')); return; } try { - await copyTextToClipboard(selectedMCPClientCommandText, '启动命令已复制'); + await copyTextToClipboard(selectedMCPClientCommandText, copy('ai_chat.mcp_client.install.message.launch_command_copied', 'Launch command copied')); } catch (error: any) { - void messageApi.error(error?.message || '复制启动命令失败'); + void messageApi.error(error?.message || copy('ai_chat.mcp_client.install.message.launch_command_copy_failed', 'Failed to copy launch command')); } - }, [copyTextToClipboard, messageApi, selectedMCPClientCommandText]); + }, [copy, copyTextToClipboard, messageApi, selectedMCPClientCommandText]); return { handleCopySelectedMCPConfigPath, diff --git a/frontend/src/components/ai/useAISlashCommandMenu.ts b/frontend/src/components/ai/useAISlashCommandMenu.ts index a947131..800558d 100644 --- a/frontend/src/components/ai/useAISlashCommandMenu.ts +++ b/frontend/src/components/ai/useAISlashCommandMenu.ts @@ -1,22 +1,24 @@ import React from 'react'; -import { filterAISlashCommands, type AISlashCommandDefinition } from './aiSlashCommands'; +import { filterAISlashCommands, type AISlashCommandDefinition, type AISlashCommandTranslate } from './aiSlashCommands'; interface UseAISlashCommandMenuParams { setInput: (val: string) => void; textareaRef: React.RefObject; + translate?: AISlashCommandTranslate; } export const useAISlashCommandMenu = ({ setInput, textareaRef, + translate, }: UseAISlashCommandMenuParams) => { const [showSlashMenu, setShowSlashMenu] = React.useState(false); const [slashFilter, setSlashFilter] = React.useState(''); const filteredSlashCmds = React.useMemo( - () => filterAISlashCommands(slashFilter), - [slashFilter], + () => filterAISlashCommands(slashFilter, translate), + [slashFilter, translate], ); const handleComposerInputChange = React.useCallback((value: string) => { diff --git a/frontend/src/components/jvm/JVMCommandPresetBar.tsx b/frontend/src/components/jvm/JVMCommandPresetBar.tsx index 3a79388..8bce975 100644 --- a/frontend/src/components/jvm/JVMCommandPresetBar.tsx +++ b/frontend/src/components/jvm/JVMCommandPresetBar.tsx @@ -7,6 +7,7 @@ import { resolveJVMDiagnosticRiskColor, type JVMDiagnosticCommandPreset, } from "../../utils/jvmDiagnosticPresentation"; +import { useI18n } from "../../i18n/provider"; const { Text } = Typography; @@ -16,52 +17,56 @@ type JVMCommandPresetBarProps = { const JVMCommandPresetBar: React.FC = ({ onSelectPreset, -}) => ( -
- {groupJVMDiagnosticPresets().map((group) => ( - - {group.items.map((preset) => ( -
- - - - {formatJVMDiagnosticRiskLabel(preset.riskLevel)} - - - {preset.description} - - {preset.command} - -
- ))} -
- ))} -
-); +}) => { + const { t } = useI18n(); + + return ( +
+ {groupJVMDiagnosticPresets(undefined, t).map((group) => ( + + {group.items.map((preset) => ( +
+ + + + {formatJVMDiagnosticRiskLabel(preset.riskLevel, t)} + + + {preset.description} + + {preset.command} + +
+ ))} +
+ ))} +
+ ); +}; export default JVMCommandPresetBar; diff --git a/frontend/src/components/jvm/JVMDiagnosticHistory.tsx b/frontend/src/components/jvm/JVMDiagnosticHistory.tsx index ebf0d6d..866a9d2 100644 --- a/frontend/src/components/jvm/JVMDiagnosticHistory.tsx +++ b/frontend/src/components/jvm/JVMDiagnosticHistory.tsx @@ -12,6 +12,7 @@ import { formatJVMDiagnosticPhaseLabel, formatJVMDiagnosticTransportLabel, } from "../../utils/jvmDiagnosticPresentation"; +import { useI18n } from "../../i18n/provider"; const { Text } = Typography; type JVMDiagnosticHistoryProps = { @@ -26,71 +27,75 @@ const JVMDiagnosticHistory: React.FC = ({ records = [], showSession = true, maxHeight = 360, -}) => ( -
- {showSession ? ( -
- 当前会话 - {session ? ( -
- {session.sessionId} - {formatJVMDiagnosticTransportLabel(session.transport)} +}) => { + const { t } = useI18n(); + + return ( +
+ {showSession ? ( +
+ {t("jvm_diagnostic.history.current_session")} + {session ? ( +
+ {session.sessionId} + {formatJVMDiagnosticTransportLabel(session.transport, t)} +
+ ) : ( + + )} +
+ ) : null} + +
+ {t("jvm_diagnostic.history.recent_records")} + {records.length ? ( +
+ ( + +
+ + {record.command} + +
+ {record.status ? ( + {formatJVMDiagnosticPhaseLabel(record.status, t)} + ) : null} + {record.riskLevel ? ( + {formatJVMDiagnosticRiskLabel(record.riskLevel, t)} + ) : null} + {record.commandType ? ( + {formatJVMDiagnosticCommandTypeLabel(record.commandType, t)} + ) : null} + {record.source ? {formatJVMDiagnosticSourceLabel(record.source, t)} : null} +
+ + {record.reason || t("jvm_diagnostic.history.reason_missing")} + +
+
+ )} + />
) : ( - + )}
- ) : null} - -
- 最近记录 - {records.length ? ( -
- ( - -
- - {record.command} - -
- {record.status ? ( - {formatJVMDiagnosticPhaseLabel(record.status)} - ) : null} - {record.riskLevel ? ( - {formatJVMDiagnosticRiskLabel(record.riskLevel)} - ) : null} - {record.commandType ? ( - {formatJVMDiagnosticCommandTypeLabel(record.commandType)} - ) : null} - {record.source ? {formatJVMDiagnosticSourceLabel(record.source)} : null} -
- - {record.reason || "未填写诊断原因"} - -
-
- )} - /> -
- ) : ( - - )}
-
-); + ); +}; export default JVMDiagnosticHistory; diff --git a/frontend/src/components/jvm/JVMDiagnosticHistoryOutput.i18n.test.tsx b/frontend/src/components/jvm/JVMDiagnosticHistoryOutput.i18n.test.tsx new file mode 100644 index 0000000..8530057 --- /dev/null +++ b/frontend/src/components/jvm/JVMDiagnosticHistoryOutput.i18n.test.tsx @@ -0,0 +1,113 @@ +import React from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import JVMDiagnosticHistory from "./JVMDiagnosticHistory"; +import JVMDiagnosticOutput from "./JVMDiagnosticOutput"; +import JVMCommandPresetBar from "./JVMCommandPresetBar"; +import { t as catalogTranslate } from "../../i18n/catalog"; + +vi.mock("../../i18n/provider", () => ({ + useI18n: () => ({ + t: (key: string, params?: Record) => + catalogTranslate("en-US", key, params), + }), +})); + +describe("JVMDiagnosticHistory and JVMDiagnosticOutput i18n", () => { + it("renders history and output chrome with the active language", () => { + const historyMarkup = renderToStaticMarkup( + , + ); + + expect(historyMarkup).toContain("Current session"); + expect(historyMarkup).toContain("No diagnostic session yet"); + expect(historyMarkup).toContain("Recent records"); + expect(historyMarkup).toContain("Running"); + expect(historyMarkup).toContain("High risk"); + expect(historyMarkup).toContain("Trace"); + expect(historyMarkup).toContain("AI plan"); + expect(historyMarkup).toContain("No diagnostic reason provided"); + expect(historyMarkup).not.toContain("当前会话"); + expect(historyMarkup).not.toContain("尚未建立诊断会话"); + expect(historyMarkup).not.toContain("最近记录"); + expect(historyMarkup).not.toContain("执行中"); + expect(historyMarkup).not.toContain("高风险"); + expect(historyMarkup).not.toContain("未填写诊断原因"); + + const emptyHistoryMarkup = renderToStaticMarkup( + , + ); + expect(emptyHistoryMarkup).toContain("No diagnostic history"); + expect(emptyHistoryMarkup).not.toContain("尚无诊断历史"); + + const outputMarkup = renderToStaticMarkup(); + expect(outputMarkup).toContain("No live output yet."); + expect(outputMarkup).not.toContain("暂无实时输出"); + + const presetMarkup = renderToStaticMarkup( + , + ); + expect(presetMarkup).toContain("Observation commands"); + expect(presetMarkup).toContain("View the busiest threads"); + expect(presetMarkup).not.toContain("观察类命令"); + expect(presetMarkup).not.toContain("查看最繁忙线程"); + }); + + it("keeps raw diagnostic command and chunk content unchanged", () => { + const historyMarkup = renderToStaticMarkup( + , + ); + + expect(historyMarkup).toContain("thread -n 5"); + expect(historyMarkup).toContain("用户输入的诊断原因"); + + const outputMarkup = renderToStaticMarkup( + , + ); + + expect(outputMarkup).toContain("后端原始输出"); + expect(outputMarkup).toContain("Running"); + expect(outputMarkup).toContain("Execution finished"); + }); +}); diff --git a/frontend/src/components/jvm/JVMDiagnosticOutput.tsx b/frontend/src/components/jvm/JVMDiagnosticOutput.tsx index 2fd046b..a0f812e 100644 --- a/frontend/src/components/jvm/JVMDiagnosticOutput.tsx +++ b/frontend/src/components/jvm/JVMDiagnosticOutput.tsx @@ -7,6 +7,7 @@ import { formatJVMDiagnosticEventLabel, formatJVMDiagnosticPhaseLabel, } from "../../utils/jvmDiagnosticPresentation"; +import { useI18n } from "../../i18n/provider"; const { Text } = Typography; type JVMDiagnosticOutputProps = { @@ -18,16 +19,18 @@ const JVMDiagnosticOutput: React.FC = ({ chunks, maxHeight = 420, }) => { + const { t } = useI18n(); + if (!chunks.length) { return ( ); } - const chunkTexts = formatJVMDiagnosticChunksForDisplay(chunks); + const chunkTexts = formatJVMDiagnosticChunksForDisplay(chunks, t); return (
@@ -50,9 +53,9 @@ const JVMDiagnosticOutput: React.FC = ({
{chunk.phase ? ( - {formatJVMDiagnosticPhaseLabel(chunk.phase)} + {formatJVMDiagnosticPhaseLabel(chunk.phase, t)} ) : null} - {chunk.event ? {formatJVMDiagnosticEventLabel(chunk.event)} : null} + {chunk.event ? {formatJVMDiagnosticEventLabel(chunk.event, t)} : null} {chunk.commandId ? {chunk.commandId} : null}
diff --git a/frontend/src/components/sidebarCoreUtils.ts b/frontend/src/components/sidebarCoreUtils.ts index ba8fd9d..36dd729 100644 --- a/frontend/src/components/sidebarCoreUtils.ts +++ b/frontend/src/components/sidebarCoreUtils.ts @@ -1,5 +1,6 @@ import type React from 'react'; import type { SavedConnection } from '../types'; +import { t as catalogTranslate } from '../i18n/catalog'; import { isPostgresSchemaDialect as resolveIsPostgresSchemaDialect, normalizeDriverType as normalizeConnectionDriverType, @@ -13,6 +14,10 @@ export const SIDEBAR_CONTEXT_MENU_FALLBACK_HEIGHT = 420; export type ExternalSQLFileModalMode = 'create' | 'rename' | 'create-directory' | 'rename-directory'; export type SearchScope = 'smart' | 'object' | 'database' | 'host' | 'tag'; +type SidebarCoreTranslate = (key: string) => string; + +const translateSidebarCoreZhCN: SidebarCoreTranslate = (key) => catalogTranslate('zh-CN', key); + type SidebarObjectNodeLike = { title?: string; type?: string; @@ -88,14 +93,18 @@ export const normalizeDriverType = normalizeConnectionDriverType; export const resolveSavedConnectionDriverType = resolveSavedConnectionDriverTypeBase; export const isPostgresSchemaDialect = resolveIsPostgresSchemaDialect; -export const SEARCH_SCOPE_OPTIONS: Array<{ value: SearchScope; label: string }> = [ - { value: 'smart', label: '智能' }, - { value: 'object', label: '表对象' }, - { value: 'database', label: '库' }, - { value: 'host', label: 'Host' }, - { value: 'tag', label: '标签' }, +export const buildSearchScopeOptions = ( + translate: SidebarCoreTranslate = translateSidebarCoreZhCN, +): Array<{ value: SearchScope; label: string }> => [ + { value: 'smart', label: translate('sidebar.search.scope.smart') }, + { value: 'object', label: translate('sidebar.search.scope.object') }, + { value: 'database', label: translate('sidebar.search.scope.database') }, + { value: 'host', label: translate('sidebar.search.scope.host') }, + { value: 'tag', label: translate('sidebar.search.scope.tag') }, ]; +export const SEARCH_SCOPE_OPTIONS: Array<{ value: SearchScope; label: string }> = buildSearchScopeOptions(); + export const SEARCH_SCOPE_LABEL_MAP: Record = SEARCH_SCOPE_OPTIONS.reduce((acc, option) => { acc[option.value] = option.label; return acc; diff --git a/frontend/src/components/sidebarV2Utils.ts b/frontend/src/components/sidebarV2Utils.ts index 67637be..e8a7c87 100644 --- a/frontend/src/components/sidebarV2Utils.ts +++ b/frontend/src/components/sidebarV2Utils.ts @@ -8,6 +8,11 @@ import { } from '../store'; import type { ConnectionTag, SavedConnection } from '../types'; import { t } from '../i18n'; +import { t as catalogTranslate } from '../i18n/catalog'; + +type SidebarV2Translate = (key: string) => string; + +const translateSidebarV2ZhCN: SidebarV2Translate = (key) => catalogTranslate('zh-CN', key); export type SidebarTreeNodeType = | 'connection' @@ -132,6 +137,7 @@ export const sortSidebarTableEntries = ( export const buildV2SidebarTableSectionedChildren = ( parentKey: string, tableNodes: SidebarTreeNode[], + translate: SidebarV2Translate = translateSidebarV2ZhCN, ): SidebarTreeNode[] => { const pinnedTables = tableNodes.filter((node) => node?.dataRef?.pinnedSidebarTable); if (pinnedTables.length === 0) return tableNodes; @@ -149,9 +155,9 @@ export const buildV2SidebarTableSectionedChildren = ( }); return [ - buildSectionNode('pinned', '置顶'), + buildSectionNode('pinned', translate('table_overview.section.pinned')), ...pinnedTables, - buildSectionNode('all', '全部'), + buildSectionNode('all', translate('table_overview.section.all')), ...regularTables, ]; }; @@ -160,9 +166,10 @@ export const buildSidebarTableChildrenForUi = ( parentKey: string, tableNodes: SidebarTreeNode[], isV2Ui: boolean, + translate: SidebarV2Translate = translateSidebarV2ZhCN, ): SidebarTreeNode[] => { if (!isV2Ui) return tableNodes; - return buildV2SidebarTableSectionedChildren(parentKey, tableNodes); + return buildV2SidebarTableSectionedChildren(parentKey, tableNodes, translate); }; export const formatSidebarRowCount = (count: number): string => { @@ -288,14 +295,18 @@ export const getV2RailConnectionGroupBadgeText = (name: unknown, fallback = t('c export type V2ExplorerFilter = 'all' | 'tables' | 'views' | 'routines' | 'events'; -export const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; label: string }> = [ - { key: 'all', label: '全部' }, - { key: 'tables', label: '表' }, - { key: 'views', label: '视图' }, - { key: 'routines', label: '函数' }, - { key: 'events', label: '事件' }, +export const buildV2ExplorerFilterOptions = ( + translate: SidebarV2Translate = translateSidebarV2ZhCN, +): Array<{ key: V2ExplorerFilter; label: string }> => [ + { key: 'all', label: translate('sidebar.command_search.object_kind.all') }, + { key: 'tables', label: translate('sidebar.command_search.object_kind.tables') }, + { key: 'views', label: translate('sidebar.command_search.object_kind.views') }, + { key: 'routines', label: translate('sidebar.command_search.object_kind.routines') }, + { key: 'events', label: translate('sidebar.command_search.object_kind.events') }, ]; +export const V2_EXPLORER_FILTER_OPTIONS: Array<{ key: V2ExplorerFilter; label: string }> = buildV2ExplorerFilterOptions(); + const V2_EXPLORER_FILTER_GROUP_KEYS: Record, string[]> = { tables: ['tables'], views: ['views', 'materializedViews'], diff --git a/frontend/src/components/tableDataDangerActions.ts b/frontend/src/components/tableDataDangerActions.ts index cd4fa38..a51400f 100644 --- a/frontend/src/components/tableDataDangerActions.ts +++ b/frontend/src/components/tableDataDangerActions.ts @@ -1,5 +1,7 @@ export type TableDataDangerActionKind = 'truncate' | 'clear'; +type TableDataDangerActionTranslator = (key: string) => string; + const resolveCustomDriverDialect = (driver: string): string => { const normalized = String(driver || '').trim().toLowerCase(); switch (normalized) { @@ -105,12 +107,24 @@ export const supportsTableTruncateAction = (type: string, driver?: string): bool } }; -export const getTableDataDangerActionMeta = (action: TableDataDangerActionKind): { +const tableDataDangerActionCopy = ( + translate: TableDataDangerActionTranslator | undefined, + key: string, + fallback: string, +) => translate ? translate(key) : fallback; + +export const getTableDataDangerActionMeta = (action: TableDataDangerActionKind, translate?: TableDataDangerActionTranslator): { label: string; progressLabel: string; } => { if (action === 'truncate') { - return { label: '截断表', progressLabel: '截断' }; + return { + label: tableDataDangerActionCopy(translate, 'sidebar.table_action.truncate.label', 'Truncate table'), + progressLabel: tableDataDangerActionCopy(translate, 'sidebar.table_action.truncate.progress', 'Truncating'), + }; } - return { label: '清空表', progressLabel: '清空' }; + return { + label: tableDataDangerActionCopy(translate, 'sidebar.table_action.clear.label', 'Clear table'), + progressLabel: tableDataDangerActionCopy(translate, 'sidebar.table_action.clear.progress', 'Clearing'), + }; }; diff --git a/frontend/src/components/tableDesignerIndexSql.test.ts b/frontend/src/components/tableDesignerIndexSql.test.ts index 3571f43..3a5b325 100644 --- a/frontend/src/components/tableDesignerIndexSql.test.ts +++ b/frontend/src/components/tableDesignerIndexSql.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { buildIndexCreateSqlPreview } from './tableDesignerIndexSql'; +import { t as catalogTranslate } from '../i18n/catalog'; describe('tableDesignerIndexSql', () => { it('builds SQL Server nonclustered index create SQL', () => { @@ -41,6 +42,57 @@ describe('tableDesignerIndexSql', () => { expect(result.sql).toBeNull(); expect(result.severity).toBe('error'); - expect(result.message).toContain('请输入索引名'); + expect(result.message).toContain(catalogTranslate('zh-CN', 'table_designer.message.index_name_required')); + }); + + it('uses the provided translator for user-visible validation messages', () => { + const translate = (key: string, params?: Record) => + catalogTranslate('en-US', key, params); + + expect(buildIndexCreateSqlPreview({ + dbType: 'postgres', + tableRef: '"public"."users"', + name: 'idx_users_name', + columnNames: [], + kind: 'NORMAL', + translate, + }).message).toBe('Select at least one column'); + + expect(buildIndexCreateSqlPreview({ + dbType: 'mysql', + tableRef: '`users`', + name: '', + columnNames: ['name'], + kind: 'NORMAL', + translate, + }).message).toBe('Enter an index name'); + + expect(buildIndexCreateSqlPreview({ + dbType: 'mysql', + tableRef: '`users`', + name: 'idx_users_name', + columnNames: ['name'], + kind: 'NORMAL', + indexType: 'FULLTEXT', + translate, + }).message).toBe('Switch Index category to FULLTEXT index'); + + expect(buildIndexCreateSqlPreview({ + dbType: 'postgres', + tableRef: '"public"."users"', + name: 'idx_users_name', + columnNames: ['name'], + kind: 'SPATIAL', + translate, + }).message).toBe('This database only supports maintaining normal and unique indexes'); + + expect(buildIndexCreateSqlPreview({ + dbType: 'redis', + tableRef: 'users', + name: 'idx_users_name', + columnNames: ['name'], + kind: 'NORMAL', + translate, + }).message).toBe('This data source does not support relational index maintenance'); }); }); diff --git a/frontend/src/components/tableDesignerIndexSql.ts b/frontend/src/components/tableDesignerIndexSql.ts index 123cc82..f6b6bab 100644 --- a/frontend/src/components/tableDesignerIndexSql.ts +++ b/frontend/src/components/tableDesignerIndexSql.ts @@ -5,8 +5,11 @@ import { isSqlServerDialect, quoteSqlIdentifierPart, } from '../utils/sqlDialect'; +import { t as catalogTranslate } from '../i18n/catalog'; export type TableDesignerIndexKind = 'NORMAL' | 'UNIQUE' | 'PRIMARY' | 'FULLTEXT' | 'SPATIAL'; +type TableDesignerIndexMessageParams = Record; +type TableDesignerIndexTranslate = (key: string, params?: TableDesignerIndexMessageParams) => string; export interface BuildIndexCreateSqlInput { dbType: string; @@ -15,6 +18,7 @@ export interface BuildIndexCreateSqlInput { columnNames: string[]; kind: TableDesignerIndexKind; indexType?: string; + translate?: TableDesignerIndexTranslate; } export interface BuildIndexCreateSqlResult { @@ -25,13 +29,29 @@ export interface BuildIndexCreateSqlResult { const isNonRelationalDialect = (dbType: string): boolean => dbType === 'redis' || dbType === 'mongodb' || dbType === 'elasticsearch'; +const formatTableDesignerIndexMessage = ( + translate: TableDesignerIndexTranslate | undefined, + key: string, + params?: TableDesignerIndexMessageParams, +): string => { + if (translate) { + return translate(key, params); + } + return catalogTranslate('zh-CN', key, params); +}; + export const buildIndexCreateSqlPreview = (input: BuildIndexCreateSqlInput): BuildIndexCreateSqlResult => { const dbType = input.dbType; + const translate = input.translate; const kind = input.kind || 'NORMAL'; const indexName = String(input.name || '').trim(); const cleanedCols = input.columnNames.map(col => String(col || '').trim()).filter(Boolean); if (cleanedCols.length === 0) { - return { sql: null, message: '请至少选择一个字段', severity: 'error' }; + return { + sql: null, + message: formatTableDesignerIndexMessage(translate, 'table_designer.message.select_at_least_one_column'), + severity: 'error', + }; } const colSql = cleanedCols .map(col => quoteSqlIdentifierPart(dbType, col)) @@ -43,7 +63,11 @@ export const buildIndexCreateSqlPreview = (input: BuildIndexCreateSqlInput): Bui } if (!indexName) { - return { sql: null, message: '请输入索引名', severity: 'error' }; + return { + sql: null, + message: formatTableDesignerIndexMessage(translate, 'table_designer.message.index_name_required'), + severity: 'error', + }; } const indexRef = quoteSqlIdentifierPart(dbType, indexName); @@ -56,7 +80,11 @@ export const buildIndexCreateSqlPreview = (input: BuildIndexCreateSqlInput): Bui const normalizedType = String(input.indexType || '').trim().toUpperCase() || 'DEFAULT'; if (normalizedType === 'FULLTEXT' || normalizedType === 'SPATIAL') { - return { sql: null, message: `请将“索引类别”切换为 ${normalizedType} 索引`, severity: 'error' }; + return { + sql: null, + message: formatTableDesignerIndexMessage(translate, 'table_designer.message.switch_index_kind', { kind: normalizedType }), + severity: 'error', + }; } const usingSql = normalizedType !== 'DEFAULT' ? ` USING ${normalizedType}` : ''; const prefix = kind === 'UNIQUE' ? 'ADD UNIQUE INDEX' : 'ADD INDEX'; @@ -64,10 +92,18 @@ export const buildIndexCreateSqlPreview = (input: BuildIndexCreateSqlInput): Bui } if (kind === 'PRIMARY' || kind === 'FULLTEXT' || kind === 'SPATIAL') { - return { sql: null, message: '当前数据库仅支持普通索引与唯一索引维护', severity: 'warning' }; + return { + sql: null, + message: formatTableDesignerIndexMessage(translate, 'table_designer.message.only_normal_unique_index_supported'), + severity: 'warning', + }; } if (!indexName) { - return { sql: null, message: '请输入索引名', severity: 'error' }; + return { + sql: null, + message: formatTableDesignerIndexMessage(translate, 'table_designer.message.index_name_required'), + severity: 'error', + }; } const indexRef = quoteSqlIdentifierPart(dbType, indexName); @@ -91,7 +127,11 @@ export const buildIndexCreateSqlPreview = (input: BuildIndexCreateSqlInput): Bui } if (isNonRelationalDialect(dbType)) { - return { sql: null, message: '当前数据源不支持关系型索引维护', severity: 'warning' }; + return { + sql: null, + message: formatTableDesignerIndexMessage(translate, 'table_designer.message.relational_index_unsupported'), + severity: 'warning', + }; } return { sql: `CREATE ${uniquePrefix}INDEX ${indexRef} ON ${input.tableRef} (${colSql});` }; }; diff --git a/frontend/src/components/tableDesignerSchemaSql.test.ts b/frontend/src/components/tableDesignerSchemaSql.test.ts index a485eda..38a8a86 100644 --- a/frontend/src/components/tableDesignerSchemaSql.test.ts +++ b/frontend/src/components/tableDesignerSchemaSql.test.ts @@ -1,3 +1,5 @@ +import { readFileSync } from 'node:fs'; + import { describe, expect, it } from 'vitest'; import { @@ -8,6 +10,30 @@ import { type BuildAlterTablePreviewInput, type EditableColumnSnapshot, } from './tableDesignerSchemaSql'; +import { t as catalogTranslate } from '../i18n/catalog'; + +const sharedI18nDir = new URL('../../../shared/i18n/', import.meta.url); +const sharedI18nLocaleFiles = [ + 'de-DE.json', + 'en-US.json', + 'ja-JP.json', + 'ru-RU.json', + 'zh-CN.json', + 'zh-TW.json', +] as const; + +const schemaSqlI18nKeys = [ + 'table_designer.schema_sql.doris.primary_key_hint', + 'table_designer.schema_sql.duckdb.comment_hint', + 'table_designer.schema_sql.duckdb.primary_key_hint', + 'table_designer.schema_sql.limited_column_hint', + 'table_designer.schema_sql.sqlite.modify_column_hint', + 'table_designer.schema_sql.sqlserver.drop_primary_key_hint', + 'table_designer.schema_sql.tdengine.timestamp_hint', +] as const; + +const translateEn = (key: string, params?: Record) => + catalogTranslate('en-US', key, params); const baseColumn = (overrides: Partial): EditableColumnSnapshot => ({ _key: overrides._key || 'col', @@ -32,6 +58,82 @@ const buildInput = (overrides: Partial): BuildAlter }); describe('tableDesignerSchemaSql', () => { + it('keeps generated SQL warning comments in i18n catalogs without source Chinese literals', () => { + const source = readFileSync(new URL('./tableDesignerSchemaSql.ts', import.meta.url), 'utf8'); + + for (const localeFile of sharedI18nLocaleFiles) { + const catalog = JSON.parse(readFileSync(new URL(localeFile, sharedI18nDir), 'utf8')) as Record; + for (const key of schemaSqlI18nKeys) { + expect(catalog[key], `${localeFile} ${key}`).toBeTruthy(); + } + } + + for (const key of schemaSqlI18nKeys) { + expect(source).toContain(key); + } + + for (const literal of [ + 'Doris 修改主键/Key 模型需要按表模型手工迁移', + 'SQL Server 删除旧主键需要原约束名', + 'SQLite 不支持直接修改字段属性', + 'DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注', + 'DuckDB 当前仅支持为无主键表新增 PRIMARY KEY', + '字段约束/默认值/备注语法与 MySQL 不同', + 'TDengine 普通表通常需要 TIMESTAMP 时间列', + ]) { + expect(source).not.toContain(literal); + } + }); + + it('localizes generated SQL warning comments while keeping SQL and identifiers raw', () => { + const sqliteSql = buildAlterTablePreviewSql(buildInput({ + dbType: 'sqlite', + tableName: 'users', + originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'TEXT', nullable: 'YES' })], + columns: [baseColumn({ _key: 'name', name: 'display_name', type: 'INTEGER', nullable: 'NO' })], + translate: translateEn, + })); + const duckCommentSql = buildAlterTablePreviewSql(buildInput({ + dbType: 'duckdb', + tableName: 'main.users', + originalColumns: [baseColumn({ _key: 'name', name: 'name', type: 'VARCHAR', nullable: 'YES', comment: '' })], + columns: [baseColumn({ _key: 'name', name: 'name', type: 'VARCHAR', nullable: 'YES', comment: 'visible name' })], + translate: translateEn, + })); + const limitedSql = buildAlterTablePreviewSql(buildInput({ + dbType: 'clickhouse', + tableName: 'events', + originalColumns: [], + columns: [baseColumn({ _key: 'name', name: 'name', type: 'String', nullable: 'NO', default: 'guest', comment: 'raw comment' })], + translate: translateEn, + })); + const tdengineSql = buildCreateTablePreviewSql({ + dbType: 'tdengine', + tableName: 'meters', + columns: [baseColumn({ _key: 'value', name: 'value', type: 'FLOAT', nullable: 'YES' })], + translate: translateEn, + }); + + expect(sqliteSql).toContain('-- SQLite cannot alter column properties directly.'); + expect(sqliteSql).toContain('column display_name'); + expect(sqliteSql).toContain('ALTER TABLE "users"'); + expect(sqliteSql).not.toContain('不支持直接修改字段属性'); + + expect(duckCommentSql).toContain('-- DuckDB cannot persist column comments through COMMENT ON COLUMN.'); + expect(duckCommentSql).toContain('column name'); + expect(duckCommentSql).toContain('COMMENT ON COLUMN'); + expect(duckCommentSql).not.toContain('字段 name 的备注'); + + expect(limitedSql).toContain('-- ClickHouse column constraint, default, and comment syntax differs from MySQL.'); + expect(limitedSql).toContain('ALTER TABLE `events`'); + expect(limitedSql).toContain('ADD COLUMN `name` String;'); + expect(limitedSql).not.toContain('字段约束/默认值/备注语法'); + + expect(tdengineSql).toContain('CREATE TABLE `meters`'); + expect(tdengineSql).toContain('-- TDengine regular tables usually require a TIMESTAMP column.'); + expect(tdengineSql).not.toContain('普通表通常需要 TIMESTAMP 时间列'); + }); + it('detects when alter table drafts contain unsaved column changes', () => { expect(hasAlterTableDraftChanges(buildInput({ dbType: 'mysql' }))).toBe(true); expect( @@ -138,7 +240,7 @@ describe('tableDesignerSchemaSql', () => { })); expect(sql).toContain('ALTER TABLE "users"\nRENAME COLUMN "name" TO "display_name";'); - expect(sql).toContain('-- SQLite 不支持直接修改字段属性'); + expect(sql).toContain('-- SQLite cannot alter column properties directly.'); expect(sql).not.toContain('CHANGE COLUMN'); expect(sql).not.toContain('MODIFY COLUMN'); expect(sql).not.toContain('AFTER'); @@ -195,7 +297,7 @@ describe('tableDesignerSchemaSql', () => { ], })); - expect(sql).toContain('-- DuckDB 当前仅支持为无主键表新增 PRIMARY KEY;已有主键的修改或删除需要通过重建表完成。'); + expect(sql).toContain('-- DuckDB currently only supports adding PRIMARY KEY to tables without an existing primary key.'); expect(sql).not.toContain('DROP CONSTRAINT'); expect(sql).not.toContain('DROP PRIMARY KEY'); }); diff --git a/frontend/src/components/tableDesignerSchemaSql.ts b/frontend/src/components/tableDesignerSchemaSql.ts index c9f7802..1e906bb 100644 --- a/frontend/src/components/tableDesignerSchemaSql.ts +++ b/frontend/src/components/tableDesignerSchemaSql.ts @@ -11,6 +11,9 @@ import { unquoteSqlIdentifierPath, } from '../utils/sqlDialect'; import { splitQualifiedNameLast } from '../utils/qualifiedName'; +import { t as translateCatalog, type I18nParams } from '../i18n'; + +type SchemaSqlTranslator = (key: string, params?: I18nParams) => string; export interface EditableColumnSnapshot { _key: string; @@ -29,6 +32,7 @@ export interface BuildAlterTablePreviewInput { tableName: string; originalColumns: EditableColumnSnapshot[]; columns: EditableColumnSnapshot[]; + translate?: SchemaSqlTranslator; } export interface BuildCreateTablePreviewInput { @@ -38,6 +42,7 @@ export interface BuildCreateTablePreviewInput { charset?: string; collation?: string; starRocksOptions?: StarRocksCreateTableOptions; + translate?: SchemaSqlTranslator; } export type StarRocksTableKind = 'olap' | 'external'; @@ -87,6 +92,15 @@ const collectPrimaryKeyColumnKeys = (columns: EditableColumnSnapshot[]): string[ const escapeSqlString = (value: string) => String(value || '').replace(/'/g, "''"); +const translateSchemaSqlComment = ( + translate: SchemaSqlTranslator | undefined, + key: string, + params?: I18nParams, +): string => { + const resolved = (translate || translateCatalog)(key, params); + return resolved && resolved !== key ? resolved : key; +}; + const stripIdentifierQuotes = unquoteSqlIdentifierPart; const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => { @@ -347,7 +361,7 @@ const buildDorisAlterPreviewSql = (input: BuildAlterTablePreviewInput, dbType: s const newPKKeys = input.columns.filter((col) => col.key === 'PRI').map((col) => col._key); const keysChanged = origPKKeys.length !== newPKKeys.length || !origPKKeys.every((key) => newPKKeys.includes(key)); if (keysChanged) { - statements.push('-- Doris 修改主键/Key 模型需要按表模型手工迁移,已避免生成 MySQL 专属的 DROP/ADD PRIMARY KEY。'); + statements.push(translateSchemaSqlComment(input.translate, 'table_designer.schema_sql.doris.primary_key_hint')); } return statements.join('\n'); @@ -528,7 +542,7 @@ const buildSqlServerAlterPreviewSql = (input: BuildAlterTablePreviewInput): stri const { objectName } = splitQualifiedName(input.tableName); const constraintName = quoteIdentifierPart(`PK_${objectName || 'table'}`, dbType); if (origPKKeys.length > 0) { - statements.push(`-- SQL Server 删除旧主键需要原约束名;请先在索引页确认后删除。`); + statements.push(translateSchemaSqlComment(input.translate, 'table_designer.schema_sql.sqlserver.drop_primary_key_hint')); } if (newPKKeys.length > 0) { const pkNames = input.columns.filter((col) => col.key === 'PRI').map((col) => quoteIdentifierPart(col.name, dbType)).join(', '); @@ -563,7 +577,9 @@ const buildSqliteAlterPreviewSql = (input: BuildAlterTablePreviewInput): string currentName = curr.name; } if (physicalDefinitionChanged(curr, orig) || (curr.comment || '') !== (orig.comment || '')) { - statements.push(`-- SQLite 不支持直接修改字段属性,请通过创建新表、迁移数据、替换旧表的方式处理字段 ${currentName}。`); + statements.push(translateSchemaSqlComment(input.translate, 'table_designer.schema_sql.sqlite.modify_column_hint', { + column: currentName, + })); } }); @@ -609,7 +625,9 @@ const buildDuckDbAlterPreviewSql = (input: BuildAlterTablePreviewInput): string statements.push(`ALTER TABLE ${tableRef}\nALTER COLUMN ${quoteIdentifierPart(currentName, dbType)} ${curr.nullable === 'NO' ? 'SET NOT NULL' : 'DROP NOT NULL'};`); } if ((curr.comment || '') !== (orig.comment || '')) { - statements.push(`-- DuckDB 不支持通过 COMMENT ON COLUMN 持久化字段备注,字段 ${currentName} 的备注仅保留在设计器预览中。`); + statements.push(translateSchemaSqlComment(input.translate, 'table_designer.schema_sql.duckdb.comment_hint', { + column: currentName, + })); } }); @@ -624,7 +642,7 @@ const buildDuckDbAlterPreviewSql = (input: BuildAlterTablePreviewInput): string .join(', '); statements.push(`ALTER TABLE ${tableRef}\nADD PRIMARY KEY (${pkNames});`); } else { - statements.push('-- DuckDB 当前仅支持为无主键表新增 PRIMARY KEY;已有主键的修改或删除需要通过重建表完成。'); + statements.push(translateSchemaSqlComment(input.translate, 'table_designer.schema_sql.duckdb.primary_key_hint')); } } @@ -646,7 +664,9 @@ const buildLimitedBacktickAlterPreviewSql = (input: BuildAlterTablePreviewInput, if (!orig) { statements.push(`ALTER TABLE ${tableRef}\nADD COLUMN ${quoteIdentifierPart(curr.name, dbType)} ${curr.type};`); if (curr.nullable === 'NO' || normalizeDefaultText(curr.default) || String(curr.comment || '').trim()) { - statements.push(`-- ${label} 的字段约束/默认值/备注语法与 MySQL 不同,已避免生成 MySQL 专属子句,请按目标库能力补充。`); + statements.push(translateSchemaSqlComment(input.translate, 'table_designer.schema_sql.limited_column_hint', { + dialect: label, + })); } return; } @@ -665,7 +685,9 @@ const buildLimitedBacktickAlterPreviewSql = (input: BuildAlterTablePreviewInput, (curr.comment || '') !== (orig.comment || '') || Boolean(curr.isAutoIncrement) !== Boolean(orig.isAutoIncrement) ) { - statements.push(`-- ${label} 的字段约束/默认值/备注语法与 MySQL 不同,已避免生成 MySQL 专属子句,请按目标库能力补充。`); + statements.push(translateSchemaSqlComment(input.translate, 'table_designer.schema_sql.limited_column_hint', { + dialect: label, + })); } }); @@ -890,7 +912,7 @@ export const buildCreateTablePreviewSql = (input: BuildCreateTablePreviewInput): const suffixComments = comments.length > 0 ? `\n${comments.join('\n')}` : ''; if (dbType === 'tdengine' && !input.columns.some((column) => /^timestamp$/i.test(String(column.type || '').trim()))) { - return `${createSql};\n-- TDengine 普通表通常需要 TIMESTAMP 时间列,执行前请确认表模型。${suffixComments}`; + return `${createSql};\n${translateSchemaSqlComment(input.translate, 'table_designer.schema_sql.tdengine.timestamp_hint')}${suffixComments}`; } if (isBacktickIdentifierDialect(dbType) && dbType !== 'mysql' && dbType !== 'mariadb') { diff --git a/frontend/src/components/useDataGridDdlView.i18n.test.tsx b/frontend/src/components/useDataGridDdlView.i18n.test.tsx new file mode 100644 index 0000000..c0b8881 --- /dev/null +++ b/frontend/src/components/useDataGridDdlView.i18n.test.tsx @@ -0,0 +1,102 @@ +import React from 'react'; +import { act, create, type ReactTestRenderer } from 'react-test-renderer'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { useDataGridDdlView } from './useDataGridDdlView'; +import { t as catalogTranslate } from '../i18n/catalog'; + +const backendApp = vi.hoisted(() => ({ + DBShowCreateTable: vi.fn(), +})); + +vi.mock('../../wailsjs/go/app/App', () => backendApp); + +describe('useDataGridDdlView i18n', () => { + let controller: ReturnType | null = null; + let renderer: ReactTestRenderer | null = null; + const messageApi = { + error: vi.fn(), + }; + + const translate = (key: string, params?: Record) => + catalogTranslate('en-US', key, params); + + const renderHook = (overrides: Partial[0]> = {}) => { + const Harness = () => { + controller = useDataGridDdlView({ + canViewDdl: true, + currentConnConfig: { type: 'mysql', host: '127.0.0.1', port: 3306 }, + dbName: 'app', + tableName: 'users', + dbType: 'mysql', + isV2Ui: true, + cellEditMode: false, + selectedRowKeys: [], + mergedDisplayDataRef: { current: [] }, + rowKeyStr: (key) => String(key), + closeCellEditModeRef: { current: vi.fn() }, + setTextRecordIndex: vi.fn(), + messageApi, + translate, + ...overrides, + }); + return null; + }; + + act(() => { + renderer = create(); + }); + }; + + beforeEach(() => { + controller = null; + renderer = null; + messageApi.error.mockReset(); + backendApp.DBShowCreateTable.mockReset(); + }); + + afterEach(() => { + act(() => { + renderer?.unmount(); + }); + }); + + it('uses the active language for local DDL validation and fallback errors', async () => { + renderHook({ tableName: '' }); + + await act(async () => { + await controller?.handleOpenTableDdl(); + }); + + expect(messageApi.error).toHaveBeenCalledWith('The current table is missing a connection or table name, so DDL cannot be viewed'); + expect(backendApp.DBShowCreateTable).not.toHaveBeenCalled(); + + act(() => { + renderer?.unmount(); + renderer = null; + }); + + backendApp.DBShowCreateTable.mockResolvedValueOnce({ success: false }); + renderHook(); + + await act(async () => { + await controller?.handleOpenTableDdl(); + }); + + expect(messageApi.error).toHaveBeenLastCalledWith('Failed to load DDL'); + }); + + it('keeps backend DDL error text raw', async () => { + backendApp.DBShowCreateTable.mockResolvedValueOnce({ + success: false, + message: 'ORA-31603: object "USERS" not found', + }); + renderHook(); + + await act(async () => { + await controller?.handleOpenTableDdl(); + }); + + expect(messageApi.error).toHaveBeenCalledWith('ORA-31603: object "USERS" not found'); + }); +}); diff --git a/frontend/src/components/useDataGridDdlView.ts b/frontend/src/components/useDataGridDdlView.ts index 5ab694c..c3b7df0 100644 --- a/frontend/src/components/useDataGridDdlView.ts +++ b/frontend/src/components/useDataGridDdlView.ts @@ -1,10 +1,12 @@ import React from 'react'; import { DBShowCreateTable } from '../../wailsjs/go/app/App'; +import { t as catalogTranslate } from '../i18n/catalog'; import { buildRpcConnectionConfig } from '../utils/connectionRpcConfig'; import { formatDdlForDisplay } from '../utils/ddlFormat'; type GridViewMode = 'table' | 'json' | 'text' | 'fields' | 'ddl' | 'er'; type DdlViewLayoutMode = 'bottom' | 'side'; +type TranslateParams = Record; interface UseDataGridDdlViewParams { canViewDdl: boolean; @@ -22,6 +24,7 @@ interface UseDataGridDdlViewParams { error: (content: string) => void; }; dbType?: string; + translate?: (key: string, params?: TranslateParams) => string; } export interface UseDataGridDdlViewResult { @@ -57,6 +60,7 @@ export const useDataGridDdlView = ({ setTextRecordIndex, messageApi, dbType, + translate, }: UseDataGridDdlViewParams): UseDataGridDdlViewResult => { const [viewMode, setViewMode] = React.useState('table'); const [ddlModalOpen, setDdlModalOpen] = React.useState(false); @@ -76,9 +80,13 @@ export const useDataGridDdlView = ({ const isTableSurfaceActive = viewMode === 'table' || (isV2Ui && viewMode === 'ddl' && ddlViewLayout === 'side'); + const translateMessage = React.useCallback((key: string, params?: TranslateParams) => { + return translate ? translate(key, params) : catalogTranslate('zh-CN', key, params); + }, [translate]); + const handleOpenTableDdl = React.useCallback(async (options?: { asView?: boolean }) => { if (!canViewDdl || !currentConnConfig || !tableName) { - messageApi.error('当前表缺少连接或表名,无法查看 DDL'); + messageApi.error(translateMessage('data_grid.message.ddl_missing_context')); return; } const asView = options?.asView === true && isV2Ui; @@ -98,16 +106,16 @@ export const useDataGridDdlView = ({ setDdlText(formatDdlForDisplay(res.data, dbType || String((currentConnConfig as any)?.type || ''))); return; } - messageApi.error(res.message || '获取 DDL 失败'); + messageApi.error(res.message || translateMessage('data_grid.message.ddl_load_failed')); } catch (error: any) { if (requestSeq !== ddlRequestSeqRef.current) return; - messageApi.error(error?.message || '获取 DDL 失败'); + messageApi.error(error?.message || translateMessage('data_grid.message.ddl_load_failed')); } finally { if (requestSeq === ddlRequestSeqRef.current) { setDdlLoading(false); } } - }, [canViewDdl, currentConnConfig, dbName, dbType, isV2Ui, messageApi, tableName]); + }, [canViewDdl, currentConnConfig, dbName, dbType, isV2Ui, messageApi, tableName, translateMessage]); React.useEffect(() => { if (isV2Ui || (viewMode !== 'fields' && viewMode !== 'ddl' && viewMode !== 'er')) return; diff --git a/frontend/src/components/useSqlEditorTransactionController.test.tsx b/frontend/src/components/useSqlEditorTransactionController.test.tsx index 53166de..e747404 100644 --- a/frontend/src/components/useSqlEditorTransactionController.test.tsx +++ b/frontend/src/components/useSqlEditorTransactionController.test.tsx @@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { useSqlEditorTransactionController } from './useSqlEditorTransactionController'; import type { PendingSqlEditorTransaction } from './QueryEditorTransactionToolbar'; +import { t as catalogTranslate } from '../i18n/catalog'; const storeState = vi.hoisted(() => ({ setSqlEditorPendingTransaction: vi.fn(), @@ -42,9 +43,12 @@ describe('useSqlEditorTransactionController', () => { let controller: ReturnType | null = null; let renderer: ReactTestRenderer | null = null; - const renderController = () => { + const translate = (key: string, params?: Record) => + catalogTranslate('en-US', key, params); + + const renderController = (overrides: Record = {}) => { const Harness = () => { - controller = useSqlEditorTransactionController({ tabId: 'tab-1' }); + controller = (useSqlEditorTransactionController as any)({ tabId: 'tab-1', ...overrides }); return null; }; @@ -87,7 +91,7 @@ describe('useSqlEditorTransactionController', () => { expect(backendApp.DBCommitTransaction).toHaveBeenCalledTimes(1); expect(backendApp.DBCommitTransaction).toHaveBeenCalledWith('tx-1'); expect(backendApp.DBRollbackTransaction).not.toHaveBeenCalled(); - expect(messageApi.success).toHaveBeenCalledWith('SQL 事务已提交'); + expect(messageApi.success).toHaveBeenCalledWith('事务已提交'); }); it('does not rollback a transaction while its auto commit is in flight', async () => { @@ -118,6 +122,52 @@ describe('useSqlEditorTransactionController', () => { await finishPromise; }); - expect(messageApi.success).toHaveBeenCalledWith('SQL 事务已自动提交'); + expect(messageApi.success).toHaveBeenCalledWith('自动提交成功'); + }); + + it('uses the active language for transaction success messages', async () => { + renderController({ translate }); + + await act(async () => { + controller?.activatePendingSqlTransaction(createPendingTransaction()); + await controller?.finishPendingSqlTransaction('commit', 'manual'); + }); + expect(messageApi.success).toHaveBeenLastCalledWith('Transaction committed'); + + await act(async () => { + controller?.activatePendingSqlTransaction(createPendingTransaction({ id: 'tx-2' })); + await controller?.finishPendingSqlTransaction('rollback', 'manual'); + }); + expect(messageApi.success).toHaveBeenLastCalledWith('Transaction rolled back'); + + await act(async () => { + controller?.activatePendingSqlTransaction(createPendingTransaction({ id: 'tx-3' })); + await controller?.finishPendingSqlTransaction('commit', 'auto'); + }); + expect(messageApi.success).toHaveBeenLastCalledWith('Auto commit succeeded'); + }); + + it('uses the active language for transaction failure wrappers and keeps raw error details', async () => { + backendApp.DBCommitTransaction.mockResolvedValueOnce({ + success: false, + message: 'ORA-00060: deadlock detected while waiting for resource', + }); + renderController({ translate }); + + await act(async () => { + controller?.activatePendingSqlTransaction(createPendingTransaction()); + await controller?.finishPendingSqlTransaction('commit', 'manual'); + }); + + expect(messageApi.error).toHaveBeenLastCalledWith('Commit failed: ORA-00060: deadlock detected while waiting for resource'); + + backendApp.DBRollbackTransaction.mockRejectedValueOnce(new Error('SQLSTATE 40001 serialization failure')); + + await act(async () => { + controller?.activatePendingSqlTransaction(createPendingTransaction({ id: 'tx-2' })); + await controller?.finishPendingSqlTransaction('rollback', 'manual'); + }); + + expect(messageApi.error).toHaveBeenLastCalledWith('Rollback failed: SQLSTATE 40001 serialization failure'); }); }); diff --git a/frontend/src/components/useSqlEditorTransactionController.ts b/frontend/src/components/useSqlEditorTransactionController.ts index 4ba0f00..6b8ad62 100644 --- a/frontend/src/components/useSqlEditorTransactionController.ts +++ b/frontend/src/components/useSqlEditorTransactionController.ts @@ -2,19 +2,22 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { message } from 'antd'; import { DBCommitTransaction, DBRollbackTransaction } from '../../wailsjs/go/app/App'; +import { t as catalogTranslate } from '../i18n/catalog'; import { useStore } from '../store'; -import { formatSqlExecutionError } from '../utils/sqlErrorSemantics'; import type { PendingSqlEditorTransaction } from './QueryEditorTransactionToolbar'; type FinishSqlEditorTransactionAction = 'commit' | 'rollback'; type FinishSqlEditorTransactionSource = 'manual' | 'auto'; +type TranslateParams = Record; type UseSqlEditorTransactionControllerOptions = { tabId: string; + translate?: (key: string, params?: TranslateParams) => string; }; export const useSqlEditorTransactionController = ({ tabId, + translate, }: UseSqlEditorTransactionControllerOptions) => { const setSqlEditorPendingTransaction = useStore(state => state.setSqlEditorPendingTransaction); const [pendingSqlTransaction, setPendingSqlTransaction] = useState(null); @@ -24,6 +27,19 @@ export const useSqlEditorTransactionController = ({ const autoCommitCountdownRef = useRef | null>(null); const [autoCommitRemainingSeconds, setAutoCommitRemainingSeconds] = useState(null); + const translateMessage = useCallback((key: string, params?: TranslateParams) => { + return translate ? translate(key, params) : catalogTranslate('zh-CN', key, params); + }, [translate]); + + const rawErrorDetail = useCallback((error: unknown) => { + if (typeof error === 'string' && error.trim()) return error; + if (error instanceof Error && error.message.trim()) return error.message; + const messageValue = (error as any)?.message; + if (typeof messageValue === 'string' && messageValue.trim()) return messageValue; + if (error !== undefined && error !== null) return String(error); + return translateMessage('common.unknown'); + }, [translateMessage]); + const clearAutoCommitTimer = useCallback(() => { if (autoCommitTimerRef.current) { clearTimeout(autoCommitTimerRef.current); @@ -63,21 +79,33 @@ export const useSqlEditorTransactionController = ({ : await DBRollbackTransaction(transaction.id); if (res?.success) { if (action === 'commit') { - message.success(source === 'auto' ? 'SQL 事务已自动提交' : 'SQL 事务已提交'); + message.success(source === 'auto' + ? translateMessage('data_grid.message.auto_commit_success') + : translateMessage('data_grid.message.transaction_committed')); } else { - message.success('SQL 事务已回滚'); + message.success(translateMessage('data_grid.message.transaction_rolled_back')); } return; } - const fallback = action === 'commit' ? '提交失败' : '回滚失败'; - message.error(`${source === 'auto' ? '自动提交失败' : fallback}: ${formatSqlExecutionError(res?.message || '未知错误')}`); + const detail = rawErrorDetail(res?.message); + const key = source === 'auto' + ? 'data_grid.message.auto_commit_failed' + : action === 'commit' + ? 'data_grid.message.commit_failed' + : 'data_grid.message.rollback_failed'; + message.error(translateMessage(key, { detail })); } catch (err: any) { - const fallback = action === 'commit' ? '提交失败' : '回滚失败'; - message.error(`${source === 'auto' ? '自动提交失败' : fallback}: ${formatSqlExecutionError(err?.message || err || '未知错误')}`); + const detail = rawErrorDetail(err); + const key = source === 'auto' + ? 'data_grid.message.auto_commit_failed' + : action === 'commit' + ? 'data_grid.message.commit_failed' + : 'data_grid.message.rollback_failed'; + message.error(translateMessage(key, { detail })); } finally { finishingTransactionIdsRef.current.delete(transaction.id); } - }, [clearAutoCommitTimer, updatePendingSqlTransaction]); + }, [clearAutoCommitTimer, rawErrorDetail, translateMessage, updatePendingSqlTransaction]); const activatePendingSqlTransaction = useCallback((transaction: PendingSqlEditorTransaction) => { clearAutoCommitTimer(); diff --git a/frontend/src/dev/PerfDataGridHarness.tsx b/frontend/src/dev/PerfDataGridHarness.tsx index 31902a4..e832962 100644 --- a/frontend/src/dev/PerfDataGridHarness.tsx +++ b/frontend/src/dev/PerfDataGridHarness.tsx @@ -2,6 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from 'react'; import { Alert, Button, Card, InputNumber, Segmented, Select, Space, Typography } from 'antd'; import DataGrid, { GONAVI_ROW_KEY } from '../components/DataGrid'; +import { t } from '../i18n'; import { useStore } from '../store'; import type { EditRowLocator } from '../utils/rowLocator'; import type { DataTableDensity } from '../utils/dataGridDisplay'; @@ -286,13 +287,13 @@ const PerfDataGridHarness: React.FC = () => { }} > - DataGrid 性能复现页 + {t('dev.perf_data_grid.title')} setUiVersion(value as HarnessUiVersion)} options={[ - { label: '旧版 UI', value: 'legacy' }, - { label: '新版 UI', value: 'v2' }, + { label: t('dev.perf_data_grid.ui_version.legacy'), value: 'legacy' }, + { label: t('dev.perf_data_grid.ui_version.v2'), value: 'v2' }, ]} /> { step={500} value={rowCount} onChange={(value) => setRowCount(Number(value) || 10000)} - addonBefore="行数" + addonBefore={t('dev.perf_data_grid.rows')} /> { step={2} value={columnCount} onChange={(value) => setColumnCount(Number(value) || 24)} - addonBefore="列数" + addonBefore={t('dev.perf_data_grid.columns')} />