Merge branch 'dev' into feature/20260602_connection_driver_i18n

# Conflicts:
#	frontend/package.json.md5
#	frontend/src/App.tsx
#	frontend/src/components/AIChatPanel.message-boundary.test.tsx
#	frontend/src/components/AIChatPanel.tsx
#	frontend/src/components/AISettingsModal.tsx
#	frontend/src/components/ConnectionModal.tsx
#	frontend/src/components/DataGrid.ddl.test.tsx
#	frontend/src/components/DataGrid.layout.test.tsx
#	frontend/src/components/DataGrid.tsx
#	frontend/src/components/DataGridColumnTitle.test.tsx
#	frontend/src/components/DataGridLegacyCellContextMenu.tsx
#	frontend/src/components/DataGridSecondaryActions.tsx
#	frontend/src/components/DataGridToolbarFrame.tsx
#	frontend/src/components/DataSyncModal.tsx
#	frontend/src/components/DataViewer.tsx
#	frontend/src/components/DefinitionViewer.tsx
#	frontend/src/components/DriverManagerModal.tsx
#	frontend/src/components/QueryEditor.external-sql-save.test.tsx
#	frontend/src/components/QueryEditor.tsx
#	frontend/src/components/RedisViewer.tsx
#	frontend/src/components/Sidebar.locate-toolbar.test.tsx
#	frontend/src/components/Sidebar.tsx
#	frontend/src/components/TabManager.hover.test.tsx
#	frontend/src/components/TabManager.tsx
#	frontend/src/components/TableDesigner.tsx
#	frontend/src/components/V2TableContextMenu.tsx
#	frontend/src/components/ai/AIChatHeader.tsx
#	frontend/src/components/ai/AIHistoryDrawer.tsx
#	frontend/src/main.tsx
#	frontend/src/store.ts
#	frontend/src/utils/aiComposerNotice.test.ts
#	frontend/src/utils/aiComposerNotice.ts
#	frontend/src/utils/connectionModalPresentation.ts
#	frontend/src/utils/driverImportGuidance.ts
#	frontend/src/utils/externalSqlTree.test.ts
#	frontend/src/utils/externalSqlTree.ts
#	frontend/src/utils/sqlDialect.ts
#	internal/ai/service/service.go
This commit is contained in:
tianqijiuyun-latiao
2026-06-16 18:35:11 +08:00
631 changed files with 101151 additions and 10056 deletions

View File

@@ -0,0 +1,319 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_DATABASE_TOOL_INFO: AIBuiltinToolInfo[] = [
{
name: "get_connections",
icon: "🔗",
desc: "获取所有可用的数据库连接",
detail:
"返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。",
params: "无参数",
tool: {
type: "function",
function: {
name: "get_connections",
description:
"当需要查询、操作数据库但用户没有选择任何连接上下文时获取当前软件中可用的所有数据库连接信息。返回的数据包含连接ID(id)和名称(name)。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "get_databases",
icon: "🗄️",
desc: "获取指定连接下的所有数据库",
detail: "传入 connectionId返回该连接下的数据库/Schema 名称列表。",
params: "connectionId: 连接 ID",
tool: {
type: "function",
function: {
name: "get_databases",
description: "获取指定连接connectionId下的所有数据库(Database/Schema)名。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID (从 get_connections 获取)" },
},
required: ["connectionId"],
},
},
},
},
{
name: "get_tables",
icon: "📋",
desc: "获取指定数据库下的所有表名",
detail:
"传入 connectionId 和 dbName返回表名列表。AI 用它来定位用户提到的目标表。",
params: "connectionId, dbName",
tool: {
type: "function",
function: {
name: "get_tables",
description:
"当已经确定了目标连接和数据库名后,如果用户询问或隐式提到了表但你不知道确切表名,调用此工具获取该数据库下的所有表名列表(只含表名,帮助你推断目标表)。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
},
required: ["connectionId", "dbName"],
},
},
},
},
{
name: "get_all_columns",
icon: "🧱",
desc: "获取指定数据库下所有表的字段摘要",
detail:
"传入 connectionId 和 dbName返回跨表字段列表表名、字段名、类型、注释。适合用户只知道业务字段、不知道具体在哪张表时快速定位目标表。",
params: "connectionId, dbName",
tool: {
type: "function",
function: {
name: "get_all_columns",
description:
"获取指定数据库下全部表的字段摘要,返回表名、字段名、类型和注释。适用于按字段反查表、跨表梳理相同字段、做数据地图探索。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
},
required: ["connectionId", "dbName"],
},
},
},
},
{
name: "get_columns",
icon: "🔍",
desc: "获取指定表的字段结构",
detail:
"传入 connectionId、dbName 和 tableName返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_columns",
description:
"获取指定表的字段列表(字段名、类型、是否可空、默认值、注释等)。在生成 SQL 之前必须先调用此工具确认真实字段名,禁止猜测字段名。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "get_indexes",
icon: "🧭",
desc: "获取指定表的索引定义",
detail:
"传入 connectionId、dbName 和 tableName返回索引名、索引列、唯一性和索引类型。AI 在做慢 SQL 分析、索引优化和执行计划推断时应优先调用。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_indexes",
description:
"获取指定表的索引定义,包括索引名、字段顺序、唯一性和索引类型。适用于慢 SQL 分析、索引优化建议和确认现有索引覆盖情况。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "get_foreign_keys",
icon: "🧬",
desc: "获取指定表的外键关系",
detail:
"传入 connectionId、dbName 和 tableName返回当前表到其他表的外键映射。AI 在推断表关系、生成联表 SQL 和评审数据一致性时可直接使用。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_foreign_keys",
description:
"获取指定表的外键关系包括本表字段、引用表、引用字段和约束名。适用于联表路径分析、ER 关系梳理和约束检查。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "get_triggers",
icon: "⏱️",
desc: "获取指定表的触发器定义",
detail:
"传入 connectionId、dbName 和 tableName返回触发器名、触发时机、事件类型和语句体。AI 在分析隐式写入、副作用和审计逻辑时可直接查看。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_triggers",
description:
"获取指定表的触发器定义,包括触发时机、事件和触发语句。适用于排查隐式数据变更、审计逻辑和表级副作用。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "get_table_ddl",
icon: "📝",
desc: "获取表的建表语句 (DDL)",
detail:
"传入 connectionId、dbName 和 tableName返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。",
params: "connectionId, dbName, tableName",
tool: {
type: "function",
function: {
name: "get_table_ddl",
description: "获取指定表的完整建表语句CREATE TABLE DDL包含字段、索引、约束等完整结构信息。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "preview_table_rows",
icon: "👀",
desc: "抽样预览指定表的前几行数据",
detail:
"传入 connectionId、dbName、tableName 和可选 limit返回该表的前几行真实样例数据。适合先看数据形态、空值分布和枚举值再决定怎么写 SQL。",
params: "connectionId, dbName, tableName, limit?",
tool: {
type: "function",
function: {
name: "preview_table_rows",
description:
"预览指定表的前几行样例数据。适用于快速理解字段取值形态、空值情况、时间格式和状态枚举,减少模型盲写 SQL。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
limit: { type: "number", description: "可选,预览行数,默认 20最大 100" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "inspect_table_bundle",
icon: "🧰",
desc: "一次抓取指定表的结构快照",
detail:
"传入 connectionId、dbName 和 tableName返回字段、索引、外键、触发器和 DDL还可以附带前几行样例数据。适合在写 SQL、评审表设计或排查副作用前先做完整摸底。",
params: "connectionId, dbName, tableName, includeSampleRows?, sampleLimit?",
tool: {
type: "function",
function: {
name: "inspect_table_bundle",
description:
"一次性获取指定表的结构快照返回字段、索引、外键、触发器、DDL以及可选样例数据。适用于做完整表设计摸底、快速理解表关系和降低模型多次往返调用。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
tableName: { type: "string", description: "表名" },
includeSampleRows: { type: "boolean", description: "可选,是否附带前几行样例数据" },
sampleLimit: { type: "number", description: "可选,样例行数,默认 10最大 100" },
},
required: ["connectionId", "dbName", "tableName"],
},
},
},
},
{
name: "inspect_database_bundle",
icon: "🗂️",
desc: "一次抓取指定数据库的结构总览",
detail:
"传入 connectionId 和 dbName返回库内表清单、表数量、总字段数以及按表聚合的字段摘要预览。适合刚接手陌生库时先做全局摸底再决定深入哪张表。",
params: "connectionId, dbName, includeColumns?, tableLimit?, perTableColumnLimit?",
tool: {
type: "function",
function: {
name: "inspect_database_bundle",
description:
"一次性获取指定数据库的结构总览,返回表名列表、总字段数,以及按表聚合的字段摘要预览。适用于陌生数据库摸底、做数据地图和快速选择下一步要深入分析的表。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
includeColumns: { type: "boolean", description: "可选,是否附带按表聚合的字段摘要,默认 true" },
tableLimit: { type: "number", description: "可选,最多返回多少张表,默认 80最大 200" },
perTableColumnLimit: { type: "number", description: "可选,每张表最多返回多少个字段摘要,默认 8最大 30" },
},
required: ["connectionId", "dbName"],
},
},
},
},
{
name: "execute_sql",
icon: "▶️",
desc: "执行 SQL 查询并返回结果",
detail:
"传入 connectionId、dbName 和 sql在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE。",
params: "connectionId, dbName, sql",
tool: {
type: "function",
function: {
name: "execute_sql",
description:
"在指定连接和数据库上执行 SQL 查询并返回结果。受安全级别控制,只读模式下只能执行 SELECT/SHOW/DESCRIBE 等查询操作。结果最多返回 50 行。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
sql: { type: "string", description: "要执行的 SQL 语句" },
},
required: ["connectionId", "dbName", "sql"],
},
},
},
},
];

View File

@@ -0,0 +1,226 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_INSPECTION_CONTEXT_TOOL_INFO: AIBuiltinToolInfo[] = [
{
name: "inspect_ai_guidance",
icon: "🧠",
desc: "查看当前 AI 提示词与 Skills 配置",
detail:
"返回当前用户自定义的全局/数据库/JVM 提示词,以及当前启用的 Skills、作用域、依赖工具和 skill prompt 内容。适合用户问“你现在到底带了哪些提示词”“为什么你会这样回答”“当前有哪些 Skills 在生效”时先读真实配置。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_ai_guidance",
description:
"读取当前 AI 的提示与技能配置快照,包括用户自定义提示词、当前启用的 Skills、作用域、依赖工具和各自的 system prompt。适用于用户提到当前提示词、当前 Skill、为什么 AI 当前会这样回答、当前有哪些规则在生效时,先读取真实配置再解释。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_ai_context",
icon: "🧷",
desc: "查看当前 AI 已关联的表结构上下文",
detail:
"返回当前对话已经挂载到 AI 上下文里的表清单、所属连接与数据库,以及每张表的 DDL 预览。适合用户说“看看我现在带了哪些表结构”“当前 AI 上下文是什么”时,先读取真实挂载状态再继续分析。",
params: "includeDDL?(默认 false), ddlLimit?(默认 4000)",
tool: {
type: "function",
function: {
name: "inspect_ai_context",
description:
"读取当前对话已经关联到 AI 上下文里的表结构快照,包括连接、数据库、表名,以及可选的 DDL 内容。适用于用户提到当前 AI 上下文、当前关联表、当前挂载的表结构时,先读取真实状态,避免模型凭记忆复述。",
parameters: {
type: "object",
properties: {
includeDDL: { type: "boolean", description: "可选,是否附带每张表的 DDL 内容,默认 false" },
ddlLimit: { type: "number", description: "可选DDL 截断长度,默认 4000最大 12000" },
},
},
},
},
},
{
name: "inspect_current_connection",
icon: "🛰️",
desc: "查看当前活动连接/数据源摘要",
detail:
"返回当前活动连接的类型、地址、端口、当前数据库、是否启用 SSH/代理/HTTP 隧道,以及当前活动页签绑定的表信息。适合用户问“我现在连的是哪个库”“这个连接走没走 SSH”“当前数据源是什么类型”时先读取真实连接状态。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_current_connection",
description:
"读取当前活动连接或当前页签对应数据源的真实摘要包括连接类型、地址、端口、当前数据库、SSH/代理/HTTP 隧道状态,以及当前页签绑定的表上下文。适用于用户提到当前连接、当前数据源、当前库地址、是否走 SSH、当前连的是哪种数据库时先读取真实界面上下文避免模型猜测。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_connection_capabilities",
icon: "🧱",
desc: "查看当前连接支持哪些前端能力",
detail:
"返回当前或指定连接的数据源能力矩阵包括是否支持查询编辑器、SQL 导出、复制 INSERT、新建/重命名/删除数据库、结果是否强制只读,以及是否倾向手动总数或近似计数。适合用户问“为什么这里不能建库/删库”“这个数据源为什么结果不能编辑”“这个类型支持哪些操作”时,先读取真实能力边界。",
params: "connectionId?(默认取当前活动连接)",
tool: {
type: "function",
function: {
name: "inspect_connection_capabilities",
description:
"读取当前活动连接或指定 saved connection 的前端能力矩阵包括是否支持查询编辑器、SQL 导出、复制 INSERT、新建/重命名/删除数据库、结果是否强制只读,以及是否适合手动总数或近似计数。适用于用户提到当前连接为什么不能建库、为什么结果集不能编辑、某种数据库类型到底支持哪些前端动作时,先读取真实能力配置,避免模型凭经验猜测。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "可选,指定要查看的连接 ID不传时默认读取当前活动连接" },
},
},
},
},
},
{
name: "inspect_saved_connections",
icon: "🧭",
desc: "查看本地已保存连接清单",
detail:
"可按关键词或数据库类型过滤返回本地保存的数据源列表、连接类型分布以及每条连接的地址、当前库、SSH/代理/HTTP 隧道状态。适合用户问“我本地存了哪些连接”“帮我找 mysql / postgres 连接”“哪条连接配置了 SSH”时先读真实本地连接资产。",
params: "keyword?, type?, limit?",
tool: {
type: "function",
function: {
name: "inspect_saved_connections",
description:
"读取本地已保存连接清单可按关键词和数据库类型过滤并返回每条连接的类型、地址、当前库、SSH/代理/HTTP 隧道等摘要。适用于用户提到本地保存了哪些连接、要找哪条 mysql/postgres 连接、哪条连接启用了 SSH 或代理时,先读取真实本地连接资产再回答。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选按连接名、ID、类型、主机、数据库名或 SSH/代理地址做关键词筛选" },
type: { type: "string", description: "可选,只看某种数据库类型,例如 mysql、postgres、redis、mongodb" },
limit: { type: "number", description: "可选,最多返回多少条连接,默认 20最大 100" },
},
},
},
},
},
{
name: "inspect_redis_topology",
icon: "🧰",
desc: "诊断 Redis 单机/哨兵/集群配置",
detail:
"读取本地 Redis 连接拓扑摘要返回单机、Sentinel、Cluster 的节点、master、认证状态、DB 范围、脱敏 URI 示例、状态分级和下一步动作。适合用户问 Redis 哨兵/集群怎么配、为什么切库后失败、Cluster 多 DB 怎么处理时先读真实配置。",
params: "connectionId?, keyword?, limit?, includeRecommendations?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_redis_topology",
description:
"读取本地 Redis 连接的单机、Sentinel、Cluster 拓扑配置摘要返回节点列表、Sentinel master、认证状态、DB 选择、TLS/SSH/代理状态、后端适配器、脱敏 URI 示例、状态分级、阻断原因、潜在配置风险和建议。适用于用户提到 Redis 哨兵、Redis Cluster、切换数据库失败、多节点地址、Sentinel master、Cluster 逻辑库或跨网络访问 Redis 时,先读取真实连接配置再回答;结果不会回显 Redis 密码或 Sentinel 密码。",
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "可选,只诊断某个 Redis 连接 ID" },
keyword: { type: "string", description: "可选按连接名、地址、拓扑、Sentinel master 或节点地址筛选" },
limit: { type: "number", description: "可选,最多返回多少条 Redis 连接,默认 20最大 100" },
includeRecommendations: { type: "boolean", description: "可选,是否返回修复建议,默认 true" },
},
},
},
},
},
{
name: "inspect_external_sql_directories",
icon: "🗂️",
desc: "查看本地外部 SQL 目录资产",
detail:
"可按关键词、连接或数据库过滤,返回本地配置的外部 SQL 目录、目录路径、绑定连接/数据库,以及当前是否已经打开这些目录里的 SQL 文件。适合用户提到“外部 SQL 目录”“某个脚本在哪个目录”“现在打开的 SQL 文件来自哪个外部目录”时,先读真实资产。",
params: "keyword?, connectionId?, dbName?, limit?",
tool: {
type: "function",
function: {
name: "inspect_external_sql_directories",
description:
"读取本地配置的外部 SQL 目录清单,可按关键词、连接和数据库过滤,并返回目录路径、绑定连接/数据库,以及当前打开的外部 SQL 文件页签摘要。适用于用户提到外部 SQL 目录、某个 SQL 文件放在哪、当前打开的脚本来自哪个目录时,先读取真实本地资产再回答。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选,按目录名、路径、连接名或数据库名做关键词筛选" },
connectionId: { type: "string", description: "可选,只看绑定到某个连接的外部 SQL 目录" },
dbName: { type: "string", description: "可选,只看绑定到某个数据库的外部 SQL 目录" },
limit: { type: "number", description: "可选,最多返回多少条目录,默认 20最大 100" },
},
},
},
},
},
{
name: "inspect_external_sql_file",
icon: "📄",
desc: "读取外部 SQL 文件内容",
detail:
"传入具体 filePath读取已配置外部 SQL 目录中的 SQL 文件内容,并返回所属目录、绑定连接/数据库、是否已有打开页签,以及截断后的正文预览。适合用户提到“看一下这个目录里的某个脚本”“帮我解释 report.sql 在写什么”时,先读取真实文件内容再分析。",
params: "filePath, previewCharLimit?",
tool: {
type: "function",
function: {
name: "inspect_external_sql_file",
description:
"读取指定外部 SQL 文件的内容预览,仅用于已配置外部 SQL 目录中的 SQL 文件。返回文件路径、所属目录、绑定连接/数据库、是否已在工作区打开,以及截断后的正文内容。适用于用户提到某个目录中的具体 SQL 脚本、想让 AI 直接解释脚本逻辑、或想确认某个外部 SQL 文件内容时,先读真实文件再回答。",
parameters: {
type: "object",
properties: {
filePath: { type: "string", description: "必填,要读取的 SQL 文件绝对路径,通常先通过 inspect_external_sql_directories 找到" },
previewCharLimit: { type: "number", description: "可选,正文预览最多返回多少字符,默认 12000最大 40000" },
},
required: ["filePath"],
},
},
},
},
{
name: "inspect_active_tab",
icon: "📍",
desc: "查看当前活动页签上下文",
detail:
"返回当前活动页签的类型、连接、数据库、表名,以及当前 SQL / 命令页签里的草稿内容(超长会截断)。适合用户说“看我当前这条 SQL”“优化这个编辑器里的语句”时先让 AI 直接读取当前工作区上下文。",
params: "includeContent?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_active_tab",
description:
"获取当前活动页签的上下文快照,包括页签类型、连接、数据库、表名,以及当前 SQL / 命令页签里的草稿内容。适用于用户提到当前页签、当前 SQL、当前编辑器、这条语句时先读取真实界面上下文避免让模型猜测。",
parameters: {
type: "object",
properties: {
includeContent: { type: "boolean", description: "可选,是否附带页签中的 SQL / 命令草稿内容,默认 true" },
},
},
},
},
},
{
name: "inspect_workspace_tabs",
icon: "🗃️",
desc: "查看当前工作区打开的页签总览",
detail:
"返回当前工作区里打开的页签列表、哪个是活动页签,以及每个页签对应的连接、数据库、表名等上下文。适合用户说“我现在开了哪些 SQL”“看看我工作区里有哪些页签”“帮我对比这几个查询页签”时先读取真实工作区布局再继续分析。",
params: "limit?(默认 12), includeContent?(默认 false)",
tool: {
type: "function",
function: {
name: "inspect_workspace_tabs",
description:
"获取当前工作区已打开页签的总览,包括活动页签、页签类型、连接、数据库、表名,以及可选的 SQL / 命令草稿内容。适用于用户提到当前工作区、打开了哪些页签、哪几个查询页签、想对比多个编辑器内容时,先读取真实界面状态,避免模型猜测。",
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "可选,最多返回多少个页签,默认 12最大 30" },
includeContent: { type: "boolean", description: "可选,是否附带页签中的 SQL / 命令草稿内容,默认 false" },
},
},
},
},
},
];

View File

@@ -0,0 +1,206 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_INSPECTION_CORE_TOOL_INFO: AIBuiltinToolInfo[] = [
{
name: "inspect_app_health",
icon: "🧭",
desc: "一键查看 AI 应用健康总览",
detail:
"汇总 AI 配置、供应商发送前置、MCP 接入、应用日志 ERROR/WARN、最近连接失败/冷却、AI 回复气泡渲染异常和当前工作区页签给出阻塞项、运行期异常信号和下一步探针建议。适合用户说“AI 不稳定”“整体帮我看看”“连接和 MCP 一起排查”时先做一次全局摸底。",
params: "keyword?, connectionKeyword?, lineLimit?(默认 120), includeLogLines?(默认 false)",
tool: {
type: "function",
function: {
name: "inspect_app_health",
description:
"读取 GoNavi AI 应用健康总览,汇总 AI 供应商与发送前置、MCP 接入、应用日志 ERROR/WARN、最近连接失败/冷却、AI 回复气泡渲染异常和当前工作区页签,并返回阻塞项、运行期异常信号与下一步探针建议。适用于用户提到 AI 不稳定、整体不成熟、连接/MCP/日志/回复气泡异常需要一起排查或要求先看全局状态时,优先调用该工具。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选,读取应用日志时按关键词过滤,例如 ai、mcp、mysql、error不传则读取最近日志窗口" },
connectionKeyword: { type: "string", description: "可选,分析连接失败日志时按连接类型、地址或错误关键词过滤;不传时复用 keyword" },
lineLimit: { type: "number", description: "可选,每次最多分析多少行日志,默认 120最大 240" },
includeLogLines: { type: "boolean", description: "可选,是否在结果里附带日志原文行,默认 false需要引用原文时再开启" },
},
},
},
},
},
{
name: "inspect_ai_support_bundle",
icon: "📦",
desc: "导出 AI 排障支持包",
detail:
"一次性汇总 AI 应用健康、供应商与 MCP 状态、应用日志摘要、连接失败摘要、消息流结构、上下文体量、远程 MCP 接入和工具目录索引。适合用户反馈“AI 不稳定”“MCP/连接/日志一起看”“要给开发排障材料”时先生成一份不含密钥和数据库密码的支持包。",
params: "keyword?, sessionId?, lineLimit?(默认 120), includeLogLines?(默认 false), includeMessageContent?(默认 false), publicUrl?, tokenConfigured?",
tool: {
type: "function",
function: {
name: "inspect_ai_support_bundle",
description:
"生成 GoNavi AI 排障支持包,汇总 AI 应用健康、供应商和发送前置、MCP 配置和远程接入、应用日志摘要、数据库连接失败摘要、当前 AI 消息流、上下文体量风险和工具目录索引。默认不包含数据库密码、供应商密钥、MCP 环境变量值、日志原文或完整消息内容。适用于用户反馈 AI 不稳定、MCP/连接/日志问题交织、需要一次性导出排障证据或准备给开发定位时优先调用。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选,按关键词过滤日志和工具目录,例如 ai、mcp、mysql、error、openclaw" },
connectionKeyword: { type: "string", description: "可选,分析连接失败日志时使用的关键词;不传时复用 keyword" },
sessionId: { type: "string", description: "可选,指定要诊断的 AI 会话 ID不传时使用当前活动会话" },
lineLimit: { type: "number", description: "可选,最多分析多少行应用日志,默认 120最大 240" },
includeLogLines: { type: "boolean", description: "可选,是否附带日志原文行,默认 false需要引用原文时再开启" },
includeMessageContent: { type: "boolean", description: "可选,是否附带消息内容预览,默认 false排查气泡内容时再开启" },
includeDetails: { type: "boolean", description: "可选,是否附带上下文体量明细,默认 false" },
publicUrl: { type: "string", description: "可选,云端 Agent 访问 GoNavi MCP 的公网/隧道 URL用于远程 MCP 支持包" },
localAddr: { type: "string", description: "可选Windows 本机 HTTP MCP 监听地址,默认 127.0.0.1:8765" },
path: { type: "string", description: "可选Streamable HTTP MCP 路径,默认 /mcp" },
exposeStrategy: {
type: "string",
enum: ["reverse_proxy", "ssh_reverse_tunnel", "cloudflare_tunnel", "tailscale", "custom"],
description: "可选,远程暴露方式,用于生成对应安全提醒",
},
tokenConfigured: { type: "boolean", description: "可选,是否已经准备随机 Bearer Token传 false 会返回鉴权告警" },
},
},
},
},
},
{
name: "inspect_ai_setup_health",
icon: "🩺",
desc: "一键体检当前 AI 配置健康度",
detail:
"汇总当前 AI 供应商、聊天发送前置、MCP 服务与外部客户端接入、提示词与 Skills、上下文挂载情况并给出阻塞项、告警项和下一步建议。适合用户说“AI 为什么不好用”“帮我看下 AI 整体有没有问题”“现在这套 AI 配置还缺什么”时先做一次总览诊断。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_ai_setup_health",
description:
"体检当前 AI 配置健康度返回供应商、模型、聊天发送前置、MCP 接入、提示词与 Skills、表结构上下文挂载等整体快照并给出阻塞项、建议项和下一步动作。适用于用户提到 AI 为什么不好用、当前 AI 配置哪里还缺、是否已经能稳定工作时,优先读取这份总览诊断,不要拆成多次猜测。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_ai_runtime",
icon: "🎛️",
desc: "查看当前 AI 自身运行状态",
detail:
"返回当前启用的模型供应商、模型名、安全级别、上下文级别、启用的 Skills以及当前已暴露的内置工具和 MCP 工具。适合用户问“你现在能调用什么”“当前用的哪个模型”“为什么不能执行写操作”时,先读真实运行状态再回答。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_ai_runtime",
description:
"读取当前 AI 运行时快照,包括当前供应商、模型、安全级别、上下文级别、启用的 Skills、当前可用的内置工具与 MCP 工具。适用于用户询问当前 AI 能力边界、当前使用哪个模型、为什么不能执行某些操作时,先读取真实运行状态,避免模型猜测。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_ai_safety",
icon: "🛡️",
desc: "查看当前 AI 写入安全边界",
detail:
"返回当前 AI 安全级别对应的 SQL 允许范围、非只读语句是否仍需确认 / allowMutating以及当前活动连接、页签或 JVM 诊断权限是否还叠加了只读限制。适合用户问“为什么现在不能写”“DDL 能不能执行”“allowMutating 要不要传”时先读真实边界。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_ai_safety",
description:
"读取当前 AI 安全边界快照,包括当前安全级别允许的 SQL 范围、非查询语句的确认要求、MCP execute_sql 对 allowMutating 的要求,以及当前活动连接、结果页签或 JVM 诊断权限是否额外处于只读限制。适用于用户提到为什么现在不能写、当前是不是只读、DDL 能不能执行、allowMutating 是否必须传时,先读取真实边界再回答。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_ai_providers",
icon: "🪪",
desc: "查看当前 AI 供应商与模型配置",
detail:
"返回当前配置了哪些 AI 供应商、哪个正在生效、各自的 baseUrl、已选模型、声明模型列表、密钥是否存在、自定义请求头 key以及缺少密钥/模型/地址等待检查项。适合用户问“为什么没有模型”“API Key 有没有配”“当前到底配了哪些供应商”时先读真实配置。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_ai_providers",
description:
"读取当前 AI 供应商配置快照,包括供应商列表、活动供应商、接口地址、已选模型、声明模型列表、是否存在密钥、自定义请求头 key以及缺少密钥/模型/地址等待检查项。适用于用户提到当前供应商、模型列表为空、API Key 是否配置、为什么 AI 不能正常发起请求时,先读取真实配置再解释。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_ai_chat_readiness",
icon: "🚦",
desc: "查看当前 AI 聊天是否具备发送条件",
detail:
"返回当前聊天输入区是否已经具备发送条件,包括有没有活动供应商、当前供应商是否缺密钥或接口地址、是否已选模型、当前连接/表结构上下文是否已挂载,以及下一步建议动作。适合用户问“为什么现在不能发送”“输入框到底缺什么配置”“当前 AI 聊天准备好了没有”时先读真实状态。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_ai_chat_readiness",
description:
"读取当前 AI 聊天输入区的发送前置状态,包括活动供应商、密钥和接口地址是否完整、是否已选模型、当前连接上下文和已挂载表结构数量,以及建议的下一步动作。适用于用户提到为什么现在不能发送、为什么输入区还没准备好、当前到底缺什么配置时,先读取真实状态再回答。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_ai_upstream_logs",
icon: "📡",
desc: "查看 AI 上游请求入参与状态",
detail:
"从 gonavi.log 读取最近的 AI 上游请求开始/完成/失败记录,按 provider、requestId 或关键词过滤,返回请求体 body 预览、payload 结构摘要、endpoint、状态码、耗时和错误摘要。适合用户想核对发给上游模型的真实入参、排查请求参数兼容、确认工具是否随请求下发或脱敏日志是否写入时先调用。",
params: "provider?, requestId?, keyword?, lineLimit?(默认 160), requestLimit?(默认 12), includeBody?(默认 true), includePayloadSummary?(默认 true), includeLines?(默认 false)",
tool: {
type: "function",
function: {
name: "inspect_ai_upstream_logs",
description:
"读取 GoNavi 应用日志中的 AI 上游请求记录,返回 requestId、provider、method、endpoint、请求 body 预览、脱敏 payload 结构摘要、状态码、耗时和错误摘要。适用于用户提到 AI 请求入参、上游请求体、requestId、provider 请求参数、工具调用没有触发、模型接口报错、或需要核对刚才发给上游模型的真实 payload 时,先读取该工具,不要只凭界面响应推断。",
parameters: {
type: "object",
properties: {
provider: { type: "string", description: "可选,只看某个供应商,例如 openai、anthropic、gemini大小写不敏感" },
requestId: { type: "string", description: "可选,按日志里的 requestId 精确过滤,适合从错误日志继续追踪同一次请求" },
keyword: { type: "string", description: "可选,在 requestId、provider、endpoint、bodyPreview 或 error 中继续过滤,例如模型名、接口路径、参数名" },
lineLimit: { type: "number", description: "可选,最多读取多少行日志尾部,默认 160最大 300" },
requestLimit: { type: "number", description: "可选,最多返回多少个请求摘要,默认 12最大 40" },
includeBody: { type: "boolean", description: "可选,是否返回已脱敏的请求 body 预览,默认 true只看状态时可设为 false" },
includePayloadSummary: { type: "boolean", description: "可选,是否解析请求 body 并返回模型、消息角色分布、工具数量/名称、stream/tool_choice 等结构摘要,默认 true不返回消息正文或密钥" },
includeLines: { type: "boolean", description: "可选,是否附带脱敏后的原始日志行,默认 false需要引用原文时再开启" },
bodyPreviewLimit: { type: "number", description: "可选,单个 body 预览最大字符数,默认 6000最大 12000" },
},
},
},
},
},
{
name: "inspect_ai_tool_catalog",
icon: "🧭",
desc: "查看 AI 内置工具目录和参数提示",
detail:
"按关键词或工具名返回 GoNavi AI 内置工具、推荐探针流程、参数说明和当前 MCP 工具摘要。适合用户问“你该用哪个工具”“这个工具参数怎么填”“有哪些内置工具”或 AI 需要先选择探针路线时调用。",
params: "keyword?, toolName?, includeMCPTools?(默认 true), limit?(默认 12)",
tool: {
type: "function",
function: {
name: "inspect_ai_tool_catalog",
description:
"读取 GoNavi AI 工具目录快照,可按关键词或工具名筛选,返回推荐工具调用流程、内置工具说明、参数提示和当前已发现 MCP 工具摘要。适用于用户询问当前有哪些内置工具、某类问题该先调用哪个探针、工具 arguments 怎么填、或 AI 在处理复杂问题前需要先选择工具路线时优先调用。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选,按问题关键词过滤工具和流程,例如 mcp、连接失败、事务、快捷键、schema、日志" },
toolName: { type: "string", description: "可选,按内置工具名精确查询,例如 inspect_mcp_draft 或 inspect_sql_risk" },
includeMCPTools: { type: "boolean", description: "可选,是否同时返回当前已发现的 MCP 工具摘要,默认 true" },
limit: { type: "number", description: "可选,最多返回多少条流程、内置工具和 MCP 工具,默认 12最大 40" },
},
},
},
},
},
];

View File

@@ -0,0 +1,240 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_INSPECTION_DIAGNOSTICS_TOOL_INFO: AIBuiltinToolInfo[] = [
{
name: "inspect_app_logs",
icon: "🪵",
desc: "查看 GoNavi 应用日志尾部",
detail:
"可按关键词过滤,返回最近一段 GoNavi 应用日志里的 INFO/WARN/ERROR 行、级别分布、日志文件路径以及当前是否发生了日志窗口截断。适合用户提到“gonavi.log”“启动报错”“MCP 拉不起来”“数据库连接为什么失败”时,先读真实日志尾部再继续定位。",
params: "keyword?, lineLimit?(默认 80)",
tool: {
type: "function",
function: {
name: "inspect_app_logs",
description:
"读取 GoNavi 应用日志尾部,可按关键词过滤,并返回最近日志行、级别分布、日志路径和截断状态。适用于用户提到 gonavi.log、应用启动异常、MCP 启动失败、数据库连接报错或要求“看一下最近日志”时,优先读取真实应用日志,不要只凭界面现象推测。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选,按日志内容关键词过滤,例如 mcp、mysql、timeout、error" },
lineLimit: { type: "number", description: "可选,最多返回多少行日志,默认 80最大 200" },
},
},
},
},
},
{
name: "inspect_recent_connection_failures",
icon: "🧯",
desc: "总结最近数据库连接失败与冷却原因",
detail:
"从最近一段 gonavi.log 里提取数据库连接失败、连接验证失败、SSH 隧道异常和连接冷却命中记录自动归类主要问题类型、最新地址、最新根因和下一步建议。适合用户提到“为什么连接不上”“连接最近失败正在冷却”“验证失败”“SSH 隧道是不是有问题”时,先读这份结构化总结,而不是人工翻整段日志。",
params: "keyword?, lineLimit?(默认 120)",
tool: {
type: "function",
function: {
name: "inspect_recent_connection_failures",
description:
"汇总最近 GoNavi 应用日志中的数据库连接失败、连接验证失败、SSH 隧道失败和冷却命中记录并返回主要异常类别、最新地址、最新根因与建议动作。适用于用户提到为什么连接不上、最近一直命中连接冷却、服务端验证失败、multiStatements 或参数兼容异常时,优先读取这份结构化连接失败总结,不要直接让模型肉眼翻整段日志。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选,按连接类型、地址或异常关键词过滤,例如 mysql、ssh、timeout、127.0.0.1" },
lineLimit: { type: "number", description: "可选,最多分析多少行日志,默认 120最大 240" },
},
},
},
},
},
{
name: "inspect_ai_last_render_error",
icon: "🧯",
desc: "查看最近一次 AI 消息渲染异常记录",
detail:
"返回最近一次被前端隔离下来的 AI 消息渲染异常包括是哪条消息、消息内容预览、错误摘要和组件栈摘要。适合用户提到“AI 某条回复空白了”“某个气泡渲染失败”“消息块报错但面板没全挂”时,先读这份真实前端异常快照。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_ai_last_render_error",
description:
"读取最近一次 AI 消息渲染异常的本地快照,包括消息 ID、角色、内容预览、错误摘要、组件栈摘要和下一步排查建议。适用于用户提到 AI 消息空白、某条回复渲染失败、气泡局部报错但面板仍然存活时,先读取真实前端异常记录,不要只凭现象猜测。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_saved_queries",
icon: "💾",
desc: "查看本地已保存的 SQL 查询",
detail:
"可按关键词、连接或数据库过滤,返回保存查询的名称、所属连接、数据库和 SQL 预览。适合用户提到“我之前保存过的查询”“帮我找那条历史 SQL”时先从真实本地收藏里检索。",
params: "keyword?, connectionId?, dbName?, limit?, includeSql?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_saved_queries",
description:
"读取本地已保存的 SQL 查询列表,可按关键词、连接和数据库过滤,并返回每条查询的名称、所属连接、数据库与 SQL 预览。适用于用户想找历史查询、复用旧 SQL、核对保存脚本时先读取真实本地记录。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选按查询名称、SQL 文本、连接名或数据库名做关键词筛选" },
connectionId: { type: "string", description: "可选,只看某个连接下保存的查询" },
dbName: { type: "string", description: "可选,只看某个数据库下保存的查询" },
limit: { type: "number", description: "可选,最多返回多少条,默认 12最大 50" },
includeSql: { type: "boolean", description: "可选,是否附带 SQL 预览,默认 true" },
},
},
},
},
},
{
name: "inspect_ai_sessions",
icon: "🗂️",
desc: "查看本地 AI 历史会话清单",
detail:
"可按关键词过滤,返回本地 AI 会话标题、更新时间、消息数量、是否是当前会话,以及首条用户提问和最近一条消息预览。适合用户提到“之前那条 AI 对话”“帮我找上次聊过的记录”“最近哪个会话讲过这个问题”时先读真实会话资产。",
params: "keyword?, limit?, includePreview?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_ai_sessions",
description:
"读取本地 AI 历史会话清单,可按关键词过滤,并返回会话标题、更新时间、消息数量、是否是当前活动会话,以及首条用户问题和最近消息预览。适用于用户提到之前的 AI 对话、上次聊过的记录、最近哪个会话讲过某个问题时,先读取真实会话清单再继续定位。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选,按会话标题、会话 ID、首条用户问题或最近消息内容做关键词筛选" },
limit: { type: "number", description: "可选,最多返回多少条会话,默认 10最大 50" },
includePreview: { type: "boolean", description: "可选,是否附带首条用户问题和最近消息预览,默认 true" },
},
},
},
},
},
{
name: "inspect_ai_message_flow",
icon: "🧬",
desc: "诊断当前 AI 会话消息流",
detail:
"读取当前或指定 AI 会话的最近消息流,统计用户/助手/tool 消息、工具调用是否都有结果、是否出现连续 assistant 气泡、空 assistant 占位或未清理 loading。适合用户反馈“AI 回复被拆成多个气泡”“工具调用后没继续回答”“消息流看着不对”时先看真实消息结构。",
params: "sessionId?(默认当前会话), limit?(默认 24), includeContent?(默认 true), previewLimit?(默认 180)",
tool: {
type: "function",
function: {
name: "inspect_ai_message_flow",
description:
"读取当前或指定 AI 会话的最近消息流诊断包括消息角色序列、assistant/tool 消息数量、工具调用与 tool 结果匹配情况、连续 assistant 消息、空 assistant 消息和 loading 残留。适用于用户提到 AI 回复被拆成多个气泡、流式追加异常、工具调用没有闭环、某轮回答没有继续生成时,先读取真实消息结构再定位。",
parameters: {
type: "object",
properties: {
sessionId: { type: "string", description: "可选,指定要诊断的 AI 会话 ID不传时读取当前活动会话" },
limit: { type: "number", description: "可选,最多返回最近多少条消息,默认 24最大 80" },
includeContent: { type: "boolean", description: "可选,是否附带消息内容预览,默认 true" },
previewLimit: { type: "number", description: "可选,每条消息预览字符数,默认 180最大 1000" },
},
},
},
},
},
{
name: "inspect_ai_context_budget",
icon: "📦",
desc: "诊断 AI 上下文体量与稳定性风险",
detail:
"统计当前或指定 AI 会话的最近消息、工具结果、已挂载表结构、MCP 工具 schema、用户提示词和 Skills 体量,返回 low/medium/high/critical 风险、主要膨胀来源和收窄建议。适合用户反馈 AI 变慢、乱答、上下文太大、工具结果过长或表结构挂太多时先做预算体检。",
params: "sessionId?(默认当前会话), messageLimit?(默认 40), includeDetails?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_ai_context_budget",
description:
"读取当前 AI 上下文体量与稳定性风险快照包括最近消息窗口、tool 结果长度、已挂载表结构 DDL、MCP 工具 schema、用户提示词和启用 Skills 的估算体量,并返回风险级别、告警和收窄建议。适用于用户提到 AI 回复变慢、上下文过大、表结构带太多、工具结果过长、模型开始乱答或复杂任务前需要判断是否应拆小上下文时优先调用。",
parameters: {
type: "object",
properties: {
sessionId: { type: "string", description: "可选,指定要诊断的 AI 会话 ID不传时读取当前活动会话" },
messageLimit: { type: "number", description: "可选,最多统计最近多少条消息,默认 40最大 120" },
includeDetails: { type: "boolean", description: "可选,是否返回最大消息、最大 DDL 表和最大 MCP schema 明细,默认 true" },
},
},
},
},
},
{
name: "inspect_codebase_hotspots",
icon: "🧱",
desc: "查看前端大文件和拆分热点",
detail:
"返回当前 GoNavi 前端代码中的大文件热点、行数、风险等级、拆分成熟度、安全边界、建议拆分切片和应该运行的回归测试。适合用户要求继续治理几千行大文件、评估下一步该拆哪个组件,或 AI 在修改前需要先判断改动风险时调用。",
params: "keyword?, minLines?(默认 1000), limit?(默认 8), includeRecommendations?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_codebase_hotspots",
description:
"读取 GoNavi 前端大文件和拆分热点快照,返回文件路径、行数、风险等级、拆分成熟度、首选切片、安全拆分边界、建议拆分切片、测试目标和验证计划。适用于用户提到几千行文件太臃肿、需要继续拆分组件、评估下一个重构切入点或在改 UI/AI/MCP 前需要先判断代码热点风险时优先调用。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选,按路径、模块、风险、拆分切片或测试目标过滤,例如 Sidebar、DataGrid、Redis、事务、连接" },
minLines: { type: "number", description: "可选,只返回不少于多少行的热点文件,默认 1000最大 20000" },
limit: { type: "number", description: "可选,最多返回多少个热点,默认 8最大 30" },
includeRecommendations: { type: "boolean", description: "可选,是否返回 suggestedSlices、testTargets 和 nextActions默认 true" },
},
},
},
},
},
{
name: "inspect_sql_snippets",
icon: "🧩",
desc: "查看 SQL 片段模板",
detail:
"返回本地 SQL 片段的 prefix、名称、说明和模板预览可按关键词过滤。适合用户想找现成模板、补全片段、团队约定 SQL 模板时先读取真实片段库。",
params: "keyword?, limit?, includeBody?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_sql_snippets",
description:
"读取本地 SQL 片段模板列表,可按关键词过滤,并返回 prefix、名称、说明和模板预览。适用于用户想找 snippet、复用模板、核对 SQL 片段配置时,先读取真实本地片段库。",
parameters: {
type: "object",
properties: {
keyword: { type: "string", description: "可选,按 prefix、名称、描述或模板内容做关键词筛选" },
limit: { type: "number", description: "可选,最多返回多少条,默认 20最大 80" },
includeBody: { type: "boolean", description: "可选,是否附带模板内容预览,默认 true" },
},
},
},
},
},
{
name: "inspect_shortcuts",
icon: "⌨️",
desc: "查看当前快捷键配置与平台差异",
detail:
"返回当前快捷键动作、当前平台绑定、Win/Mac 双平台组合键、是否被用户改过以及默认值对照。适合用户问“当前这个快捷键是什么”“Win 和 Mac 分别怎么按”“我是不是改过默认快捷键”时先读真实配置。",
params: "action?, keyword?, includeDisabled?(默认 true), includeAllPlatforms?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_shortcuts",
description:
"读取当前 GoNavi 快捷键配置快照可按动作名或关键词过滤并返回当前平台绑定、Win/Mac 双平台组合键、默认值和是否被用户改过。适用于用户提到快捷键、Win/Mac 键位差异、当前结果区/AI/查询相关快捷键是什么时,先读取真实配置,不要凭记忆回答默认值。",
parameters: {
type: "object",
properties: {
action: { type: "string", description: "可选,按动作 key 精确过滤,例如 toggleQueryResultsPanel、sendAIChatMessage、toggleAIPanel" },
keyword: { type: "string", description: "可选,按动作名、说明、作用域、组合键或默认值做关键词筛选" },
includeDisabled: { type: "boolean", description: "可选,是否包含当前被禁用的快捷键,默认 true" },
includeAllPlatforms: { type: "boolean", description: "可选,是否同时返回 Windows 和 macOS 两个平台绑定,默认 true" },
},
},
},
},
},
];

View File

@@ -0,0 +1,176 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_INSPECTION_MCP_TOOL_INFO: AIBuiltinToolInfo[] = [
{
name: "inspect_mcp_setup",
icon: "🪛",
desc: "查看当前 MCP 配置与外部接入状态",
detail:
"返回当前本地配置了哪些 MCP 服务、哪些已启用、每个服务声明了什么启动命令,以及 Claude Code / Codex 本机客户端写入状态、OpenClaw / Hermans 远程 Agent 接入边界与命令检测结果。适合用户问“我现在配了哪些 MCP”“为什么外部客户端还用不了”“MCP 到底写没写进去”时先读真实状态。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_mcp_setup",
description:
"读取当前本地 MCP 配置快照,包括 MCP 服务列表、启用状态、启动命令、环境变量 key、已发现工具以及外部客户端的 GoNavi MCP 写入状态、本机 CLI 检测结果和远程 Agent 接入边界。适用于用户提到 MCP 服务配置、Claude/Codex/OpenClaw/Hermans 是否已接入、为什么外部客户端用不了、当前到底启用了哪些 MCP 时,先读取真实配置再回答。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_mcp_remote_access",
icon: "🌉",
desc: "查看 OpenClaw/Hermans 远程 MCP 接入方式",
detail:
"返回 GoNavi Streamable HTTP MCP 的本机启动命令、远程 URL/鉴权填写方式、OpenClaw/Hermans 云端 Agent 接入边界、可选桥接方案和安全提醒。适合用户说“OpenClaw 在云上怎么连 Windows GoNavi”“不要把数据库密码交给 Agent”“HTTP MCP 该怎么暴露”时先读这份远程接入快照。",
params: "publicUrl?, localAddr?, path?, exposeStrategy?, tokenConfigured?",
tool: {
type: "function",
function: {
name: "inspect_mcp_remote_access",
description:
"读取 GoNavi MCP 远程 Agent 接入快照,返回 Streamable HTTP 模式启动命令、/mcp URL、Bearer Token 鉴权要求、OpenClaw/Hermans 云端接入步骤、数据库密码留在 Windows 本机的安全边界,以及隧道/反向代理/Tailscale 等暴露方式的风险提示。适用于用户提到 OpenClaw、Hermans、云端 Linux Agent、远程 MCP、不要复制数据库密码、或本机 GoNavi 如何给外部 Agent 访问表结构时优先调用。",
parameters: {
type: "object",
properties: {
publicUrl: { type: "string", description: "可选,云端 Agent 最终能访问的 HTTPS 或私有网络 URL如果没带 /mcp工具会按 path 补上" },
localAddr: { type: "string", description: "可选Windows 本机 HTTP MCP 监听地址,默认 127.0.0.1:8765不建议直接绑定 0.0.0.0" },
path: { type: "string", description: "可选Streamable HTTP MCP 路径,默认 /mcp" },
exposeStrategy: {
type: "string",
enum: ["reverse_proxy", "ssh_reverse_tunnel", "cloudflare_tunnel", "tailscale", "custom"],
description: "可选,计划使用的远程暴露方式,用于返回对应风险提醒",
},
tokenConfigured: { type: "boolean", description: "可选,是否已经准备随机 Bearer Token传 false 会返回鉴权告警" },
},
},
},
},
},
{
name: "inspect_mcp_runtime_failures",
icon: "🧯",
desc: "诊断 MCP 启动与调用失败",
detail:
"读取 gonavi.log 中最近的 MCP 启动、工具发现、工具调用和 HTTP MCP 子进程异常,结合当前已保存 MCP 服务与已发现工具,返回失败类型、疑似原因、涉及服务和下一步修复动作。适合用户反馈“新增 MCP 测试失败”“工具发现 0 个”“MCP 工具调用失败”“HTTP MCP 启动失败”时先调用。",
params: "serverName?, keyword?, lineLimit?(默认 160), includeLines?(默认 false)",
tool: {
type: "function",
function: {
name: "inspect_mcp_runtime_failures",
description:
"读取 GoNavi 应用日志中的 MCP 运行期失败信号,归类 MCP 服务启动失败、工具发现失败、工具调用失败和 HTTP MCP 子进程异常,并结合当前 MCP 服务配置与已发现工具数量返回疑似原因和 nextActions。适用于用户提到新增 MCP 测试失败、工具发现 0 个、MCP 工具调用失败、stdio 断开、命令找不到、Docker MCP 退出或 HTTP MCP 启动失败时,先读取该工具,不要只凭弹窗文案猜测。",
parameters: {
type: "object",
properties: {
serverName: { type: "string", description: "可选,只看某个 MCP 服务名或日志中的 server= 名称,例如 GitHub、Browser、DockerFetch" },
keyword: { type: "string", description: "可选,在 MCP 相关日志里继续按关键词过滤,例如 timeout、stdio、permission、401、docker" },
lineLimit: { type: "number", description: "可选,最多读取多少行日志尾部,默认 160最大 200" },
includeLines: { type: "boolean", description: "可选,是否附带脱敏后的 MCP 日志原文行,默认 false需要引用原文时再开启" },
},
},
},
},
},
{
name: "inspect_mcp_authoring_guide",
icon: "🧭",
desc: "查看新增 MCP 的填写指引",
detail:
"返回新增 MCP 表单里各字段的作用、推荐填写顺序、完整命令自动拆分规则,以及 npx / Node / uvx / Python / Docker / EXE 模板样例。适合用户问“command/args/env 到底怎么填”“给我一个 npx / node / uvx / python / docker 示例”“为什么启动命令不能整行填”时,先读这份真实接入指引。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_mcp_authoring_guide",
description:
"读取 GoNavi 当前内置的 MCP 新增指引,包括推荐填写顺序、字段作用、常见命令示例、完整命令自动拆分规则,以及 npx / Node / uvx / Python / Docker / EXE 模板样例。适用于用户提到新增 MCP 不知道 command、args、env、timeout 怎么填,或想要一个最接近的模板时,先读取这份真实前端接入指南,不要凭记忆口述。",
parameters: { type: "object", properties: {} },
},
},
},
{
name: "inspect_mcp_docker_setup",
icon: "🐳",
desc: "检查 Docker MCP 启动配置",
detail:
"读取当前已保存的 Docker MCP 服务,检查 command/args 是否正确拆成 docker、run、--rm、-i、镜像名和容器参数并返回缺失参数、已发现工具数、超时建议和下一步修复动作。适合用户按 Docker README 新增 MCP 后工具发现失败、容器一启动就退出、或不确定 docker run 参数该怎么填时调用。",
params: "serverId?, includeDisabled?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_mcp_docker_setup",
description:
"检查当前已保存 Docker MCP 服务的启动参数,返回 Docker MCP 服务列表、docker run/-i/镜像名/--rm/env/timeout 状态、工具发现数量、配置告警和 nextActions。适用于用户提到 Docker MCP、docker run、容器化 MCP、工具发现 0 个、容器 stdio 断开、或 AI 准备指导用户修复 Docker MCP 配置时,先读取真实配置快照。",
parameters: {
type: "object",
properties: {
serverId: { type: "string", description: "可选,只检查某个 MCP serverId不传则检查全部 Docker MCP" },
includeDisabled: { type: "boolean", description: "可选,是否包含已禁用 Docker MCP默认 true" },
},
},
},
},
},
{
name: "inspect_mcp_draft",
icon: "🧪",
desc: "校验 MCP 新增草稿",
detail:
"按完整启动命令或分字段草稿试算 GoNavi 的 MCP 新增配置,返回自动拆分结果、启动预览、可应用草稿、命令参数用途提示、环境变量用途提示、字段校验问题、推荐模板和下一步修复建议;命令参数里的敏感值会脱敏。适合用户贴出一整行 MCP 启动命令、问 command/args/env/timeout 该怎么拆,或保存前想确认配置有没有明显问题时使用。",
params: "fullCommand?, command?, args?, envText?, timeoutSeconds?, templateKey?, name?",
tool: {
type: "function",
function: {
name: "inspect_mcp_draft",
description:
"校验一份待新增的 MCP 服务草稿。支持传 fullCommand/rawCommand/commandLine 让 GoNavi 自动拆分,也支持传 command、args、envText、timeoutSeconds 和 templateKey 做分字段校验返回解析后的字段、脱敏启动命令预览、suggestedServerSeed、命令参数用途提示、环境变量 key 的用途和风险提示、错误/告警、推荐模板和 nextActions。适用于用户贴出 MCP README 启动命令、问新增 MCP 参数怎么填、或 AI 准备指导用户保存前,先用真实校验器试算;结果不会回显 api-key/token/password 等敏感参数值。",
parameters: {
type: "object",
properties: {
fullCommand: { type: "string", description: "可选README 或用户贴出的一整行 MCP 启动命令,例如 $env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio" },
command: { type: "string", description: "可选,分字段草稿里的启动命令,只应是 npx、node、uvx、python 或 exe 路径本身" },
args: {
oneOf: [
{ type: "array", items: { type: "string" } },
{ type: "string" },
],
description: "可选,分字段草稿里的命令参数;数组更准确,也可传逗号或换行分隔字符串",
},
envText: { type: "string", description: "可选,环境变量草稿,每行 KEY=VALUE不要传 export、set 或 $env: 前缀" },
timeoutSeconds: { type: "number", description: "可选,单次工具发现或调用超时秒数;推荐 20慢启动服务可用 45 或 60" },
templateKey: { type: "string", enum: ["npx", "uvx", "node", "python", "docker", "exe"], description: "可选,先套用一个内置模板再覆盖用户传入字段" },
name: { type: "string", description: "可选MCP 服务名称,例如 GitHub、Filesystem、Browser" },
},
},
},
},
},
{
name: "inspect_mcp_tool_schema",
icon: "🧩",
desc: "查看 MCP 工具参数怎么传",
detail:
"按 alias、serverId 或关键词查看当前已发现 MCP 工具的 inputSchema返回必填参数、字段类型、枚举值、嵌套对象路径和调用前提示。适合新增 MCP 成功后,用户或 AI 不知道某个 MCP 工具到底该传哪些参数时先读真实 schema。",
params: "alias?, serverId?, keyword?, includeSchema?(默认 false), limit?(默认 8)",
tool: {
type: "function",
function: {
name: "inspect_mcp_tool_schema",
description:
"读取当前已发现 MCP 工具的参数 schema 摘要,可按 alias、serverId 或关键词过滤,并返回必填字段、类型、枚举值、嵌套参数路径和调用前提示。适用于用户问某个 MCP 工具参数怎么填、AI 准备调用外部 MCP 工具但不确定 arguments JSON 怎么写、或工具调用报参数错误时,先读取真实 inputSchema 再继续。",
parameters: {
type: "object",
properties: {
alias: { type: "string", description: "可选,按 MCP 工具 alias 精确查询,例如 github_create_issue优先通过 inspect_mcp_setup 获取真实 alias" },
serverId: { type: "string", description: "可选,只看某个 MCP serverId 下发现的工具" },
keyword: { type: "string", description: "可选,按工具 alias、原始名称、标题、描述或服务名做关键词筛选" },
includeSchema: { type: "boolean", description: "可选,是否附带完整原始 inputSchema默认 false需要深查复杂嵌套 schema 时再开启" },
limit: { type: "number", description: "可选,最多返回多少个匹配工具,默认 8最大 30" },
},
},
},
},
},
];

View File

@@ -0,0 +1,110 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_INSPECTION_SQL_TOOL_INFO: AIBuiltinToolInfo[] = [
{
name: "inspect_recent_sql_logs",
icon: "🧾",
desc: "查看最近 SQL 执行日志",
detail:
"传入可选 limit 和 status返回最近 SQL 执行记录,包括数据库、耗时、成功/失败、报错、受影响行数和 SQL 文本。适合追查刚执行失败的语句、定位慢查询,并让 AI 基于真实执行历史给出解释或优化建议。",
params: "limit?, status?(all|success|error)",
tool: {
type: "function",
function: {
name: "inspect_recent_sql_logs",
description:
"获取最近 SQL 执行日志摘要,可按成功/失败过滤。适用于回看刚执行过的 SQL、排查失败原因、定位慢查询以及让 AI 基于真实执行历史给出解释和优化建议。",
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "可选,返回多少条日志,默认 20最大 100" },
status: {
type: "string",
description: "可选,按执行状态过滤,支持 all、success、error默认 all",
enum: ["all", "success", "error"],
},
},
},
},
},
},
{
name: "inspect_recent_sql_activity",
icon: "📊",
desc: "总结最近 SQL 活动分布",
detail:
"可按 status、activityKind、dbName 和 keyword 过滤,返回最近 SQL 活动的结构化总结,包括读写/DDL 比例、语句类型分布、数据库分布、最近报错、最近写操作和最慢语句。适合用户提到“最近都执行了什么”“是不是刚删过数据”“哪个库最近报错最多”“最近主要在跑查询还是写入”时先读真实执行画像。",
params: "limit?, status?(all|success|error), activityKind?(all|read|write|ddl|transaction|session|other), dbName?, keyword?",
tool: {
type: "function",
function: {
name: "inspect_recent_sql_activity",
description:
"汇总最近 SQL 活动的结构化画像,可按执行状态、活动类型、数据库名和关键词过滤。适用于排查最近主要在执行哪些读写操作、某个库近期错误是否集中、是否发生过删除或 DDL、以及让 AI 基于真实执行现场先做全局判断。",
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "可选,最近活动样例最多返回多少条,默认 30最大 100" },
status: {
type: "string",
description: "可选,按执行状态过滤,支持 all、success、error默认 all",
enum: ["all", "success", "error"],
},
activityKind: {
type: "string",
description: "可选,按活动类型过滤,支持 all、read、write、ddl、transaction、session、other默认 all",
enum: ["all", "read", "write", "ddl", "transaction", "session", "other"],
},
dbName: { type: "string", description: "可选,只看数据库名里包含该关键词的日志" },
keyword: { type: "string", description: "可选,按 SQL 文本、报错信息、语句类型或数据库名做关键词筛选" },
},
},
},
},
},
{
name: "inspect_sql_editor_transaction",
icon: "🔁",
desc: "查看 SQL 编辑器事务提交状态",
detail:
"返回 SQL 编辑器 DML 托管事务语义、当前手动/自动提交设置、活动 SQL 页签是否会进入托管事务、待提交事务以及最近写入/事务执行记录。适合用户问“手动/自动提交到底是什么意思”“当前有没有事务没提交”“执行 update/insert/delete 会不会自动提交”时先读真实状态。",
params: "includeSqlPreview?(默认 true)",
tool: {
type: "function",
function: {
name: "inspect_sql_editor_transaction",
description:
"读取 SQL 编辑器事务状态快照,包括 DML 始终进入托管事务的真实语义、当前提交模式、自动提交延迟、活动 SQL 页签是否会触发托管事务、待提交事务列表和最近写入/事务日志。适用于用户提到 SQL 编辑器手动提交、自动提交、未提交事务、DML 执行后是否提交或事务语义不清时,先读取真实状态再解释。",
parameters: {
type: "object",
properties: {
includeSqlPreview: { type: "boolean", description: "可选,是否返回活动 SQL 页签的 SQL 预览,默认 true" },
},
},
},
},
},
{
name: "inspect_sql_risk",
icon: "🛑",
desc: "检查当前或指定 SQL 的执行风险",
detail:
"读取传入 SQL 或当前活动查询页签内容识别多语句、写入、DDL、DELETE/UPDATE 无 WHERE、DROP/TRUNCATE 等风险,并结合当前 AI 安全策略返回是否允许执行。适合用户让 AI 执行、解释风险、确认能不能跑某条 SQL 前先做一次安全体检。",
params: "sql?(默认读取当前活动查询页签), previewCharLimit?",
tool: {
type: "function",
function: {
name: "inspect_sql_risk",
description:
"检查传入 SQL 或当前活动查询页签 SQL 的执行风险,返回语句数量、活动类型、风险级别、危险点、是否需要用户确认,以及当前 AI 安全策略检查结果。适用于用户要求执行、删除、更新、DDL、批量 SQL、或询问某条 SQL 能不能跑时,先读取这份风险快照再回答或继续执行。",
parameters: {
type: "object",
properties: {
sql: { type: "string", description: "可选,要检查的 SQL不传时默认读取当前活动查询页签的 SQL 草稿" },
previewCharLimit: { type: "number", description: "可选SQL 预览最多返回多少字符,默认 12000最大 40000" },
},
},
},
},
},
];

View File

@@ -0,0 +1,14 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
import { BUILTIN_AI_INSPECTION_CONTEXT_TOOL_INFO } from "./aiBuiltinInspectionContextToolInfo";
import { BUILTIN_AI_INSPECTION_CORE_TOOL_INFO } from "./aiBuiltinInspectionCoreToolInfo";
import { BUILTIN_AI_INSPECTION_DIAGNOSTICS_TOOL_INFO } from "./aiBuiltinInspectionDiagnosticsToolInfo";
import { BUILTIN_AI_INSPECTION_MCP_TOOL_INFO } from "./aiBuiltinInspectionMcpToolInfo";
import { BUILTIN_AI_INSPECTION_SQL_TOOL_INFO } from "./aiBuiltinInspectionSqlToolInfo";
export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [
...BUILTIN_AI_INSPECTION_CORE_TOOL_INFO,
...BUILTIN_AI_INSPECTION_MCP_TOOL_INFO,
...BUILTIN_AI_INSPECTION_CONTEXT_TOOL_INFO,
...BUILTIN_AI_INSPECTION_SQL_TOOL_INFO,
...BUILTIN_AI_INSPECTION_DIAGNOSTICS_TOOL_INFO,
];

View File

@@ -0,0 +1,97 @@
import { describe, expect, it } from 'vitest';
import {
BUILTIN_TOOL_FLOWS,
describeBuiltinToolParameters,
filterBuiltinToolFlows,
filterBuiltinTools,
} from './aiBuiltinToolCatalog';
import type { AIBuiltinToolInfo } from './aiBuiltinToolInfo.types';
import { BUILTIN_AI_TOOL_INFO } from './aiToolRegistry';
describe('describeBuiltinToolParameters', () => {
it('extracts type, required, enum, default, and example hints from builtin tool schemas', () => {
const tool: AIBuiltinToolInfo = {
name: 'inspect_demo',
icon: '🧪',
desc: '测试工具',
detail: '用于测试参数提示提取。',
params: 'lineLimit?, mode?, serverName?',
tool: {
type: 'function',
function: {
name: 'inspect_demo',
description: '测试工具',
parameters: {
type: 'object',
required: ['mode'],
properties: {
lineLimit: { type: 'number', description: '可选,最多读取多少行,默认 160最大 200' },
mode: { type: 'string', enum: ['fast', 'safe'], default: 'safe', description: '运行模式' },
serverName: { type: 'string', description: '可选,例如 GitHub、Browser、DockerFetch' },
includeDisabled: { type: ['boolean', 'null'], description: '是否包含禁用项,默认 false' },
},
},
},
},
};
expect(describeBuiltinToolParameters(tool)).toEqual([
{
name: 'lineLimit',
required: false,
typeLabel: 'number',
description: '可选,最多读取多少行,默认 160最大 200',
enumValues: [],
defaultValue: '160',
exampleValue: '',
},
{
name: 'mode',
required: true,
typeLabel: 'string',
description: '运行模式',
enumValues: ['fast', 'safe'],
defaultValue: 'safe',
exampleValue: '',
},
{
name: 'serverName',
required: false,
typeLabel: 'string',
description: '可选,例如 GitHub、Browser、DockerFetch',
enumValues: [],
defaultValue: '',
exampleValue: 'GitHub、Browser、DockerFetch',
},
{
name: 'includeDisabled',
required: false,
typeLabel: 'boolean | null',
description: '是否包含禁用项,默认 false',
enumValues: [],
defaultValue: 'false',
exampleValue: '',
},
]);
});
it('filters flows and tools by parameter names and descriptions', () => {
const allowMutatingTools = filterBuiltinTools(BUILTIN_AI_TOOL_INFO, 'allowMutating')
.map((tool) => tool.name);
expect(allowMutatingTools).toContain('inspect_ai_safety');
expect(allowMutatingTools).not.toContain('inspect_mcp_runtime_failures');
const executeSqlTools = filterBuiltinTools(BUILTIN_AI_TOOL_INFO, '要执行的 SQL 语句')
.map((tool) => tool.name);
expect(executeSqlTools).toContain('execute_sql');
const mcpFlows = filterBuiltinToolFlows(BUILTIN_TOOL_FLOWS, '运行期失败日志')
.map((flow) => flow.title);
expect(mcpFlows).toContain('排查 MCP 接入状态');
const codebaseFlows = filterBuiltinToolFlows(BUILTIN_TOOL_FLOWS, '拆分热点')
.map((flow) => flow.title);
expect(codebaseFlows).toContain('治理前端大文件');
});
});

View File

@@ -0,0 +1,349 @@
import type { AIBuiltinToolInfo } from './aiBuiltinToolInfo.types';
export interface AIBuiltinToolFlow {
title: string;
steps: string;
description: string;
}
export interface AIBuiltinToolParameterHint {
name: string;
required: boolean;
typeLabel: string;
description: string;
enumValues: string[];
defaultValue: string;
exampleValue: string;
}
export const BUILTIN_TOOL_FLOWS: AIBuiltinToolFlow[] = [
{
title: '定位表与字段',
steps: 'get_connections -> get_databases -> get_tables -> get_columns',
description: '适合先找连接、找库、找表,再确认真实字段名后生成 SQL。',
},
{
title: '字段反查表',
steps: 'get_databases -> get_all_columns',
description: '适合只知道字段名、业务含义或注释关键词,但还不确定具体落在哪张表。',
},
{
title: '结构深挖',
steps: 'get_columns -> get_indexes -> get_foreign_keys -> get_triggers -> get_table_ddl',
description: '适合做索引优化、关系梳理、隐式副作用排查和 DDL 审查。',
},
{
title: '一键结构快照',
steps: 'inspect_table_bundle',
description: '适合一次带回字段、索引、外键、触发器和 DDL必要时还能附带样例行减少来回调用。',
},
{
title: '全库快速摸底',
steps: 'inspect_database_bundle -> inspect_table_bundle',
description: '适合先看整库有哪些表、每张表大概有哪些字段,再对目标表继续做深挖快照。',
},
{
title: 'AI 应用健康总览',
steps: 'inspect_app_health -> inspect_ai_setup_health / inspect_app_logs / inspect_recent_connection_failures / inspect_ai_last_render_error / inspect_ai_message_flow',
description: '适合用户反馈 AI 不稳定、连接和 MCP 问题交织、回复气泡显示异常,或需要先看整体健康状态时,一次汇总配置、日志、连接失败、渲染异常、消息流和工作区现场。',
},
{
title: '导出 AI 排障支持包',
steps: 'inspect_ai_support_bundle -> inspect_app_health / inspect_ai_context_budget / inspect_ai_message_flow / inspect_mcp_remote_access',
description: '适合需要一次性带走排障证据,或用户反馈 AI 不成熟、不稳定、MCP/连接/日志/上下文都可能相关时,先生成不含密钥和数据库密码的支持包。',
},
{
title: '选择 AI 工具路线',
steps: 'inspect_ai_tool_catalog -> inspect_ai_runtime / inspect_mcp_setup',
description: '适合先按关键词确认该用哪些内置探针、每个工具 arguments 怎么填,以及当前有没有外部 MCP 工具可用。',
},
{
title: '一键体检 AI 配置',
steps: 'inspect_ai_setup_health -> inspect_ai_providers / inspect_mcp_setup / inspect_ai_guidance',
description: '适合先拿到一份 AI 配置健康快照看清当前是供应商没配好、聊天发送前置没满足、MCP 没接入,还是提示词 / Skills / 上下文还不完整,再决定往哪条探针继续下钻。',
},
{
title: '查看 AI 当前能力',
steps: 'inspect_ai_runtime -> inspect_ai_context / inspect_current_connection',
description: '适合先确认当前模型、安全级别、上下文级别、Skills 和 MCP 工具,再决定让 AI 走哪条探针链路。',
},
{
title: '核对写入安全边界',
steps: 'inspect_ai_safety -> inspect_ai_runtime -> inspect_current_connection',
description: '适合先确认当前是不是只读、DDL/DML 到底允不允许、MCP 写操作是否还需要 allowMutating再决定后续该走查询、改数据还是改结构。',
},
{
title: '排查供应商与模型',
steps: 'inspect_ai_providers -> inspect_ai_runtime',
description: '适合先确认当前到底配置了哪些供应商、哪个在生效、有没有缺密钥或没选模型,再解释为什么 AI 不能发送、为什么模型列表为空。',
},
{
title: '排查聊天发送状态',
steps: 'inspect_ai_chat_readiness -> inspect_ai_providers',
description: '适合先确认当前聊天输入区到底缺什么前置条件,例如没选活动供应商、缺密钥、缺接口地址、没选模型,避免只凭界面现象猜测。',
},
{
title: '追踪 AI 上游请求',
steps: 'inspect_ai_upstream_logs -> inspect_ai_providers / inspect_ai_message_flow',
description: '适合用户想看发给上游模型的真实入参、requestId、状态码、耗时或请求体预览时先读脱敏后的 gonavi.log 请求记录,再结合供应商配置和当前消息流继续排查。',
},
{
title: '排查 MCP 接入状态',
steps: 'inspect_mcp_setup -> inspect_mcp_runtime_failures -> inspect_ai_runtime',
description: '适合先确认当前配置了哪些 MCP 服务、哪些已启用、外部客户端有没有写入当前 GoNavi 路径,再结合 MCP 运行期失败日志判断为什么某个工具没暴露出来。',
},
{
title: '远程 Agent 接入 GoNavi MCP',
steps: 'inspect_mcp_remote_access -> inspect_mcp_setup -> inspect_ai_safety',
description: '适合 OpenClaw/Hermans 部署在云端 Linux但数据库连接和密码只在 Windows GoNavi 本机时,先生成 HTTP MCP、Bearer Token、隧道和安全边界指引。',
},
{
title: '新增 MCP 填写指引',
steps: 'inspect_mcp_authoring_guide -> inspect_mcp_draft -> inspect_mcp_setup',
description: '适合先读真实字段说明、模板样例和整行命令拆分规则,再把用户贴出的命令或草稿交给真实校验器试算,最后结合当前 MCP 配置现状判断应该新增哪种启动方式。',
},
{
title: '排查 Docker MCP 启动',
steps: 'inspect_mcp_runtime_failures -> inspect_mcp_docker_setup -> inspect_mcp_draft',
description: '适合用户按 Docker README 新增 MCP 后发现 0 个工具、容器一启动就退出,或不确定 docker run 参数是否拆对时,先看运行期失败原因,再检查 run、-i、镜像名和超时设置。',
},
{
title: '查看 MCP 工具参数',
steps: 'inspect_mcp_setup -> inspect_mcp_tool_schema',
description: '适合先找到当前真实发现到的 MCP 工具 alias再读取对应 inputSchema、必填字段、枚举和嵌套参数路径避免调用外部 MCP 工具时乱填 arguments。',
},
{
title: '查看当前提示与 Skills',
steps: 'inspect_ai_guidance -> inspect_ai_runtime',
description: '适合先确认当前自定义提示词、启用的 Skills、依赖工具和生效范围再解释为什么 AI 当前会这样回答或为什么某个规则没有触发。',
},
{
title: '查看当前 AI 上下文',
steps: 'inspect_ai_context -> inspect_table_bundle / get_columns',
description: '适合先确认这轮对话当前到底挂了哪些表结构,再继续做字段核对、表设计评审或 SQL 生成。',
},
{
title: '查看当前连接',
steps: 'inspect_current_connection -> get_databases / get_tables',
description: '适合先确认当前活动数据源的类型、地址、当前库和 SSH/代理状态,再继续做库表探索或连接问题排查。',
},
{
title: '核对数据源能力边界',
steps: 'inspect_connection_capabilities -> inspect_current_connection',
description: '适合先确认当前连接到底支不支持建库、删库、结果编辑、SQL 导出或近似计数,再解释为什么某些按钮没出现或某类操作只能只读。',
},
{
title: '盘点本地连接资产',
steps: 'inspect_saved_connections -> inspect_current_connection / get_databases',
description: '适合先按关键词或类型筛出本地保存的数据源,再挑目标连接继续看当前状态或库表结构。',
},
{
title: '诊断 Redis 拓扑',
steps: 'inspect_redis_topology -> inspect_current_connection / inspect_app_logs',
description: '适合用户问 Redis 哨兵、Cluster、多节点、切库失败或 SSH 隧道不可用时,先拿到状态分级、脱敏 URI、后端适配器、DB 语义和下一步动作。',
},
{
title: '盘点外部 SQL 目录',
steps: 'inspect_external_sql_directories -> inspect_workspace_tabs / inspect_active_tab',
description: '适合先确认本地配置了哪些外部 SQL 目录、目录绑定到哪个连接/库,以及当前打开的 SQL 文件来自哪里,再继续分析脚本内容。',
},
{
title: '读取外部 SQL 文件',
steps: 'inspect_external_sql_directories -> inspect_external_sql_file -> inspect_active_tab',
description: '适合先定位具体脚本路径,再直接读取目录中的 SQL 文件内容;如果这个文件已经在编辑器里打开,再继续结合当前页签草稿一起分析。',
},
{
title: '读取当前页签',
steps: 'inspect_active_tab -> get_columns / get_indexes / execute_sql',
description: '适合先读取当前编辑器里的 SQL 草稿或当前表页签,再继续做字段核对、索引分析和只读验证。',
},
{
title: '盘点当前工作区',
steps: 'inspect_workspace_tabs -> inspect_active_tab -> get_columns / execute_sql',
description: '适合先看当前打开了哪些 SQL / 表 / 命令页签,再切到目标页签继续做字段核对、对比分析和只读验证。',
},
{
title: '查看当前快捷键配置',
steps: 'inspect_shortcuts -> inspect_active_tab / inspect_workspace_tabs',
description: '适合先确认当前 Win / Mac 快捷键、是否改过默认值以及结果区、AI 面板、查询执行等动作到底该怎么按,再结合当前页签解释具体使用场景。',
},
{
title: '回看最近执行记录',
steps: 'inspect_recent_sql_logs -> get_columns / get_indexes / execute_sql',
description: '适合追查刚刚执行失败的 SQL、慢查询耗时或基于真实执行历史继续让 AI 给解释和优化建议。',
},
{
title: '总结最近 SQL 活动',
steps: 'inspect_recent_sql_activity -> inspect_recent_sql_logs -> inspect_current_connection',
description: '适合先看最近到底以读还是写为主、有没有 DDL 或删除、哪个库最近报错最多,再决定继续下钻哪条日志或哪个连接。',
},
{
title: '核对 SQL 编辑器事务',
steps: 'inspect_sql_editor_transaction -> inspect_recent_sql_activity -> inspect_sql_risk',
description: '适合先确认 SQL 编辑器 DML 是否会进入托管事务、当前是手动还是自动提交、有没有待提交事务,再解释 update/insert/delete 执行后的提交语义。',
},
{
title: 'SQL 风险预检',
steps: 'inspect_sql_risk -> inspect_ai_safety -> execute_sql',
description: '适合用户要求执行、删除、更新、DDL 或批量 SQL 前,先检查语句数量、写入/DDL 风险、WHERE 条件和当前安全策略,再决定是否需要用户确认。',
},
{
title: '排查应用日志',
steps: 'inspect_app_logs -> inspect_mcp_setup / inspect_saved_connections / inspect_current_connection',
description: '适合先回看 gonavi.log 尾部的 ERROR/WARN再结合 MCP、连接和当前数据源状态继续定位启动异常、连接失败或外部工具拉起问题。',
},
{
title: '排查连接失败与冷却',
steps: 'inspect_recent_connection_failures -> inspect_current_connection / inspect_saved_connections / inspect_app_logs',
description: '适合用户直接问“为什么连接不上”或已经看到冷却/验证失败提示时,先拿到结构化根因、最新地址和下一步建议,再决定回到连接配置还是看更长日志。',
},
{
title: '排查 AI 气泡渲染异常',
steps: 'inspect_ai_last_render_error -> inspect_active_tab / inspect_ai_runtime',
description: '适合用户反馈 AI 某条消息空白、气泡局部报错但整个面板没挂时,先拿到最近一次被隔离的渲染异常快照,再回到具体会话和运行时上下文继续缩小范围。',
},
{
title: '诊断 AI 消息流',
steps: 'inspect_ai_message_flow -> inspect_ai_last_render_error / inspect_app_logs',
description: '适合用户反馈回复被拆成多个气泡、工具调用后没继续回答、消息流状态不对时,先读取当前会话的真实消息结构和异常信号。',
},
{
title: '诊断 AI 上下文体量',
steps: 'inspect_ai_context_budget -> inspect_ai_context / inspect_ai_message_flow / inspect_ai_tool_catalog',
description: '适合用户反馈 AI 变慢、乱答、上下文太大、工具结果过长或表结构挂太多时先看消息、DDL、MCP schema、提示词和 Skills 的体量来源,再决定收窄上下文或拆任务。',
},
{
title: '治理前端大文件',
steps: 'inspect_codebase_hotspots -> inspect_ai_tool_catalog',
description: '适合用户要求继续拆分几千行组件、评估下一步重构切入点,或 AI 修改 UI/AI/MCP 前先判断大文件拆分热点、风险和验证范围。',
},
{
title: '复用历史 SQL',
steps: 'inspect_saved_queries -> get_columns / execute_sql',
description: '适合先找本地保存过的查询脚本,再核对字段和只读验证,避免把之前写过的 SQL 重新手打一遍。',
},
{
title: '回看 AI 历史对话',
steps: 'inspect_ai_sessions -> inspect_active_tab / inspect_saved_queries',
description: '适合先定位之前聊过的 AI 会话、首条问题和最近回复,再继续复用当前页签或历史 SQL 上下文。',
},
{
title: '查找模板片段',
steps: 'inspect_sql_snippets',
description: '适合先找团队已有的 SQL 片段模板、补全前缀和常用骨架,再决定是否继续改写。',
},
{
title: '理解样例数据',
steps: 'get_columns -> preview_table_rows',
description: '适合先确认字段,再直接查看前几行真实样例数据和空值形态。',
},
{
title: '只读验证',
steps: 'get_columns -> preview_table_rows -> execute_sql',
description: '适合生成 SQL 后做小范围结果核对,仍会受 AI 安全级别控制。',
},
];
const stringifyHintValue = (value: unknown): string => {
if (value === undefined) return '';
if (value === null) return 'null';
if (typeof value === 'string') return value;
if (typeof value === 'number' || typeof value === 'boolean') return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
};
const readTypeLabel = (schema: Record<string, any>): string => {
if (Array.isArray(schema.type)) {
return schema.type.map((item) => String(item)).filter(Boolean).join(' | ') || 'any';
}
if (typeof schema.type === 'string' && schema.type.trim()) {
return schema.type.trim();
}
if (Array.isArray(schema.enum) && schema.enum.length > 0) {
return 'enum';
}
return 'any';
};
const readDefaultValue = (schema: Record<string, any>, description: string): string => {
if (Object.prototype.hasOwnProperty.call(schema, 'default')) {
return stringifyHintValue(schema.default);
}
const match = description.match(/\s*([^\s,;)]+)/u);
return match?.[1]?.trim() || '';
};
const readExampleValue = (description: string): string => {
const match = description.match(/(?:|?[:])\s*([^;\n]+)/u);
return match?.[1]?.trim() || '';
};
export const describeBuiltinToolParameters = (tool: AIBuiltinToolInfo): AIBuiltinToolParameterHint[] => {
const schema = tool.tool.function.parameters;
const properties = schema && typeof schema === 'object' && typeof schema.properties === 'object'
? schema.properties
: {};
const required = new Set(
Array.isArray(schema?.required) ? schema.required.map((item) => String(item)) : [],
);
return Object.entries(properties).map(([name, config]) => {
const normalized = config && typeof config === 'object' ? config as Record<string, any> : {};
const description = typeof normalized.description === 'string' ? normalized.description : '';
return {
name,
required: required.has(name),
typeLabel: readTypeLabel(normalized),
description,
enumValues: Array.isArray(normalized.enum) ? normalized.enum.map((item) => String(item)) : [],
defaultValue: readDefaultValue(normalized, description),
exampleValue: readExampleValue(description),
};
});
};
export const normalizeBuiltinToolCatalogSearch = (value: string): string =>
value.trim().toLowerCase();
const matchesCatalogSearch = (keyword: string, values: unknown[]): boolean =>
!keyword || values.some((value) => String(value || '').toLowerCase().includes(keyword));
export const filterBuiltinToolFlows = (
flows: AIBuiltinToolFlow[],
searchText: string,
): AIBuiltinToolFlow[] => {
const keyword = normalizeBuiltinToolCatalogSearch(searchText);
return flows.filter((flow) => matchesCatalogSearch(keyword, [
flow.title,
flow.steps,
flow.description,
]));
};
export const filterBuiltinTools = (
tools: AIBuiltinToolInfo[],
searchText: string,
): AIBuiltinToolInfo[] => {
const keyword = normalizeBuiltinToolCatalogSearch(searchText);
return tools.filter((tool) => {
const parameterDetails = describeBuiltinToolParameters(tool);
return matchesCatalogSearch(keyword, [
tool.name,
tool.desc,
tool.detail,
tool.params,
...parameterDetails.flatMap((item) => [
item.name,
item.typeLabel,
item.description,
item.defaultValue,
item.exampleValue,
item.enumValues.join(' '),
]),
]);
});
};

View File

@@ -0,0 +1,12 @@
import { BUILTIN_AI_DATABASE_TOOL_INFO } from "./aiBuiltinDatabaseToolInfo";
import { BUILTIN_AI_INSPECTION_TOOL_INFO } from "./aiBuiltinInspectionToolInfo";
export type {
AIChatToolDefinition,
AIBuiltinToolInfo,
} from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_TOOL_INFO = [
...BUILTIN_AI_DATABASE_TOOL_INFO,
...BUILTIN_AI_INSPECTION_TOOL_INFO,
];

View File

@@ -0,0 +1,17 @@
export interface AIChatToolDefinition {
type: "function";
function: {
name: string;
description: string;
parameters: Record<string, any>;
};
}
export interface AIBuiltinToolInfo {
name: string;
icon: string;
desc: string;
detail: string;
params: string;
tool: AIChatToolDefinition;
}

View File

@@ -0,0 +1,28 @@
import { describe, expect, it } from 'vitest';
import { compressContextIfNeeded, getDynamicMaxContextChars, sanitizeErrorMsg } from './aiChatRuntime';
describe('aiChatRuntime', () => {
it('maps modern model families to practical context windows', () => {
expect(getDynamicMaxContextChars('gemini-2.5-pro')).toBe(5000000);
expect(getDynamicMaxContextChars('gpt-5')).toBe(1000000);
expect(getDynamicMaxContextChars('claude-4-sonnet')).toBe(1000000);
expect(getDynamicMaxContextChars('gpt-4o')).toBe(128000);
expect(getDynamicMaxContextChars()).toBe(258000);
});
it('sanitizes html gateway errors and truncates oversized plain text errors', () => {
expect(sanitizeErrorMsg('<html><head><title>502 Bad Gateway</title></head></html>')).toBe('HTTP 502: 502 Bad Gateway');
expect(sanitizeErrorMsg('x'.repeat(320))).toBe(`${'x'.repeat(280)}...(已截断)`);
expect(sanitizeErrorMsg('permission denied')).toBe('permission denied');
});
it('skips compression when the payload is still within the configured limit', async () => {
const result = await compressContextIfNeeded('session-1', [
{ role: 'user', content: 'short prompt' },
{ role: 'assistant', content: 'short answer' },
], 1000);
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,90 @@
import { useStore } from '../store';
const genCompressionMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
export const getDynamicMaxContextChars = (modelName?: string) => {
if (!modelName) return 258000;
const lower = modelName.toLowerCase();
if (lower.includes('gemini-1.5-pro') || lower.includes('gemini-2') || lower.includes('gemini-3')) {
return 5000000;
}
if (lower.includes('glm-5') || lower.includes('claude-4') || lower.includes('claude-3.7') || lower.includes('gpt-5') || lower.includes('qwen3') || lower.includes('deepseek-v4')) {
return 1000000;
}
if (lower.includes('claude-3-opus') || lower.includes('claude-3.5') || lower.includes('glm-4-long') || lower.includes('qwen-long')) {
return 1000000;
}
if (lower.includes('claude') || lower.includes('deepseek') || lower.includes('gpt-4.5') || lower.includes('qwen2.5')) {
return 258000;
}
if (lower.includes('gpt-4') || lower.includes('gpt-4o') || lower.includes('glm') || lower.includes('z-ai')) {
return 128000;
}
if (lower.includes('qwen')) {
return 128000;
}
return 258000;
};
export const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number) => {
try {
const chars = messagesPayload.reduce((sum, message) =>
sum + (message.content?.length || 0) + (message.reasoning_content?.length || 0) + JSON.stringify(message.tool_calls || []).length, 0);
if (chars < maxLimit) return null;
const Service = (window as any).go?.aiservice?.Service;
if (!Service?.AIChatSend) return null;
const connectingMsgId = genCompressionMessageId();
useStore.getState().addAIChatMessage(sid, {
id: connectingMsgId,
role: 'assistant',
phase: 'connecting',
content: '⚙️ 对话已超载,正在启动记忆压缩...',
timestamp: Date.now(),
loading: true,
});
const summaryPrompt = `这是一段超长对话的历史记录。为了释放上下文空间同时保留你的记忆核心,请你仔细阅读并以“技术事实、已探索出的数据结构状态、用户的中心诉求、当前进展”为准则,进行高度浓缩的结构化总结。
注意:
1. 客观准确,不能遗漏关键业务逻辑或探索出的表名/字段。
2. 剔除无效执行过程、客套话、JSON返回值本身。
3. 请控制在 1000-2000 字左右,输出纯干货 Markdown。
4. 开头直接输出总结,不要带寒暄。`;
const result = await Service.AIChatSend([
{ role: 'system', content: summaryPrompt },
...messagesPayload,
]);
if (result?.success && result.content) {
useStore.getState().deleteAIChatMessage(sid, connectingMsgId);
return result.content;
}
useStore.getState().updateAIChatMessage(sid, connectingMsgId, {
loading: false,
phase: 'idle',
content: '❌ 记忆压缩失败,将尝试原样接续...',
});
} catch (error) {
console.error('Compression exception:', error);
}
return null;
};
export const sanitizeErrorMsg = (raw: string): string => {
if (!raw || typeof raw !== 'string') return '未知错误';
if (raw.includes('<html') || raw.includes('<!DOCTYPE') || raw.includes('<head')) {
const titleMatch = raw.match(/<title[^>]*>([^<]+)<\/title>/i);
const codeMatch = raw.match(/\b(4\d{2}|5\d{2})\b/);
const title = titleMatch?.[1]?.trim();
const code = codeMatch?.[1];
if (title) return code ? `HTTP ${code}: ${title}` : title;
if (code) return `HTTP ${code} 服务端错误`;
return '服务端返回了异常 HTML 响应(可能是网关超时或服务不可用)';
}
if (raw.length > 300) return `${raw.substring(0, 280)}...(已截断)`;
return raw;
};

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import {
buildAIComposerNotice,
buildIncompleteProviderNotice,
buildModelFetchFailedNotice,
buildMissingModelNotice,
buildMissingProviderNotice,
@@ -13,19 +14,39 @@ const t = (key: string, params?: Record<string, unknown>) => {
};
describe('ai composer notice helpers', () => {
it('builds a translated compact notice for missing provider', () => {
it('builds a translated compact notice for missing provider with an action', () => {
expect(buildMissingProviderNotice(t)).toEqual({
tone: 'warning',
title: 'ai_chat.composer_notice.missing_provider.title',
description: 'ai_chat.composer_notice.missing_provider.description',
action: {
key: 'open-settings',
label: '打开 AI 设置',
},
});
});
it('builds a translated compact notice for missing model selection', () => {
it('builds a translated compact notice for missing model selection with an action', () => {
expect(buildMissingModelNotice(t)).toEqual({
tone: 'warning',
title: 'ai_chat.composer_notice.missing_model.title',
description: 'ai_chat.composer_notice.missing_model.description',
action: {
key: 'reload-models',
label: '重新加载模型',
},
});
});
it('builds a translated incomplete provider notice from readiness issues', () => {
expect(buildIncompleteProviderNotice(['missing_secret', 'missing_base_url'], t)).toEqual({
tone: 'error',
title: '当前供应商还缺少 密钥、接口地址',
description: '先补全供应商配置再发送,避免请求刚发起就失败。',
action: {
key: 'open-settings',
label: '修复供应商配置',
},
});
});
@@ -34,6 +55,10 @@ describe('ai composer notice helpers', () => {
tone: 'error',
title: 'ai_chat.composer_notice.model_fetch_failed.title',
description: 'ai_chat.composer_notice.model_fetch_failed.detail_description:当前接口未返回可用模型',
action: {
key: 'reload-models',
label: '重新加载模型',
},
});
});
@@ -42,6 +67,22 @@ describe('ai composer notice helpers', () => {
tone: 'error',
title: 'ai_chat.composer_notice.model_fetch_failed.title',
description: 'ai_chat.composer_notice.model_fetch_failed.default_description',
action: {
key: 'reload-models',
label: '重新加载模型',
},
});
});
it('keeps a non-translated compatibility path for direct notices', () => {
expect(buildModelFetchFailedNotice('当前接口未返回可用模型')).toEqual({
tone: 'error',
title: '模型列表加载失败',
description: '当前接口未返回可用模型',
action: {
key: 'reload-models',
label: '重新加载模型',
},
});
});
@@ -60,11 +101,19 @@ describe('ai composer notice helpers', () => {
tone: 'error',
title: 'zh:ai_chat.composer_notice.model_fetch_failed.title',
description: 'zh:ai_chat.composer_notice.model_fetch_failed.detail_description:HTTP 401 原始错误',
action: {
key: 'reload-models',
label: 'zh:ai_chat.composer_notice.action.reload_models',
},
});
expect(relocalized).toEqual({
tone: 'error',
title: 'en:ai_chat.composer_notice.model_fetch_failed.title',
description: 'en:ai_chat.composer_notice.model_fetch_failed.detail_description:HTTP 401 原始错误',
action: {
key: 'reload-models',
label: 'en:ai_chat.composer_notice.action.reload_models',
},
});
});

View File

@@ -1,14 +1,23 @@
import type { AIChatReadinessIssue } from '../components/ai/aiChatReadiness';
import { formatAIChatProviderIssueLabels } from '../components/ai/aiChatReadiness';
export type AIComposerNoticeTone = 'warning' | 'error';
export type AIComposerNoticeAction = 'open-settings' | 'reload-models';
export interface AIComposerNotice {
tone: AIComposerNoticeTone;
title: string;
description: string;
action?: {
key: AIComposerNoticeAction;
label: string;
};
}
export type AIComposerNoticeDescriptor =
| { kind: 'missing_provider' }
| { kind: 'missing_model' }
| { kind: 'provider_incomplete'; issues?: AIChatReadinessIssue[] }
| { kind: 'model_fetch_failed'; detail?: string | number | boolean | null | undefined };
export type AIComposerNoticeTranslator = (
@@ -16,33 +25,141 @@ export type AIComposerNoticeTranslator = (
params?: Record<string, string | number | boolean | null | undefined>
) => string;
export const buildMissingProviderNotice = (t: AIComposerNoticeTranslator): AIComposerNotice => ({
tone: 'warning',
title: t('ai_chat.composer_notice.missing_provider.title'),
description: t('ai_chat.composer_notice.missing_provider.description'),
const defaultCopy = {
missingProviderTitle: '还没有可用供应商',
missingProviderDescription: '先在 AI 设置里添加并启用一个模型供应商。',
missingModelTitle: '先选择一个模型',
missingModelDescription: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
incompleteProviderTitle: '当前供应商配置还不完整',
incompleteProviderDescription: '先补全供应商配置再发送,避免请求刚发起就失败。',
modelFetchFailedTitle: '模型列表加载失败',
modelFetchFailedDescription: '请检查供应商入口、API Key 或账号权限,然后重新打开模型下拉。',
openSettingsAction: '打开 AI 设置',
fixProviderAction: '修复供应商配置',
reloadModelsAction: '重新加载模型',
} as const;
const translateWithFallback = (
t: AIComposerNoticeTranslator | undefined,
key: string,
fallback: string,
params?: Record<string, string | number | boolean | null | undefined>,
): string => {
if (!t) {
return fallback;
}
const translated = t(key, params);
return translated && translated !== key ? translated : fallback;
};
const buildNoticeAction = (
key: AIComposerNoticeAction,
labelKey: string,
fallbackLabel: string,
t?: AIComposerNoticeTranslator,
): AIComposerNotice['action'] => ({
key,
label: translateWithFallback(t, labelKey, fallbackLabel),
});
export const buildMissingModelNotice = (t: AIComposerNoticeTranslator): AIComposerNotice => ({
export const buildMissingProviderNotice = (t?: AIComposerNoticeTranslator): AIComposerNotice => ({
tone: 'warning',
title: t('ai_chat.composer_notice.missing_model.title'),
description: t('ai_chat.composer_notice.missing_model.description'),
title: t
? t('ai_chat.composer_notice.missing_provider.title')
: defaultCopy.missingProviderTitle,
description: t
? t('ai_chat.composer_notice.missing_provider.description')
: defaultCopy.missingProviderDescription,
action: buildNoticeAction(
'open-settings',
'ai_chat.composer_notice.action.open_settings',
defaultCopy.openSettingsAction,
t,
),
});
export const buildModelFetchFailedNotice = (
t: AIComposerNoticeTranslator,
error?: string | number | boolean | null | undefined
export const buildMissingModelNotice = (t?: AIComposerNoticeTranslator): AIComposerNotice => ({
tone: 'warning',
title: t
? t('ai_chat.composer_notice.missing_model.title')
: defaultCopy.missingModelTitle,
description: t
? t('ai_chat.composer_notice.missing_model.description')
: defaultCopy.missingModelDescription,
action: buildNoticeAction(
'reload-models',
'ai_chat.composer_notice.action.reload_models',
defaultCopy.reloadModelsAction,
t,
),
});
export const buildIncompleteProviderNotice = (
issues: AIChatReadinessIssue[] = [],
t?: AIComposerNoticeTranslator,
): AIComposerNotice => {
const detail = String(error || '').trim();
const missingLabels = formatAIChatProviderIssueLabels(issues.filter((issue) => issue !== 'missing_selected_model'));
const fallbackTitle = missingLabels.length > 0
? `当前供应商还缺少 ${missingLabels.join('、')}`
: defaultCopy.incompleteProviderTitle;
return {
tone: 'error',
title: t('ai_chat.composer_notice.model_fetch_failed.title'),
description: detail
? t('ai_chat.composer_notice.model_fetch_failed.detail_description', { detail })
: t('ai_chat.composer_notice.model_fetch_failed.default_description'),
title: translateWithFallback(
t,
'ai_chat.composer_notice.provider_incomplete.title',
fallbackTitle,
{ labels: missingLabels.join('、') },
),
description: translateWithFallback(
t,
'ai_chat.composer_notice.provider_incomplete.description',
defaultCopy.incompleteProviderDescription,
),
action: buildNoticeAction(
'open-settings',
'ai_chat.composer_notice.action.fix_provider',
defaultCopy.fixProviderAction,
t,
),
};
};
export function buildModelFetchFailedNotice(
t: AIComposerNoticeTranslator,
error?: string | number | boolean | null | undefined,
): AIComposerNotice;
export function buildModelFetchFailedNotice(
error?: string | number | boolean | null | undefined,
): AIComposerNotice;
export function buildModelFetchFailedNotice(
tOrError?: AIComposerNoticeTranslator | string | number | boolean | null,
error?: string | number | boolean | null | undefined,
): AIComposerNotice {
const hasTranslator = typeof tOrError === 'function';
const t = hasTranslator ? tOrError : undefined;
const rawDetail = hasTranslator ? error : tOrError;
const detail = String(rawDetail ?? '').trim();
return {
tone: 'error',
title: t
? t('ai_chat.composer_notice.model_fetch_failed.title')
: defaultCopy.modelFetchFailedTitle,
description: t
? detail
? t('ai_chat.composer_notice.model_fetch_failed.detail_description', { detail })
: t('ai_chat.composer_notice.model_fetch_failed.default_description')
: detail || defaultCopy.modelFetchFailedDescription,
action: buildNoticeAction(
'reload-models',
'ai_chat.composer_notice.action.reload_models',
defaultCopy.reloadModelsAction,
t,
),
};
}
export const buildAIComposerNotice = (
t: AIComposerNoticeTranslator,
descriptor: AIComposerNoticeDescriptor | null
@@ -57,6 +174,9 @@ export const buildAIComposerNotice = (
if (descriptor.kind === 'missing_model') {
return buildMissingModelNotice(t);
}
if (descriptor.kind === 'provider_incomplete') {
return buildIncompleteProviderNotice(descriptor.issues, t);
}
return buildModelFetchFailedNotice(t, descriptor.detail);
};

View File

@@ -75,4 +75,24 @@ describe('toAIRequestMessage', () => {
images: ['data:image/png;base64,abc'],
});
});
it('appends extracted file attachment content to the user request payload', () => {
const payload = toAIRequestMessage(message({
role: 'user',
content: '帮我看附件',
attachments: [{
id: 'att-1',
name: 'report.md',
mimeType: 'text/markdown',
size: 24,
kind: 'markdown',
text: '# 周报\n收入下降',
}],
}));
expect(payload.content).toContain('帮我看附件');
expect(payload.content).toContain('<用户上传附件>');
expect(payload.content).toContain('report.md');
expect(payload.content).toContain('收入下降');
});
});

View File

@@ -1,4 +1,5 @@
import type { AIChatMessage, AIToolCall } from '../types';
import { appendAIChatAttachmentsToContent } from '../components/ai/aiChatAttachments';
export interface AIRequestMessage {
role: AIChatMessage['role'];
@@ -12,7 +13,7 @@ export interface AIRequestMessage {
export const toAIRequestMessage = (message: AIChatMessage): AIRequestMessage => {
const payload: AIRequestMessage = {
role: message.role,
content: message.content,
content: appendAIChatAttachmentsToContent(message.content, message.attachments),
};
if (message.images && message.images.length > 0) {

View File

@@ -0,0 +1,310 @@
import { describe, expect, it } from 'vitest';
import { BUILTIN_AI_TOOL_INFO, buildAvailableAIChatTools } from './aiToolRegistry';
describe('aiToolRegistry', () => {
it('registers the ai-runtime inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_runtime');
expect(info).toBeTruthy();
expect(info?.desc).toContain('AI 自身运行状态');
expect(info?.tool.function.description).toContain('当前供应商');
});
it('registers the ai-setup-health inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_setup_health');
expect(info).toBeTruthy();
expect(info?.desc).toContain('体检当前 AI 配置');
expect(info?.tool.function.description).toContain('聊天发送前置');
});
it('registers the ai-support-bundle inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_support_bundle');
expect(info).toBeTruthy();
expect(info?.desc).toContain('排障支持包');
expect(info?.tool.function.description).toContain('默认不包含数据库密码');
expect(info?.tool.function.parameters?.properties?.includeMessageContent?.description).toContain('默认 false');
});
it('registers the ai-safety inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_safety');
expect(info).toBeTruthy();
expect(info?.desc).toContain('写入安全边界');
expect(info?.tool.function.description).toContain('allowMutating');
});
it('registers the mcp-setup inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_mcp_setup');
expect(info).toBeTruthy();
expect(info?.desc).toContain('MCP 配置');
expect(info?.tool.function.description).toContain('外部客户端');
});
it('registers the mcp-remote-access inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_mcp_remote_access');
expect(info).toBeTruthy();
expect(info?.desc).toContain('OpenClaw/Hermans');
expect(info?.tool.function.description).toContain('Bearer Token');
expect(info?.tool.function.parameters?.properties?.exposeStrategy?.enum).toContain('cloudflare_tunnel');
});
it('registers the mcp-runtime-failure inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_mcp_runtime_failures');
expect(info).toBeTruthy();
expect(info?.desc).toContain('启动与调用失败');
expect(info?.tool.function.description).toContain('工具发现失败');
expect(info?.tool.function.parameters?.properties?.serverName?.description).toContain('MCP 服务名');
});
it('registers the mcp-authoring-guide inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_mcp_authoring_guide');
expect(info).toBeTruthy();
expect(info?.desc).toContain('新增 MCP');
expect(info?.tool.function.description).toContain('command、args、env、timeout');
});
it('registers the mcp-draft inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_mcp_draft');
expect(info).toBeTruthy();
expect(info?.desc).toContain('MCP 新增草稿');
expect(info?.tool.function.description).toContain('真实校验器试算');
expect(info?.tool.function.parameters?.properties?.fullCommand?.description).toContain('一整行 MCP 启动命令');
expect(info?.tool.function.parameters?.properties?.templateKey?.enum).toContain('docker');
});
it('registers the mcp-docker-setup inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_mcp_docker_setup');
expect(info).toBeTruthy();
expect(info?.desc).toContain('Docker MCP');
expect(info?.tool.function.description).toContain('docker run');
expect(info?.tool.function.parameters?.properties?.includeDisabled?.description).toContain('默认 true');
});
it('registers the mcp-tool-schema inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_mcp_tool_schema');
expect(info).toBeTruthy();
expect(info?.desc).toContain('MCP 工具参数');
expect(info?.tool.function.description).toContain('inputSchema');
expect(info?.tool.function.parameters?.properties?.alias?.description).toContain('真实 alias');
});
it('registers the ai-provider inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_providers');
expect(info).toBeTruthy();
expect(info?.desc).toContain('供应商与模型配置');
expect(info?.tool.function.description).toContain('模型列表为空');
});
it('registers the chat-readiness inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_chat_readiness');
expect(info).toBeTruthy();
expect(info?.desc).toContain('发送条件');
expect(info?.tool.function.description).toContain('当前 AI 聊天输入区');
});
it('registers the ai-upstream-log inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_upstream_logs');
expect(info).toBeTruthy();
expect(info?.desc).toContain('上游请求入参');
expect(info?.tool.function.description).toContain('请求 body 预览');
expect(info?.tool.function.parameters?.properties?.requestId?.description).toContain('requestId');
expect(info?.tool.function.parameters?.properties?.includePayloadSummary?.description).toContain('工具数量');
});
it('registers the ai-tool-catalog inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_tool_catalog');
expect(info).toBeTruthy();
expect(info?.desc).toContain('内置工具目录');
expect(info?.tool.function.description).toContain('推荐工具调用流程');
expect(info?.tool.function.parameters?.properties?.keyword?.description).toContain('连接失败');
expect(info?.tool.function.parameters?.properties?.includeMCPTools?.description).toContain('MCP 工具摘要');
});
it('registers the ai-guidance inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_guidance');
expect(info).toBeTruthy();
expect(info?.desc).toContain('提示词与 Skills');
expect(info?.tool.function.description).toContain('自定义提示词');
});
it('registers the current-connection inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_current_connection');
expect(info).toBeTruthy();
expect(info?.desc).toContain('当前活动连接');
expect(info?.tool.function.description).toContain('SSH/代理/HTTP 隧道状态');
});
it('registers the connection-capability inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_connection_capabilities');
expect(info).toBeTruthy();
expect(info?.desc).toContain('前端能力');
expect(info?.tool.function.description).toContain('结果是否强制只读');
});
it('registers the saved-connections inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_saved_connections');
expect(info).toBeTruthy();
expect(info?.desc).toContain('已保存连接');
expect(info?.tool.function.description).toContain('本地已保存连接清单');
});
it('registers the Redis topology inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_redis_topology');
expect(info).toBeTruthy();
expect(info?.desc).toContain('Redis 单机/哨兵/集群');
expect(info?.tool.function.description).toContain('Sentinel');
expect(info?.tool.function.description).toContain('不会回显 Redis 密码');
expect(info?.tool.function.parameters?.properties?.connectionId?.description).toContain('Redis 连接 ID');
});
it('registers the external-sql-directory inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_external_sql_directories');
expect(info).toBeTruthy();
expect(info?.desc).toContain('外部 SQL 目录');
expect(info?.tool.function.description).toContain('当前打开的外部 SQL 文件页签');
});
it('registers the external-sql-file inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_external_sql_file');
expect(info).toBeTruthy();
expect(info?.desc).toContain('外部 SQL 文件内容');
expect(info?.tool.function.description).toContain('目录中的具体 SQL 脚本');
});
it('registers the shortcut inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_shortcuts');
expect(info).toBeTruthy();
expect(info?.desc).toContain('快捷键配置');
expect(info?.tool.function.description).toContain('Win/Mac');
});
it('registers the app-log inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_app_logs');
expect(info).toBeTruthy();
expect(info?.desc).toContain('应用日志');
expect(info?.tool.function.description).toContain('gonavi.log');
});
it('registers the recent-connection-failure inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_connection_failures');
expect(info).toBeTruthy();
expect(info?.desc).toContain('连接失败');
expect(info?.tool.function.description).toContain('multiStatements');
});
it('registers the ai-render-error inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_last_render_error');
expect(info).toBeTruthy();
expect(info?.desc).toContain('渲染异常');
expect(info?.tool.function.description).toContain('消息渲染异常');
});
it('registers the ai-message-flow inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_message_flow');
expect(info).toBeTruthy();
expect(info?.desc).toContain('消息流');
expect(info?.tool.function.description).toContain('连续 assistant 消息');
});
it('registers the ai-context-budget inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_context_budget');
expect(info).toBeTruthy();
expect(info?.desc).toContain('上下文体量');
expect(info?.tool.function.description).toContain('MCP 工具 schema');
expect(info?.tool.function.parameters?.properties?.messageLimit?.description).toContain('最大 120');
});
it('registers the codebase-hotspots inspector as a builtin tool', () => {
const info = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_codebase_hotspots');
expect(info).toBeTruthy();
expect(info?.desc).toContain('前端大文件');
expect(info?.tool.function.description).toContain('拆分热点快照');
expect(info?.tool.function.parameters?.properties?.minLines?.description).toContain('默认 1000');
});
it('registers the recent-sql-activity, saved-query, and sql-snippet inspectors as builtin tools', () => {
const recentActivityTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_sql_activity');
const sqlEditorTransactionTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_editor_transaction');
const sqlRiskTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_risk');
const appLogTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_app_logs');
const connectionFailureTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_recent_connection_failures');
const renderErrorTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_last_render_error');
const messageFlowTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_message_flow');
const savedQueryTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_saved_queries');
const aiSessionsTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_ai_sessions');
const snippetTool = BUILTIN_AI_TOOL_INFO.find((item) => item.name === 'inspect_sql_snippets');
expect(recentActivityTool?.desc).toContain('最近 SQL 活动');
expect(recentActivityTool?.tool.function.description).toContain('最近 SQL 活动');
expect(sqlEditorTransactionTool?.desc).toContain('SQL 编辑器事务');
expect(sqlEditorTransactionTool?.tool.function.description).toContain('托管事务');
expect(sqlRiskTool?.desc).toContain('SQL 的执行风险');
expect(sqlRiskTool?.tool.function.description).toContain('危险点');
expect(appLogTool?.desc).toContain('GoNavi 应用日志');
expect(appLogTool?.tool.function.description).toContain('应用日志');
expect(connectionFailureTool?.desc).toContain('连接失败');
expect(connectionFailureTool?.tool.function.description).toContain('连接冷却');
expect(renderErrorTool?.desc).toContain('渲染异常记录');
expect(renderErrorTool?.tool.function.description).toContain('气泡局部报错');
expect(messageFlowTool?.desc).toContain('消息流');
expect(messageFlowTool?.tool.function.description).toContain('工具调用没有闭环');
expect(savedQueryTool?.desc).toContain('已保存的 SQL 查询');
expect(savedQueryTool?.tool.function.description).toContain('历史查询');
expect(aiSessionsTool?.desc).toContain('AI 历史会话');
expect(aiSessionsTool?.tool.function.description).toContain('之前的 AI 对话');
expect(snippetTool?.desc).toContain('SQL 片段模板');
expect(snippetTool?.tool.function.description).toContain('片段模板');
});
it('keeps builtin tools and MCP tools in the unified runtime tool chain', () => {
const tools = buildAvailableAIChatTools([{
alias: 'custom_probe',
originalName: 'custom_probe',
serverId: 'server-1',
serverName: 'demo',
title: '自定义探针',
description: '读取额外环境信息',
inputSchema: {
type: 'object',
properties: {
id: { type: 'string' },
},
},
}]);
expect(tools.some((item) => item.function.name === 'inspect_ai_runtime')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_setup_health')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_support_bundle')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_safety')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_providers')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_chat_readiness')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_upstream_logs')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_tool_catalog')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_mcp_setup')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_mcp_remote_access')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_mcp_runtime_failures')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_mcp_authoring_guide')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_mcp_draft')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_mcp_tool_schema')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_guidance')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_current_connection')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_connection_capabilities')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_saved_connections')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_redis_topology')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_external_sql_directories')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_external_sql_file')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_recent_sql_activity')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_sql_editor_transaction')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_sql_risk')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_app_logs')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_recent_connection_failures')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_last_render_error')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_message_flow')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_context_budget')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_codebase_hotspots')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_saved_queries')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_ai_sessions')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_sql_snippets')).toBe(true);
expect(tools.some((item) => item.function.name === 'inspect_shortcuts')).toBe(true);
expect(tools.some((item) => item.function.name === 'custom_probe')).toBe(true);
});
});

View File

@@ -0,0 +1,39 @@
import type { AIMCPToolDescriptor } from "../types";
import {
BUILTIN_AI_TOOL_INFO,
type AIChatToolDefinition,
type AIBuiltinToolInfo,
} from "./aiBuiltinToolInfo";
export {
BUILTIN_AI_TOOL_INFO,
type AIChatToolDefinition,
type AIBuiltinToolInfo,
} from "./aiBuiltinToolInfo";
export const BUILTIN_AI_TOOLS: AIChatToolDefinition[] = BUILTIN_AI_TOOL_INFO.map((item) => item.tool);
export const BUILTIN_AI_TOOL_NAME_SET = new Set<string>(
BUILTIN_AI_TOOL_INFO.map((item) => item.name),
);
export const buildMCPAIChatTools = (
tools: AIMCPToolDescriptor[],
): AIChatToolDefinition[] =>
(tools || []).map((tool) => ({
type: "function",
function: {
name: tool.alias,
description:
tool.description ||
`${tool.serverName} 提供的 MCP 工具 ${tool.title || tool.originalName}`,
parameters:
tool.inputSchema && Object.keys(tool.inputSchema).length > 0
? tool.inputSchema
: { type: "object", properties: {} },
},
}));
export const buildAvailableAIChatTools = (
tools: AIMCPToolDescriptor[],
): AIChatToolDefinition[] => [...BUILTIN_AI_TOOLS, ...buildMCPAIChatTools(tools)];

View File

@@ -28,4 +28,57 @@ describe('columnDefinition metadata normalization', () => {
comment: '更新时间',
});
});
it('prefers complete column type aliases over base data type', () => {
const column = {
COLUMN_NAME: 'USER_NAME',
DATA_TYPE: 'varchar',
COLUMN_TYPE: 'varchar(64)',
IS_NULLABLE: 'NO',
};
expect(normalizeColumnDefinition(column)).toMatchObject({
name: 'USER_NAME',
type: 'varchar(64)',
nullable: 'NO',
});
});
it('builds display type from base type and length metadata', () => {
const column = {
column_name: 'amount',
data_type: 'decimal',
numeric_precision: 10,
numeric_scale: 2,
is_nullable: 'YES',
};
expect(normalizeColumnDefinition(column)).toMatchObject({
name: 'amount',
type: 'decimal(10,2)',
nullable: 'YES',
});
});
it('normalizes Dameng style data length and nullable flags', () => {
const column = {
COLUMN_NAME: 'USER_NAME',
DATA_TYPE: 'VARCHAR2',
DATA_LENGTH: 64,
NULLABLE: 'N',
};
expect(normalizeColumnDefinition(column)).toMatchObject({
name: 'USER_NAME',
type: 'VARCHAR2(64)',
nullable: 'NO',
});
});
it('maps boolean primary and unique metadata aliases to GoNavi keys', () => {
expect(getColumnDefinitionKey({ column_name: 'id', isPrimary: true })).toBe('PRI');
expect(getColumnDefinitionKey({ column_name: 'id', primary_key: 't' })).toBe('PRI');
expect(getColumnDefinitionKey({ column_name: 'email', is_unique: 'yes' })).toBe('UNI');
expect(getColumnDefinitionKey({ column_name: 'id', column_key: 'primary key' })).toBe('PRI');
});
});

View File

@@ -20,17 +20,134 @@ const readStringProperty = (value: unknown, keys: string[]): string => {
return '';
};
const readProperty = (value: unknown, keys: string[]): unknown => {
const source = value as Record<string, unknown> | null | undefined;
if (!source || typeof source !== 'object') return undefined;
for (const key of keys) {
if (source[key] !== undefined && source[key] !== null) {
return source[key];
}
}
for (const [sourceKey, raw] of Object.entries(source)) {
if (keys.some((key) => sourceKey.toLowerCase() === key.toLowerCase())) {
return raw;
}
}
return undefined;
};
const readBooleanProperty = (value: unknown, keys: string[]): boolean => {
const raw = readProperty(value, keys);
if (raw === undefined || raw === null) return false;
if (typeof raw === 'boolean') return raw;
if (typeof raw === 'number') return raw !== 0;
const text = String(raw).trim().toLowerCase();
return text === '1' || text === 't' || text === 'true' || text === 'y' || text === 'yes' || text === 'pri' || text === 'primary';
};
const readNumberProperty = (value: unknown, keys: string[]): number => {
const raw = readProperty(value, keys);
if (raw === undefined || raw === null || raw === '') return 0;
const parsed = Number(raw);
return Number.isFinite(parsed) && parsed > 0 ? Math.trunc(parsed) : 0;
};
const normalizeNullable = (value: string): string => {
const normalized = String(value || '').trim();
if (!normalized) return '';
const upper = normalized.toUpperCase();
if (upper === 'N' || upper === 'NO' || upper === 'FALSE' || upper === '0' || upper === 'NOT NULL') {
return 'NO';
}
if (upper === 'Y' || upper === 'YES' || upper === 'TRUE' || upper === '1' || upper === 'NULL' || upper === 'NULLABLE') {
return 'YES';
}
return normalized;
};
export const getColumnDefinitionName = (column: unknown): string => (
readStringProperty(column, ['name', 'Name', 'COLUMN_NAME', 'column_name', 'field', 'Field'])
);
export const getColumnDefinitionType = (column: unknown): string => (
readStringProperty(column, ['type', 'Type', 'DATA_TYPE', 'data_type'])
);
export const getColumnDefinitionType = (column: unknown): string => {
const fullType = readStringProperty(column, [
'COLUMN_TYPE',
'column_type',
'FULL_TYPE',
'full_type',
'FULL_DATA_TYPE',
'full_data_type',
'TYPE_NAME',
'type_name',
'Type',
'type',
]);
if (fullType) return fullType;
export const getColumnDefinitionKey = (column: unknown): string => (
readStringProperty(column, ['key', 'Key', 'COLUMN_KEY', 'column_key'])
);
const dataType = readStringProperty(column, ['DATA_TYPE', 'data_type']);
if (!dataType || /\(.+\)/.test(dataType)) return dataType;
const upperType = dataType.toUpperCase();
const charLength = readNumberProperty(column, [
'CHARACTER_MAXIMUM_LENGTH',
'character_maximum_length',
'CHARACTER_MAX_LENGTH',
'character_max_length',
'CHAR_LENGTH',
'char_length',
'DATA_LENGTH',
'data_length',
'LENGTH',
'length',
]);
if (charLength > 0 && /(CHAR|VARCHAR|BINARY|VARBINARY|NCHAR|NVARCHAR)/.test(upperType)) {
return `${dataType}(${charLength})`;
}
const precision = readNumberProperty(column, [
'NUMERIC_PRECISION',
'numeric_precision',
'DATA_PRECISION',
'data_precision',
'PRECISION',
'precision',
]);
if (precision > 0 && /(DECIMAL|NUMERIC|NUMBER)/.test(upperType)) {
const scale = readNumberProperty(column, [
'NUMERIC_SCALE',
'numeric_scale',
'DATA_SCALE',
'data_scale',
'SCALE',
'scale',
]);
return scale > 0 ? `${dataType}(${precision},${scale})` : `${dataType}(${precision})`;
}
return dataType;
};
export const getColumnDefinitionKey = (column: unknown): string => {
const key = readStringProperty(column, ['key', 'Key', 'COLUMN_KEY', 'column_key']);
if (key) {
const normalized = key.trim();
const lowered = normalized.toLowerCase();
if (lowered === 'pri' || lowered === 'primary' || lowered === 'primary key') return 'PRI';
if (lowered === 'uni' || lowered === 'unique') return 'UNI';
if (lowered === 'mul' || lowered === 'multiple') return 'MUL';
return normalized;
}
if (readBooleanProperty(column, ['primaryKey', 'primary_key', 'isPrimary', 'is_primary', 'IS_PRIMARY', 'pk', 'PK'])) {
return 'PRI';
}
if (readBooleanProperty(column, ['unique', 'isUnique', 'is_unique', 'UNIQUE', 'IS_UNIQUE'])) {
return 'UNI';
}
return '';
};
export const getColumnDefinitionExtra = (column: unknown): string => (
readStringProperty(column, ['extra', 'Extra'])
@@ -46,7 +163,7 @@ export const normalizeColumnDefinition = (column: unknown): ColumnDefinition =>
...source,
name: getColumnDefinitionName(column),
type: getColumnDefinitionType(column),
nullable: readStringProperty(column, ['nullable', 'Nullable', 'NULLABLE', 'is_nullable']),
nullable: normalizeNullable(readStringProperty(column, ['nullable', 'Nullable', 'NULLABLE', 'is_nullable', 'IS_NULLABLE', 'Null', 'null'])),
key: getColumnDefinitionKey(column),
default: source.default,
extra: getColumnDefinitionExtra(column),

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import {
isPostgresSchemaDialect,
normalizeDriverType,
resolveConnectionDriverType,
resolveSavedConnectionDriverType,
} from './connectionDriverType';
describe('connectionDriverType', () => {
it('normalizes built-in driver aliases shared by connection modal and sidebar', () => {
expect(normalizeDriverType('postgresql')).toBe('postgres');
expect(normalizeDriverType('pgx')).toBe('postgres');
expect(normalizeDriverType('elastic')).toBe('elasticsearch');
expect(normalizeDriverType('chromadb')).toBe('chroma');
expect(normalizeDriverType('chroma-db')).toBe('chroma');
expect(normalizeDriverType('qdrantdb')).toBe('qdrant');
expect(normalizeDriverType('qdrant-db')).toBe('qdrant');
expect(normalizeDriverType('apache-iotdb')).toBe('iotdb');
expect(normalizeDriverType('apache_iotdb')).toBe('iotdb');
expect(normalizeDriverType('apache-kafka')).toBe('kafka');
expect(normalizeDriverType('apache_kafka')).toBe('kafka');
expect(normalizeDriverType('doris')).toBe('diros');
expect(normalizeDriverType('open-gauss')).toBe('opengauss');
expect(normalizeDriverType('gauss-db')).toBe('gaussdb');
expect(normalizeDriverType('greatdb')).toBe('goldendb');
expect(normalizeDriverType('gdb')).toBe('goldendb');
expect(normalizeDriverType('InterSystemsIRIS')).toBe('iris');
});
it('resolves custom connection driver types from the selected driver field', () => {
expect(resolveConnectionDriverType('mysql', 'postgresql')).toBe('mysql');
expect(resolveConnectionDriverType('custom', 'postgresql')).toBe('postgres');
expect(resolveConnectionDriverType('custom', 'open_gauss')).toBe('opengauss');
expect(resolveConnectionDriverType('custom', 'gauss_db')).toBe('gaussdb');
expect(resolveConnectionDriverType('custom', 'goldendb')).toBe('goldendb');
expect(resolveConnectionDriverType('custom', '')).toBe('');
});
it('resolves saved custom connections using the same driver aliases', () => {
const conn = {
config: {
type: 'custom',
driver: 'pg',
},
} as any;
expect(resolveSavedConnectionDriverType(conn)).toBe('postgres');
});
it('detects postgres-compatible schema dialects', () => {
expect(isPostgresSchemaDialect('postgres')).toBe(true);
expect(isPostgresSchemaDialect('kingbase')).toBe(true);
expect(isPostgresSchemaDialect('open-gauss')).toBe(true);
expect(isPostgresSchemaDialect('gauss-db')).toBe(true);
expect(isPostgresSchemaDialect('mysql')).toBe(false);
});
});

View File

@@ -0,0 +1,64 @@
import type { SavedConnection } from '../types';
export type DriverStatusSnapshot = {
type: string;
name: string;
connectable: boolean;
expectedRevision?: string;
needsUpdate?: boolean;
updateReason?: string;
affectedConnections?: number;
message?: string;
};
export const normalizeDriverType = (value: string): string => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'postgresql' || normalized === 'pg' || normalized === 'pq' || normalized === 'pgx') return 'postgres';
if (normalized === 'elastic') return 'elasticsearch';
if (normalized === 'chromadb' || normalized === 'chroma-db') return 'chroma';
if (normalized === 'qdrantdb' || normalized === 'qdrant-db') return 'qdrant';
if (normalized === 'rocket-mq' || normalized === 'rocket_mq' || normalized === 'apache-rocketmq' || normalized === 'apache_rocketmq' || normalized === 'rmq') return 'rocketmq';
if (normalized === 'apache-iotdb' || normalized === 'apache_iotdb') return 'iotdb';
if (normalized === 'mqtts') return 'mqtt';
if (normalized === 'apache-kafka' || normalized === 'apache_kafka') return 'kafka';
if (normalized === 'rabbit-mq' || normalized === 'rabbit_mq') return 'rabbitmq';
if (normalized === 'doris') return 'diros';
if (
normalized === 'open_gauss' ||
normalized === 'open-gauss' ||
normalized === 'opengauss'
) return 'opengauss';
if (
normalized === 'gaussdb' ||
normalized === 'gauss_db' ||
normalized === 'gauss-db'
) return 'gaussdb';
if (
normalized === 'goldendb' ||
normalized === 'greatdb' ||
normalized === 'gdb'
) return 'goldendb';
if (
normalized === 'intersystems' ||
normalized === 'intersystemsiris' ||
normalized === 'inter-systems' ||
normalized === 'inter-systems-iris'
) return 'iris';
return normalized;
};
export const resolveConnectionDriverType = (type: string, driver?: string): string => {
const normalizedType = normalizeDriverType(type);
if (normalizedType !== 'custom') {
return normalizedType;
}
return normalizeDriverType(driver || '');
};
export const resolveSavedConnectionDriverType = (conn: SavedConnection | undefined): string => {
return resolveConnectionDriverType(conn?.config?.type || '', conn?.config?.driver || '');
};
export const isPostgresSchemaDialect = (dialect: string): boolean => (
['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss', 'gaussdb'].includes(normalizeDriverType(dialect))
);

View File

@@ -93,6 +93,13 @@ describe('connectionExport', () => {
]))).toBe('legacy-json');
});
it('detects Navicat NCX xml exports', () => {
expect(detectConnectionImportKind(`<?xml version="1.0" encoding="UTF-8"?>
<Connections>
<Connection ConnType="MYSQL" ConnectionName="Local MySQL" Host="127.0.0.1" Port="3306" UserName="root" Password="ABCD" SavePassword="true" />
</Connections>`)).toBe('navicat-ncx');
});
it('returns invalid for malformed or unsupported content', () => {
expect(detectConnectionImportKind('{not-json}')).toBe('invalid');
expect(detectConnectionImportKind(JSON.stringify({

View File

@@ -1,7 +1,7 @@
import type { ConnectionConfig, SavedConnection } from '../types';
import { t } from '../i18n';
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | 'invalid';
export type ConnectionImportKind = 'app-managed-package' | 'encrypted-package' | 'legacy-json' | 'mysql-workbench-xml' | 'navicat-ncx' | 'invalid';
export type ConnectionPackageDialogSnapshot = {
open: boolean;
mode: 'export' | 'import';
@@ -110,10 +110,19 @@ const isMySQLWorkbenchXML = (raw: string): boolean => (
raw.includes('<data') && raw.includes('grt_format') && raw.includes('db.mgmt.Connection')
);
const isNavicatNCX = (raw: string): boolean => (
raw.includes('<Connection')
&& raw.includes('ConnType=')
&& raw.includes('ConnectionName=')
);
export const detectConnectionImportKind = (raw: unknown): ConnectionImportKind => {
if (typeof raw === 'string' && isMySQLWorkbenchXML(raw)) {
return 'mysql-workbench-xml';
}
if (typeof raw === 'string' && isNavicatNCX(raw)) {
return 'navicat-ncx';
}
const parsed = parseConnectionImportRaw(raw);

View File

@@ -103,6 +103,7 @@ describe('connectionModalPresentation', () => {
it('assigns card-based configuration sections to every supported data source type', () => {
const allTypes = [
'mysql',
'goldendb',
'mariadb',
'oceanbase',
'doris',
@@ -120,10 +121,16 @@ describe('connectionModalPresentation', () => {
'highgo',
'vastbase',
'opengauss',
'gaussdb',
'iris',
'mongodb',
'elasticsearch',
'chroma',
'qdrant',
'redis',
'tdengine',
'iotdb',
'kafka',
'custom',
'jvm',
];
@@ -147,6 +154,15 @@ describe('connectionModalPresentation', () => {
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('goldendb').sections).toEqual([
'identity',
'uri',
'target',
'connectionMode',
'replica',
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('mongodb').sections).toEqual([
'identity',
'uri',
@@ -183,11 +199,63 @@ describe('connectionModalPresentation', () => {
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('elasticsearch').sections).toEqual([
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('chroma').sections).toEqual([
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('qdrant').sections).toEqual([
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('iotdb').sections).toEqual([
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('gaussdb').sections).toEqual([
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
]);
expect(resolveConnectionConfigLayout('kafka').sections).toEqual([
'identity',
'uri',
'target',
'connectionMode',
'replica',
'service',
'credentials',
]);
});
it('uses localized labels for layout kinds shown in the modal', () => {
expect(getConnectionConfigLayoutKindLabel('mysql-compatible')).toBe('MySQL 兼容');
expect(getConnectionConfigLayoutKindLabel('file')).toBe('文件型数据库');
expect(getConnectionConfigLayoutKindLabel('search')).toBe('搜索引擎');
expect(getConnectionConfigLayoutKindLabel('vector')).toBe('向量数据库');
expect(getConnectionConfigLayoutKindLabel('timeseries')).toBe('时序数据库');
});
it('switches section copy and failure feedback to English when the current language is en-US', () => {

View File

@@ -41,6 +41,9 @@ export type ConnectionConfigLayoutKind =
| 'postgres-compatible'
| 'oracle'
| 'file'
| 'search'
| 'vector'
| 'timeseries'
| 'custom'
| 'jvm'
| 'generic-sql';
@@ -57,6 +60,7 @@ type ConnectionConfigSectionCopy = {
const mysqlCompatibleTypes = new Set([
'mysql',
'goldendb',
'mariadb',
'oceanbase',
'doris',
@@ -70,6 +74,7 @@ const postgresCompatibleTypes = new Set([
'highgo',
'vastbase',
'opengauss',
'gaussdb',
]);
const fileDatabaseTypes = new Set(['sqlite', 'duckdb']);
@@ -119,6 +124,12 @@ export const getConnectionConfigLayoutKindLabel = (
return t('connection.modal.layoutKind.oracle');
case 'file':
return t('connection.modal.layoutKind.file');
case 'search':
return '搜索引擎';
case 'vector':
return '向量数据库';
case 'timeseries':
return '时序数据库';
case 'custom':
return t('connection.modal.layoutKind.custom');
case 'jvm':
@@ -195,6 +206,100 @@ export const resolveConnectionConfigLayout = (
],
};
}
if (type === 'elasticsearch') {
return {
kind: 'search',
sections: [
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
],
};
}
if (type === 'chroma' || type === 'qdrant') {
return {
kind: 'vector',
sections: [
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
],
};
}
if (type === 'iotdb') {
return {
kind: 'timeseries',
sections: [
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
],
};
}
if (type === 'mqtt') {
return {
kind: 'generic-sql',
sections: [
'identity',
'uri',
'target',
'connectionMode',
'replica',
'service',
'credentials',
],
};
}
if (type === 'rocketmq') {
return {
kind: 'generic-sql',
sections: [
'identity',
'uri',
'target',
'connectionMode',
'replica',
'service',
'credentials',
],
};
}
if (type === 'kafka') {
return {
kind: 'generic-sql',
sections: [
'identity',
'uri',
'target',
'connectionMode',
'replica',
'service',
'credentials',
],
};
}
if (type === 'rabbitmq') {
return {
kind: 'generic-sql',
sections: [
'identity',
'uri',
'target',
'service',
'credentials',
'databaseScope',
],
};
}
if (postgresCompatibleTypes.has(type)) {
return {
kind: 'postgres-compatible',

View File

@@ -199,6 +199,30 @@ describe('buildRpcConnectionConfig', () => {
});
});
it('preserves Redis cluster and Sentinel topology fields for RPC calls', () => {
const result = buildRpcConnectionConfig({
id: 'conn-redis-sentinel',
type: 'redis',
host: 'sentinel-a.local',
port: '26379' as unknown as number,
hosts: ['sentinel-b.local:26379', 'sentinel-c.local:26379'],
topology: 'sentinel',
user: 'default',
password: 'redis-secret',
redisSentinelMaster: 'mymaster',
redisSentinelUser: 'sentinel-user',
redisSentinelPassword: 'sentinel-secret',
redisDB: '3' as unknown as number,
} as any);
expect(result.topology).toBe('sentinel');
expect(result.hosts).toEqual(['sentinel-b.local:26379', 'sentinel-c.local:26379']);
expect(result.redisSentinelMaster).toBe('mymaster');
expect(result.redisSentinelUser).toBe('sentinel-user');
expect(result.redisSentinelPassword).toBe('sentinel-secret');
expect(result.redisDB).toBe(3);
});
it('returns a Wails connection model instance for RPC compatibility', () => {
const result = buildRpcConnectionConfig({
id: 'conn-model',

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import {
isFileDatabaseType,
isMySQLCompatibleType,
isPostgresCompatibleSSLType,
singleHostUriSchemesByType,
supportsConnectionParamsForType,
supportsSSLCAPathForType,
supportsSSLClientCertificateForType,
supportsSSLForType,
} from './connectionTypeCapabilities';
describe('connectionTypeCapabilities', () => {
it('keeps single-host URI scheme aliases for URI parsing', () => {
expect(singleHostUriSchemesByType.postgres).toEqual(['postgresql', 'postgres']);
expect(singleHostUriSchemesByType.opengauss).toContain('jdbc:opengauss');
expect(singleHostUriSchemesByType.gaussdb).toEqual(['gaussdb', 'postgresql', 'postgres']);
expect(singleHostUriSchemesByType.dameng).toEqual(['dameng', 'dm']);
expect(singleHostUriSchemesByType.elasticsearch).toEqual(['http', 'https']);
expect(singleHostUriSchemesByType.chroma).toEqual(['http', 'https', 'chroma']);
expect(singleHostUriSchemesByType.qdrant).toEqual(['http', 'https', 'qdrant']);
expect(singleHostUriSchemesByType.iotdb).toEqual(['iotdb']);
expect(singleHostUriSchemesByType.redis).toEqual(['redis']);
});
it('detects SSL-capable connection types with case-insensitive normalization', () => {
expect(supportsSSLForType('redis')).toBe(true);
expect(supportsSSLForType('MongoDB')).toBe(true);
expect(supportsSSLForType('elasticsearch')).toBe(true);
expect(supportsSSLForType('gaussdb')).toBe(true);
expect(supportsSSLForType('greatdb')).toBe(true);
expect(supportsSSLForType('chroma')).toBe(true);
expect(supportsSSLForType('qdrant')).toBe(true);
expect(supportsSSLForType('kafka')).toBe(true);
expect(supportsSSLForType('tdengine')).toBe(true);
expect(supportsSSLForType('iotdb')).toBe(false);
expect(supportsSSLForType('dameng')).toBe(true);
expect(supportsSSLForType('sqlite')).toBe(false);
});
it('keeps CA path and client certificate support distinct', () => {
expect(supportsSSLCAPathForType('dameng')).toBe(false);
expect(supportsSSLClientCertificateForType('dameng')).toBe(true);
expect(supportsSSLCAPathForType('gaussdb')).toBe(true);
expect(supportsSSLClientCertificateForType('gaussdb')).toBe(true);
expect(supportsSSLCAPathForType('sqlserver')).toBe(true);
expect(supportsSSLClientCertificateForType('sqlserver')).toBe(false);
expect(supportsSSLCAPathForType('redis')).toBe(true);
expect(supportsSSLClientCertificateForType('redis')).toBe(true);
expect(supportsSSLCAPathForType('chroma')).toBe(true);
expect(supportsSSLClientCertificateForType('chroma')).toBe(false);
expect(supportsSSLCAPathForType('qdrant')).toBe(true);
expect(supportsSSLClientCertificateForType('qdrant')).toBe(false);
expect(supportsSSLCAPathForType('kafka')).toBe(true);
expect(supportsSSLClientCertificateForType('kafka')).toBe(true);
});
it('detects postgres-compatible SSL parameter dialects', () => {
expect(isPostgresCompatibleSSLType('postgres')).toBe(true);
expect(isPostgresCompatibleSSLType('kingbase')).toBe(true);
expect(isPostgresCompatibleSSLType('gaussdb')).toBe(true);
expect(isPostgresCompatibleSSLType('HighGo')).toBe(true);
expect(isPostgresCompatibleSSLType('mysql')).toBe(false);
});
it('keeps file and MySQL-compatible database detection explicit', () => {
expect(isFileDatabaseType('sqlite')).toBe(true);
expect(isFileDatabaseType('duckdb')).toBe(true);
expect(isFileDatabaseType('DuckDB')).toBe(false);
expect(isMySQLCompatibleType('mysql')).toBe(true);
expect(isMySQLCompatibleType('goldendb')).toBe(true);
expect(isMySQLCompatibleType('oceanbase')).toBe(true);
expect(isMySQLCompatibleType('diros')).toBe(true);
expect(isMySQLCompatibleType('postgres')).toBe(false);
});
it('keeps advanced connection params enabled only for supported database types', () => {
expect(supportsConnectionParamsForType('mysql')).toBe(true);
expect(supportsConnectionParamsForType('gdb')).toBe(true);
expect(supportsConnectionParamsForType('postgres')).toBe(true);
expect(supportsConnectionParamsForType('gaussdb')).toBe(true);
expect(supportsConnectionParamsForType('oracle')).toBe(true);
expect(supportsConnectionParamsForType('mongodb')).toBe(true);
expect(supportsConnectionParamsForType('dameng')).toBe(true);
expect(supportsConnectionParamsForType('tdengine')).toBe(true);
expect(supportsConnectionParamsForType('iotdb')).toBe(true);
expect(supportsConnectionParamsForType('elasticsearch')).toBe(true);
expect(supportsConnectionParamsForType('chroma')).toBe(true);
expect(supportsConnectionParamsForType('qdrant')).toBe(true);
expect(supportsConnectionParamsForType('kafka')).toBe(true);
expect(supportsConnectionParamsForType('redis')).toBe(false);
expect(supportsConnectionParamsForType('sqlite')).toBe(false);
expect(supportsConnectionParamsForType('jvm')).toBe(false);
});
});

View File

@@ -0,0 +1,180 @@
export const singleHostUriSchemesByType: Record<string, string[]> = {
postgres: ["postgresql", "postgres"],
opengauss: ["opengauss", "jdbc:opengauss", "postgresql", "postgres"],
gaussdb: ["gaussdb", "postgresql", "postgres"],
clickhouse: ["clickhouse"],
oracle: ["oracle"],
sqlserver: ["sqlserver"],
iris: ["iris", "intersystems"],
redis: ["redis"],
tdengine: ["tdengine"],
iotdb: ["iotdb"],
dameng: ["dameng", "dm"],
kingbase: ["kingbase"],
highgo: ["highgo"],
vastbase: ["vastbase"],
elasticsearch: ["http", "https"],
chroma: ["http", "https", "chroma"],
qdrant: ["http", "https", "qdrant"],
rocketmq: ["rocketmq", "rmq"],
mqtt: ["mqtt", "mqtts", "tcp", "ssl", "tls"],
rabbitmq: ["rabbitmq", "http", "https"],
};
const normalizeConnectionType = (type: string) =>
{
const normalized = String(type || "")
.trim()
.toLowerCase();
switch (normalized) {
case "goldendb":
case "greatdb":
case "gdb":
return "goldendb";
case "rocket-mq":
case "rocket_mq":
case "apache-rocketmq":
case "apache_rocketmq":
case "rmq":
return "rocketmq";
case "mqtts":
return "mqtt";
default:
return normalized;
}
};
const sslSupportedTypes = new Set([
"mysql",
"goldendb",
"mariadb",
"oceanbase",
"doris",
"diros",
"starrocks",
"sphinx",
"dameng",
"clickhouse",
"postgres",
"sqlserver",
"oracle",
"kingbase",
"highgo",
"vastbase",
"opengauss",
"gaussdb",
"mongodb",
"redis",
"tdengine",
"elasticsearch",
"chroma",
"qdrant",
"mqtt",
"kafka",
"rabbitmq",
]);
export const supportsSSLForType = (type: string) =>
sslSupportedTypes.has(normalizeConnectionType(type));
const sslCAPathSupportedTypes = new Set([
"mysql",
"goldendb",
"mariadb",
"oceanbase",
"diros",
"starrocks",
"sphinx",
"clickhouse",
"postgres",
"sqlserver",
"kingbase",
"highgo",
"vastbase",
"opengauss",
"gaussdb",
"mongodb",
"redis",
"elasticsearch",
"chroma",
"qdrant",
"mqtt",
"kafka",
"rabbitmq",
]);
const sslClientCertificateSupportedTypes = new Set([
"mysql",
"goldendb",
"mariadb",
"oceanbase",
"diros",
"starrocks",
"sphinx",
"dameng",
"clickhouse",
"postgres",
"kingbase",
"highgo",
"vastbase",
"opengauss",
"gaussdb",
"mongodb",
"redis",
"mqtt",
"kafka",
"rabbitmq",
]);
export const supportsSSLCAPathForType = (type: string) =>
sslCAPathSupportedTypes.has(normalizeConnectionType(type));
export const supportsSSLClientCertificateForType = (type: string) =>
sslClientCertificateSupportedTypes.has(normalizeConnectionType(type));
export const isPostgresCompatibleSSLType = (type: string) =>
[
"postgres",
"kingbase",
"highgo",
"vastbase",
"opengauss",
"gaussdb",
].includes(normalizeConnectionType(type));
export const isFileDatabaseType = (type: string) =>
type === "sqlite" || type === "duckdb";
export const isMySQLCompatibleType = (type: string) =>
normalizeConnectionType(type) === "mysql" ||
normalizeConnectionType(type) === "goldendb" ||
normalizeConnectionType(type) === "mariadb" ||
normalizeConnectionType(type) === "oceanbase" ||
normalizeConnectionType(type) === "doris" ||
normalizeConnectionType(type) === "diros" ||
normalizeConnectionType(type) === "starrocks" ||
normalizeConnectionType(type) === "sphinx";
export const supportsConnectionParamsForType = (type: string) =>
isMySQLCompatibleType(type) ||
type === "postgres" ||
type === "kingbase" ||
type === "highgo" ||
type === "vastbase" ||
type === "opengauss" ||
type === "gaussdb" ||
type === "oracle" ||
type === "sqlserver" ||
type === "iris" ||
type === "clickhouse" ||
type === "mongodb" ||
type === "dameng" ||
type === "tdengine" ||
type === "iotdb" ||
type === "elasticsearch" ||
type === "chroma" ||
type === "qdrant" ||
type === "rocketmq" ||
type === "mqtt" ||
type === "kafka" ||
type === "rabbitmq";

View File

@@ -0,0 +1,72 @@
import { describe, expect, it } from 'vitest';
import {
CONNECTION_TYPE_GROUPS,
getAllConnectionTypeCatalogItems,
getConnectionTypeDefaultPort,
getConnectionTypeHint,
} from './connectionTypeCatalog';
describe('connectionTypeCatalog', () => {
it('keeps supported connection types grouped for the creation modal', () => {
expect(CONNECTION_TYPE_GROUPS.map((group) => group.label)).toEqual([
'关系型数据库',
'国产数据库',
'NoSQL',
'向量数据库',
'时序数据库',
'消息队列',
'其他',
]);
const keys = getAllConnectionTypeCatalogItems().map((item) => item.key);
expect(keys).toContain('mysql');
expect(keys).toContain('oceanbase');
expect(keys).toContain('gaussdb');
expect(keys).toContain('goldendb');
expect(keys).toContain('mongodb');
expect(keys).toContain('redis');
expect(keys).toContain('elasticsearch');
expect(keys).toContain('chroma');
expect(keys).toContain('qdrant');
expect(keys).toContain('iotdb');
expect(keys).toContain('kafka');
expect(keys).toContain('jvm');
expect(keys).toContain('custom');
expect(new Set(keys).size).toBe(keys.length);
});
it('returns the existing default port mapping for supported connection types', () => {
expect(getConnectionTypeDefaultPort('mysql')).toBe(3306);
expect(getConnectionTypeDefaultPort('oceanbase')).toBe(2881);
expect(getConnectionTypeDefaultPort('goldendb')).toBe(1523);
expect(getConnectionTypeDefaultPort('diros')).toBe(9030);
expect(getConnectionTypeDefaultPort('postgres')).toBe(5432);
expect(getConnectionTypeDefaultPort('gaussdb')).toBe(5432);
expect(getConnectionTypeDefaultPort('redis')).toBe(6379);
expect(getConnectionTypeDefaultPort('oracle')).toBe(1521);
expect(getConnectionTypeDefaultPort('mongodb')).toBe(27017);
expect(getConnectionTypeDefaultPort('elasticsearch')).toBe(9200);
expect(getConnectionTypeDefaultPort('chroma')).toBe(8000);
expect(getConnectionTypeDefaultPort('qdrant')).toBe(6333);
expect(getConnectionTypeDefaultPort('iotdb')).toBe(6667);
expect(getConnectionTypeDefaultPort('kafka')).toBe(9092);
expect(getConnectionTypeDefaultPort('sqlite')).toBe(0);
expect(getConnectionTypeDefaultPort('duckdb')).toBe(0);
expect(getConnectionTypeDefaultPort('unknown')).toBe(3306);
});
it('keeps concise localized hints for special connection types', () => {
expect(getConnectionTypeHint('redis')).toBe('单机 / 哨兵 / 集群');
expect(getConnectionTypeHint('mongodb')).toBe('单机 / 副本集');
expect(getConnectionTypeHint('elasticsearch')).toContain('Mapping');
expect(getConnectionTypeHint('chroma')).toContain('向量');
expect(getConnectionTypeHint('qdrant')).toContain('Payload');
expect(getConnectionTypeHint('iotdb')).toContain('Timeseries');
expect(getConnectionTypeHint('kafka')).toContain('Consumer Group');
expect(getConnectionTypeHint('oceanbase')).toBe('MySQL / Oracle 租户');
expect(getConnectionTypeHint('goldendb')).toBe('MySQL 兼容 / 分布式事务');
expect(getConnectionTypeHint('duckdb')).toBe('本地文件连接');
expect(getConnectionTypeHint('mysql')).toBe('标准连接配置');
});
});

View File

@@ -0,0 +1,189 @@
export type ConnectionTypeCatalogItem = {
key: string;
name: string;
};
export type ConnectionTypeCatalogGroup = {
label: string;
items: ConnectionTypeCatalogItem[];
};
export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
{
label: '关系型数据库',
items: [
{ key: 'mysql', name: 'MySQL' },
{ key: 'mariadb', name: 'MariaDB' },
{ key: 'diros', name: 'Doris' },
{ key: 'starrocks', name: 'StarRocks' },
{ key: 'sphinx', name: 'Sphinx' },
{ key: 'clickhouse', name: 'ClickHouse' },
{ key: 'postgres', name: 'PostgreSQL' },
{ key: 'sqlserver', name: 'SQL Server' },
{ key: 'iris', name: 'InterSystems IRIS' },
{ key: 'sqlite', name: 'SQLite' },
{ key: 'duckdb', name: 'DuckDB' },
{ key: 'oracle', name: 'Oracle' },
],
},
{
label: '国产数据库',
items: [
{ key: 'oceanbase', name: 'OceanBase' },
{ key: 'dameng', name: 'Dameng (达梦)' },
{ key: 'kingbase', name: 'Kingbase (人大金仓)' },
{ key: 'highgo', name: 'HighGo (瀚高)' },
{ key: 'vastbase', name: 'Vastbase (海量)' },
{ key: 'opengauss', name: 'OpenGauss' },
{ key: 'gaussdb', name: 'GaussDB' },
{ key: 'goldendb', name: 'GoldenDB' },
],
},
{
label: 'NoSQL',
items: [
{ key: 'mongodb', name: 'MongoDB' },
{ key: 'redis', name: 'Redis' },
{ key: 'elasticsearch', name: 'Elasticsearch' },
],
},
{
label: '向量数据库',
items: [
{ key: 'chroma', name: 'Chroma' },
{ key: 'qdrant', name: 'Qdrant' },
],
},
{
label: '时序数据库',
items: [
{ key: 'tdengine', name: 'TDengine' },
{ key: 'iotdb', name: 'Apache IoTDB' },
],
},
{
label: '消息队列',
items: [
{ key: 'rocketmq', name: 'RocketMQ' },
{ key: 'mqtt', name: 'MQTT' },
{ key: 'kafka', name: 'Kafka' },
{ key: 'rabbitmq', name: 'RabbitMQ' },
],
},
{
label: '其他',
items: [
{ key: 'jvm', name: 'JVM Runtime' },
{ key: 'custom', name: 'Custom (自定义)' },
],
},
];
export const getConnectionTypeDefaultPort = (type: string): number => {
switch (String(type || '').trim().toLowerCase()) {
case 'jvm':
return 9010;
case 'mysql':
return 3306;
case 'oceanbase':
return 2881;
case 'goldendb':
return 1523;
case 'doris':
case 'diros':
case 'starrocks':
return 9030;
case 'sphinx':
return 9306;
case 'clickhouse':
return 9000;
case 'postgres':
case 'opengauss':
case 'gaussdb':
return 5432;
case 'redis':
return 6379;
case 'tdengine':
return 6041;
case 'iotdb':
return 6667;
case 'oracle':
return 1521;
case 'dameng':
return 5236;
case 'kingbase':
return 54321;
case 'sqlserver':
return 1433;
case 'iris':
return 1972;
case 'mongodb':
return 27017;
case 'elasticsearch':
return 9200;
case 'chroma':
return 8000;
case 'qdrant':
return 6333;
case 'rocketmq':
return 9876;
case 'mqtt':
return 1883;
case 'kafka':
return 9092;
case 'rabbitmq':
return 15672;
case 'highgo':
return 5866;
case 'mariadb':
return 3306;
case 'vastbase':
return 5432;
case 'sqlite':
case 'duckdb':
return 0;
default:
return 3306;
}
};
export const getConnectionTypeHint = (type: string): string => {
switch (String(type || '').trim().toLowerCase()) {
case 'jvm':
return 'JMX / Endpoint / Agent';
case 'custom':
return '自定义驱动与 DSN';
case 'redis':
return '单机 / 哨兵 / 集群';
case 'mongodb':
return '单机 / 副本集';
case 'elasticsearch':
return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';
case 'chroma':
return 'Collection 浏览、向量检索和元数据过滤';
case 'qdrant':
return 'Collection 浏览、向量搜索和 Payload 过滤';
case 'iotdb':
return 'Storage Group / Device / Timeseries';
case 'rocketmq':
return 'NameServer / Topic / Consumer Group';
case 'mqtt':
return 'Broker / Topic Filter / QoS';
case 'kafka':
return 'Broker / Topic / Consumer Group';
case 'rabbitmq':
return 'Management API / Virtual Host / Queue';
case 'oceanbase':
return 'MySQL / Oracle 租户';
case 'goldendb':
return 'MySQL 兼容 / 分布式事务';
case 'sqlite':
case 'duckdb':
return '本地文件连接';
default:
return '标准连接配置';
}
};
export const getAllConnectionTypeCatalogItems = (): ConnectionTypeCatalogItem[] =>
CONNECTION_TYPE_GROUPS.flatMap((group) => group.items);

View File

@@ -30,6 +30,24 @@ describe('dataSourceCapabilities', () => {
});
});
it('treats GoldenDB as an editable MySQL-family datasource with database-level DDL actions', () => {
expect(getDataSourceCapabilities({ type: 'goldendb' })).toMatchObject({
type: 'goldendb',
supportsQueryEditor: true,
supportsSqlQueryExport: true,
supportsCopyInsert: true,
supportsCreateDatabase: true,
supportsRenameDatabase: false,
supportsDropDatabase: true,
forceReadOnlyQueryResult: false,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'greatdb' })).toMatchObject({
type: 'goldendb',
supportsQueryEditor: true,
supportsCopyInsert: true,
});
});
it('keeps StarRocks as an independent SQL datasource capability', () => {
expect(getDataSourceCapabilities({ type: 'starrocks' })).toMatchObject({
type: 'starrocks',
@@ -54,6 +72,181 @@ describe('dataSourceCapabilities', () => {
});
});
it('treats GaussDB as an editable PostgreSQL-family datasource with database-level DDL actions', () => {
expect(getDataSourceCapabilities({ type: 'gaussdb' })).toMatchObject({
type: 'gaussdb',
supportsQueryEditor: true,
supportsSqlQueryExport: true,
supportsCopyInsert: true,
supportsCreateDatabase: true,
supportsRenameDatabase: true,
supportsDropDatabase: true,
forceReadOnlyQueryResult: false,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'gauss-db' })).toMatchObject({
type: 'gaussdb',
supportsQueryEditor: true,
supportsCopyInsert: true,
supportsRenameDatabase: true,
});
});
it('treats Elasticsearch as a queryable read-only datasource', () => {
expect(getDataSourceCapabilities({ type: 'elasticsearch' })).toMatchObject({
type: 'elasticsearch',
supportsQueryEditor: true,
supportsSqlQueryExport: false,
supportsCopyInsert: false,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
forceReadOnlyQueryResult: false,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'elastic' })).toMatchObject({
type: 'elasticsearch',
supportsQueryEditor: true,
forceReadOnlyQueryResult: false,
});
});
it('treats Chroma as a queryable vector datasource without SQL export actions', () => {
expect(getDataSourceCapabilities({ type: 'chroma' })).toMatchObject({
type: 'chroma',
supportsQueryEditor: true,
supportsSqlQueryExport: false,
supportsCopyInsert: false,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
forceReadOnlyQueryResult: false,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'chromadb' })).toMatchObject({
type: 'chroma',
supportsQueryEditor: true,
supportsCopyInsert: false,
});
});
it('treats Qdrant as a queryable vector datasource without SQL export actions', () => {
expect(getDataSourceCapabilities({ type: 'qdrant' })).toMatchObject({
type: 'qdrant',
supportsQueryEditor: true,
supportsSqlQueryExport: false,
supportsCopyInsert: false,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
forceReadOnlyQueryResult: false,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'qdrantdb' })).toMatchObject({
type: 'qdrant',
supportsQueryEditor: true,
supportsCopyInsert: false,
});
});
it('treats Apache IoTDB as a queryable timeseries datasource with IoTDB-specific writes', () => {
expect(getDataSourceCapabilities({ type: 'iotdb' })).toMatchObject({
type: 'iotdb',
supportsQueryEditor: true,
supportsSqlQueryExport: false,
supportsCopyInsert: false,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
forceReadOnlyQueryResult: true,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'apache-iotdb' })).toMatchObject({
type: 'iotdb',
supportsQueryEditor: true,
supportsCopyInsert: false,
forceReadOnlyQueryResult: true,
});
});
it('treats RocketMQ as a queryable messaging datasource with manual total count and publish support', () => {
expect(getDataSourceCapabilities({ type: 'rocketmq' })).toMatchObject({
type: 'rocketmq',
supportsQueryEditor: true,
supportsSqlQueryExport: false,
supportsCopyInsert: false,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
supportsMessagePublish: true,
forceReadOnlyQueryResult: true,
preferManualTotalCount: true,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'rmq' })).toMatchObject({
type: 'rocketmq',
supportsQueryEditor: true,
supportsMessagePublish: true,
forceReadOnlyQueryResult: true,
preferManualTotalCount: true,
});
});
it('treats MQTT as a queryable messaging datasource with manual total count and publish support', () => {
expect(getDataSourceCapabilities({ type: 'mqtt' })).toMatchObject({
type: 'mqtt',
supportsQueryEditor: true,
supportsSqlQueryExport: false,
supportsCopyInsert: false,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
supportsMessagePublish: true,
forceReadOnlyQueryResult: true,
preferManualTotalCount: true,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'mqtts' })).toMatchObject({
type: 'mqtt',
supportsQueryEditor: true,
supportsMessagePublish: true,
preferManualTotalCount: true,
});
});
it('treats Kafka as a queryable read-only messaging datasource', () => {
expect(getDataSourceCapabilities({ type: 'kafka' })).toMatchObject({
type: 'kafka',
supportsQueryEditor: true,
supportsSqlQueryExport: false,
supportsCopyInsert: false,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
supportsMessagePublish: true,
forceReadOnlyQueryResult: true,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'apache-kafka' })).toMatchObject({
type: 'kafka',
supportsQueryEditor: true,
supportsMessagePublish: true,
forceReadOnlyQueryResult: true,
});
});
it('treats RabbitMQ as a queryable messaging datasource with publish support', () => {
expect(getDataSourceCapabilities({ type: 'rabbitmq' })).toMatchObject({
type: 'rabbitmq',
supportsQueryEditor: true,
supportsSqlQueryExport: false,
supportsCopyInsert: false,
supportsCreateDatabase: false,
supportsRenameDatabase: false,
supportsDropDatabase: false,
supportsMessagePublish: true,
forceReadOnlyQueryResult: true,
});
expect(getDataSourceCapabilities({ type: 'custom', driver: 'rabbit-mq' })).toMatchObject({
type: 'rabbitmq',
supportsQueryEditor: true,
supportsMessagePublish: true,
forceReadOnlyQueryResult: true,
});
});
it('treats OceanBase Oracle protocol as Oracle capabilities', () => {
expect(getDataSourceCapabilities({
type: 'oceanbase',

View File

@@ -16,8 +16,46 @@ const normalizeDataSourceToken = (raw: string): string => {
case 'open_gauss':
case 'open-gauss':
return 'opengauss';
case 'gaussdb':
case 'gauss_db':
case 'gauss-db':
return 'gaussdb';
case 'goldendb':
case 'greatdb':
case 'gdb':
return 'goldendb';
case 'dm':
return 'dameng';
case 'elastic':
case 'elasticsearch':
return 'elasticsearch';
case 'chromadb':
case 'chroma-db':
return 'chroma';
case 'qdrantdb':
case 'qdrant-db':
return 'qdrant';
case 'rocketmq':
case 'rocket-mq':
case 'rocket_mq':
case 'apache-rocketmq':
case 'apache_rocketmq':
case 'rmq':
return 'rocketmq';
case 'mqtt':
case 'mqtts':
return 'mqtt';
case 'apache-iotdb':
case 'apache_iotdb':
return 'iotdb';
case 'kafka':
case 'apache-kafka':
case 'apache_kafka':
return 'kafka';
case 'rabbitmq':
case 'rabbit-mq':
case 'rabbit_mq':
return 'rabbitmq';
case 'intersystems':
case 'intersystemsiris':
case 'inter-systems':
@@ -46,6 +84,7 @@ export const resolveDataSourceType = (config: ConnectionLike): string => {
const SQL_QUERY_EXPORT_TYPES = new Set([
'mysql',
'goldendb',
'mariadb',
'oceanbase',
'diros',
@@ -56,6 +95,7 @@ const SQL_QUERY_EXPORT_TYPES = new Set([
'highgo',
'vastbase',
'opengauss',
'gaussdb',
'sqlserver',
'iris',
'sqlite',
@@ -68,6 +108,7 @@ const SQL_QUERY_EXPORT_TYPES = new Set([
const COPY_INSERT_TYPES = new Set([
'mysql',
'goldendb',
'mariadb',
'oceanbase',
'diros',
@@ -78,6 +119,7 @@ const COPY_INSERT_TYPES = new Set([
'highgo',
'vastbase',
'opengauss',
'gaussdb',
'sqlserver',
'iris',
'sqlite',
@@ -89,8 +131,9 @@ const COPY_INSERT_TYPES = new Set([
]);
const QUERY_EDITOR_DISABLED_TYPES = new Set(['redis']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'clickhouse']);
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle']);
const FORCE_READ_ONLY_QUERY_TYPES = new Set(['tdengine', 'iotdb', 'clickhouse', 'rocketmq', 'mqtt', 'kafka', 'rabbitmq']);
const MESSAGE_PUBLISH_TYPES = new Set(['rocketmq', 'mqtt', 'kafka', 'rabbitmq']);
const MANUAL_TOTAL_COUNT_TYPES = new Set(['duckdb', 'oracle', 'rocketmq', 'mqtt']);
const APPROXIMATE_TABLE_COUNT_TYPES = new Set(['duckdb', 'oracle']);
const APPROXIMATE_TOTAL_PAGE_TYPES = new Set(['duckdb']);
@@ -102,6 +145,7 @@ export type DataSourceCapabilities = {
supportsCreateDatabase: boolean;
supportsRenameDatabase: boolean;
supportsDropDatabase: boolean;
supportsMessagePublish: boolean;
forceReadOnlyQueryResult: boolean;
preferManualTotalCount: boolean;
supportsApproximateTableCount: boolean;
@@ -110,6 +154,7 @@ export type DataSourceCapabilities = {
const CREATE_DATABASE_TYPES = new Set([
'mysql',
'goldendb',
'mariadb',
'oceanbase',
'diros',
@@ -119,6 +164,7 @@ const CREATE_DATABASE_TYPES = new Set([
'highgo',
'vastbase',
'opengauss',
'gaussdb',
'sqlserver',
'tdengine',
'clickhouse',
@@ -131,10 +177,12 @@ const RENAME_DATABASE_TYPES = new Set([
'highgo',
'vastbase',
'opengauss',
'gaussdb',
]);
const DROP_DATABASE_TYPES = new Set([
'mysql',
'goldendb',
'mariadb',
'oceanbase',
'diros',
@@ -144,6 +192,7 @@ const DROP_DATABASE_TYPES = new Set([
'highgo',
'vastbase',
'opengauss',
'gaussdb',
'tdengine',
'clickhouse',
]);
@@ -158,6 +207,7 @@ export const getDataSourceCapabilities = (config: ConnectionLike): DataSourceCap
supportsCreateDatabase: CREATE_DATABASE_TYPES.has(type),
supportsRenameDatabase: RENAME_DATABASE_TYPES.has(type),
supportsDropDatabase: DROP_DATABASE_TYPES.has(type),
supportsMessagePublish: MESSAGE_PUBLISH_TYPES.has(type),
forceReadOnlyQueryResult: FORCE_READ_ONLY_QUERY_TYPES.has(type),
preferManualTotalCount: MANUAL_TOTAL_COUNT_TYPES.has(type),
supportsApproximateTableCount: APPROXIMATE_TABLE_COUNT_TYPES.has(type),

View File

@@ -0,0 +1,22 @@
import { describe, expect, it } from 'vitest';
import { formatDdlForDisplay } from './ddlFormat';
describe('formatDdlForDisplay', () => {
it('formats DuckDB create table SQL into multiline output', () => {
const raw = 'CREATE TABLE customers(customer_id BIGINT, customer_code VARCHAR, city VARCHAR, tier VARCHAR, signup_date DATE, lifetime_value DECIMAL(12,2), PRIMARY KEY(customer_id));';
const formatted = formatDdlForDisplay(raw, 'duckdb');
expect(formatted).toContain('CREATE TABLE customers (');
expect(formatted).toContain('customer_id BIGINT,');
expect(formatted).toContain('PRIMARY KEY (customer_id)');
expect(formatted).toContain('\n');
});
it('returns original text when formatter cannot parse the statement', () => {
const raw = 'not valid ddl(';
expect(formatDdlForDisplay(raw, 'duckdb')).toBe(raw);
});
});

View File

@@ -0,0 +1,52 @@
import { format, type SqlLanguage } from 'sql-formatter';
const resolveDdlFormatterLanguage = (dbType: string): SqlLanguage => {
const normalized = String(dbType || '').trim().toLowerCase();
switch (normalized) {
case 'duckdb':
return 'duckdb';
case 'sqlite':
return 'sqlite';
case 'postgres':
case 'postgresql':
case 'kingbase':
case 'highgo':
case 'opengauss':
case 'gaussdb':
case 'vastbase':
return 'postgresql';
case 'mariadb':
return 'mariadb';
case 'mysql':
case 'goldendb':
case 'sphinx':
return 'mysql';
case 'sqlserver':
return 'transactsql';
case 'oracle':
case 'dameng':
case 'oceanbase':
return 'plsql';
case 'clickhouse':
return 'clickhouse';
default:
return 'sql';
}
};
export const formatDdlForDisplay = (ddlText: unknown, dbType: string): string => {
const raw = String(ddlText ?? '').trim();
if (!raw) {
return '';
}
const language = resolveDdlFormatterLanguage(dbType);
try {
return format(raw, {
language,
keywordCase: 'upper',
linesBetweenQueries: 1,
});
} catch {
return raw;
}
};

View File

@@ -9,5 +9,19 @@ export const getDriverLocalImportDirectoryHelp = (language?: SupportedLanguage |
export const getDriverLocalImportSingleFileHelp = (language?: SupportedLanguage | string) =>
t('driver_manager.import.single_file_help', undefined, language ?? getCurrentLanguage());
const includeCustomDriverRawAliases = (helpText: string): string => {
let next = String(helpText || '');
if (!/\bgaussdb\b/i.test(next)) {
next = next.replace(/\bopengauss\b/i, (match) => `${match}, gaussdb`);
}
if (!/gauss_db\/gauss-db/i.test(next)) {
next = next.replace(/open_gauss\/open-gauss([,])(\s*)/u, (_match, separator: string, spacing: string) => {
const gap = separator === ',' ? spacing || ' ' : '';
return `open_gauss/open-gauss${separator}${gap}gauss_db/gauss-db${separator}${spacing || ''}`;
});
}
return next;
};
export const getCustomConnectionDriverHelp = (language?: SupportedLanguage | string) =>
t('driver.guidance.customConnectionDriverHelp', undefined, language ?? getCurrentLanguage());
includeCustomDriverRawAliases(t('driver.guidance.customConnectionDriverHelp', undefined, language ?? getCurrentLanguage()));

View File

@@ -0,0 +1,82 @@
import { describe, expect, it } from 'vitest';
import { normalizeDriverProgressUpdate, type DriverProgressState } from './driverProgress';
describe('normalizeDriverProgressUpdate', () => {
it('keeps downloading progress monotonic within one install session', () => {
const previous: DriverProgressState = {
status: 'downloading',
message: '写入驱动元数据',
percent: 90,
};
const actual = normalizeDriverProgressUpdate(previous, {
status: 'downloading',
message: '下载驱动总包',
percent: 30,
});
expect(actual).toEqual({
status: 'downloading',
message: '下载驱动总包',
percent: 90,
});
});
it('allows start to reset progress for a new install session', () => {
const actual = normalizeDriverProgressUpdate({
status: 'error',
message: '安装失败',
percent: 90,
}, {
status: 'start',
message: '开始安装',
percent: 0,
});
expect(actual).toEqual({
status: 'start',
message: '开始安装',
percent: 0,
});
});
it('does not let stale downloading events overwrite terminal states', () => {
const done = normalizeDriverProgressUpdate({
status: 'downloading',
message: '写入驱动元数据',
percent: 95,
}, {
status: 'done',
message: '驱动代理安装完成',
percent: 100,
});
expect(normalizeDriverProgressUpdate(done, {
status: 'downloading',
message: '下载驱动总包',
percent: 40,
})).toBe(done);
const failed = normalizeDriverProgressUpdate({
status: 'downloading',
message: '写入驱动元数据',
percent: 95,
}, {
status: 'error',
message: '安装失败',
percent: 0,
});
expect(failed).toEqual({
status: 'error',
message: '安装失败',
percent: 95,
});
expect(normalizeDriverProgressUpdate(failed, {
status: 'downloading',
message: '下载驱动总包',
percent: 40,
})).toBe(failed);
});
});

View File

@@ -0,0 +1,59 @@
export type DriverProgressStatus = 'start' | 'downloading' | 'done' | 'error';
export type DriverProgressState = {
status: DriverProgressStatus;
message: string;
percent: number;
};
const clampDriverProgressPercent = (value: number): number => {
if (!Number.isFinite(value)) {
return 0;
}
return Math.max(0, Math.min(100, value));
};
export const normalizeDriverProgressUpdate = (
previous: DriverProgressState | undefined,
incoming: DriverProgressState,
): DriverProgressState => {
const next: DriverProgressState = {
status: incoming.status,
message: String(incoming.message || '').trim(),
percent: clampDriverProgressPercent(Number(incoming.percent || 0)),
};
if (next.status === 'start') {
return {
...next,
percent: 0,
};
}
if (next.status === 'done') {
return {
...next,
percent: 100,
};
}
if (next.status === 'error') {
return {
...next,
percent: Math.max(clampDriverProgressPercent(previous?.percent || 0), next.percent),
};
}
if (previous?.status === 'done' || previous?.status === 'error') {
return previous;
}
if (previous?.status === 'start' || previous?.status === 'downloading') {
return {
...next,
percent: Math.max(clampDriverProgressPercent(previous.percent || 0), next.percent),
};
}
return next;
};

View File

@@ -10,8 +10,6 @@ describe('externalSqlTree helpers', () => {
id: 'dir-1',
name: 'scripts',
path: 'D:/sql/scripts',
connectionId: 'conn-1',
dbName: 'demo',
createdAt: 1,
},
];
@@ -33,14 +31,12 @@ describe('externalSqlTree helpers', () => {
};
const node = buildExternalSQLRootNode({
dbNodeKey: 'conn-1-demo',
connectionId: 'conn-1',
dbName: 'demo',
directories,
directoryTrees: trees,
});
expect(node.type).toBe('external-sql-root');
expect(node.key).toBe('external-sql-root');
expect(node.title).toBe('External SQL files (1)');
expect(node.children).toHaveLength(1);
expect(node.children?.[0]).toMatchObject({
@@ -113,4 +109,77 @@ describe('externalSqlTree helpers', () => {
expect(first).toContain('demo');
expect(first).not.toBe(second);
});
it('filters non-sql file entries even when the backend returns them', () => {
const directories: ExternalSQLDirectory[] = [
{
id: 'dir-1',
name: 'scripts',
path: 'D:/sql/scripts',
createdAt: 1,
},
];
const trees: Record<string, ExternalSQLTreeEntry[]> = {
'dir-1': [
{
name: 'readme.md',
path: 'D:/sql/scripts/readme.md',
isDir: false,
},
{
name: 'nested',
path: 'D:/sql/scripts/nested',
isDir: true,
children: [
{
name: 'notes.txt',
path: 'D:/sql/scripts/nested/notes.txt',
isDir: false,
},
{
name: 'report.SQL',
path: 'D:/sql/scripts/nested/report.SQL',
isDir: false,
},
],
},
{
name: 'docs',
path: 'D:/sql/scripts/docs',
isDir: true,
children: [
{
name: 'manual.md',
path: 'D:/sql/scripts/docs/manual.md',
isDir: false,
},
],
},
],
};
const node = buildExternalSQLRootNode({
directories,
directoryTrees: trees,
});
const folderChildren = node.children?.[0].children || [];
const docsFolder = folderChildren.find((child) => child.title === 'docs');
const nestedFolder = folderChildren.find((child) => child.title === 'nested');
expect(folderChildren).toHaveLength(2);
expect(docsFolder).toMatchObject({
title: 'docs',
type: 'external-sql-folder',
});
expect(docsFolder?.children).toBeUndefined();
expect(nestedFolder).toMatchObject({
title: 'nested',
type: 'external-sql-folder',
});
expect(nestedFolder?.children).toHaveLength(1);
expect(nestedFolder?.children?.[0]).toMatchObject({
title: 'report.SQL',
type: 'external-sql-file',
});
});
});

View File

@@ -16,9 +16,9 @@ export interface ExternalSQLTreeNode {
}
type BuildExternalSQLRootNodeParams = {
dbNodeKey: string;
connectionId: string;
dbName: string;
dbNodeKey?: string;
connectionId?: string;
dbName?: string;
directories: ExternalSQLDirectory[];
directoryTrees: Record<string, ExternalSQLTreeEntry[]>;
labels?: Partial<ExternalSQLTreeLabels>;
@@ -55,7 +55,7 @@ const resolveDirectoryDisplayName = (
};
export const buildExternalSQLDirectoryId = (connectionId: string, dbName: string, directoryPath: string): string =>
`external-sql-dir:${String(connectionId || '').trim()}:${String(dbName || '').trim()}:${normalizeExternalSQLPath(directoryPath)}`;
`external-sql-dir:${normalizeExternalSQLPath(directoryPath)}`;
export const buildExternalSQLTabId = (connectionId: string, dbName: string, filePath: string): string =>
`external-sql-tab:${String(connectionId || '').trim()}:${String(dbName || '').trim()}:${normalizeExternalSQLPath(filePath)}`;
@@ -63,14 +63,20 @@ export const buildExternalSQLTabId = (connectionId: string, dbName: string, file
const buildExternalSQLNodeKey = (type: ExternalSQLNodeType, base: string): string =>
`${type}:${normalizeExternalSQLPath(base)}`;
const isExternalSQLFileEntry = (entry: ExternalSQLTreeEntry): boolean => {
const name = String(entry.name || '').trim();
const path = normalizeExternalSQLPath(entry.path);
return /\.sql$/i.test(name) || /\.sql$/i.test(path);
};
const mapExternalSQLTreeEntries = (
entries: ExternalSQLTreeEntry[],
context: { connectionId: string; dbName: string; dbNodeKey: string; directoryId: string },
): ExternalSQLTreeNode[] => entries.map((entry) => {
): ExternalSQLTreeNode[] => entries.flatMap((entry): ExternalSQLTreeNode[] => {
const entryPath = normalizeExternalSQLPath(entry.path);
if (entry.isDir) {
const children = mapExternalSQLTreeEntries(entry.children || [], context);
return {
return [{
title: entry.name,
key: buildExternalSQLNodeKey('external-sql-folder', entryPath),
type: 'external-sql-folder',
@@ -84,10 +90,14 @@ const mapExternalSQLTreeEntries = (
path: entry.path,
name: entry.name,
},
};
}];
}
return {
if (!isExternalSQLFileEntry(entry)) {
return [];
}
return [{
title: entry.name,
key: buildExternalSQLNodeKey('external-sql-file', entryPath),
type: 'external-sql-file',
@@ -100,13 +110,13 @@ const mapExternalSQLTreeEntries = (
path: entry.path,
name: entry.name,
},
};
}];
});
export const buildExternalSQLRootNode = ({
dbNodeKey,
connectionId,
dbName,
dbNodeKey = 'external-sql-root',
connectionId = '',
dbName = '',
directories,
directoryTrees,
labels,
@@ -142,7 +152,7 @@ export const buildExternalSQLRootNode = ({
return {
title: children.length > 0 ? `${resolvedLabels.root} (${children.length})` : resolvedLabels.root,
key: `${dbNodeKey}-external-sql`,
key: dbNodeKey === 'external-sql-root' ? 'external-sql-root' : `${dbNodeKey}-external-sql`,
type: 'external-sql-root',
isLeaf: children.length === 0,
children: children.length > 0 ? children : undefined,

View File

@@ -0,0 +1,33 @@
import { describe, expect, it } from 'vitest';
import {
getLinuxCJKFontInstallHint,
hasInstalledCJKFontFamily,
} from './fontFamilies';
describe('fontFamilies helpers', () => {
it('detects installed CJK font families on Linux', () => {
expect(hasInstalledCJKFontFamily([
{ family: 'Ubuntu' },
{ family: 'Noto Sans CJK SC' },
])).toBe(true);
expect(hasInstalledCJKFontFamily([
{ family: 'DejaVu Sans' },
{ family: 'Liberation Sans' },
])).toBe(false);
});
it('returns an Ubuntu CJK font install hint only when Linux lacks CJK fonts', () => {
expect(getLinuxCJKFontInstallHint('linux', [
{ family: 'DejaVu Sans' },
])).toBe('sudo apt install fonts-noto-cjk fonts-wqy-microhei && fc-cache -fv');
expect(getLinuxCJKFontInstallHint('linux', [
{ family: 'Source Han Sans SC' },
])).toBeNull();
expect(getLinuxCJKFontInstallHint('windows', [
{ family: 'DejaVu Sans' },
])).toBeNull();
});
});

View File

@@ -1,9 +1,36 @@
export const DEFAULT_UI_FONT_FAMILY =
'"Inter", "PingFang SC", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", sans-serif';
'"Inter", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei", "Microsoft YaHei", -apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "Ubuntu", sans-serif';
export const DEFAULT_MONO_FONT_FAMILY =
'"JetBrains Mono", ui-monospace, "SF Mono", Menlo, Consolas, monospace';
'"JetBrains Mono", "Noto Sans Mono CJK SC", "Noto Sans Mono", ui-monospace, "SF Mono", Menlo, Consolas, "DejaVu Sans Mono", monospace';
const MAX_FONT_FAMILY_LENGTH = 512;
const LINUX_CJK_FONT_INSTALL_COMMAND = 'sudo apt install fonts-noto-cjk fonts-wqy-microhei && fc-cache -fv';
const CJK_FONT_KEYWORDS = [
'noto sans cjk',
'noto sans sc',
'noto serif cjk',
'noto serif sc',
'source han sans',
'source han serif',
'思源',
'wenquanyi',
'文泉驿',
'sarasa',
'更纱',
'lxgw',
'霞鹜',
'microsoft yahei',
'微软雅黑',
'simsun',
'宋体',
'simhei',
'黑体',
'pingfang',
'苹方',
'hiragino',
'冬青',
];
export type FontFamilyOption = {
value: string;
@@ -17,9 +44,9 @@ export type InstalledFontFamily = {
};
const UI_FONT_FALLBACK_STACK =
'-apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "PingFang SC", sans-serif';
'-apple-system, BlinkMacSystemFont, "Helvetica Neue", "Segoe UI", "PingFang SC", "Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", "WenQuanYi Micro Hei", "Microsoft YaHei", "Ubuntu", sans-serif';
const MONO_FONT_FALLBACK_STACK =
'ui-monospace, "SF Mono", Menlo, Consolas, monospace';
'ui-monospace, "SF Mono", Menlo, Consolas, "Noto Sans Mono CJK SC", "Noto Sans Mono", "DejaVu Sans Mono", monospace';
const MONO_FONT_PRIORITY_HINTS = [
'mono',
@@ -46,6 +73,11 @@ const normalizeFontSearchToken = (value: string): string => String(value || '')
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fff]+/gi, '');
const normalizeInstalledFontNameForCJK = (entry: string | InstalledFontFamily): string => {
const raw = typeof entry === 'string' ? entry : entry.family;
return String(raw || '').trim().toLowerCase();
};
const insertFontNameWordBreaks = (value: string): string => value
.replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2')
.replace(/([a-z\d])([A-Z])/g, '$1 $2');
@@ -83,10 +115,12 @@ const MAC_MONO_FONTS: FontFamilyOption[] = [
];
const LINUX_UI_FONTS: FontFamilyOption[] = [
{ value: '"Noto Sans", "Noto Sans CJK SC", sans-serif', label: 'Noto Sans', keywords: ['linux', 'default'] },
{ value: '"Ubuntu", "Noto Sans", sans-serif', label: 'Ubuntu', keywords: ['linux', 'ubuntu'] },
{ value: '"DejaVu Sans", "Noto Sans", sans-serif', label: 'DejaVu Sans', keywords: ['linux'] },
{ value: '"Liberation Sans", "Noto Sans", sans-serif', label: 'Liberation Sans', keywords: ['linux'] },
{ value: '"Noto Sans", "Noto Sans CJK SC", "Noto Sans SC", sans-serif', label: 'Noto Sans', keywords: ['linux', 'default'] },
{ value: '"Noto Sans CJK SC", "Noto Sans SC", "Source Han Sans SC", sans-serif', label: 'Noto Sans CJK SC', keywords: ['linux', 'cjk', '中文'] },
{ value: '"Source Han Sans SC", "Noto Sans CJK SC", sans-serif', label: 'Source Han Sans SC', keywords: ['linux', 'cjk', '思源黑体'] },
{ value: '"Ubuntu", "Noto Sans CJK SC", "Noto Sans", sans-serif', label: 'Ubuntu', keywords: ['linux', 'ubuntu'] },
{ value: '"DejaVu Sans", "Noto Sans CJK SC", "Noto Sans", sans-serif', label: 'DejaVu Sans', keywords: ['linux'] },
{ value: '"Liberation Sans", "Noto Sans CJK SC", "Noto Sans", sans-serif', label: 'Liberation Sans', keywords: ['linux'] },
{ value: '"WenQuanYi Micro Hei", "Noto Sans CJK SC", sans-serif', label: 'WenQuanYi Micro Hei', keywords: ['linux', '文泉驿'] },
];
@@ -103,6 +137,7 @@ const SHARED_UI_FONTS: FontFamilyOption[] = [
{ value: '"PingFang SC", sans-serif', label: 'PingFang SC', keywords: ['shared', '苹方'] },
{ value: '"Microsoft YaHei", sans-serif', label: 'Microsoft YaHei', keywords: ['shared', '雅黑'] },
{ value: '"Noto Sans CJK SC", sans-serif', label: 'Noto Sans CJK SC', keywords: ['shared', 'noto'] },
{ value: '"Source Han Sans SC", sans-serif', label: 'Source Han Sans SC', keywords: ['shared', 'source han', '思源黑体'] },
];
const SHARED_MONO_FONTS: FontFamilyOption[] = [
@@ -264,6 +299,32 @@ export const resolveMonoFontFamily = (customValue: unknown): string => {
return sanitizeFontFamilyInput(customValue) ?? DEFAULT_MONO_FONT_FAMILY;
};
export const hasInstalledCJKFontFamily = (
installedFamilies: Array<string | InstalledFontFamily>,
): boolean => {
return installedFamilies.some((entry) => {
const family = normalizeInstalledFontNameForCJK(entry);
if (!family) {
return false;
}
const compactFamily = normalizeFontSearchToken(family);
return CJK_FONT_KEYWORDS.some((keyword) => {
const normalizedKeyword = keyword.toLowerCase();
return family.includes(normalizedKeyword) || compactFamily.includes(normalizeFontSearchToken(normalizedKeyword));
});
});
};
export const getLinuxCJKFontInstallHint = (
platform: string,
installedFamilies: Array<string | InstalledFontFamily>,
): string | null => {
if (String(platform || '').toLowerCase() !== 'linux') {
return null;
}
return hasInstalledCJKFontFamily(installedFamilies) ? null : LINUX_CJK_FONT_INSTALL_COMMAND;
};
export const getPlatformFontFamilyOptions = (
platform: string,
kind: "ui" | "mono",

View File

@@ -0,0 +1,281 @@
import {
type BusinessArgumentHintTemplate,
type MCPBusinessArgumentHintCategory,
hasDockerImageArg,
hasPackageLikeArg,
normalizeFlagName,
resolveBusinessArgumentHintTemplate,
sanitizeFlagForDisplay,
toTrimmedString,
} from './mcpArgumentHints';
export interface MCPArgumentDetailHint {
key: string;
argument: string;
category: MCPBusinessArgumentHintCategory;
label: string;
detail: string;
valueHint: string;
sensitive: boolean;
}
const VALUE_ARG_FLAGS = new Set([
'api-key',
'token',
'access-token',
'password',
'secret',
'config',
'config-file',
'c',
'directory',
'dir',
'root',
'workspace',
'path',
'url',
'endpoint',
'base-url',
'host',
'port',
'transport',
'mode',
'profile',
'tenant',
'project',
'account',
'executable-path',
'repo',
'e',
'env',
'name',
'network',
'v',
'volume',
'p',
'publish',
'entrypoint',
'w',
'workdir',
'u',
'user',
'platform',
'h',
'hostname',
]);
const flagExpectsValue = (flag: string): boolean => VALUE_ARG_FLAGS.has(flag);
const fallbackArgumentHint = (flag: string): BusinessArgumentHintTemplate => ({
category: 'generic',
label: '未识别参数',
detail: `GoNavi 不能从参数名 --${flag} 准确判断业务含义,但会按当前顺序原样传给 MCP 进程。`,
valueHint: '请对照 MCP README 确认这个参数是否需要值;需要值时把值作为下一个参数标签,或使用 --name=value。',
sensitive: false,
});
const sanitizeArgumentValueForDisplay = (value: string, sensitive = false): string => {
const text = toTrimmedString(value);
if (!text) return '';
if (sensitive) return '<已隐藏>';
if (/^(.{0,24})=(.*)$/u.test(text) && /(token|api[-_]?key|secret|password|credential)/iu.test(text.split('=')[0])) {
return `${text.split('=')[0]}=<已隐藏>`;
}
if (/(sk-[a-z0-9_-]{8,}|ghp_[a-z0-9_]{8,}|xox[baprs]-[a-z0-9-]{8,})/iu.test(text)) {
return '<疑似密钥,已隐藏>';
}
return text;
};
const buildArgumentDetail = (
key: string,
argument: string,
template: BusinessArgumentHintTemplate,
): MCPArgumentDetailHint => ({
key,
argument,
category: template.category,
label: template.label,
detail: template.detail,
valueHint: template.valueHint,
sensitive: template.sensitive,
});
const runtimeArgumentTemplate = (
commandName: string,
args: string[],
arg: string,
index: number,
): BusinessArgumentHintTemplate | null => {
const text = toTrimmedString(arg);
const lower = text.toLowerCase();
if (lower === '--stdio' || lower === 'stdio') {
return {
category: 'mode',
label: 'stdio 通信模式',
detail: '让 MCP Server 通过标准输入输出和 GoNavi 保持通信。',
valueHint: '这是开关参数,一般不需要额外值。',
sensitive: false,
};
}
if (lower === '-y' && ['npx', 'npm', 'pnpm', 'yarn'].includes(commandName)) {
return {
category: 'runtime',
label: '跳过安装确认',
detail: '避免 npx 首次启动包时等待交互确认,适合后台工具发现。',
valueHint: '这是开关参数,不需要额外值。',
sensitive: false,
};
}
if (lower === '-m' && ['python', 'python3', 'py'].includes(commandName)) {
return {
category: 'runtime',
label: 'Python 模块启动',
detail: '表示后一个参数是 Python 模块名,而不是脚本文件路径。',
valueHint: '后面补模块名,例如 your_mcp_server。',
sensitive: false,
};
}
if (commandName === 'docker') {
if (lower === 'run') {
return {
category: 'runtime',
label: 'Docker 运行子命令',
detail: '表示启动一个容器来运行 MCP Server。',
valueHint: '通常放在 docker 后面的第一个参数。',
sensitive: false,
};
}
if (lower === '-i' || lower === '--interactive') {
return {
category: 'runtime',
label: '保持标准输入',
detail: 'MCP stdio 需要容器 stdin 持续打开,否则工具发现可能启动后立刻断开。',
valueHint: '这是 Docker MCP 的关键参数。',
sensitive: false,
};
}
if (lower === '--rm') {
return {
category: 'runtime',
label: '退出后清理容器',
detail: '测试和日常使用后自动删除临时容器,避免残留。',
valueHint: '这是开关参数,不需要额外值。',
sensitive: false,
};
}
if (!text.startsWith('-') && hasDockerImageArg(args.slice(0, index + 1))) {
return {
category: 'runtime',
label: 'Docker 镜像或容器参数',
detail: '这是 docker run 中的镜像名或传给容器内 MCP 服务的位置参数。',
valueHint: '镜像名应来自 MCP README镜像后的参数会传给容器入口程序。',
sensitive: false,
};
}
}
if (!text.startsWith('-')) {
if (['npx', 'npm', 'pnpm', 'yarn'].includes(commandName) && hasPackageLikeArg([text])) {
return {
category: 'runtime',
label: 'MCP 包名或位置参数',
detail: '通常是 README 里的 npm 包名,也可能是包自己的业务参数。',
valueHint: '包名一般放在 -y 后、--stdio 前;业务参数以 README 为准。',
sensitive: false,
};
}
if (commandName === 'uvx' || commandName === 'uv') {
return {
category: 'runtime',
label: 'Python MCP 包名或位置参数',
detail: 'uvx 后面通常跟 MCP 包名;后续位置参数会传给该 MCP 服务。',
valueHint: '第一个位置参数应是 README 里的包名。',
sensitive: false,
};
}
if (['node', 'bun', 'deno'].includes(commandName)) {
return {
category: /\.(c?m?[jt]s)$/iu.test(text) || /[\\/]/u.test(text) ? 'path' : 'runtime',
label: '脚本或位置参数',
detail: '通常是本地 MCP Server 的入口脚本;脚本后的值会作为业务参数传入。',
valueHint: '入口脚本建议使用本机可访问的相对或绝对路径。',
sensitive: false,
};
}
if (['python', 'python3', 'py'].includes(commandName)) {
return {
category: args[index - 1] === '-m' ? 'runtime' : 'path',
label: args[index - 1] === '-m' ? 'Python 模块名' : 'Python 脚本或位置参数',
detail: args[index - 1] === '-m'
? '这是 -m 后面的模块名,不要带 .py 后缀。'
: '通常是本地 Python MCP 脚本路径,或传给脚本的位置参数。',
valueHint: '以 README 的启动示例为准。',
sensitive: false,
};
}
}
return null;
};
export const buildMCPArgumentDetailHints = (commandName: string, args: string[]): MCPArgumentDetailHint[] => {
const result: MCPArgumentDetailHint[] = [];
for (let index = 0; index < args.length; index += 1) {
const text = toTrimmedString(args[index]);
if (!text) continue;
const previousFlag = index > 0 ? normalizeFlagName(args[index - 1]) : '';
const previousHasInlineValue = index > 0 && toTrimmedString(args[index - 1]).includes('=');
if (previousFlag && !previousHasInlineValue && flagExpectsValue(previousFlag) && !text.startsWith('-')) {
const template = resolveBusinessArgumentHintTemplate(previousFlag, true) || fallbackArgumentHint(previousFlag);
result.push(buildArgumentDetail(
`value-${index}-${previousFlag}`,
sanitizeArgumentValueForDisplay(text, template.sensitive),
{
...template,
label: `${template.label}的值`,
detail: template.sensitive
? `这是前一个 ${sanitizeFlagForDisplay(args[index - 1])} 的敏感值,提示中已脱敏。`
: `这是前一个 ${sanitizeFlagForDisplay(args[index - 1])} 参数的值。`,
},
));
continue;
}
const runtimeTemplate = runtimeArgumentTemplate(commandName, args, text, index);
if (runtimeTemplate) {
result.push(buildArgumentDetail(
`runtime-${index}-${text}`,
sanitizeArgumentValueForDisplay(text, runtimeTemplate.sensitive),
runtimeTemplate,
));
continue;
}
const flag = normalizeFlagName(text);
if (flag) {
const template = resolveBusinessArgumentHintTemplate(flag, true) || fallbackArgumentHint(flag);
result.push(buildArgumentDetail(
`flag-${index}-${flag}`,
sanitizeFlagForDisplay(text),
template,
));
continue;
}
result.push(buildArgumentDetail(
`positional-${index}`,
sanitizeArgumentValueForDisplay(text),
{
category: 'generic',
label: '位置参数',
detail: '这是没有参数名的位置参数GoNavi 会按当前顺序原样传入 MCP 进程。',
valueHint: '请对照 README 判断它是包名、路径、镜像名还是业务参数。',
sensitive: false,
},
));
}
return result;
};

View File

@@ -0,0 +1,166 @@
import { describe, expect, it } from 'vitest';
import { buildMCPArgumentDetailHints } from './mcpArgumentDetailHints';
import { buildMCPArgumentHintProfile } from './mcpArgumentHints';
describe('mcpArgumentHints', () => {
it('guides npx users to split package and stdio arguments', () => {
const profile = buildMCPArgumentHintProfile('npx', ['-y']);
expect(profile?.title).toContain('npx');
expect(profile?.orderHint).toContain('-y -> 包名 -> --stdio');
expect(profile?.nextActions).toContain('补充 MCP 包名,示例:@modelcontextprotocol/server-filesystem');
expect(profile?.nextActions).toContain('补充 stdio 参数,示例:--stdio');
});
it('recognizes a complete node script launch', () => {
const profile = buildMCPArgumentHintProfile('node', ['server.js', '--stdio']);
expect(profile?.title).toContain('Node');
expect(profile?.steps.find((item) => item.key === 'script')?.satisfied).toBe(true);
expect(profile?.nextActions).toEqual([]);
});
it('explains python module launches as independent args', () => {
const profile = buildMCPArgumentHintProfile('C:\\Python312\\python.exe', ['-m']);
expect(profile?.commandName).toBe('python');
expect(profile?.orderHint).toContain('-m -> 模块名 -> --stdio');
expect(profile?.nextActions).toContain('补充 模块名示例your_mcp_server');
});
it('guides docker users to keep stdin and provide an image', () => {
const profile = buildMCPArgumentHintProfile('docker', ['run', '--rm']);
expect(profile?.title).toContain('Docker');
expect(profile?.orderHint).toContain('run -> --rm -> -i');
expect(profile?.nextActions).toContain('补充 保持标准输入,示例:-i');
expect(profile?.nextActions).toContain('补充 镜像名示例mcp/server-fetch:latest');
});
it('detects full command lines pasted into the command field', () => {
const profile = buildMCPArgumentHintProfile('docker run --rm mcp/server-fetch:latest', []);
expect(profile?.normalizedCommand).toBe('docker');
expect(profile?.inlineArgs).toEqual(['run', '--rm', 'mcp/server-fetch:latest']);
expect(profile?.commandFieldWarning).toContain('启动命令字段里还包含 3 个参数');
expect(profile?.steps.find((item) => item.key === 'run')?.satisfied).toBe(true);
expect(profile?.steps.find((item) => item.key === 'image')?.satisfied).toBe(true);
expect(profile?.nextActions).toContain('补充 保持标准输入,示例:-i');
});
it('falls back to executable guidance for custom binaries', () => {
const profile = buildMCPArgumentHintProfile('D:\\tools\\acme-mcp-server.exe', []);
expect(profile?.title).toContain('本机可执行文件');
expect(profile?.summary).toContain('GoNavi 会原样按标签顺序传入');
});
it('explains common business arguments beyond startup order', () => {
const profile = buildMCPArgumentHintProfile('npx', [
'-y',
'@modelcontextprotocol/server-filesystem',
'--stdio',
'--directory',
'D:\\Work',
'--transport',
'stdio',
'--port=8080',
]);
expect(profile?.businessHints).toEqual(expect.arrayContaining([
expect.objectContaining({
key: 'directory',
label: '授权目录',
category: 'path',
}),
expect.objectContaining({
key: 'transport',
label: '传输模式',
category: 'mode',
}),
expect.objectContaining({
key: 'port',
label: '端口',
category: 'network',
}),
]));
});
it('builds per-argument explanations for unknown flags and positional values', () => {
const hints = buildMCPArgumentDetailHints('acme-mcp-server', [
'--tenant',
'prod',
'--workspace',
'D:\\Work',
'extra-target',
]);
expect(hints).toEqual(expect.arrayContaining([
expect.objectContaining({
argument: '--tenant',
label: '未识别参数',
category: 'generic',
}),
expect.objectContaining({
argument: 'prod',
label: '未识别参数的值',
}),
expect.objectContaining({
argument: '--workspace',
label: '工作区目录',
category: 'path',
}),
expect.objectContaining({
argument: 'D:\\Work',
label: '工作区目录的值',
}),
expect.objectContaining({
argument: 'extra-target',
label: '位置参数',
}),
]));
});
it('sanitizes sensitive inline argument values in hints', () => {
const args = [
'mcp-server-demo',
'--api-key=sk-real-secret',
'--token',
'ghp_real-secret-token',
'--endpoint',
'https://api.example.com',
];
const profile = buildMCPArgumentHintProfile('uvx', [
...args,
]);
const argumentHints = buildMCPArgumentDetailHints('uvx', args);
expect(profile?.businessHints).toEqual(expect.arrayContaining([
expect.objectContaining({
key: 'api-key',
argument: '--api-key',
category: 'secret',
sensitive: true,
}),
expect.objectContaining({
key: 'endpoint',
category: 'endpoint',
}),
]));
expect(JSON.stringify(profile?.businessHints)).not.toContain('sk-real-secret');
expect(argumentHints).toEqual(expect.arrayContaining([
expect.objectContaining({
argument: '--api-key',
sensitive: true,
}),
expect.objectContaining({
argument: '<已隐藏>',
label: 'Token的值',
sensitive: true,
}),
]));
expect(JSON.stringify(argumentHints)).not.toContain('sk-real-secret');
expect(JSON.stringify(argumentHints)).not.toContain('ghp_real-secret-token');
});
});

View File

@@ -0,0 +1,555 @@
import { splitShellLikeCommand } from './mcpCommandDraft';
export interface MCPArgumentHintStep {
key: string;
label: string;
example: string;
detail: string;
required: boolean;
satisfied: boolean;
}
export type MCPBusinessArgumentHintCategory = 'secret' | 'path' | 'endpoint' | 'network' | 'mode' | 'runtime' | 'generic';
export interface MCPBusinessArgumentHint {
key: string;
argument: string;
category: MCPBusinessArgumentHintCategory;
label: string;
detail: string;
valueHint: string;
sensitive: boolean;
}
export interface MCPArgumentHintProfile {
commandName: string;
normalizedCommand: string;
inlineArgs: string[];
commandFieldWarning?: string;
title: string;
summary: string;
orderHint: string;
steps: MCPArgumentHintStep[];
businessHints: MCPBusinessArgumentHint[];
nextActions: string[];
}
export const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const parseCommandField = (command: string): { normalizedCommand: string; commandName: string; inlineArgs: string[] } => {
const { tokens } = splitShellLikeCommand(command);
const raw = toTrimmedString(tokens[0] || command);
const lastPathPart = raw.split(/[\\/]/u).pop() || raw;
const commandName = lastPathPart
.replace(/\.(exe|cmd|bat|ps1)$/iu, '')
.toLowerCase();
const inlineArgs = tokens.length > 1 && isInlineArgHintCommand(commandName)
? tokens.slice(1).map(toTrimmedString).filter(Boolean)
: [];
return {
normalizedCommand: raw,
commandName,
inlineArgs,
};
};
const isInlineArgHintCommand = (commandName: string): boolean =>
['npx', 'npm', 'pnpm', 'yarn', 'node', 'bun', 'deno', 'python', 'python3', 'py', 'uvx', 'uv', 'docker'].includes(commandName);
const normalizeArgs = (args?: string[]): string[] =>
(Array.isArray(args) ? args : []).map(toTrimmedString).filter(Boolean);
const hasArg = (args: string[], expected: string): boolean =>
args.some((arg) => arg.toLowerCase() === expected.toLowerCase());
const hasStdioArg = (args: string[]): boolean =>
hasArg(args, '--stdio') || hasArg(args, 'stdio');
export const hasPackageLikeArg = (args: string[]): boolean =>
args.some((arg) => {
const text = arg.trim();
if (!text || text.startsWith('-')) return false;
return !['stdio'].includes(text.toLowerCase());
});
const hasScriptLikeArg = (args: string[]): boolean =>
args.some((arg) => /\.(c?m?[jt]s|py)$/iu.test(arg) || /[\\/]/u.test(arg));
const hasPythonModuleArg = (args: string[]): boolean => {
const moduleFlagIndex = args.findIndex((arg) => arg === '-m');
return moduleFlagIndex >= 0 && Boolean(args[moduleFlagIndex + 1]);
};
const hasDockerRunArg = (args: string[]): boolean =>
args.some((arg) => arg.toLowerCase() === 'run');
const hasDockerInteractiveArg = (args: string[]): boolean =>
hasArg(args, '-i') || hasArg(args, '--interactive');
export const hasDockerImageArg = (args: string[]): boolean => {
const runIndex = args.findIndex((arg) => arg.toLowerCase() === 'run');
const candidates = runIndex >= 0 ? args.slice(runIndex + 1) : args;
for (let index = 0; index < candidates.length; index += 1) {
const arg = candidates[index];
if (!arg || arg.startsWith('-')) {
const lower = arg.toLowerCase();
if ([
'-e',
'--env',
'--name',
'--network',
'-v',
'--volume',
'-p',
'--publish',
'--entrypoint',
'-w',
'--workdir',
'-u',
'--user',
'--platform',
'-h',
'--hostname',
].includes(lower)) {
index += 1;
}
continue;
}
if (arg.includes('=') || arg.includes(':') || arg.includes('/')) {
return true;
}
}
return false;
};
const buildStep = (
key: string,
label: string,
example: string,
detail: string,
required: boolean,
satisfied: boolean,
): MCPArgumentHintStep => ({
key,
label,
example,
detail,
required,
satisfied,
});
const buildNextActions = (steps: MCPArgumentHintStep[]): string[] =>
steps
.filter((step) => step.required && !step.satisfied)
.map((step) => `补充 ${step.label},示例:${step.example}`);
export type BusinessArgumentHintTemplate = Omit<MCPBusinessArgumentHint, 'key' | 'argument'>;
const BUSINESS_ARGUMENT_HINTS: Record<string, BusinessArgumentHintTemplate> = {
'api-key': {
category: 'secret',
label: 'API Key',
detail: '用于把外部 API 密钥传给 MCP 服务。除非 README 明确要求命令参数,否则更建议放到环境变量里。',
valueHint: '填真实 key不要截图或粘贴到聊天里。',
sensitive: true,
},
token: {
category: 'secret',
label: 'Token',
detail: '用于鉴权外部平台或远程 MCP 服务。命令行参数可能被进程列表或日志看到。',
valueHint: '优先改用环境变量,例如 GITHUB_TOKEN、API_TOKEN。',
sensitive: true,
},
'access-token': {
category: 'secret',
label: 'Access Token',
detail: '用于访问第三方 API 或私有资源。',
valueHint: '按最小权限创建 token并优先放环境变量。',
sensitive: true,
},
password: {
category: 'secret',
label: '密码',
detail: '密码类参数会进入启动参数列表,风险高于环境变量。',
valueHint: '确认 MCP README 没有环境变量替代方案后再使用。',
sensitive: true,
},
secret: {
category: 'secret',
label: '密钥',
detail: '密钥类参数用于鉴权或签名。',
valueHint: '优先使用环境变量或配置文件,避免明文出现在启动参数里。',
sensitive: true,
},
config: {
category: 'path',
label: '配置文件',
detail: '指向 MCP 服务自己的配置文件。',
valueHint: '填写本机 MCP 进程能访问的绝对路径。',
sensitive: false,
},
'config-file': {
category: 'path',
label: '配置文件',
detail: '指向 MCP 服务自己的配置文件。',
valueHint: 'Windows 建议填写带盘符的绝对路径。',
sensitive: false,
},
c: {
category: 'path',
label: '配置文件',
detail: '短参数通常表示 config以 README 为准。',
valueHint: '填写配置文件路径,或按 README 确认 -c 的含义。',
sensitive: false,
},
directory: {
category: 'path',
label: '授权目录',
detail: '限制文件系统类 MCP 可访问的目录范围。',
valueHint: '填写要授权给 MCP 的工作目录,不要直接授权整个磁盘。',
sensitive: false,
},
dir: {
category: 'path',
label: '目录',
detail: '通常表示文件或项目根目录。',
valueHint: '填写本机绝对路径,确认该 MCP 进程有读取权限。',
sensitive: false,
},
root: {
category: 'path',
label: '根目录',
detail: '通常表示 MCP 服务允许访问或扫描的根目录。',
valueHint: '选择最小必要目录,避免范围过大。',
sensitive: false,
},
workspace: {
category: 'path',
label: '工作区目录',
detail: '通常表示项目或文件系统服务的工作区。',
valueHint: '填写项目目录或业务数据目录。',
sensitive: false,
},
path: {
category: 'path',
label: '路径',
detail: '通常表示文件、目录或可执行程序路径。',
valueHint: '填写本机 MCP 进程可访问的路径。',
sensitive: false,
},
url: {
category: 'endpoint',
label: '服务 URL',
detail: 'MCP 服务要访问的 HTTP/HTTPS 地址。',
valueHint: '填写完整 URL例如 https://api.example.com。',
sensitive: false,
},
endpoint: {
category: 'endpoint',
label: 'Endpoint',
detail: '远程服务或 API 的访问入口。',
valueHint: '按 README 填写 endpoint不要混入 token。',
sensitive: false,
},
'base-url': {
category: 'endpoint',
label: 'Base URL',
detail: '第三方 API 或自建服务的基础地址。',
valueHint: '填写协议、域名和可选端口,不要附带密钥。',
sensitive: false,
},
host: {
category: 'network',
label: '主机地址',
detail: '目标服务主机或本地监听地址。',
valueHint: '本机服务常用 127.0.0.1;远程服务填写域名或 IP。',
sensitive: false,
},
port: {
category: 'network',
label: '端口',
detail: '目标服务端口或 MCP 服务监听端口。',
valueHint: '填写 1-65535 的端口号。',
sensitive: false,
},
transport: {
category: 'mode',
label: '传输模式',
detail: '控制 MCP 服务使用 stdio、sse 或 http 等通信方式。',
valueHint: 'GoNavi 当前本机 MCP 配置使用 stdio除非 README 特别要求,否则填 stdio。',
sensitive: false,
},
mode: {
category: 'mode',
label: '运行模式',
detail: '控制 MCP 服务的业务模式或兼容模式。',
valueHint: '按 README 的枚举值填写。',
sensitive: false,
},
profile: {
category: 'mode',
label: '配置档',
detail: '选择 MCP 服务使用哪套配置或账号档案。',
valueHint: '填写 README 或本机配置中定义的 profile 名称。',
sensitive: false,
},
'read-only': {
category: 'mode',
label: '只读模式',
detail: '限制 MCP 服务只读访问,降低误写风险。',
valueHint: '通常是开关参数,不需要额外值。',
sensitive: false,
},
readonly: {
category: 'mode',
label: '只读模式',
detail: '限制 MCP 服务只读访问,降低误写风险。',
valueHint: '通常是开关参数,不需要额外值。',
sensitive: false,
},
headless: {
category: 'runtime',
label: '无头模式',
detail: '浏览器类 MCP 是否使用无界面浏览器。',
valueHint: '需要真实窗口调试时关闭;自动化运行通常开启。',
sensitive: false,
},
'executable-path': {
category: 'path',
label: '浏览器或程序路径',
detail: '指定 MCP 服务要启动的浏览器或外部程序。',
valueHint: '填写本机绝对路径。',
sensitive: false,
},
repo: {
category: 'path',
label: '仓库路径',
detail: '限制 Git/GitHub 相关 MCP 操作的本地仓库。',
valueHint: '填写目标仓库目录。',
sensitive: false,
},
};
export const normalizeFlagName = (arg: string): string => {
const text = toTrimmedString(arg);
if (!text.startsWith('-') || text === '-' || text === '--') {
return '';
}
const withoutValue = text.split('=')[0];
return withoutValue.replace(/^-+/u, '').trim().toLowerCase();
};
export const sanitizeFlagForDisplay = (arg: string): string => {
const text = toTrimmedString(arg);
const withoutValue = text.split('=')[0];
return withoutValue || text;
};
const inferBusinessArgumentHint = (flag: string): BusinessArgumentHintTemplate | null => {
if (!flag) return null;
if (/(token|api-?key|secret|password|pass|credential)/iu.test(flag)) {
return BUSINESS_ARGUMENT_HINTS.token;
}
if (/(config|file|path|dir|root|workspace|repo|repository)/iu.test(flag)) {
return {
category: 'path',
label: '路径 / 配置',
detail: '参数名看起来像路径、目录或配置文件。',
valueHint: '填写 MCP 进程能访问的本机路径,并尽量限制到最小范围。',
sensitive: false,
};
}
if (/(url|uri|endpoint|base-url|host|addr|address)/iu.test(flag)) {
return {
category: 'endpoint',
label: '地址 / Endpoint',
detail: '参数名看起来像远程服务地址或监听地址。',
valueHint: '填写完整地址或 host密钥不要拼进 URL。',
sensitive: false,
};
}
if (/(port|listen)/iu.test(flag)) {
return BUSINESS_ARGUMENT_HINTS.port;
}
if (/(mode|profile|transport|readonly|read-only|headless)/iu.test(flag)) {
return {
category: 'mode',
label: '模式参数',
detail: '参数名看起来像运行模式、传输模式或开关。',
valueHint: '按 README 的枚举值或开关语义填写。',
sensitive: false,
};
}
return null;
};
const buildGenericArgumentHint = (flag: string): BusinessArgumentHintTemplate => ({
category: 'generic',
label: '未识别参数',
detail: `GoNavi 不能从参数名 --${flag} 准确判断业务含义,但会按当前顺序原样传给 MCP 进程。`,
valueHint: '请对照 MCP README 确认这个参数是否需要值;需要值时把值作为下一个参数标签,或使用 --name=value。',
sensitive: false,
});
export const resolveBusinessArgumentHintTemplate = (flag: string, fallbackGeneric = false): BusinessArgumentHintTemplate | null =>
BUSINESS_ARGUMENT_HINTS[flag] || inferBusinessArgumentHint(flag) || (fallbackGeneric && flag ? buildGenericArgumentHint(flag) : null);
const buildBusinessArgumentHints = (args: string[]): MCPBusinessArgumentHint[] => {
const result: MCPBusinessArgumentHint[] = [];
const seen = new Set<string>();
for (const arg of args) {
const flag = normalizeFlagName(arg);
if (!flag || flag === 'stdio') {
continue;
}
const template = resolveBusinessArgumentHintTemplate(flag);
if (!template) {
continue;
}
const key = flag;
if (seen.has(key)) {
continue;
}
seen.add(key);
result.push({
key,
argument: sanitizeFlagForDisplay(arg),
...template,
});
}
return result;
};
export const buildMCPArgumentHintProfile = (
command: string,
args?: string[],
): MCPArgumentHintProfile | null => {
const { normalizedCommand, commandName, inlineArgs } = parseCommandField(command);
if (!commandName) {
return null;
}
const normalizedArgs = [...inlineArgs, ...normalizeArgs(args)];
const commandFieldWarning = inlineArgs.length > 0
? `检测到启动命令字段里还包含 ${inlineArgs.length} 个参数:${inlineArgs.join(' / ')}。建议 command 只保留 ${normalizedCommand},其余移到命令参数。`
: undefined;
if (commandName === 'npx' || commandName === 'npm' || commandName === 'pnpm' || commandName === 'yarn') {
const steps = [
buildStep('yes', '跳过安装确认', '-y', '避免首次启动时等待交互确认。pnpm/yarn 场景可按 README 调整。', commandName === 'npx', hasArg(normalizedArgs, '-y')),
buildStep('package', 'MCP 包名', '@modelcontextprotocol/server-filesystem', 'README 里的 npm 包名或本地包入口。', true, hasPackageLikeArg(normalizedArgs)),
buildStep('stdio', 'stdio 参数', '--stdio', '让服务通过标准输入输出和 GoNavi 通信。', true, hasStdioArg(normalizedArgs)),
buildStep('scope', '授权目录或业务参数', 'C:\\Users\\me\\workspace', '文件系统、浏览器、数据库代理等服务可能还需要目录、端口或模式参数。', false, normalizedArgs.length > 3),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'npx / npm 参数顺序建议',
summary: 'npm 生态 MCP 通常要把安装确认、包名和 --stdio 拆成独立参数标签。',
orderHint: '推荐顺序:-y -> 包名 -> --stdio -> 服务自己的业务参数',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
}
if (commandName === 'node' || commandName === 'bun' || commandName === 'deno') {
const steps = [
buildStep('script', '脚本路径', 'server.js', '本地 MCP Server 的 js/mjs/ts 入口文件或包内启动脚本。', true, hasScriptLikeArg(normalizedArgs) || hasPackageLikeArg(normalizedArgs)),
buildStep('stdio', 'stdio 参数', '--stdio', '如果 README 要求 stdio 模式,请单独填一个 --stdio 或 stdio。', false, hasStdioArg(normalizedArgs)),
buildStep('business', '业务参数', '--port 8811', '只有 README 明确要求时再补,例如工作区路径、端口或模式。', false, normalizedArgs.length > 2),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'Node 脚本参数顺序建议',
summary: 'Node 类启动器的命令只填 node/bun/deno脚本路径和 --stdio 放到参数里。',
orderHint: '推荐顺序:脚本路径 -> --stdio -> 服务自己的业务参数',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
}
if (commandName === 'python' || commandName === 'python3' || commandName === 'py') {
const steps = [
buildStep('module-flag', '模块启动标记或脚本', '-m', '模块方式用 -m脚本方式直接填 server.py。二选一即可。', true, hasArg(normalizedArgs, '-m') || hasScriptLikeArg(normalizedArgs)),
buildStep('module-name', '模块名', 'your_mcp_server', '使用 -m 时这里填模块名,不要带 .py 后缀。', true, hasPythonModuleArg(normalizedArgs) || hasScriptLikeArg(normalizedArgs)),
buildStep('stdio', 'stdio 参数', '--stdio', '如果服务支持 stdio按 README 要求补 --stdio。', false, hasStdioArg(normalizedArgs)),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'Python 参数顺序建议',
summary: 'Python MCP 常见形式是 python -m 模块名,-m 和模块名都要作为独立参数。',
orderHint: '推荐顺序:-m -> 模块名 -> --stdio',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
}
if (commandName === 'uvx' || commandName === 'uv') {
const steps = [
buildStep('package', 'Python MCP 包名', 'mcp-server-fetch', 'uvx 后面通常直接跟已发布的 MCP 包名。', true, hasPackageLikeArg(normalizedArgs)),
buildStep('stdio', 'stdio 参数', '--stdio', '如果 README 要求 stdio单独补 --stdio。', false, hasStdioArg(normalizedArgs)),
buildStep('business', '业务参数', '--config ./config.json', '服务自己的配置文件、模式或地址参数。', false, normalizedArgs.length > 2),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'uvx 参数顺序建议',
summary: 'uvx 类 MCP 通常把包名作为第一个参数,再按 README 补 stdio 或配置参数。',
orderHint: '推荐顺序:包名 -> --stdio -> 服务自己的业务参数',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
}
if (commandName === 'docker') {
const steps = [
buildStep('run', '运行子命令', 'run', 'Docker MCP 通常要以 docker run 启动容器。', true, hasDockerRunArg(normalizedArgs)),
buildStep('interactive', '保持标准输入', '-i', 'MCP 需要 stdio 持续连接Docker 容器必须保留 stdin。', true, hasDockerInteractiveArg(normalizedArgs)),
buildStep('cleanup', '退出后清理容器', '--rm', '测试和日常使用建议自动删除临时容器,避免残留。', false, hasArg(normalizedArgs, '--rm')),
buildStep('image', '镜像名', 'mcp/server-fetch:latest', 'README 里的 Docker 镜像名,放在 docker run 选项之后。', true, hasDockerImageArg(normalizedArgs)),
buildStep('container-env', '容器环境变量', '-e API_KEY=...', '容器内应用需要的 token 通常要用 -e/--env 传给容器。', false, normalizedArgs.some((arg) => arg === '-e' || arg === '--env' || arg.startsWith('-e='))),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'Docker MCP 参数顺序建议',
summary: 'Docker 场景 command 只填 dockerrun、-i、--rm、镜像名和容器参数都放到 args 里。',
orderHint: '推荐顺序run -> --rm -> -i -> -e KEY=VALUE -> 镜像名 -> 服务自己的业务参数',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
}
const steps = [
buildStep('stdio', 'stdio 模式参数', 'stdio 或 --stdio', '多数本机 MCP 二进制需要显式 stdio 参数;以 README 为准。', false, hasStdioArg(normalizedArgs)),
buildStep('business', '业务参数', '--config ./config.json', '二进制自己的配置文件、工作目录、端口或模式参数。', false, normalizedArgs.length > 0),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: '本机可执行文件参数建议',
summary: '自研或已编译 MCP Server 的参数以 README 为准GoNavi 会原样按标签顺序传入。',
orderHint: '常见顺序stdio/--stdio -> 配置文件或业务参数',
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
};
};

View File

@@ -0,0 +1,163 @@
import { describe, expect, it } from 'vitest';
import type { AIMCPClientInstallStatus } from '../types';
import {
buildRemoteMCPClientGuide,
buildRemoteMCPClientQuickStart,
EMPTY_MCP_CLIENT_STATUSES,
formatMCPLaunchCommand,
isRemoteMCPClientStatus,
normalizeMCPClientStatuses,
pickPreferredMCPClient,
} from './mcpClientInstallStatus';
describe('mcpClientInstallStatus helpers', () => {
it('fills missing clients with default placeholder statuses', () => {
const statuses = normalizeMCPClientStatuses([
{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: true,
message: '已检测到 Codex 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致',
},
]);
expect(statuses).toEqual([
EMPTY_MCP_CLIENT_STATUSES[0],
{
client: 'codex',
displayName: 'Codex',
installMode: 'auto',
installed: true,
matchesCurrent: true,
clientDetected: false,
clientCommand: 'codex',
clientPath: '',
message: '已检测到 Codex 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致',
args: [],
},
EMPTY_MCP_CLIENT_STATUSES[2],
EMPTY_MCP_CLIENT_STATUSES[3],
]);
});
it('prefers an already-installed but outdated client over a completely uninstalled one', () => {
const statuses: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: false,
message: '已检测到 Codex 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新',
},
];
expect(pickPreferredMCPClient(statuses)).toBe('codex');
});
it('prefers a locally detected client command when neither client has existing GoNavi MCP config', () => {
const statuses: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'claude',
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
},
{
client: 'codex',
displayName: 'Codex',
installed: false,
matchesCurrent: false,
clientDetected: true,
clientCommand: 'codex',
clientPath: 'C:/Users/mock/AppData/Roaming/npm/codex.cmd',
message: '未检测到 Codex 用户级 GoNavi MCP 配置',
},
];
expect(pickPreferredMCPClient(statuses)).toBe('codex');
});
it('prefers a client that already matches current GoNavi over another client with a stale config', () => {
const statuses: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installed: true,
matchesCurrent: true,
clientDetected: true,
clientCommand: 'claude',
message: '已检测到 Claude Code 用户级 GoNavi MCP 配置,且与当前 GoNavi 安装路径一致',
},
{
client: 'codex',
displayName: 'Codex',
installed: true,
matchesCurrent: false,
clientDetected: true,
clientCommand: 'codex',
message: '已检测到 Codex 中的 GoNavi MCP 记录,但与当前 GoNavi 安装路径不一致,建议更新',
},
];
expect(pickPreferredMCPClient(statuses)).toBe('claude-code');
});
it('keeps the user-selected client when it is still present in the latest status list', () => {
expect(pickPreferredMCPClient(EMPTY_MCP_CLIENT_STATUSES, 'codex')).toBe('codex');
expect(pickPreferredMCPClient(EMPTY_MCP_CLIENT_STATUSES, 'openclaw')).toBe('openclaw');
});
it('formats quoted launch commands for display and clipboard use', () => {
expect(formatMCPLaunchCommand({
command: 'C:/Program Files/GoNavi/GoNavi.exe',
args: ['mcp-server', '--stdio'],
})).toBe('"C:/Program Files/GoNavi/GoNavi.exe" mcp-server --stdio');
});
it('marks OpenClaw and Hermans as remote bridge clients and builds a safe guide', () => {
const openClaw = EMPTY_MCP_CLIENT_STATUSES.find((item) => item.client === 'openclaw');
expect(isRemoteMCPClientStatus(openClaw)).toBe(true);
const guide = buildRemoteMCPClientGuide(openClaw);
expect(guide).toContain('GoNavi MCP 远程接入说明 - OpenClaw');
expect(guide).toContain('云端 Agent 不需要保存数据库密码');
expect(guide).toContain('默认使用 schema-only 模式,不注册 execute_sql');
expect(guide).toContain('不能直接使用 Windows 本地 stdio 命令');
expect(guide).toContain('allowMutating=true');
expect(guide).toContain('"type": "streamable-http"');
expect(guide).toContain('"Authorization": "Bearer <随机token>"');
expect(guide).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<你的域名或隧道地址>/mcp --token <随机token> --schema-only');
expect(guide).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only');
});
it('builds remote quick-start snippets for cloud agents without database secrets', () => {
const quickStart = buildRemoteMCPClientQuickStart({
client: 'hermans',
displayName: 'OpenClaw',
});
expect(quickStart.displayName).toBe('OpenClaw');
expect(quickStart.configJson).toContain('"type": "streamable-http"');
expect(quickStart.configJson).toContain('"url": "https://<你的域名或隧道地址>/mcp"');
expect(quickStart.configJson).toContain('"Authorization": "Bearer <随机token>"');
expect(quickStart.configJson).not.toContain('password');
expect(quickStart.configCommand).toBe('GoNavi.exe mcp-server remote-config --client hermans --url https://<你的域名或隧道地址>/mcp --token <随机token> --schema-only');
expect(quickStart.launchCommand).toBe('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only');
expect(quickStart.standaloneCommand).toBe('gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <随机token> --schema-only');
expect(quickStart.verificationSteps.join('\n')).toContain('get_connections');
expect(quickStart.securityNotes.join('\n')).toContain('默认 --schema-only 不注册 execute_sql');
expect(quickStart.securityNotes.join('\n')).toContain('allowMutating=true');
});
});

View File

@@ -0,0 +1,300 @@
import type { AIMCPClientInstallStatus } from '../types';
export type MCPClientKey = 'claude-code' | 'codex' | 'openclaw' | 'hermans';
const AUTO_MCP_CLIENTS = new Set<MCPClientKey>(['claude-code', 'codex']);
const REMOTE_MCP_CLIENTS = new Set<MCPClientKey>(['openclaw', 'hermans']);
const DEFAULT_REMOTE_MCP_PUBLIC_URL = 'https://<你的域名或隧道地址>/mcp';
const DEFAULT_REMOTE_MCP_LOCAL_ADDR = '127.0.0.1:8765';
const DEFAULT_REMOTE_MCP_PATH = '/mcp';
export interface RemoteMCPClientQuickStart {
displayName: string;
configJson: string;
configCommand: string;
launchCommand: string;
standaloneCommand: string;
verificationSteps: string[];
securityNotes: string[];
}
export interface RemoteMCPParameterGuide {
key: string;
title: string;
required: boolean;
fill: string;
example: string;
avoid: string;
}
export const REMOTE_MCP_PARAMETER_GUIDES: RemoteMCPParameterGuide[] = [
{
key: 'publicUrl',
title: '公网/隧道 URL',
required: true,
fill: '填云端 Agent 能访问到的 Streamable HTTP MCP 地址,通常以 /mcp 结尾。',
example: 'https://agent-gateway.example.com/mcp',
avoid: '不要填 Windows 本机的 127.0.0.1;云端 Linux 访问不到这个地址。',
},
{
key: 'bearerToken',
title: 'Bearer Token',
required: true,
fill: '填一段随机长 tokenWindows 启动命令和云端 Agent 配置必须一致。',
example: 'Authorization: Bearer gnv_xxx',
avoid: '不要使用空 token、短 token也不要把数据库密码当 token 填进去。',
},
{
key: 'localAddr',
title: '本机监听地址',
required: true,
fill: 'Windows GoNavi HTTP MCP 默认监听 127.0.0.1:8765再交给隧道或反向代理转发。',
example: DEFAULT_REMOTE_MCP_LOCAL_ADDR,
avoid: '没有网关隔离时不要直接绑定 0.0.0.0 暴露到公网。',
},
{
key: 'path',
title: 'MCP 路径',
required: true,
fill: '本机启动命令、隧道 URL 和云端 Agent 配置里的路径要保持一致。',
example: DEFAULT_REMOTE_MCP_PATH,
avoid: '不要一边用 /mcp另一边配置 /api/mcp路径不一致会 404。',
},
{
key: 'serverId',
title: '服务 ID',
required: false,
fill: '给云端 Agent 识别这条 MCP 服务的名称,默认 gonavi 即可。',
example: 'gonavi',
avoid: '不要频繁改名,否则 Agent 里已有的工具引用可能失效。',
},
];
export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installMode: 'auto',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'claude',
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
},
{
client: 'codex',
displayName: 'Codex',
installMode: 'auto',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'codex',
message: '未检测到 Codex 用户级 GoNavi MCP 配置',
},
{
client: 'openclaw',
displayName: 'OpenClaw',
installMode: 'remote',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'openclaw',
message: 'OpenClaw 通常部署在云端 Linux请通过远程 MCP 桥接接入 Windows GoNavi不要复制数据库密码。',
},
{
client: 'hermans',
displayName: 'Hermans',
installMode: 'remote',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'hermans',
message: 'Hermans 这类远程 Agent 请通过远程 MCP 桥接接入 Windows GoNavi不要复制数据库密码。',
},
];
const MCP_CLIENT_ORDER: MCPClientKey[] = ['claude-code', 'codex', 'openclaw', 'hermans'];
const quoteMCPCommandPart = (value: string): string => {
const text = String(value || '').trim();
if (!text) {
return '';
}
return /[\s"]/u.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
};
export const isMCPClientKey = (client: string): client is MCPClientKey =>
client === 'claude-code' || client === 'codex' || client === 'openclaw' || client === 'hermans';
export const isRemoteMCPClientStatus = (status?: Pick<AIMCPClientInstallStatus, 'client' | 'installMode'> | null): boolean => {
const client = String(status?.client || '').trim();
return status?.installMode === 'remote' || (isMCPClientKey(client) && REMOTE_MCP_CLIENTS.has(client));
};
export const supportsAutoMCPClientInstall = (status?: Pick<AIMCPClientInstallStatus, 'client' | 'installMode'> | null): boolean => {
const client = String(status?.client || '').trim();
return status?.installMode === 'auto' || (isMCPClientKey(client) && AUTO_MCP_CLIENTS.has(client));
};
const hasStatusError = (status: AIMCPClientInstallStatus): boolean =>
/|||/u.test(String(status.message || ''));
const getMCPClientPriority = (status: AIMCPClientInstallStatus): number => {
if (status.matchesCurrent) {
return 0;
}
if (status.installed && !status.matchesCurrent) {
return 1;
}
if (status.clientDetected) {
return 2;
}
if (hasStatusError(status)) {
return 3;
}
return 4;
};
export const normalizeMCPClientStatuses = (items?: AIMCPClientInstallStatus[]): AIMCPClientInstallStatus[] => {
const baseMap = new Map<string, AIMCPClientInstallStatus>(
EMPTY_MCP_CLIENT_STATUSES.map((item) => [item.client, { ...item }]),
);
(Array.isArray(items) ? items : []).forEach((item) => {
if (!item || !item.client) {
return;
}
const base = baseMap.get(item.client) || {
client: item.client,
displayName: item.client,
installed: false,
matchesCurrent: false,
message: '',
};
baseMap.set(item.client, {
...base,
...item,
displayName: item.displayName || base.displayName,
installMode: item.installMode || base.installMode || 'auto',
clientDetected: item.clientDetected ?? base.clientDetected ?? false,
clientCommand: item.clientCommand || base.clientCommand,
clientPath: item.clientPath || '',
message: item.message || base.message,
args: Array.isArray(item.args) ? item.args : (base.args || []),
});
});
return MCP_CLIENT_ORDER
.map((client) => baseMap.get(client))
.filter((item): item is AIMCPClientInstallStatus => Boolean(item));
};
export const pickPreferredMCPClient = (
items: AIMCPClientInstallStatus[],
current?: MCPClientKey,
): MCPClientKey => {
if (current && items.some((item) => item.client === current)) {
return current;
}
const ranked = items
.filter((item): item is AIMCPClientInstallStatus & { client: MCPClientKey } => isMCPClientKey(item.client))
.slice()
.sort((left, right) => {
const priorityDiff = getMCPClientPriority(left) - getMCPClientPriority(right);
if (priorityDiff !== 0) {
return priorityDiff;
}
return MCP_CLIENT_ORDER.indexOf(left.client) - MCP_CLIENT_ORDER.indexOf(right.client);
});
return ranked[0]?.client || 'claude-code';
};
export const formatMCPLaunchCommand = (
input?: Pick<AIMCPClientInstallStatus, 'command' | 'args'> | { command?: string; args?: string[] } | null,
): string => {
const command = String(input?.command || '').trim();
if (!command) {
return '';
}
const args = Array.isArray(input?.args)
? input.args.map((item) => String(item || '').trim()).filter(Boolean)
: [];
return [command, ...args].map(quoteMCPCommandPart).filter(Boolean).join(' ');
};
export const buildRemoteMCPClientGuide = (
status?: Partial<Pick<AIMCPClientInstallStatus, 'client' | 'displayName' | 'message'>> | null,
): string => {
const quickStart = buildRemoteMCPClientQuickStart(status);
return [
`GoNavi MCP 远程接入说明 - ${quickStart.displayName}`,
'',
'目标:',
'- 数据库连接、账号和密码继续保存在 Windows 上的 GoNavi。云端 Agent 不需要保存数据库密码。',
'- 云端 Agent 只通过 MCP tools 读取 get_connections/get_databases/get_tables/get_columns/get_table_ddl 等结果。',
'- 远程接入默认使用 schema-only 模式,不注册 execute_sql适合只给 OpenClaw/Hermans 读取库表结构。',
'',
'当前边界:',
'- GoNavi 内置 MCP 本机入口是 stdio适合 Claude Code / Codex 这类和 GoNavi 在同一台机器上的客户端。',
'- 如果 OpenClaw/Hermans 部署在云端 Linux不能直接使用 Windows 本地 stdio 命令;可在 Windows 上启动 GoNavi Streamable HTTP 模式,再通过隧道或反向代理给云端 Agent 调用。',
'',
'建议接入方式:',
'1. Windows 本机保持 GoNavi 可访问,由 GoNavi 读取保存连接和系统凭据。',
`2. 在 Windows 或可信内网侧运行:${quickStart.launchCommand}`,
`3. 在 ${quickStart.displayName} 中添加远程 MCP Servertransport 选择 Streamable HTTPURL 填隧道/反向代理后的 /mcp 地址,并设置 Authorization: Bearer <随机token>。`,
'4. 先调用 get_connections 获取 connectionId再调用表结构工具不要把数据库 host/user/password 写进云端 Agent 配置。',
'',
'可复制配置片段(适用于支持 mcpServers JSON 的 Agent',
...quickStart.configJson.split('\n'),
'',
'无 GUI / CLI 生成配置命令:',
quickStart.configCommand,
'',
'CLI / 服务启动命令:',
quickStart.launchCommand,
`或设置环境变量GONAVI_MCP_HTTP_TOKEN=<随机token> 后运行 ${quickStart.standaloneCommand.replace(' --token <随机token>', '')}`,
'如果明确需要远程执行 SQL可去掉 --schema-only此时 execute_sql 仍受 GoNavi AI 安全控制约束,写操作必须显式传 allowMutating=true。',
'',
status?.message ? `当前提示:${status.message}` : '',
].filter((line, index, lines) => line || index < lines.length - 1).join('\n');
};
export const buildRemoteMCPClientQuickStart = (
status?: Partial<Pick<AIMCPClientInstallStatus, 'client' | 'displayName'>> | null,
): RemoteMCPClientQuickStart => {
const displayName = String(status?.displayName || '远程 Agent').trim();
const client = isMCPClientKey(String(status?.client || '')) ? String(status?.client || '').trim() : 'openclaw';
const launchCommand = `GoNavi.exe mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token> --schema-only`;
const standaloneCommand = `gonavi-mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <随机token> --schema-only`;
const configCommand = `GoNavi.exe mcp-server remote-config --client ${client} --url ${DEFAULT_REMOTE_MCP_PUBLIC_URL} --token <随机token> --schema-only`;
const configJson = JSON.stringify({
mcpServers: {
gonavi: {
type: 'streamable-http',
url: DEFAULT_REMOTE_MCP_PUBLIC_URL,
headers: {
Authorization: 'Bearer <随机token>',
},
},
},
}, null, 2);
return {
displayName,
configJson,
configCommand,
launchCommand,
standaloneCommand,
verificationSteps: [
'Windows 本机先访问 http://127.0.0.1:8765/healthz确认 GoNavi MCP HTTP 服务已启动。',
`${displayName} 里配置 Streamable HTTP MCPURL 指向隧道或反向代理后的 /mcp 地址。`,
'先调用 get_connections 获取 connectionId再读取 get_databases / get_tables / get_columns。',
],
securityNotes: [
'数据库账号和密码仍保存在 Windows GoNavi本段配置不要写数据库密码。',
'默认 --schema-only 不注册 execute_sql远程 Agent 只能走库表结构类工具。',
'HTTP MCP 必须使用随机 Bearer Token并放在 HTTPS、私有网络或受控隧道后面。',
'如去掉 --schema-only 开放 execute_sql仍受 GoNavi AI 安全控制约束,写操作仍必须显式传 allowMutating=true。',
],
};
};

View File

@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import { parseMCPCommandDraft, splitShellLikeCommand } from './mcpCommandDraft';
describe('mcpCommandDraft helpers', () => {
it('splits quoted command lines and leading env assignments into dedicated fields', () => {
const result = parseMCPCommandDraft('OPENAI_API_KEY="abc 123" "C:\\Program Files\\GoNavi\\gonavi-mcp-server.exe" stdio --port 8811');
expect(result).toEqual({
ok: true,
draft: {
command: 'C:\\Program Files\\GoNavi\\gonavi-mcp-server.exe',
args: ['stdio', '--port', '8811'],
env: {
OPENAI_API_KEY: 'abc 123',
},
},
});
});
it('keeps python module style launches as command plus independent args', () => {
const result = parseMCPCommandDraft('PYTHONPATH=./tools python -m my_mcp_server --stdio');
expect(result.ok).toBe(true);
expect(result.draft).toEqual({
command: 'python',
args: ['-m', 'my_mcp_server', '--stdio'],
env: {
PYTHONPATH: './tools',
},
});
});
it('parses PowerShell env prefixes before the MCP command', () => {
const result = parseMCPCommandDraft('$env:GITHUB_TOKEN="ghp test"; uvx mcp-server-github --stdio');
expect(result.ok).toBe(true);
expect(result.draft).toEqual({
command: 'uvx',
args: ['mcp-server-github', '--stdio'],
env: {
GITHUB_TOKEN: 'ghp test',
},
});
});
it('parses Windows cmd set prefixes before the MCP command', () => {
const result = parseMCPCommandDraft('set GITHUB_TOKEN=ghp_test && node server.js --stdio');
expect(result.ok).toBe(true);
expect(result.draft).toEqual({
command: 'node',
args: ['server.js', '--stdio'],
env: {
GITHUB_TOKEN: 'ghp_test',
},
});
});
it('parses env launcher style prefixes before the MCP command', () => {
const result = parseMCPCommandDraft('env OPENAI_API_KEY=sk-test uvx mcp-server-fetch --stdio');
expect(result.ok).toBe(true);
expect(result.draft).toEqual({
command: 'uvx',
args: ['mcp-server-fetch', '--stdio'],
env: {
OPENAI_API_KEY: 'sk-test',
},
});
});
it('keeps npx package launches as command plus split args', () => {
const result = parseMCPCommandDraft('npx -y @modelcontextprotocol/server-filesystem --stdio C:\\Users\\me\\workspace');
expect(result.ok).toBe(true);
expect(result.draft).toEqual({
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '--stdio', 'C:\\Users\\me\\workspace'],
env: {},
});
});
it('reports unclosed quotes instead of producing a broken parse', () => {
expect(splitShellLikeCommand('uvx "broken command')).toEqual({
tokens: ['uvx'],
error: '命令中存在未闭合的引号,请检查后重试。',
});
});
});

View File

@@ -0,0 +1,182 @@
export interface ParsedMCPCommandDraft {
command: string;
args: string[];
env: Record<string, string>;
}
export interface ParseMCPCommandDraftResult {
ok: boolean;
draft?: ParsedMCPCommandDraft;
error?: string;
}
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=.*/u;
const POWERSHELL_ENV_ASSIGNMENT_RE = /^\$env:([A-Za-z_][A-Za-z0-9_]*)=(.*)$/iu;
const pushToken = (tokens: string[], current: string) => {
if (current) {
tokens.push(current);
}
};
export const splitShellLikeCommand = (input: string): { tokens: string[]; error?: string } => {
const text = String(input || '').trim();
if (!text) {
return { tokens: [] };
}
const tokens: string[] = [];
let current = '';
let quoteMode: '"' | "'" | null = null;
for (let index = 0; index < text.length; index += 1) {
const char = text[index];
if (quoteMode) {
if (char === quoteMode) {
quoteMode = null;
continue;
}
if (char === '\\' && quoteMode === '"' && index + 1 < text.length) {
const nextChar = text[index + 1];
if (nextChar === '"' || nextChar === '\\') {
current += nextChar;
index += 1;
continue;
}
}
current += char;
continue;
}
if (char === '"' || char === "'") {
quoteMode = char;
continue;
}
if (char === ';') {
pushToken(tokens, current);
current = '';
continue;
}
if (char === '&' && text[index + 1] === '&') {
pushToken(tokens, current);
current = '';
index += 1;
continue;
}
if (/\s/u.test(char)) {
pushToken(tokens, current);
current = '';
continue;
}
if (char === '\\' && index + 1 < text.length) {
const nextChar = text[index + 1];
if (/\s/u.test(nextChar) || nextChar === '"' || nextChar === "'" || nextChar === '\\') {
current += nextChar;
index += 1;
continue;
}
}
current += char;
}
if (quoteMode) {
return {
tokens,
error: '命令中存在未闭合的引号,请检查后重试。',
};
}
pushToken(tokens, current);
return { tokens };
};
const consumeEnvAssignmentToken = (token: string, env: Record<string, string>): boolean => {
const text = String(token || '').trim();
if (!text) return false;
const powershellMatch = text.match(POWERSHELL_ENV_ASSIGNMENT_RE);
if (powershellMatch) {
env[powershellMatch[1]] = powershellMatch[2] || '';
return true;
}
if (!ENV_ASSIGNMENT_RE.test(text)) return false;
const separatorIndex = text.indexOf('=');
const key = text.slice(0, separatorIndex).trim();
if (!key) return false;
env[key] = text.slice(separatorIndex + 1);
return true;
};
const isEnvAssignmentToken = (token: string): boolean => {
const text = String(token || '').trim();
return Boolean(text.match(POWERSHELL_ENV_ASSIGNMENT_RE)) || ENV_ASSIGNMENT_RE.test(text);
};
const consumeLeadingEnvAssignments = (tokens: string[], env: Record<string, string>): number => {
let commandIndex = 0;
while (commandIndex < tokens.length) {
const token = tokens[commandIndex];
const normalizedToken = String(token || '').trim().toLowerCase();
if (normalizedToken === 'set' && tokens[commandIndex + 1] && isEnvAssignmentToken(tokens[commandIndex + 1])) {
consumeEnvAssignmentToken(tokens[commandIndex + 1], env);
commandIndex += 2;
continue;
}
if (normalizedToken === 'env' && tokens[commandIndex + 1] && isEnvAssignmentToken(tokens[commandIndex + 1])) {
commandIndex += 1;
while (commandIndex < tokens.length && consumeEnvAssignmentToken(tokens[commandIndex], env)) {
commandIndex += 1;
}
continue;
}
if (consumeEnvAssignmentToken(token, env)) {
commandIndex += 1;
continue;
}
break;
}
return commandIndex;
};
export const parseMCPCommandDraft = (input: string): ParseMCPCommandDraftResult => {
const { tokens, error } = splitShellLikeCommand(input);
if (error) {
return { ok: false, error };
}
if (tokens.length === 0) {
return { ok: false, error: '请先粘贴完整命令。' };
}
const env: Record<string, string> = {};
const commandIndex = consumeLeadingEnvAssignments(tokens, env);
const command = String(tokens[commandIndex] || '').trim();
if (!command) {
return {
ok: false,
error: '没有解析出启动命令,请至少提供可执行程序名。',
};
}
return {
ok: true,
draft: {
command,
args: tokens.slice(commandIndex + 1),
env,
},
};
};

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import { formatMCPEnvDraft, parseMCPEnvDraft } from './mcpEnvDraft';
describe('mcpEnvDraft helpers', () => {
it('formats env objects into editable KEY=VALUE lines', () => {
expect(formatMCPEnvDraft({
OPENAI_API_KEY: 'abc',
BASE_URL: 'https://example.com',
})).toBe('OPENAI_API_KEY=abc\nBASE_URL=https://example.com');
});
it('parses valid env lines and preserves invalid ones for warning', () => {
const result = parseMCPEnvDraft([
'OPENAI_API_KEY=abc',
'BAD LINE',
'HAS SPACE =wrong',
'EMPTY_VALUE=',
'BASE_URL=https://example.com?a=1',
].join('\n'));
expect(result.env).toEqual({
OPENAI_API_KEY: 'abc',
EMPTY_VALUE: '',
BASE_URL: 'https://example.com?a=1',
});
expect(result.validLines).toBe(3);
expect(result.invalidLines).toEqual(['BAD LINE', 'HAS SPACE =wrong']);
expect(result.totalLines).toBe(5);
});
});

View File

@@ -0,0 +1,47 @@
export interface ParsedMCPEnvDraft {
env: Record<string, string>;
invalidLines: string[];
totalLines: number;
validLines: number;
}
export const formatMCPEnvDraft = (env?: Record<string, string>): string =>
Object.entries(env || {})
.map(([key, value]) => `${key}=${value}`)
.join('\n');
export const parseMCPEnvDraft = (input: string): ParsedMCPEnvDraft => {
const env: Record<string, string> = {};
const invalidLines: string[] = [];
let totalLines = 0;
let validLines = 0;
String(input || '')
.split(/\r?\n/u)
.map((line) => line.trim())
.forEach((line) => {
if (!line) {
return;
}
totalLines += 1;
const separatorIndex = line.indexOf('=');
if (separatorIndex <= 0) {
invalidLines.push(line);
return;
}
const key = line.slice(0, separatorIndex).trim();
if (!key || /\s/u.test(key)) {
invalidLines.push(line);
return;
}
env[key] = line.slice(separatorIndex + 1);
validLines += 1;
});
return {
env,
invalidLines,
totalLines,
validLines,
};
};

View File

@@ -0,0 +1,63 @@
import { describe, expect, it } from 'vitest';
import { buildMCPEnvHintProfile } from './mcpEnvHints';
describe('mcpEnvHints', () => {
it('explains common secret and proxy env vars without exposing values', () => {
const profile = buildMCPEnvHintProfile('uvx', ['mcp-server-github', '--stdio'], {
GITHUB_TOKEN: 'ghp_real_secret_value',
HTTPS_PROXY: 'http://127.0.0.1:7890',
});
expect(profile?.envVarCount).toBe(2);
expect(profile?.secretLikeCount).toBe(1);
expect(profile?.items.find((item) => item.key === 'GITHUB_TOKEN')).toMatchObject({
label: 'GitHub Token',
category: 'secret',
sensitive: true,
known: true,
});
expect(profile?.items.find((item) => item.key === 'HTTPS_PROXY')).toMatchObject({
label: 'HTTPS 代理',
category: 'proxy',
sensitive: false,
known: true,
});
expect(JSON.stringify(profile)).not.toContain('ghp_real_secret_value');
expect(JSON.stringify(profile)).not.toContain('127.0.0.1:7890');
});
it('warns when secret env vars still contain placeholders', () => {
const profile = buildMCPEnvHintProfile('npx', ['-y', '@modelcontextprotocol/server-github', '--stdio'], {
GITHUB_TOKEN: '...',
OPENAI_API_KEY: '',
});
expect(profile?.warnings).toContain('1 个环境变量值为空,测试前需要补齐或删除。');
expect(profile?.warnings).toContain('1 个环境变量看起来仍是示例占位值。');
expect(profile?.nextActions.join('\n')).toContain('GITHUB_TOKEN');
expect(profile?.nextActions.join('\n')).toContain('OPENAI_API_KEY');
});
it('explains docker env forwarding boundaries', () => {
const profile = buildMCPEnvHintProfile('docker', ['run', '--rm', '-i', 'mcp/server-fetch:latest'], {
API_KEY: 'secret',
});
expect(profile?.warnings).toContain('command=docker 时,这里的环境变量只传给 docker CLI不会自动进入容器。');
expect(profile?.nextActions.join('\n')).toContain('-e KEY=VALUE');
});
it('does not warn about docker container env when args already forward env values', () => {
const profile = buildMCPEnvHintProfile('docker', ['run', '--rm', '-i', '-e', 'API_KEY=secret', 'mcp/server-fetch:latest'], {
DOCKER_HOST: 'npipe:////./pipe/docker_engine',
});
expect(profile?.items[0]).toMatchObject({
key: 'DOCKER_HOST',
label: 'Docker Daemon 地址',
category: 'runtime',
});
expect(profile?.warnings.join('\n')).not.toContain('不会自动进入容器');
});
});

View File

@@ -0,0 +1,287 @@
import { splitShellLikeCommand } from './mcpCommandDraft';
export type MCPEnvHintCategory = 'secret' | 'endpoint' | 'proxy' | 'path' | 'runtime' | 'generic';
export interface MCPEnvHintItem {
key: string;
category: MCPEnvHintCategory;
label: string;
detail: string;
valueHint: string;
sensitive: boolean;
known: boolean;
empty: boolean;
placeholder: boolean;
}
export interface MCPEnvHintProfile {
envVarCount: number;
secretLikeCount: number;
endpointLikeCount: number;
items: MCPEnvHintItem[];
warnings: string[];
nextActions: string[];
}
interface KnownEnvHint {
category: MCPEnvHintCategory;
label: string;
detail: string;
valueHint: string;
sensitive?: boolean;
}
const KNOWN_ENV_HINTS: Record<string, KnownEnvHint> = {
GITHUB_TOKEN: {
category: 'secret',
label: 'GitHub Token',
detail: '通常给 GitHub MCP 读取仓库、Issue、PR 或 Actions 使用。',
valueHint: '填 GitHub Personal Access Token按 MCP README 要求授予最小权限。',
sensitive: true,
},
GITLAB_TOKEN: {
category: 'secret',
label: 'GitLab Token',
detail: '通常给 GitLab MCP 访问项目、Merge Request 或 CI 使用。',
valueHint: '填 GitLab Access Token并限制到需要访问的项目范围。',
sensitive: true,
},
OPENAI_API_KEY: {
category: 'secret',
label: 'OpenAI API Key',
detail: '给依赖 OpenAI API 的 MCP 服务调用模型或 embedding 接口。',
valueHint: '填真实 API Key不要写到 command、args 或聊天消息里。',
sensitive: true,
},
ANTHROPIC_API_KEY: {
category: 'secret',
label: 'Anthropic API Key',
detail: '给依赖 Anthropic Claude API 的 MCP 服务使用。',
valueHint: '填真实 API Key确认服务确实需要该变量后再配置。',
sensitive: true,
},
GEMINI_API_KEY: {
category: 'secret',
label: 'Gemini API Key',
detail: '给依赖 Google Gemini API 的 MCP 服务使用。',
valueHint: '填真实 API Key也有服务会要求 GOOGLE_API_KEY。',
sensitive: true,
},
GOOGLE_API_KEY: {
category: 'secret',
label: 'Google API Key',
detail: '给 Google/Gemini/Maps/Search 类 MCP 服务使用。',
valueHint: '填真实 API Key并确认 README 要求的是 GOOGLE_API_KEY 还是 GEMINI_API_KEY。',
sensitive: true,
},
SLACK_BOT_TOKEN: {
category: 'secret',
label: 'Slack Bot Token',
detail: '给 Slack MCP 读取频道、消息或发送通知使用。',
valueHint: '填 xoxb- 开头的 Bot Token并控制 workspace 权限。',
sensitive: true,
},
NOTION_API_KEY: {
category: 'secret',
label: 'Notion API Key',
detail: '给 Notion MCP 访问页面、数据库或 workspace 内容使用。',
valueHint: '填 Notion integration secret并只授权需要的页面。',
sensitive: true,
},
DATABASE_URL: {
category: 'endpoint',
label: '数据库连接串',
detail: '给 MCP 服务自己连接数据库使用;这会把数据库连接信息交给该 MCP 进程。',
valueHint: '只在确实要让该 MCP 直连数据库时填写,优先考虑使用 GoNavi MCP 避免密码外泄。',
sensitive: true,
},
HTTP_PROXY: {
category: 'proxy',
label: 'HTTP 代理',
detail: '让 MCP 进程访问 HTTP 资源时走指定代理。',
valueHint: '填 http://host:port如果代理带账号密码按敏感变量处理。',
},
HTTPS_PROXY: {
category: 'proxy',
label: 'HTTPS 代理',
detail: '让 MCP 进程访问 HTTPS 资源时走指定代理。',
valueHint: '填 http://host:port 或 https://host:port。',
},
NO_PROXY: {
category: 'proxy',
label: '代理绕过列表',
detail: '指定哪些域名或地址不走代理。',
valueHint: '逗号分隔,例如 localhost,127.0.0.1,.corp.local。',
},
DOCKER_HOST: {
category: 'runtime',
label: 'Docker Daemon 地址',
detail: '给 docker CLI 指定连接哪个 Docker Engine。',
valueHint: 'Windows 常见为 npipe:////./pipe/docker_engine远端 Docker 请确认安全边界。',
},
GONAVI_MCP_HTTP_TOKEN: {
category: 'secret',
label: 'GoNavi MCP HTTP Token',
detail: '给远程 MCP HTTP 服务开启 Bearer Token 鉴权时使用。',
valueHint: '填高熵随机 token不要复用数据库密码或模型 API Key。',
sensitive: true,
},
NODE_ENV: {
category: 'runtime',
label: 'Node 运行环境',
detail: '影响部分 Node MCP 服务的日志、调试或生产模式。',
valueHint: '通常填 production、development 或 README 指定值。',
},
LOG_LEVEL: {
category: 'runtime',
label: '日志级别',
detail: '控制 MCP 服务输出多少日志。',
valueHint: '常见值为 debug、info、warn、error排障时可临时调高。',
},
};
const SECRET_KEY_RE = /(TOKEN|API[_-]?KEY|SECRET|PASSWORD|PASS|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|DATABASE_URL|DSN)/iu;
const ENDPOINT_KEY_RE = /(URL|URI|ENDPOINT|BASE[_-]?URL|HOST|ADDR|ADDRESS)/iu;
const PROXY_KEY_RE = /PROXY/iu;
const PATH_KEY_RE = /(PATH|DIR|ROOT|HOME|FILE|CONFIG)/iu;
const RUNTIME_KEY_RE = /^(NODE_ENV|LOG_LEVEL|DEBUG|ENV|TZ)$/iu;
const PLACEHOLDER_VALUE_RE = /^(\*+|\.{3}|<[^>]+>|your[-_ ].*|change[_-]?me|replace[_-]?me|xxx+|todo|token|api[_-]?key)$/iu;
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const normalizeEnvKey = (key: string): string => toTrimmedString(key).toUpperCase();
const normalizeCommandName = (command: string): string => {
const { tokens } = splitShellLikeCommand(command);
const raw = toTrimmedString(tokens[0] || command);
return (raw.split(/[\\/]/u).pop() || raw)
.replace(/\.(exe|cmd|bat|ps1)$/iu, '')
.toLowerCase();
};
const inferEnvHint = (key: string): KnownEnvHint => {
if (SECRET_KEY_RE.test(key)) {
return {
category: 'secret',
label: '密钥 / Token',
detail: '变量名看起来像密钥、Token、密码或连接串。',
valueHint: '填真实值,但只保存在本机 MCP 配置里;不要放到 command、args 或聊天内容。',
sensitive: true,
};
}
if (PROXY_KEY_RE.test(key)) {
return {
category: 'proxy',
label: '代理配置',
detail: '变量名看起来像网络代理设置。',
valueHint: '按 README 或企业代理格式填写,例如 http://127.0.0.1:7890。',
};
}
if (ENDPOINT_KEY_RE.test(key)) {
return {
category: 'endpoint',
label: '服务地址',
detail: '变量名看起来像服务地址、接口地址或主机配置。',
valueHint: '填写 MCP Server 要访问的 URL、host 或 endpoint。',
};
}
if (PATH_KEY_RE.test(key)) {
return {
category: 'path',
label: '路径 / 配置文件',
detail: '变量名看起来像本地路径、目录或配置文件位置。',
valueHint: '填写本机 MCP 进程能访问的绝对路径Windows 路径建议保留盘符。',
};
}
if (RUNTIME_KEY_RE.test(key)) {
return {
category: 'runtime',
label: '运行时开关',
detail: '变量名看起来像运行环境、日志或调试开关。',
valueHint: '按 README 指定的枚举值填写。',
};
}
return {
category: 'generic',
label: '自定义配置',
detail: '未命中内置变量库,按 MCP README 对应字段说明填写。',
valueHint: '确认变量名大小写和 README 完全一致。',
};
};
const isPlaceholderValue = (value: string): boolean => {
const text = toTrimmedString(value);
if (!text) {
return false;
}
return PLACEHOLDER_VALUE_RE.test(text) || text.includes('...');
};
const buildEnvHintItem = ([key, value]: [string, string]): MCPEnvHintItem => {
const normalizedKey = normalizeEnvKey(key);
const knownHint = KNOWN_ENV_HINTS[normalizedKey];
const hint = knownHint || inferEnvHint(normalizedKey);
return {
key: normalizedKey,
category: hint.category,
label: hint.label,
detail: hint.detail,
valueHint: hint.valueHint,
sensitive: hint.sensitive === true || SECRET_KEY_RE.test(normalizedKey),
known: Boolean(knownHint),
empty: toTrimmedString(value) === '',
placeholder: isPlaceholderValue(value),
};
};
export const buildMCPEnvHintProfile = (
command: string,
args: string[] | undefined,
env: Record<string, string> | undefined,
): MCPEnvHintProfile | null => {
const items = Object.entries(env || {})
.sort(([left], [right]) => normalizeEnvKey(left).localeCompare(normalizeEnvKey(right)))
.map(buildEnvHintItem);
if (items.length === 0) {
return null;
}
const warnings: string[] = [];
const nextActions: string[] = [];
const secretLikeCount = items.filter((item) => item.sensitive).length;
const endpointLikeCount = items.filter((item) => item.category === 'endpoint').length;
const emptyItems = items.filter((item) => item.empty);
const placeholderItems = items.filter((item) => item.placeholder);
const dockerCommand = normalizeCommandName(command) === 'docker';
const dockerEnvForwarded = (args || []).some((arg) => ['-e', '--env'].includes(toTrimmedString(arg).toLowerCase()) || toTrimmedString(arg).startsWith('-e='));
if (emptyItems.length > 0) {
warnings.push(`${emptyItems.length} 个环境变量值为空,测试前需要补齐或删除。`);
nextActions.push(`补齐 ${emptyItems.map((item) => item.key).slice(0, 3).join('、')} 的值,或删除不需要的变量。`);
}
if (placeholderItems.length > 0) {
warnings.push(`${placeholderItems.length} 个环境变量看起来仍是示例占位值。`);
nextActions.push(`${placeholderItems.map((item) => item.key).slice(0, 3).join('、')} 替换成真实值后再测试工具发现。`);
}
if (dockerCommand && items.length > 0 && !dockerEnvForwarded) {
warnings.push('command=docker 时,这里的环境变量只传给 docker CLI不会自动进入容器。');
nextActions.push('如果容器内 MCP 需要这些变量,请在 args 里按 README 增加 -e KEY=VALUE 或 --env KEY=VALUE。');
}
if (secretLikeCount > 0) {
nextActions.push('密钥类变量只保存在本机配置不要把真实值发到聊天、Issue 或截图里。');
}
if (nextActions.length === 0) {
nextActions.push('环境变量 key 已可识别;测试失败时优先核对 README 要求的变量名大小写。');
}
return {
envVarCount: items.length,
secretLikeCount,
endpointLikeCount,
items,
warnings,
nextActions,
};
};

View File

@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { parseMCPCommandDraft } from './mcpCommandDraft';
import { buildMCPQuickAddServerSeed, buildMCPServerDraftSeed } from './mcpServerDraftSeed';
describe('mcpServerDraftSeed', () => {
it('builds an editable draft seed from a parsed uvx command with env vars', () => {
const parsed = parseMCPCommandDraft('$env:GITHUB_TOKEN=***; uvx mcp-server-github --stdio');
expect(parsed.ok).toBe(true);
const seed = buildMCPQuickAddServerSeed(parsed.draft!);
expect(seed).toMatchObject({
name: 'mcp-server-github',
transport: 'stdio',
command: 'uvx',
args: ['mcp-server-github', '--stdio'],
env: { GITHUB_TOKEN: '***' },
enabled: true,
timeoutSeconds: 20,
});
});
it('uses a wider default timeout and image-based name for docker drafts', () => {
const parsed = parseMCPCommandDraft('docker run --rm -i -e API_KEY=*** mcp/server-fetch:latest');
expect(parsed.ok).toBe(true);
const seed = buildMCPQuickAddServerSeed(parsed.draft!);
expect(seed).toMatchObject({
name: 'server-fetch:latest',
command: 'docker',
args: ['run', '--rm', '-i', '-e', 'API_KEY=***', 'mcp/server-fetch:latest'],
timeoutSeconds: 45,
});
});
it('respects explicit draft names and timeouts for inspection snapshots', () => {
const seed = buildMCPServerDraftSeed({
name: 'GitHub MCP',
command: 'uvx',
args: ['mcp-server-github', '--stdio'],
timeoutSeconds: 60,
env: { GITHUB_TOKEN: '***' },
});
expect(seed).toMatchObject({
name: 'GitHub MCP',
command: 'uvx',
timeoutSeconds: 60,
env: { GITHUB_TOKEN: '***' },
});
});
});

View File

@@ -0,0 +1,111 @@
import type { AIMCPServerConfig } from '../types';
import type { ParsedMCPCommandDraft } from './mcpCommandDraft';
export interface MCPServerDraftSeedInput {
args?: string[];
command: string;
enabled?: boolean;
env?: Record<string, string>;
name?: string;
timeoutSeconds?: number;
}
const stripCommandSuffix = (value: string): string =>
value.replace(/\.(exe|cmd|bat|ps1|c?m?[jt]s|py)$/iu, '');
const toDisplayNamePart = (value: string): string => {
const text = String(value || '').trim();
if (!text) return '';
const lastPathPart = text.split(/[\\/]/u).filter(Boolean).pop() || text;
const packagePart = lastPathPart.includes('/') ? lastPathPart.split('/').filter(Boolean).pop() || lastPathPart : lastPathPart;
return stripCommandSuffix(packagePart).replace(/^@/u, '').trim();
};
const findDockerImageArg = (args: string[]): string => {
const runIndex = args.findIndex((arg) => arg.toLowerCase() === 'run');
const candidates = runIndex >= 0 ? args.slice(runIndex + 1) : args;
const optionsWithValue = new Set([
'-e',
'--env',
'--name',
'--network',
'-v',
'--volume',
'-p',
'--publish',
'--entrypoint',
'-w',
'--workdir',
'-u',
'--user',
'--platform',
'-h',
'--hostname',
]);
for (let index = 0; index < candidates.length; index += 1) {
const arg = String(candidates[index] || '').trim();
if (!arg) continue;
if (arg.startsWith('-')) {
if (optionsWithValue.has(arg.toLowerCase())) {
index += 1;
}
continue;
}
if (arg.includes('=') || arg.toLowerCase() === 'run') {
continue;
}
return arg;
}
return '';
};
const pickDraftNameCandidate = (command: string, args: string[]): string => {
const commandName = toDisplayNamePart(command).toLowerCase();
if (['npx', 'npm', 'pnpm', 'yarn', 'uvx', 'uv'].includes(commandName)) {
return args.find((arg) => arg && !arg.startsWith('-') && arg.toLowerCase() !== 'stdio') || command;
}
if (['node', 'bun', 'deno'].includes(commandName)) {
return args.find((arg) => arg && !arg.startsWith('-') && arg.toLowerCase() !== 'stdio') || command;
}
if (['python', 'python3', 'py'].includes(commandName)) {
const moduleFlagIndex = args.findIndex((arg) => arg === '-m');
return (moduleFlagIndex >= 0 ? args[moduleFlagIndex + 1] : '') || args.find((arg) => arg && !arg.startsWith('-')) || command;
}
if (commandName === 'docker') {
return findDockerImageArg(args) || command;
}
return command;
};
export const buildMCPServerDraftSeed = ({
args = [],
command,
enabled = true,
env = {},
name,
timeoutSeconds,
}: MCPServerDraftSeedInput): Partial<AIMCPServerConfig> => {
const normalizedArgs = args.map((arg) => String(arg || '').trim()).filter(Boolean);
const commandName = toDisplayNamePart(command).toLowerCase();
const namePart = toDisplayNamePart(name || pickDraftNameCandidate(command, normalizedArgs)) || 'MCP 服务';
return {
name: namePart,
transport: 'stdio',
command,
args: normalizedArgs,
env,
enabled,
timeoutSeconds: timeoutSeconds ?? (commandName === 'docker' ? 45 : 20),
};
};
export const buildMCPQuickAddServerSeed = (
draft: ParsedMCPCommandDraft,
): Partial<AIMCPServerConfig> => buildMCPServerDraftSeed({
command: draft.command,
args: draft.args,
env: draft.env,
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import {
MCP_AUTHORING_NOTES,
MCP_TROUBLESHOOTING_GUIDES,
} from './mcpServerGuidance';
describe('mcpServerGuidance', () => {
it('keeps actionable troubleshooting hints for common MCP setup mistakes', () => {
const symptoms = MCP_TROUBLESHOOTING_GUIDES.map((item) => item.symptom);
const allGuidance = MCP_TROUBLESHOOTING_GUIDES
.flatMap((item) => [item.likelyCause, item.fix, item.example || ''])
.join('\n');
expect(symptoms).toContain('测试提示找不到命令');
expect(symptoms).toContain('认证失败、401 或 403');
expect(allGuidance).toContain('命令参数');
expect(allGuidance).toContain('command=npx');
expect(allGuidance).toContain('KEY=VALUE');
expect(allGuidance).toContain('当前只支持 stdio');
});
it('warns users to keep secrets in local env config instead of chat content', () => {
const notes = MCP_AUTHORING_NOTES.join('\n');
expect(notes).toContain('本机配置');
expect(notes).toContain('不要把密钥写进聊天内容');
expect(notes).toContain('command 填 npx');
expect(notes).toContain('PowerShell $env:KEY=VALUE;');
expect(notes).toContain('Windows set KEY=VALUE &&');
});
});

View File

@@ -0,0 +1,172 @@
export type MCPFieldState = 'required' | 'optional' | 'fixed';
export interface MCPFieldGuide {
key: string;
title: string;
summary: string;
detail: string;
fill: string;
avoid: string;
fieldState: MCPFieldState;
example?: string;
}
export interface MCPFillStep {
step: string;
title: string;
detail: string;
}
export interface MCPTroubleshootingGuide {
key: string;
symptom: string;
likelyCause: string;
fix: string;
example?: string;
}
export const MCP_COMMAND_EXAMPLES = [
'npx -y @modelcontextprotocol/server-filesystem --stdio',
'uvx mcp-server-fetch',
'node server.js --stdio',
'python -m your_mcp_server',
'docker run --rm -i mcp/server-fetch:latest',
];
export const MCP_COMMAND_PARSE_EXAMPLE = '$env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio';
export const MCP_SERVER_FILL_STEPS: MCPFillStep[] = [
{ step: '1', title: '模板 / 完整命令', detail: '优先选最接近的模板,或先粘一整行命令让 GoNavi 自动拆分。' },
{ step: '2', title: '服务名称', detail: '命名成 Browser、GitHub、Filesystem 这类一眼能认出的用途名。' },
{ step: '3', title: '启动命令', detail: '这里只填程序名或启动器本身,不要把整行命令塞进去。' },
{ step: '4', title: '命令参数', detail: '把脚本名、模块名、Docker run 参数和 --stdio 这类参数拆开逐项填写。' },
{ step: '5', title: '环境变量 / 超时', detail: '只有在服务确实需要额外配置时再补,不需要可以留空。' },
];
export const MCP_FIELD_GUIDES: MCPFieldGuide[] = [
{
key: 'name',
title: '服务名称',
summary: '保存后显示给你和 AI 看的名字。',
detail: '按用途命名,建议写成 Browser、GitHub、Filesystem 这类一眼能认出的名字。',
fill: '这个 MCP 的用途名,例如 GitHub 或 Filesystem。',
avoid: '不要写 server、test、mcp1 这类看不出用途的名字。',
fieldState: 'required',
example: 'Filesystem / Browser / GitHub',
},
{
key: 'enabled',
title: '启用状态',
summary: '控制这条配置现在要不要参与工具发现和调用。',
detail: '禁用只是不使用,不会删除下面填好的配置。',
fill: '临时不用选已禁用;确认要给 AI 用时选已启用。',
avoid: '不要用删除代替临时停用,避免重新配置 command、args、env。',
fieldState: 'optional',
example: '已启用 / 已禁用',
},
{
key: 'transport',
title: '传输方式',
summary: 'GoNavi 用什么方式和这个 MCP Server 通信。',
detail: '当前固定为 stdio表示本机直接启动进程并通过标准输入输出交互。',
fill: '保持 stdio。',
avoid: '不要填写 HTTP、SSE、URL 或端口;当前新增入口不是远程 MCP URL 配置。',
fieldState: 'fixed',
example: 'stdio',
},
{
key: 'command',
title: '启动命令',
summary: '只填程序名或启动器本身。',
detail: '常见是 npx、node、uvx、python、docker包名、脚本名、run、-i 和 --stdio 这类内容放到参数里。',
fill: '填 npx、node、uvx、python、docker或某个 exe 的绝对路径。',
avoid: '不要填整行命令,例如不要填 npx -y pkg --stdio。',
fieldState: 'required',
example: 'npx / node / uvx / python / docker',
},
{
key: 'args',
title: '命令参数',
summary: '把脚本名、模块名、开关参数拆开逐项填写。',
detail: '例如 npx -y pkg --stdio要拆成 -y、pkg 和 --stdiodocker run --rm -i image 要拆成 run、--rm、-i 和 image。',
fill: '逐项填 -y、包名、脚本名、-m、--stdio、run、--rm、-i、镜像名等参数。',
avoid: '不要再填 npx/node/uvx/python/docker也不要把多个参数粘成一个长字符串。',
fieldState: 'optional',
example: '-y / @modelcontextprotocol/server-filesystem / --stdio / server.js / run / --rm / -i / image',
},
{
key: 'env',
title: '环境变量',
summary: '给 MCP Server 传入 KEY=VALUE 形式的配置。',
detail: '通常用来放 API Key、服务地址、工作目录等每行一条不要写 export。',
fill: '每行一条 KEY=VALUE例如 GITHUB_TOKEN=...。',
avoid: '不要写 export、set 或 $env: 前缀;也不要把环境变量混进 command 或 args。',
fieldState: 'optional',
example: 'OPENAI_API_KEY=... / GITHUB_TOKEN=...',
},
{
key: 'timeout',
title: '超时(秒)',
summary: '单次工具发现或调用最多等待多久。',
detail: '本机常规工具一般 20 秒就够,启动慢或远端链路再适当调大。',
fill: '常规填 20启动慢时填 45 或 60。',
avoid: '不要随意填过小3 秒以下很容易让工具发现误判失败。',
fieldState: 'optional',
example: '20 / 45 / 60',
},
];
export const MCP_AUTHORING_NOTES = [
'启动命令只填程序本身,不要把脚本名、模块名和 --stdio 混进去。',
'README 给 npx 示例时command 填 npxargs 逐项填 -y、包名和 --stdio不要把整行 npx 命令放进 command。',
'README 给 Docker 示例时command 填 dockerargs 逐项填 run、--rm、-i、镜像名和容器参数容器内 token 通常用 -e KEY=VALUE 传给容器。',
'如果 README 里只给了一整行命令,优先粘到完整命令框自动拆分;支持 KEY=VALUE、env KEY=VALUE、PowerShell $env:KEY=VALUE; 和 Windows set KEY=VALUE && 这几类前缀环境变量写法。',
'环境变量每行一条 KEY=VALUE不要写 export也不要和启动命令混成一行保存。',
'密钥类环境变量会保存到本机配置,并只在启动 MCP 进程时作为进程环境传入;不要把密钥写进聊天内容。',
'测试工具发现只会临时启动一次做探测,不会自动保存配置。',
];
export const MCP_TROUBLESHOOTING_GUIDES: MCPTroubleshootingGuide[] = [
{
key: 'command-not-found',
symptom: '测试提示找不到命令',
likelyCause: '启动命令填了整串命令、命令没加入 PATH或 Windows 路径里有空格但没有用真实 exe 路径。',
fix: '启动命令只填可执行程序本身;脚本名和 --stdio 放到命令参数里。命令不在 PATH 时,直接填绝对路径。',
example: 'command=npx, args=-y / @modelcontextprotocol/server-filesystem / --stdio',
},
{
key: 'timeout-or-no-tools',
symptom: '测试超时或发现 0 个工具',
likelyCause: '服务启动慢、缺少 stdio 参数Docker 容器缺少 -i或填成了只支持 HTTP/SSE 的 MCP 服务。',
fix: '先确认这个服务支持 stdio再补齐 --stdio 或 Docker -i 等参数;启动慢时把超时调到 45 或 60 秒。',
example: 'args=--stdio 或 docker run --rm -i image, timeout=45',
},
{
key: 'auth-failed',
symptom: '认证失败、401 或 403',
likelyCause: 'API Key、Token、服务地址等环境变量没有填或 KEY=VALUE 格式无效。',
fix: '在环境变量里每行写一条 KEY=VALUE不要写 export也不要把环境变量和启动命令混到同一行保存。',
example: 'GITHUB_TOKEN=...',
},
{
key: 'stdio-only',
symptom: 'README 只给了 URL 或 SSE 配置',
likelyCause: '这类配置通常不是本机 stdio 进程,当前 GoNavi 新增 MCP 服务暂不直接支持。',
fix: '优先找该服务的 stdio 启动方式;如果只有 HTTP/SSE请先用官方网关或本机包装器转成 stdio。',
example: '当前只支持 stdio',
},
];
const quoteCommandPart = (value: string): string => {
const text = String(value || '').trim();
if (!text) {
return '';
}
return /[\s"]/u.test(text) ? `"${text.replace(/"/g, '\\"')}"` : text;
};
export const buildMCPLaunchPreview = (command: string, args?: string[]): string =>
[command, ...(Array.isArray(args) ? args : [])]
.map((item) => quoteCommandPart(item))
.filter(Boolean)
.join(' ');

View File

@@ -0,0 +1,90 @@
import type { AIMCPServerConfig } from '../types';
export interface MCPServerDraftTemplate {
key: string;
title: string;
description: string;
detail: string;
seed: Partial<AIMCPServerConfig>;
}
export const MCP_SERVER_DRAFT_TEMPLATES: MCPServerDraftTemplate[] = [
{
key: 'npx',
title: 'npx 包',
description: '适合 README 里写着 `npx -y xxx --stdio` 的 npm MCP 包。',
detail: '示例会填成 `npx -y @modelcontextprotocol/server-filesystem --stdio`,把包名和路径参数改成实际值。',
seed: {
name: 'npx 包',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '--stdio'],
env: {},
timeoutSeconds: 20,
},
},
{
key: 'uvx',
title: 'uvx 工具',
description: '适合 Python/uv 生态里已经发布好的 MCP 包。',
detail: '示例会填成 `uvx some-mcp-server`,保存前把包名改成你自己的。',
seed: {
name: 'uvx 工具',
command: 'uvx',
args: ['some-mcp-server'],
env: {},
timeoutSeconds: 20,
},
},
{
key: 'node',
title: 'Node 脚本',
description: '适合本地 js/ts 脚本或 npm 安装后的 node 启动器。',
detail: '示例会填成 `node server.js --stdio`,脚本名和参数可以继续改。',
seed: {
name: 'Node 脚本',
command: 'node',
args: ['server.js', '--stdio'],
env: {},
timeoutSeconds: 20,
},
},
{
key: 'python',
title: 'Python 模块',
description: '适合 `python -m xxx` 这种按模块启动的服务。',
detail: '示例会填成 `python -m your_mcp_server`,模块名改成实际值即可。',
seed: {
name: 'Python 模块',
command: 'python',
args: ['-m', 'your_mcp_server'],
env: {},
timeoutSeconds: 20,
},
},
{
key: 'docker',
title: 'Docker 镜像',
description: '适合 README 里写着 `docker run -i --rm image` 的容器化 MCP。本机需要已安装 Docker。',
detail: '示例会填成 `docker run --rm -i mcp/server-fetch:latest`;容器内 token 通常用 -e KEY=VALUE 放到参数里。',
seed: {
name: 'Docker MCP',
command: 'docker',
args: ['run', '--rm', '-i', 'mcp/server-fetch:latest'],
env: {},
timeoutSeconds: 45,
},
},
{
key: 'exe',
title: '本机 EXE',
description: '适合已经编译好的本机二进制或公司内部工具。',
detail: '示例会填成 `your-mcp-server.exe stdio`,把 exe 路径换成真实值。',
seed: {
name: '本机 EXE',
command: 'your-mcp-server.exe',
args: ['stdio'],
env: {},
timeoutSeconds: 20,
},
},
];

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';
import { validateMCPServerDraft } from './mcpServerValidation';
describe('mcpServerValidation', () => {
it('blocks testing and saving when required MCP launch fields are invalid', () => {
const validation = validateMCPServerDraft({
name: 'GitHub',
transport: 'stdio',
command: '',
args: ['--stdio'],
timeoutSeconds: 20,
}, { invalidLines: [] });
expect(validation.canTest).toBe(false);
expect(validation.canSave).toBe(false);
expect(validation.errorCount).toBe(1);
expect(validation.issues.map((issue) => issue.key)).toContain('command-missing');
});
it('warns when users paste a whole command into the command field', () => {
const validation = validateMCPServerDraft({
name: 'Node',
transport: 'stdio',
command: 'node server.js --stdio',
args: [],
timeoutSeconds: 20,
}, { invalidLines: [] });
expect(validation.canTest).toBe(true);
expect(validation.warningCount).toBeGreaterThanOrEqual(1);
expect(validation.issues.map((issue) => issue.key)).toContain('command-whole-line');
expect(validation.issues.map((issue) => issue.key)).toContain('args-missing-for-launcher');
});
it('blocks save when env draft contains lines that would be silently dropped', () => {
const validation = validateMCPServerDraft({
name: 'GitHub',
transport: 'stdio',
command: 'uvx',
args: ['mcp-server-github', '--stdio'],
timeoutSeconds: 45,
}, { invalidLines: ['export GITHUB_TOKEN=abc'] });
expect(validation.canTest).toBe(false);
expect(validation.canSave).toBe(false);
expect(validation.errorCount).toBe(1);
expect(validation.issues.find((issue) => issue.key === 'env-invalid-lines')?.detail).toContain('export GITHUB_TOKEN=abc');
});
it('keeps valid drafts testable and saveable', () => {
const validation = validateMCPServerDraft({
name: 'Filesystem',
transport: 'stdio',
command: 'node',
args: ['server.js', '--stdio'],
timeoutSeconds: 20,
}, { invalidLines: [] });
expect(validation.canTest).toBe(true);
expect(validation.canSave).toBe(true);
expect(validation.errorCount).toBe(0);
});
it('warns when docker MCP launch args miss run, stdin, or image', () => {
const validation = validateMCPServerDraft({
name: 'Docker MCP',
transport: 'stdio',
command: 'docker',
args: ['--rm'],
timeoutSeconds: 45,
}, { invalidLines: [] });
expect(validation.canTest).toBe(true);
expect(validation.warningCount).toBeGreaterThanOrEqual(3);
expect(validation.issues.map((issue) => issue.key)).toContain('docker-run-missing');
expect(validation.issues.map((issue) => issue.key)).toContain('docker-interactive-missing');
expect(validation.issues.map((issue) => issue.key)).toContain('docker-image-missing');
});
it('accepts complete docker MCP launch args without docker-specific warnings', () => {
const validation = validateMCPServerDraft({
name: 'Docker MCP',
transport: 'stdio',
command: 'docker',
args: ['run', '--rm', '-i', '-e', 'API_KEY=...', 'mcp/server-fetch:latest'],
timeoutSeconds: 45,
}, { invalidLines: [] });
expect(validation.canTest).toBe(true);
expect(validation.canSave).toBe(true);
expect(validation.issues.map((issue) => issue.key)).not.toContain('docker-run-missing');
expect(validation.issues.map((issue) => issue.key)).not.toContain('docker-interactive-missing');
expect(validation.issues.map((issue) => issue.key)).not.toContain('docker-image-missing');
});
});

View File

@@ -0,0 +1,243 @@
import type { AIMCPServerConfig } from '../types';
import type { ParsedMCPEnvDraft } from './mcpEnvDraft';
import { splitShellLikeCommand } from './mcpCommandDraft';
export type MCPServerDraftIssueSeverity = 'error' | 'warning' | 'info';
export interface MCPServerDraftIssue {
key: string;
severity: MCPServerDraftIssueSeverity;
title: string;
detail: string;
}
export interface MCPServerDraftValidation {
issues: MCPServerDraftIssue[];
errorCount: number;
warningCount: number;
infoCount: number;
canTest: boolean;
canSave: boolean;
}
const KNOWN_LAUNCHER_COMMANDS = new Set([
'node',
'npm',
'npx',
'pnpm',
'yarn',
'bun',
'deno',
'python',
'python3',
'py',
'uv',
'uvx',
'docker',
'go',
'java',
'cmd',
'powershell',
'pwsh',
]);
const ENV_ASSIGNMENT_RE = /^(\$env:)?[A-Za-z_][A-Za-z0-9_]*=/u;
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const countIssues = (issues: MCPServerDraftIssue[], severity: MCPServerDraftIssueSeverity): number =>
issues.filter((issue) => issue.severity === severity).length;
const firstShellToken = (value: string): string => {
const { tokens } = splitShellLikeCommand(value);
return toTrimmedString(tokens[0]).toLowerCase();
};
const commandLooksLikeWholeLine = (command: string): boolean => {
const text = toTrimmedString(command);
if (!text) return false;
const { tokens } = splitShellLikeCommand(text);
if (tokens.length <= 1) return false;
const firstToken = toTrimmedString(tokens[0]).toLowerCase();
if (KNOWN_LAUNCHER_COMMANDS.has(firstToken)) return true;
if (ENV_ASSIGNMENT_RE.test(tokens[0])) return true;
return tokens.some((token, index) => index > 0 && String(token || '').startsWith('--'));
};
const argsContainEnvOrShellGlue = (args: string[]): boolean =>
args.some((arg) => {
const text = toTrimmedString(arg);
if (!text) return false;
const lower = text.toLowerCase();
return ENV_ASSIGNMENT_RE.test(text) || lower === 'env' || lower === 'set' || text === '&&' || text === ';';
});
const launcherUsuallyNeedsArgs = (command: string): boolean => {
const firstToken = firstShellToken(command);
return ['node', 'python', 'python3', 'py', 'uvx', 'npx', 'bun', 'deno', 'docker', 'go', 'java'].includes(firstToken);
};
const isDockerCommand = (command: string): boolean =>
firstShellToken(command) === 'docker';
const hasDockerRunArg = (args: string[]): boolean =>
args.some((arg) => arg.toLowerCase() === 'run');
const hasDockerInteractiveArg = (args: string[]): boolean =>
args.some((arg) => arg.toLowerCase() === '-i' || arg.toLowerCase() === '--interactive');
const hasDockerImageArg = (args: string[]): boolean => {
const runIndex = args.findIndex((arg) => arg.toLowerCase() === 'run');
const candidates = runIndex >= 0 ? args.slice(runIndex + 1) : args;
for (let index = 0; index < candidates.length; index += 1) {
const arg = candidates[index];
if (!arg || arg.startsWith('-')) {
const lower = arg.toLowerCase();
if ([
'-e',
'--env',
'--name',
'--network',
'-v',
'--volume',
'-p',
'--publish',
'--entrypoint',
'-w',
'--workdir',
'-u',
'--user',
'--platform',
'-h',
'--hostname',
].includes(lower)) {
index += 1;
}
continue;
}
return true;
}
return false;
};
export const validateMCPServerDraft = (
server: Pick<AIMCPServerConfig, 'name' | 'transport' | 'command' | 'args' | 'timeoutSeconds'>,
parsedEnvDraft?: Pick<ParsedMCPEnvDraft, 'invalidLines'>,
): MCPServerDraftValidation => {
const issues: MCPServerDraftIssue[] = [];
const command = toTrimmedString(server.command);
const args = Array.isArray(server.args) ? server.args.map(toTrimmedString).filter(Boolean) : [];
const timeoutSeconds = Number(server.timeoutSeconds);
if (!toTrimmedString(server.name)) {
issues.push({
key: 'name-missing',
severity: 'warning',
title: '服务名称为空',
detail: '建议写成 Browser、GitHub、Filesystem 这类用途名;否则保存后只能靠命令名识别。',
});
}
if (server.transport !== 'stdio') {
issues.push({
key: 'transport-unsupported',
severity: 'error',
title: '传输方式不支持',
detail: '当前 GoNavi 新增 MCP 服务只支持 stdio请保持传输方式为 stdio。',
});
}
if (!command) {
issues.push({
key: 'command-missing',
severity: 'error',
title: '启动命令未填写',
detail: '至少填写 node、uvx、python 或本机 exe 路径;脚本名和 --stdio 放到命令参数里。',
});
} else if (commandLooksLikeWholeLine(command)) {
issues.push({
key: 'command-whole-line',
severity: 'warning',
title: '启动命令可能填成了整行命令',
detail: '启动命令只填可执行程序本身;把脚本名、模块名、--stdio 和环境变量拆到命令参数或环境变量里。',
});
}
if (command && launcherUsuallyNeedsArgs(command) && args.length === 0) {
issues.push({
key: 'args-missing-for-launcher',
severity: 'warning',
title: '命令参数可能缺少脚本或模块名',
detail: 'node、python、uvx、npx 这类启动器通常还需要 server.js、-m your_server 或包名作为参数。',
});
}
if (command && isDockerCommand(command)) {
if (!hasDockerRunArg(args)) {
issues.push({
key: 'docker-run-missing',
severity: 'warning',
title: 'Docker 参数缺少 run',
detail: 'Docker MCP 通常需要 command=dockerargs 里单独填写 run、--rm、-i、镜像名和服务参数。',
});
}
if (!hasDockerInteractiveArg(args)) {
issues.push({
key: 'docker-interactive-missing',
severity: 'warning',
title: 'Docker 参数缺少 -i',
detail: 'MCP 需要持续读取标准输入docker run 场景请加 -i 或 --interactive否则工具发现可能立即断开。',
});
}
if (!hasDockerImageArg(args)) {
issues.push({
key: 'docker-image-missing',
severity: 'warning',
title: 'Docker 参数可能缺少镜像名',
detail: '请在 docker run 选项之后填写 README 提供的镜像名,例如 mcp/server-fetch:latest。',
});
}
}
if (argsContainEnvOrShellGlue(args)) {
issues.push({
key: 'args-contain-env-or-shell-glue',
severity: 'warning',
title: '命令参数里疑似混入环境变量或 Shell 连接符',
detail: 'KEY=VALUE、$env:KEY=VALUE、set、env、&& 这类内容应放到完整命令自动拆分或环境变量输入框里。',
});
}
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds < 3 || timeoutSeconds > 120) {
issues.push({
key: 'timeout-out-of-range',
severity: 'warning',
title: '超时时间不在推荐范围内',
detail: 'GoNavi 最终会限制在 3 到 120 秒之间;本机常规服务建议 20 秒,慢启动服务建议 45 或 60 秒。',
});
}
const invalidEnvLines = parsedEnvDraft?.invalidLines || [];
if (invalidEnvLines.length > 0) {
issues.push({
key: 'env-invalid-lines',
severity: 'error',
title: '环境变量存在无效行',
detail: `每行必须是 KEY=VALUE当前有 ${invalidEnvLines.length} 行不会保存:${invalidEnvLines.slice(0, 2).join(' / ')}`,
});
}
const errorCount = countIssues(issues, 'error');
const warningCount = countIssues(issues, 'warning');
const infoCount = countIssues(issues, 'info');
return {
issues,
errorCount,
warningCount,
infoCount,
canTest: errorCount === 0,
canSave: errorCount === 0,
};
};

View File

@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest';
import {
buildMessagePublishCommand,
createDefaultMessagePublishDraft,
} from './messagePublish';
describe('messagePublish', () => {
it('builds a Kafka publish JSON command from JSON payload inputs', () => {
const result = buildMessagePublishCommand(
{ type: 'kafka' },
{
destination: 'orders.events',
keyMode: 'json',
key: '{"tenant":"a"}',
bodyMode: 'json',
body: '{"id":1,"event":"created"}',
headers: '{"x-env":"dev"}',
},
);
expect(result.transportLabel).toBe('Kafka Topic');
expect(result.destinationLabel).toBe('orders.events');
expect(result.commandText).toContain('"publish": "orders.events"');
expect(result.commandText).toContain('"tenant": "a"');
expect(result.commandText).toContain('"id": 1');
expect(result.commandText).toContain('"x-env": "dev"');
});
it('keeps Kafka text payloads as plain strings', () => {
const result = buildMessagePublishCommand(
{ type: 'kafka' },
{
destination: 'logs.app',
keyMode: 'text',
key: 'tenant-a',
bodyMode: 'text',
body: 'hello gonavi',
headers: '',
},
);
expect(result.commandText).toContain('"key": "tenant-a"');
expect(result.commandText).toContain('"value": "hello gonavi"');
});
it('rejects non-object Kafka headers', () => {
expect(() => buildMessagePublishCommand(
{ type: 'kafka' },
{
destination: 'logs.app',
bodyMode: 'json',
body: '{"ok":true}',
headers: '["bad"]',
},
)).toThrow('Headers 必须是 JSON 对象');
});
it('seeds Kafka default publish draft with a JSON body example', () => {
expect(createDefaultMessagePublishDraft({ type: 'kafka' }, 'orders.events')).toMatchObject({
destination: 'orders.events',
keyMode: 'text',
bodyMode: 'json',
});
});
it('builds an MQTT publish JSON command with qos and retain flags', () => {
const result = buildMessagePublishCommand(
{ type: 'mqtt' },
{
destination: 'devices/device-001/telemetry',
qos: 1,
retain: true,
bodyMode: 'json',
body: '{"id":1,"event":"created"}',
},
);
expect(result.transportLabel).toBe('MQTT Topic');
expect(result.destinationLabel).toBe('devices/device-001/telemetry');
expect(result.commandText).toContain('"publish": "devices/device-001/telemetry"');
expect(result.commandText).toContain('"qos": 1');
expect(result.commandText).toContain('"retain": true');
});
it('seeds MQTT default publish draft with connection qos and retain defaults', () => {
expect(createDefaultMessagePublishDraft(
{ type: 'mqtt', database: 'devices/+/telemetry', connectionParams: 'qos=1&retain=true' },
'',
)).toMatchObject({
destination: 'devices/+/telemetry',
qos: 1,
retain: true,
bodyMode: 'json',
});
});
it('builds a RabbitMQ publish JSON command with routing and properties', () => {
const result = buildMessagePublishCommand(
{ type: 'rabbitmq', connectionParams: 'defaultQueue=orders.queue&exchange=events.topic' },
{
destination: 'orders.queue',
exchange: '',
routingKey: '',
bodyMode: 'json',
body: '{"id":1,"event":"created"}',
headers: '{"x-env":"dev"}',
properties: '{"content_type":"application/json"}',
},
);
expect(result.transportLabel).toBe('RabbitMQ Queue');
expect(result.destinationLabel).toBe('orders.queue');
expect(result.commandText).toContain('"publish": "orders.queue"');
expect(result.commandText).toContain('"exchange": "events.topic"');
expect(result.commandText).toContain('"routing_key": "orders.queue"');
expect(result.commandText).toContain('"content_type": "application/json"');
});
it('seeds RabbitMQ default publish draft with defaultQueue and exchange', () => {
expect(createDefaultMessagePublishDraft(
{ type: 'rabbitmq', connectionParams: 'defaultQueue=orders.queue&exchange=events.topic' },
'',
)).toMatchObject({
destination: 'orders.queue',
exchange: 'events.topic',
routingKey: 'orders.queue',
bodyMode: 'json',
});
});
it('builds a RocketMQ publish JSON command with tag, keys and delay level', () => {
const result = buildMessagePublishCommand(
{ type: 'rocketmq' },
{
destination: 'orders.events',
key: 'key-a,key-b',
tag: 'TagA',
delayLevel: 3,
bodyMode: 'json',
body: '{"id":1,"event":"created"}',
properties: '{"trace":"trace-1"}',
},
);
const command = JSON.parse(result.commandText);
expect(result.transportLabel).toBe('RocketMQ Topic');
expect(result.destinationLabel).toBe('orders.events');
expect(command).toMatchObject({
publish: 'orders.events',
tag: 'TagA',
delayLevel: 3,
properties: {
trace: 'trace-1',
},
});
expect(command.keys).toEqual(['key-a', 'key-b']);
expect(command.payload).toMatchObject({ id: 1, event: 'created' });
});
it('seeds RocketMQ default publish draft with connection tag and delay level defaults', () => {
expect(createDefaultMessagePublishDraft(
{ type: 'rocketmq', database: 'orders.events', connectionParams: 'tag=TagA&delayLevel=5' },
'',
)).toMatchObject({
destination: 'orders.events',
tag: 'TagA',
delayLevel: 5,
bodyMode: 'json',
});
});
});

View File

@@ -0,0 +1,443 @@
import { resolveDataSourceType } from './dataSourceCapabilities';
type ConnectionLike = {
type?: string;
driver?: string;
oceanBaseProtocol?: string;
database?: string;
uri?: string;
connectionParams?: string;
} | null | undefined;
export type MessagePublishValueMode = 'text' | 'json';
export type MessagePublishDraft = {
destination: string;
exchange?: string;
routingKey?: string;
qos?: number;
retain?: boolean;
tag?: string;
delayLevel?: number;
keyMode?: MessagePublishValueMode;
key?: string;
bodyMode?: MessagePublishValueMode;
body: string;
headers?: string;
properties?: string;
};
export type MessagePublishCommand = {
commandText: string;
destinationLabel: string;
transportLabel: string;
};
export type MessagePublishPresentation = {
transportLabel: string;
destinationLabel: string;
destinationPlaceholder: string;
destinationRequiredMessage: string;
alertMessage: string;
successHint: string;
showKey: boolean;
showKeyMode: boolean;
keyLabel: string;
keyPlaceholder: string;
showExchange: boolean;
showRoutingKey: boolean;
showHeaders: boolean;
showProperties: boolean;
showTag: boolean;
tagPlaceholder: string;
showDelayLevel: boolean;
showQos: boolean;
showRetain: boolean;
};
const normalizeMode = (value: unknown, fallback: MessagePublishValueMode): MessagePublishValueMode => {
const normalized = String(value || '').trim().toLowerCase();
if (normalized === 'text') return 'text';
if (normalized === 'json') return 'json';
return fallback;
};
const parseRequiredPayload = (
rawValue: unknown,
mode: MessagePublishValueMode,
fieldLabel: string,
): string | number | boolean | Record<string, any> | Array<any> => {
const text = String(rawValue ?? '');
if (!text.trim()) {
throw new Error(`请输入${fieldLabel}`);
}
if (mode === 'text') {
return text;
}
try {
return JSON.parse(text);
} catch (error: any) {
throw new Error(`${fieldLabel}不是合法 JSON${error?.message || String(error)}`);
}
};
const parseOptionalPayload = (
rawValue: unknown,
mode: MessagePublishValueMode,
fieldLabel: string,
): string | number | boolean | Record<string, any> | Array<any> | undefined => {
const text = String(rawValue ?? '');
if (!text.trim()) {
return undefined;
}
return parseRequiredPayload(text, mode, fieldLabel);
};
const parseOptionalJSONObject = (
rawValue: unknown,
fieldLabel: string,
): Record<string, any> | undefined => {
const text = String(rawValue ?? '');
if (!text.trim()) {
return undefined;
}
let parsed: unknown;
try {
parsed = JSON.parse(text);
} catch (error: any) {
throw new Error(`${fieldLabel}不是合法 JSON${error?.message || String(error)}`);
}
if (!parsed || Array.isArray(parsed) || typeof parsed !== 'object') {
throw new Error(`${fieldLabel} 必须是 JSON 对象`);
}
return parsed as Record<string, any>;
};
const mergeSearchParams = (target: URLSearchParams, sourceText: unknown) => {
const text = String(sourceText ?? '').trim();
if (!text) return;
const raw = text.includes('?') ? text.slice(text.indexOf('?') + 1) : text;
const params = new URLSearchParams(raw.replace(/^\?/, ''));
params.forEach((value, key) => {
if (String(key || '').trim()) {
target.set(key, value);
}
});
};
const resolveConnectionParams = (config: ConnectionLike): URLSearchParams => {
const params = new URLSearchParams();
if (!config) return params;
mergeSearchParams(params, config.uri);
mergeSearchParams(params, config.connectionParams);
return params;
};
const normalizeRabbitMQExchange = (value: unknown): string => {
const normalized = String(value ?? '').trim();
if (normalized === 'amq.default' || normalized === '(default)') {
return '';
}
return normalized;
};
const resolveDefaultDestination = (config: ConnectionLike, explicitDestination: string): string => {
const destination = String(explicitDestination || '').trim();
if (destination) return destination;
const resolvedType = resolveDataSourceType(config as any);
const params = resolveConnectionParams(config);
if (resolvedType === 'kafka') {
return String(config?.database || '').trim();
}
if (resolvedType === 'rocketmq') {
return String(config?.database || params.get('defaultTopic') || params.get('topic') || '').trim();
}
if (resolvedType === 'mqtt') {
return String(config?.database || params.get('defaultTopic') || params.get('topic') || '').trim();
}
if (resolvedType === 'rabbitmq') {
return String(params.get('defaultQueue') || params.get('queue') || '').trim();
}
return '';
};
export const getMessagePublishPresentation = (
config: ConnectionLike,
): MessagePublishPresentation => {
const resolvedType = resolveDataSourceType(config as any);
if (resolvedType === 'rabbitmq') {
return {
transportLabel: 'RabbitMQ Queue',
destinationLabel: 'Queue',
destinationPlaceholder: '例如orders.queue',
destinationRequiredMessage: '请输入 Queue',
alertMessage: '当前表单会自动拼装 RabbitMQ publish JSON 命令,并通过 Management API 执行测试发送。',
successHint: '留空 Exchange 时会使用默认交换机并按 Queue 名作为 routing key。',
showKey: false,
showKeyMode: false,
keyLabel: '消息 Key可选',
keyPlaceholder: '',
showExchange: true,
showRoutingKey: true,
showHeaders: true,
showProperties: true,
showTag: false,
tagPlaceholder: '',
showDelayLevel: false,
showQos: false,
showRetain: false,
};
}
if (resolvedType === 'rocketmq') {
return {
transportLabel: 'RocketMQ Topic',
destinationLabel: 'Topic',
destinationPlaceholder: '例如orders.events',
destinationRequiredMessage: '请输入 Topic',
alertMessage: '当前表单会自动拼装 RocketMQ publish JSON 命令,并通过 NameServer/Broker 执行测试发送。',
successHint: 'Tag、Keys、Delay Level 与 Properties 会一并写入 RocketMQ 消息属性。',
showKey: true,
showKeyMode: false,
keyLabel: '消息 Keys可选',
keyPlaceholder: '可输入多个 Key使用逗号分隔',
showExchange: false,
showRoutingKey: false,
showHeaders: false,
showProperties: true,
showTag: true,
tagPlaceholder: '例如TagA',
showDelayLevel: true,
showQos: false,
showRetain: false,
};
}
if (resolvedType === 'mqtt') {
return {
transportLabel: 'MQTT Topic',
destinationLabel: 'Topic',
destinationPlaceholder: '例如devices/device-001/telemetry',
destinationRequiredMessage: '请输入 Topic',
alertMessage: '当前表单会自动拼装 MQTT publish JSON 命令,并直接通过 broker 执行测试发送。',
successHint: 'QoS 与 retain 可单独指定;未填写时沿用当前连接中的默认参数。',
showKey: false,
showKeyMode: false,
keyLabel: '消息 Key可选',
keyPlaceholder: '',
showExchange: false,
showRoutingKey: false,
showHeaders: false,
showProperties: false,
showTag: false,
tagPlaceholder: '',
showDelayLevel: false,
showQos: true,
showRetain: true,
};
}
return {
transportLabel: 'Kafka Topic',
destinationLabel: 'Topic',
destinationPlaceholder: '例如orders.events',
destinationRequiredMessage: '请输入 Topic',
alertMessage: '当前表单会自动拼装 Kafka publish JSON 命令,并直接调用后端执行测试发送。',
successHint: 'Headers 会作为 Kafka Record Headers 一并发送。',
showKey: true,
showKeyMode: true,
keyLabel: '消息 Key可选',
keyPlaceholder: '可留空JSON 模式请输入一行合法 JSON',
showExchange: false,
showRoutingKey: false,
showHeaders: true,
showProperties: false,
showTag: false,
tagPlaceholder: '',
showDelayLevel: false,
showQos: false,
showRetain: false,
};
};
export const createDefaultMessagePublishDraft = (
config: ConnectionLike,
destination = '',
): MessagePublishDraft => {
const resolvedType = resolveDataSourceType(config as any);
const resolvedDestination = resolveDefaultDestination(config, destination);
const params = resolveConnectionParams(config);
if (resolvedType === 'rabbitmq') {
return {
destination: resolvedDestination,
exchange: normalizeRabbitMQExchange(params.get('defaultExchange') || params.get('exchange') || ''),
routingKey: resolvedDestination,
bodyMode: 'json',
body: '{\n "event": "test",\n "source": "gonavi"\n}',
headers: '{\n "x-source": "gonavi"\n}',
properties: '{\n "content_type": "application/json"\n}',
};
}
if (resolvedType === 'rocketmq') {
const delayLevel = Number(params.get('delayLevel') || params.get('delay_level'));
return {
destination: resolvedDestination,
tag: String(params.get('tag') || params.get('tags') || '').trim(),
delayLevel: Number.isFinite(delayLevel) && delayLevel > 0 ? Math.trunc(delayLevel) : undefined,
key: '',
bodyMode: 'json',
body: '{\n "event": "test",\n "source": "gonavi"\n}',
headers: '',
properties: '{\n "x-source": "gonavi"\n}',
};
}
if (resolvedType === 'mqtt') {
const qosValue = Number(params.get('qos'));
return {
destination: resolvedDestination,
qos: Number.isFinite(qosValue) ? Math.min(2, Math.max(0, Math.trunc(qosValue))) : 0,
retain: ['1', 'true', 'yes', 'on'].includes(String(params.get('retain') || '').trim().toLowerCase()),
bodyMode: 'json',
body: '{\n "event": "test",\n "source": "gonavi"\n}',
headers: '',
};
}
return {
destination: resolvedDestination,
keyMode: 'text',
key: '',
bodyMode: 'json',
body: '{\n "event": "test",\n "source": "gonavi"\n}',
headers: '{\n "x-source": "gonavi"\n}',
};
};
export const buildMessagePublishCommand = (
config: ConnectionLike,
draft: MessagePublishDraft,
): MessagePublishCommand => {
const resolvedType = resolveDataSourceType(config as any);
const destination = String(draft.destination || '').trim();
if (!destination) {
throw new Error('请输入目标 Topic / Queue');
}
if (resolvedType === 'mqtt') {
if (/[#+]/.test(destination)) {
throw new Error('MQTT 发送 Topic 不能包含 + 或 # 通配符');
}
const bodyMode = normalizeMode(draft.bodyMode, 'json');
const qosValue = Number(draft.qos);
const qos = Number.isFinite(qosValue) ? Math.min(2, Math.max(0, Math.trunc(qosValue))) : 0;
const command: Record<string, unknown> = {
publish: destination,
payload: parseRequiredPayload(draft.body, bodyMode, '消息体'),
qos,
retain: !!draft.retain,
};
return {
commandText: JSON.stringify(command, null, 2),
destinationLabel: destination,
transportLabel: 'MQTT Topic',
};
}
if (resolvedType === 'rocketmq') {
const bodyMode = normalizeMode(draft.bodyMode, 'json');
const command: Record<string, unknown> = {
publish: destination,
payload: parseRequiredPayload(draft.body, bodyMode, '消息体'),
};
const keys = String(draft.key || '')
.split(/[,;|\s]+/g)
.map((item) => item.trim())
.filter(Boolean);
if (keys.length > 0) {
command.keys = keys;
}
const tag = String(draft.tag || '').trim();
if (tag) {
command.tag = tag;
}
const delayLevel = Number(draft.delayLevel);
if (Number.isFinite(delayLevel) && delayLevel > 0) {
command.delayLevel = Math.trunc(delayLevel);
}
const properties = parseOptionalJSONObject(draft.properties, 'Properties');
if (properties && Object.keys(properties).length > 0) {
command.properties = properties;
}
return {
commandText: JSON.stringify(command, null, 2),
destinationLabel: destination,
transportLabel: 'RocketMQ Topic',
};
}
if (resolvedType === 'rabbitmq') {
const params = resolveConnectionParams(config);
const bodyMode = normalizeMode(draft.bodyMode, 'json');
const command: Record<string, unknown> = {
publish: destination,
payload: parseRequiredPayload(draft.body, bodyMode, '消息体'),
exchange: normalizeRabbitMQExchange(draft.exchange || params.get('defaultExchange') || params.get('exchange') || ''),
routing_key: String(draft.routingKey || '').trim() || destination,
};
const headers = parseOptionalJSONObject(draft.headers, 'Headers');
if (headers && Object.keys(headers).length > 0) {
command.headers = headers;
}
const properties = parseOptionalJSONObject(draft.properties, 'Properties');
if (properties && Object.keys(properties).length > 0) {
command.properties = properties;
}
return {
commandText: JSON.stringify(command, null, 2),
destinationLabel: destination,
transportLabel: 'RabbitMQ Queue',
};
}
if (resolvedType === 'kafka') {
const keyMode = normalizeMode(draft.keyMode, 'text');
const bodyMode = normalizeMode(draft.bodyMode, 'json');
const command: Record<string, unknown> = {
publish: destination,
value: parseRequiredPayload(draft.body, bodyMode, '消息体'),
};
const keyPayload = parseOptionalPayload(draft.key, keyMode, '消息 Key');
if (keyPayload !== undefined) {
command.key = keyPayload;
}
const headers = parseOptionalJSONObject(draft.headers, 'Headers');
if (headers && Object.keys(headers).length > 0) {
command.headers = headers;
}
return {
commandText: JSON.stringify(command, null, 2),
destinationLabel: destination,
transportLabel: 'Kafka Topic',
};
}
throw new Error(`当前数据源暂不支持测试发送消息:${resolvedType || 'unknown'}`);
};

View File

@@ -6,4 +6,20 @@ describe('buildTableSelectQuery', () => {
it('quotes uppercase postgres table names in new query templates', () => {
expect(buildTableSelectQuery('postgres', 'public.MyTable')).toBe('SELECT * FROM public."MyTable";');
});
it('adds a preview limit for RocketMQ topic browsing', () => {
expect(buildTableSelectQuery('rocketmq', 'orders.events')).toBe('SELECT * FROM "orders.events" LIMIT 100;');
});
it('adds a preview limit for Kafka topic browsing', () => {
expect(buildTableSelectQuery('kafka', 'logs.app-1')).toBe('SELECT * FROM "logs.app-1" LIMIT 100;');
});
it('adds a preview limit for MQTT topic browsing', () => {
expect(buildTableSelectQuery('mqtt', 'devices/+/telemetry')).toBe('SELECT * FROM "devices/+/telemetry" LIMIT 100;');
});
it('adds a preview limit for RabbitMQ queue browsing', () => {
expect(buildTableSelectQuery('rabbitmq', 'orders.events.v1')).toBe('SELECT * FROM "orders.events.v1" LIMIT 100;');
});
});

View File

@@ -5,5 +5,8 @@ export const buildTableSelectQuery = (dbType: string, tableName: string): string
if (!normalizedTableName) {
return 'SELECT * FROM ';
}
if (['rocketmq', 'mqtt', 'kafka', 'rabbitmq'].includes(String(dbType || '').trim().toLowerCase())) {
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)} LIMIT 100;`;
}
return `SELECT * FROM ${quoteQualifiedIdent(dbType, normalizedTableName)};`;
};

View File

@@ -0,0 +1,126 @@
export type QualifiedNameParts = {
parentPath: string;
objectName: string;
};
const normalizeIdentifierEscapes = (raw: string): string => {
let value = String(raw || '').trim();
for (let i = 0; i < 4; i += 1) {
const next = String(value || '').trim()
.replace(/\\\\"/g, '\\"')
.replace(/\\"/g, '"');
if (next === value) break;
value = next;
}
return String(value || '').trim();
};
export const stripIdentifierQuotes = (part: string): string => {
const text = normalizeIdentifierEscapes(part);
if (!text) return '';
if (text.length >= 2) {
const first = text[0];
const last = text[text.length - 1];
if (first === '"' && last === '"') {
return text.slice(1, -1).replace(/""/g, '"').trim();
}
if (first === '`' && last === '`') {
return text.slice(1, -1).replace(/``/g, '`').trim();
}
if (first === '[' && last === ']') {
return text.slice(1, -1).replace(/]]/g, ']').trim();
}
}
return text;
};
export const splitQualifiedNameSegments = (qualifiedName: string): string[] => {
const text = normalizeIdentifierEscapes(qualifiedName);
if (!text) return [];
const segments: string[] = [];
let current = '';
let inDouble = false;
let inBacktick = false;
let inBracket = false;
const flush = () => {
const value = current.trim();
current = '';
if (!value) return;
segments.push(stripIdentifierQuotes(value));
};
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (inDouble) {
current += ch;
if (ch === '"' && text[i + 1] === '"') {
current += text[i + 1];
i += 1;
continue;
}
if (ch === '"') inDouble = false;
continue;
}
if (inBacktick) {
current += ch;
if (ch === '`' && text[i + 1] === '`') {
current += text[i + 1];
i += 1;
continue;
}
if (ch === '`') inBacktick = false;
continue;
}
if (inBracket) {
current += ch;
if (ch === ']' && text[i + 1] === ']') {
current += text[i + 1];
i += 1;
continue;
}
if (ch === ']') inBracket = false;
continue;
}
if (ch === '"') {
inDouble = true;
current += ch;
continue;
}
if (ch === '`') {
inBacktick = true;
current += ch;
continue;
}
if (ch === '[') {
inBracket = true;
current += ch;
continue;
}
if (ch === '.') {
flush();
continue;
}
current += ch;
}
flush();
return segments;
};
export const splitQualifiedName = (qualifiedName: string): QualifiedNameParts => {
const segments = splitQualifiedNameSegments(qualifiedName);
if (segments.length === 0) return { parentPath: '', objectName: '' };
if (segments.length === 1) return { parentPath: '', objectName: segments[0] };
return {
parentPath: segments.slice(0, -1).join('.'),
objectName: segments[segments.length - 1],
};
};
export const splitQualifiedNameLast = splitQualifiedName;

View File

@@ -5,6 +5,7 @@ import { applyQueryAutoLimit } from './queryAutoLimit';
describe('applyQueryAutoLimit', () => {
const limitDialects = [
'mysql',
'goldendb',
'mariadb',
'oceanbase',
'diros',
@@ -18,6 +19,7 @@ describe('applyQueryAutoLimit', () => {
'highgo',
'vastbase',
'opengauss',
'gaussdb',
'iris',
'intersystemsiris',
'sqlite',
@@ -25,6 +27,7 @@ describe('applyQueryAutoLimit', () => {
'duckdb',
'clickhouse',
'tdengine',
'iotdb',
];
it.each(limitDialects)('adds generic LIMIT for %s connections', (dbType) => {
@@ -62,6 +65,7 @@ describe('applyQueryAutoLimit', () => {
['dm8', 'SELECT * FROM (SELECT * FROM users) WHERE ROWNUM <= 500'],
['mssql', 'SELECT TOP 500 * FROM users'],
['postgresql', 'SELECT * FROM users LIMIT 500'],
['gauss-db', 'SELECT * FROM users LIMIT 500'],
['doris', 'SELECT * FROM users LIMIT 500'],
['starrocks', 'SELECT * FROM users LIMIT 500'],
['sqlite3', 'SELECT * FROM users LIMIT 500'],

View File

@@ -3,7 +3,7 @@ import { resolveSqlDialect } from './sqlDialect';
const isWS = (ch: string) => ch === ' ' || ch === '\t' || ch === '\n' || ch === '\r';
const isWord = (ch: string) => /[A-Za-z0-9_]/.test(ch);
const getLeadingKeyword = (sql: string): string => {
export const getLeadingKeyword = (sql: string): string => {
const text = (sql || '').replace(/\r\n/g, '\n');
let inSingle = false;
let inDouble = false;
@@ -94,7 +94,7 @@ const getLeadingKeyword = (sql: string): string => {
return '';
};
const splitSqlTail = (sql: string): { main: string; tail: string } => {
export const splitSqlTail = (sql: string): { main: string; tail: string } => {
const text = (sql || '').replace(/\r\n/g, '\n');
let inSingle = false;
let inDouble = false;
@@ -181,7 +181,7 @@ const splitSqlTail = (sql: string): { main: string; tail: string } => {
return { main: text.slice(0, mainEnd), tail: text.slice(mainEnd) };
};
const findTopLevelKeyword = (sql: string, keyword: string): number => {
export const findTopLevelKeyword = (sql: string, keyword: string): number => {
const text = sql;
const kw = keyword.toLowerCase();
let inSingle = false;

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
buildQueryResultPageSql,
createInitialQueryResultPagination,
resolveQueryResultPaginationTotal,
} from './queryResultPagination';
describe('queryResultPagination', () => {
it('treats MySQL LIMIT offset,count as editor pagination and exports the base query', () => {
const page = createInitialQueryResultPagination({
executedSql: 'SELECT id, name FROM users LIMIT 0,500',
exportSql: 'SELECT id, name FROM users LIMIT 0,500',
dbType: 'mysql',
returnedRowCount: 500,
fallbackPageSize: 5000,
});
expect(page).toMatchObject({
current: 1,
pageSize: 500,
total: 1000,
totalKnown: false,
baseSql: 'SELECT id, name FROM users',
exportAllSql: 'SELECT id, name FROM users',
});
});
it('builds the next page SQL with one lookahead row', () => {
expect(buildQueryResultPageSql({
baseSql: 'SELECT id FROM users',
dbType: 'mysql',
page: 2,
pageSize: 500,
lookahead: true,
})).toBe('SELECT * FROM (SELECT id FROM users) AS __gonavi_query_page__ LIMIT 501 OFFSET 500');
});
it('marks the last full lookahead page as an exact total', () => {
expect(resolveQueryResultPaginationTotal({
current: 2,
pageSize: 500,
rowCount: 500,
hasNext: false,
})).toEqual({ total: 1000, totalKnown: true });
});
});

View File

@@ -0,0 +1,162 @@
import { buildPaginatedSelectSQL } from './sql';
import { findTopLevelKeyword, getLeadingKeyword, splitSqlTail } from './queryAutoLimit';
import { resolveSqlDialect } from './sqlDialect';
export type QueryResultPaginationState = {
current: number;
pageSize: number;
total: number;
totalKnown?: boolean;
baseSql: string;
exportAllSql?: string;
};
type LimitInfo = {
baseSql: string;
limit: number;
offset: number;
};
const normalizePositiveInteger = (value: unknown): number => {
const parsed = Math.floor(Number(value));
return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
};
const parseTopLevelLimit = (sql: string): LimitInfo | null => {
const { main } = splitSqlTail(sql);
const limitPos = findTopLevelKeyword(main, 'limit');
if (limitPos < 0) return null;
const fromPos = findTopLevelKeyword(main, 'from');
if (fromPos >= 0 && limitPos < fromPos) return null;
const beforeLimit = main.slice(0, limitPos).trimEnd();
const limitClause = main.slice(limitPos).trim();
const mysqlOffsetLimit = limitClause.match(/^limit\s+(\d+)\s*,\s*(\d+)$/i);
if (mysqlOffsetLimit) {
const offset = normalizePositiveInteger(mysqlOffsetLimit[1]);
const limit = normalizePositiveInteger(mysqlOffsetLimit[2]);
return limit > 0 ? { baseSql: beforeLimit, limit, offset } : null;
}
const limitOffset = limitClause.match(/^limit\s+(\d+)\s+offset\s+(\d+)$/i);
if (limitOffset) {
const limit = normalizePositiveInteger(limitOffset[1]);
const offset = normalizePositiveInteger(limitOffset[2]);
return limit > 0 ? { baseSql: beforeLimit, limit, offset } : null;
}
const simpleLimit = limitClause.match(/^limit\s+(\d+)$/i);
if (simpleLimit) {
const limit = normalizePositiveInteger(simpleLimit[1]);
return limit > 0 ? { baseSql: beforeLimit, limit, offset: 0 } : null;
}
return null;
};
const stripExplicitLimitForExport = (sql: string): string => {
const parsed = parseTopLevelLimit(sql);
if (parsed?.baseSql) return parsed.baseSql;
return splitSqlTail(sql).main.trim();
};
const resolveWrappedBaseSql = (dbType: string, baseSql: string): string => {
const normalizedType = String(dbType || '').trim().toLowerCase();
const base = baseSql.trim();
if (normalizedType === 'oracle' || normalizedType === 'dameng') {
return `SELECT * FROM (${base}) "__gonavi_query_page__"`;
}
return `SELECT * FROM (${base}) AS __gonavi_query_page__`;
};
export const buildQueryResultPageSql = (params: {
baseSql: string;
dbType: string;
driver?: string;
page: number;
pageSize: number;
lookahead?: boolean;
}): string => {
const pageSize = normalizePositiveInteger(params.pageSize);
if (pageSize <= 0) return String(params.baseSql || '').trim();
const page = Math.max(1, Math.floor(Number(params.page) || 1));
const limit = params.lookahead ? pageSize + 1 : pageSize;
const offset = (page - 1) * pageSize;
const dialect = resolveSqlDialect(params.dbType || 'mysql', params.driver || '');
return buildPaginatedSelectSQL(
dialect,
resolveWrappedBaseSql(dialect, params.baseSql),
'',
limit,
offset,
);
};
export const resolveQueryResultPaginationTotal = (params: {
current: number;
pageSize: number;
rowCount: number;
hasNext?: boolean;
}): Pick<QueryResultPaginationState, 'total' | 'totalKnown'> => {
const current = Math.max(1, Math.floor(Number(params.current) || 1));
const pageSize = normalizePositiveInteger(params.pageSize);
const rowCount = Math.max(0, Math.floor(Number(params.rowCount) || 0));
if (pageSize <= 0) {
return { total: rowCount, totalKnown: true };
}
if (params.hasNext === true) {
return { total: (current + 1) * pageSize, totalKnown: false };
}
if (params.hasNext === false) {
return { total: Math.max(0, (current - 1) * pageSize + rowCount), totalKnown: true };
}
if (rowCount >= pageSize) {
return { total: (current + 1) * pageSize, totalKnown: false };
}
return { total: Math.max(0, (current - 1) * pageSize + rowCount), totalKnown: true };
};
export const createInitialQueryResultPagination = (params: {
executedSql: string;
exportSql?: string;
dbType: string;
driver?: string;
returnedRowCount: number;
fallbackPageSize?: number;
}): QueryResultPaginationState | undefined => {
const executedSql = String(params.executedSql || '').trim();
if (!executedSql || getLeadingKeyword(executedSql) !== 'select') return undefined;
const explicitLimit = parseTopLevelLimit(executedSql);
const mainSql = splitSqlTail(executedSql).main.trim();
const fallbackPageSize = normalizePositiveInteger(params.fallbackPageSize);
const returnedRowCount = Math.max(0, Math.floor(Number(params.returnedRowCount) || 0));
const pageSize = explicitLimit?.limit || fallbackPageSize || returnedRowCount;
if (pageSize <= 0) return undefined;
const current = explicitLimit
? Math.max(1, Math.floor(explicitLimit.offset / pageSize) + 1)
: 1;
if (current <= 1 && returnedRowCount < pageSize) return undefined;
const baseSql = explicitLimit?.baseSql || mainSql;
if (!baseSql) return undefined;
const exportSql = String(params.exportSql || '').trim();
const exportAllSql = exportSql && getLeadingKeyword(exportSql) === 'select'
? stripExplicitLimitForExport(exportSql)
: stripExplicitLimitForExport(executedSql);
const totalState = resolveQueryResultPaginationTotal({
current,
pageSize,
rowCount: returnedRowCount,
});
return {
current,
pageSize,
...totalState,
baseSql,
exportAllSql,
};
};

View File

@@ -48,6 +48,15 @@ describe('extractQueryResultTableRef', () => {
});
});
it('keeps DuckDB schema-qualified table names for metadata lookups', () => {
expect(extractQueryResultTableRef('SELECT * FROM main.events LIMIT 500', 'duckdb', 'main'))
.toEqual({
tableName: 'main.events',
metadataDbName: 'main',
metadataTableName: 'main.events',
});
});
it('does not mark join results as editable table refs', () => {
expect(extractQueryResultTableRef('SELECT * FROM users u JOIN orders o ON u.id = o.user_id', 'oracle', 'APP'))
.toBeUndefined();

View File

@@ -21,6 +21,11 @@ const isOracleLikeDialect = (dialect: string): boolean => {
return normalized === 'oracle' || normalized === 'dameng' || normalized === 'dm' || normalized === 'dm8';
};
const keepsQualifiedTableNameForMetadata = (dialect: string): boolean => {
const normalized = String(dialect || '').trim().toLowerCase();
return normalized === 'duckdb';
};
const isQuotedIdentifier = (part: string): boolean => {
const text = String(part || '').trim();
if (!text) return false;
@@ -73,13 +78,16 @@ export const extractQueryResultTableRef = (
const owner = parts.length >= 2 ? parts[parts.length - 2] : '';
const metadataDbName = owner || normalizeCurrentDbName(currentDb, dialect);
const tableName = isOracleLikeDialect(dialect) && owner
const tableName = (isOracleLikeDialect(dialect) || keepsQualifiedTableNameForMetadata(dialect)) && owner
? `${owner}.${metadataTableName}`
: metadataTableName;
const resolvedMetadataTableName = keepsQualifiedTableNameForMetadata(dialect) && owner
? `${owner}.${metadataTableName}`
: metadataTableName;
return {
tableName,
metadataDbName,
metadataTableName,
metadataTableName: resolvedMetadataTableName,
};
};

View File

@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import {
buildRedisUriFromValues,
parseRedisUriToFormValues,
resolveRedisConfigDraft,
} from './redisConnectionUri';
describe('redisConnectionUri', () => {
it('parses Redis Sentinel URI into form values without dropping topology fields', () => {
const result = parseRedisUriToFormValues(
'rediss://default:redis%40secret@sentinel-a.local:26379,sentinel-b.local/3?topology=sentinel&master=mymaster&sentinel_user=ops&sentinel_password=s%40p&skip_verify=true&sslCAPath=C%3A%2Fcerts%2Fca.pem',
);
expect(result).toMatchObject({
host: 'sentinel-a.local',
port: 26379,
user: 'default',
password: 'redis@secret',
useSSL: true,
sslMode: 'skip-verify',
sslCAPath: 'C:/certs/ca.pem',
redisTopology: 'sentinel',
redisHosts: ['sentinel-b.local:26379'],
redisSentinelMaster: 'mymaster',
redisSentinelUser: 'ops',
redisSentinelPassword: 's@p',
redisDB: 3,
});
});
it('builds Redis Sentinel URI with Sentinel credentials separated from Redis auth', () => {
expect(buildRedisUriFromValues({
host: 'sentinel-a.local',
port: 26379,
redisHosts: ['sentinel-b.local', 'sentinel-b.local:26379'],
redisTopology: 'sentinel',
user: 'default',
password: 'redis secret',
redisSentinelMaster: 'mymaster',
redisSentinelUser: 'sentinel-user',
redisSentinelPassword: 'sentinel secret',
redisDB: 6,
useSSL: true,
sslMode: 'required',
sslCAPath: 'C:/certs/ca.pem',
})).toBe(
'rediss://default:redis%20secret@sentinel-a.local:26379,sentinel-b.local:26379/6?topology=sentinel&master=mymaster&sentinel_user=sentinel-user&sentinel_password=sentinel+secret&sslCAPath=C%3A%2Fcerts%2Fca.pem',
);
});
it('resolves Redis config draft for cluster and Sentinel save payloads', () => {
expect(resolveRedisConfigDraft({
redisTopology: 'cluster',
redisHosts: ['redis-b.local', 'redis-c.local:6380'],
redisDB: 2,
}, 'redis-a.local', 6379, 6379)).toEqual({
primaryPort: 6379,
hosts: ['redis-a.local:6379', 'redis-b.local:6379', 'redis-c.local:6380'],
topology: 'cluster',
redisSentinelMaster: '',
redisSentinelUser: '',
redisSentinelPassword: '',
redisDB: 2,
});
expect(resolveRedisConfigDraft({
redisTopology: 'sentinel',
port: 6379,
redisHosts: ['sentinel-b.local'],
redisSentinelMaster: 'mymaster',
redisSentinelUser: 'ops',
redisSentinelPassword: 'sentinel-pass',
redisDB: 99,
}, 'sentinel-a.local', 6379, 6379)).toEqual({
primaryPort: 26379,
hosts: ['sentinel-a.local:26379', 'sentinel-b.local:26379'],
topology: 'sentinel',
redisSentinelMaster: 'mymaster',
redisSentinelUser: 'ops',
redisSentinelPassword: 'sentinel-pass',
redisDB: 99,
});
});
});

View File

@@ -0,0 +1,451 @@
import type { ConnectionConfig } from '../types';
export type RedisTopology = Extract<
NonNullable<ConnectionConfig['topology']>,
'single' | 'cluster' | 'sentinel'
>;
export interface RedisUriFormValues {
host: string;
port: number;
user: string;
password: string;
useSSL: boolean;
sslMode: 'required' | 'skip-verify' | 'disable';
sslCAPath?: string;
sslCertPath?: string;
sslKeyPath?: string;
redisTopology: RedisTopology;
redisHosts: string[];
redisSentinelMaster: string;
redisSentinelUser: string;
redisSentinelPassword: string;
redisDB: number;
}
export interface RedisConfigDraft {
primaryPort: number;
hosts: string[];
topology: RedisTopology;
redisSentinelMaster: string;
redisSentinelUser: string;
redisSentinelPassword: string;
redisDB: number;
}
const REDIS_DEFAULT_PORT = 6379;
const REDIS_SENTINEL_DEFAULT_PORT = 26379;
const MAX_URI_HOSTS = 32;
const parseHostPort = (
raw: string,
defaultPort: number,
): { host: string; port: number } | null => {
const text = String(raw || '').trim();
if (!text) {
return null;
}
if (text.startsWith('[')) {
const closingBracket = text.indexOf(']');
if (closingBracket > 0) {
const host = text.slice(1, closingBracket).trim();
const portText = text
.slice(closingBracket + 1)
.trim()
.replace(/^:/, '');
const parsedPort = Number(portText);
return {
host: host || 'localhost',
port:
Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535
? parsedPort
: defaultPort,
};
}
}
const colonCount = (text.match(/:/g) || []).length;
if (colonCount === 1) {
const splitIndex = text.lastIndexOf(':');
const host = text.slice(0, splitIndex).trim();
const portText = text.slice(splitIndex + 1).trim();
const parsedPort = Number(portText);
return {
host: host || 'localhost',
port:
Number.isFinite(parsedPort) && parsedPort > 0 && parsedPort <= 65535
? parsedPort
: defaultPort,
};
}
return { host: text, port: defaultPort };
};
const toAddress = (host: string, port: number, defaultPort: number) => {
const safeHost = String(host || '').trim() || 'localhost';
const safePort =
Number.isFinite(Number(port)) && Number(port) > 0
? Number(port)
: defaultPort;
return `${safeHost}:${safePort}`;
};
const normalizeAddressList = (
rawList: unknown,
defaultPort: number,
): string[] => {
const list = Array.isArray(rawList) ? rawList : [];
const seen = new Set<string>();
const result: string[] = [];
list.forEach((entry) => {
const parsed = parseHostPort(String(entry || ''), defaultPort);
if (!parsed) {
return;
}
const normalized = toAddress(parsed.host, parsed.port, defaultPort);
if (seen.has(normalized)) {
return;
}
seen.add(normalized);
result.push(normalized);
});
return result;
};
const isValidUriHostEntry = (entry: string): boolean => {
const text = String(entry || '').trim();
if (!text) return false;
if (text.length > 255) return false;
return !/[()\\/\s]/.test(text);
};
const safeDecode = (text: string) => {
try {
return decodeURIComponent(text);
} catch {
return text;
}
};
const parseMultiHostUri = (uriText: string, expectedScheme: string) => {
const prefix = `${expectedScheme}://`;
if (!uriText.toLowerCase().startsWith(prefix)) {
return null;
}
let rest = uriText.slice(prefix.length);
const hashIndex = rest.indexOf('#');
if (hashIndex >= 0) {
rest = rest.slice(0, hashIndex);
}
let queryText = '';
const queryIndex = rest.indexOf('?');
if (queryIndex >= 0) {
queryText = rest.slice(queryIndex + 1);
rest = rest.slice(0, queryIndex);
}
let pathText = '';
const slashIndex = rest.indexOf('/');
if (slashIndex >= 0) {
pathText = rest.slice(slashIndex + 1);
rest = rest.slice(0, slashIndex);
}
let hostText = rest;
let username = '';
let password = '';
const atIndex = rest.lastIndexOf('@');
if (atIndex >= 0) {
const userInfo = rest.slice(0, atIndex);
hostText = rest.slice(atIndex + 1);
const colonIndex = userInfo.indexOf(':');
if (colonIndex >= 0) {
username = safeDecode(userInfo.slice(0, colonIndex));
password = safeDecode(userInfo.slice(colonIndex + 1));
} else {
username = safeDecode(userInfo);
}
}
const hosts = hostText
.split(',')
.map((item) => item.trim())
.filter(Boolean);
return {
username,
password,
hosts,
database: safeDecode(pathText),
params: new URLSearchParams(queryText),
};
};
const firstConnectionParamValue = (
params: URLSearchParams,
names: string[],
): string => {
for (const name of names) {
const value = String(params.get(name) || '').trim();
if (value) return value;
}
return '';
};
const extractRedisSSLPathValuesFromParams = (
params: URLSearchParams,
): Pick<RedisUriFormValues, 'sslCAPath' | 'sslCertPath' | 'sslKeyPath'> => {
const caPath = firstConnectionParamValue(params, [
'sslCAPath',
'ssl_ca_path',
'sslrootcert',
'sslRootCert',
'tlsCAFile',
'caFile',
'certificate',
'servercertificate',
'serverCertificate',
]);
const certPath = firstConnectionParamValue(params, [
'sslCertPath',
'ssl_cert_path',
'SSL_CERT_PATH',
'sslcert',
'sslCert',
'tlsCertificateFile',
]);
const keyPath = firstConnectionParamValue(params, [
'sslKeyPath',
'ssl_key_path',
'SSL_KEY_PATH',
'sslkey',
'sslKey',
'tlsKeyFile',
]);
return {
...(caPath ? { sslCAPath: caPath } : {}),
...(certPath ? { sslCertPath: certPath } : {}),
...(keyPath ? { sslKeyPath: keyPath } : {}),
};
};
const appendRedisSSLPathParamsForUri = (
params: URLSearchParams,
values: Record<string, any>,
) => {
const caPath = String(values.sslCAPath || '').trim();
const certPath = String(values.sslCertPath || '').trim();
const keyPath = String(values.sslKeyPath || '').trim();
if (caPath) {
params.set('sslCAPath', caPath);
}
if (certPath) {
params.set('sslCertPath', certPath);
}
if (keyPath) {
params.set('sslKeyPath', keyPath);
}
};
const normalizeRedisDB = (value: unknown): number => {
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return 0;
return Math.trunc(parsed);
};
const normalizeRedisTopology = (value: unknown): RedisTopology => {
const text = String(value || '').trim().toLowerCase();
if (text === 'cluster' || text === 'sentinel') {
return text;
}
return 'single';
};
export const parseRedisUriToFormValues = (
uriText: string,
): RedisUriFormValues | null => {
const trimmedUri = String(uriText || '').trim();
const parsed =
parseMultiHostUri(trimmedUri, 'redis') ||
parseMultiHostUri(trimmedUri, 'rediss');
if (!parsed) {
return null;
}
if (!parsed.hosts.length || parsed.hosts.length > MAX_URI_HOSTS) {
return null;
}
if (parsed.hosts.some((entry) => !isValidUriHostEntry(entry))) {
return null;
}
const topologyParam = String(parsed.params.get('topology') || '').toLowerCase();
const isSentinelTopology = topologyParam === 'sentinel';
const redisNodeDefaultPort = isSentinelTopology
? REDIS_SENTINEL_DEFAULT_PORT
: REDIS_DEFAULT_PORT;
const hostList = normalizeAddressList(parsed.hosts, redisNodeDefaultPort);
if (!hostList.length) {
return null;
}
const primary = parseHostPort(
hostList[0] || `localhost:${redisNodeDefaultPort}`,
redisNodeDefaultPort,
);
const dbText = String(parsed.database || '')
.trim()
.replace(/^\//, '');
const isRediss = trimmedUri.toLowerCase().startsWith('rediss://');
const skipVerifyText = String(parsed.params.get('skip_verify') || '')
.trim()
.toLowerCase();
const skipVerify =
skipVerifyText === '1' ||
skipVerifyText === 'true' ||
skipVerifyText === 'yes' ||
skipVerifyText === 'on';
return {
host: primary?.host || 'localhost',
port: primary?.port || redisNodeDefaultPort,
user: parsed.username || '',
password: parsed.password || '',
useSSL: isRediss,
sslMode: isRediss ? (skipVerify ? 'skip-verify' : 'required') : 'disable',
...extractRedisSSLPathValuesFromParams(parsed.params),
redisTopology: isSentinelTopology
? 'sentinel'
: hostList.length > 1 || topologyParam === 'cluster'
? 'cluster'
: 'single',
redisHosts: hostList.slice(1),
redisSentinelMaster: isSentinelTopology
? String(
parsed.params.get('master') ||
parsed.params.get('master_name') ||
parsed.params.get('sentinel_master') ||
'',
).trim()
: '',
redisSentinelUser: isSentinelTopology
? String(
parsed.params.get('sentinel_user') ||
parsed.params.get('sentinel_username') ||
'',
).trim()
: '',
redisSentinelPassword: isSentinelTopology
? String(parsed.params.get('sentinel_password') || '')
: '',
redisDB: normalizeRedisDB(dbText),
};
};
export const buildRedisUriFromValues = (values: Record<string, any>): string => {
const redisTopology = normalizeRedisTopology(values.redisTopology);
const redisNodeDefaultPort =
redisTopology === 'sentinel'
? REDIS_SENTINEL_DEFAULT_PORT
: REDIS_DEFAULT_PORT;
const primary = toAddress(
String(values.host || '').trim() || 'localhost',
Number(values.port || redisNodeDefaultPort),
redisNodeDefaultPort,
);
const extraRedisHosts =
redisTopology === 'cluster' || redisTopology === 'sentinel'
? normalizeAddressList(values.redisHosts, redisNodeDefaultPort)
: [];
const hosts = normalizeAddressList(
[primary, ...extraRedisHosts],
redisNodeDefaultPort,
);
const params = new URLSearchParams();
if (redisTopology === 'sentinel') {
params.set('topology', 'sentinel');
const sentinelMaster = String(values.redisSentinelMaster || '').trim();
if (sentinelMaster) {
params.set('master', sentinelMaster);
}
const sentinelUser = String(values.redisSentinelUser || '').trim();
if (sentinelUser) {
params.set('sentinel_user', sentinelUser);
}
const sentinelPassword = String(values.redisSentinelPassword || '');
if (sentinelPassword) {
params.set('sentinel_password', sentinelPassword);
}
} else if (hosts.length > 1 || redisTopology === 'cluster') {
params.set('topology', 'cluster');
}
const redisUser = String(values.user || '').trim();
const redisPassword = String(values.password || '');
let redisAuth = '';
if (redisUser || redisPassword) {
const encodedPassword = redisPassword
? encodeURIComponent(redisPassword)
: '';
redisAuth = redisUser
? `${encodeURIComponent(redisUser)}${redisPassword ? `:${encodedPassword}` : ''}@`
: `:${encodedPassword}@`;
}
const redisDB = normalizeRedisDB(values.redisDB);
if (values.useSSL) {
const mode = String(values.sslMode || 'preferred')
.trim()
.toLowerCase();
if (mode === 'skip-verify' || mode === 'preferred') {
params.set('skip_verify', 'true');
}
}
appendRedisSSLPathParamsForUri(params, values);
const query = params.toString();
const scheme = values.useSSL ? 'rediss' : 'redis';
return `${scheme}://${redisAuth}${hosts.join(',')}/${redisDB}${query ? `?${query}` : ''}`;
};
export const resolveRedisConfigDraft = (
values: Record<string, any>,
primaryHost: string,
primaryPort: number,
defaultPort: number,
): RedisConfigDraft => {
const redisTopology = normalizeRedisTopology(values.redisTopology);
const redisNodeDefaultPort =
redisTopology === 'sentinel'
? REDIS_SENTINEL_DEFAULT_PORT
: defaultPort;
const normalizedPrimaryPort =
redisTopology === 'sentinel' &&
(!Number(values.port) || Number(values.port) === defaultPort)
? redisNodeDefaultPort
: primaryPort;
const extraRedisNodes =
redisTopology === 'cluster' || redisTopology === 'sentinel'
? normalizeAddressList(values.redisHosts, redisNodeDefaultPort)
: [];
const allHosts = normalizeAddressList(
[`${primaryHost}:${normalizedPrimaryPort}`, ...extraRedisNodes],
redisNodeDefaultPort,
);
if (redisTopology === 'sentinel') {
return {
primaryPort: normalizedPrimaryPort,
hosts: allHosts,
topology: 'sentinel',
redisSentinelMaster: String(values.redisSentinelMaster || '').trim(),
redisSentinelUser: String(values.redisSentinelUser || '').trim(),
redisSentinelPassword: String(values.redisSentinelPassword || ''),
redisDB: normalizeRedisDB(values.redisDB),
};
}
return {
primaryPort: normalizedPrimaryPort,
hosts: redisTopology === 'cluster' || allHosts.length > 1 ? allHosts : [],
topology: redisTopology === 'cluster' || allHosts.length > 1 ? 'cluster' : 'single',
redisSentinelMaster: '',
redisSentinelUser: '',
redisSentinelPassword: '',
redisDB: normalizeRedisDB(values.redisDB),
};
};

View File

@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import {
DUCKDB_ROWID_LOCATOR_COLUMN,
ORACLE_ROWID_LOCATOR_COLUMN,
filterHiddenLocatorColumns,
resolveEditRowLocator,
@@ -103,6 +104,20 @@ describe('resolveEditRowLocator', () => {
readOnly: false,
});
});
it('uses DuckDB rowid when no primary or unique key is available', () => {
expect(resolveEditRowLocator({
dbType: 'duckdb',
resultColumns: ['name', DUCKDB_ROWID_LOCATOR_COLUMN],
allowDuckDBRowID: true,
})).toEqual({
strategy: 'duckdb-rowid',
columns: ['rowid'],
valueColumns: [DUCKDB_ROWID_LOCATOR_COLUMN],
hiddenColumns: [DUCKDB_ROWID_LOCATOR_COLUMN],
readOnly: false,
});
});
});
describe('resolveRowLocatorValues', () => {
@@ -142,6 +157,19 @@ describe('resolveRowLocatorValues', () => {
error: 'No safe row locator is available for this result set.',
});
});
it('extracts DuckDB rowid locator values from the original row', () => {
const locator = resolveEditRowLocator({
dbType: 'duckdb',
resultColumns: ['name', DUCKDB_ROWID_LOCATOR_COLUMN],
allowDuckDBRowID: true,
});
expect(resolveRowLocatorValues(locator, { name: 'launch', [DUCKDB_ROWID_LOCATOR_COLUMN]: 17 })).toEqual({
ok: true,
values: { rowid: 17 },
});
});
});
describe('filterHiddenLocatorColumns', () => {
@@ -154,4 +182,14 @@ describe('filterHiddenLocatorColumns', () => {
expect(filterHiddenLocatorColumns(['NAME', ORACLE_ROWID_LOCATOR_COLUMN], locator)).toEqual(['NAME']);
});
it('removes hidden DuckDB rowid columns from displayed columns', () => {
const locator = resolveEditRowLocator({
dbType: 'duckdb',
resultColumns: ['name', DUCKDB_ROWID_LOCATOR_COLUMN],
allowDuckDBRowID: true,
});
expect(filterHiddenLocatorColumns(['name', DUCKDB_ROWID_LOCATOR_COLUMN], locator)).toEqual(['name']);
});
});

View File

@@ -3,8 +3,9 @@ import { resolveUniqueKeyGroupsFromIndexes } from '../components/dataGridCopyIns
import { isOracleLikeDialect } from './sqlDialect';
export const ORACLE_ROWID_LOCATOR_COLUMN = '__gonavi_oracle_rowid__';
export const DUCKDB_ROWID_LOCATOR_COLUMN = '__gonavi_duckdb_rowid__';
export type RowLocatorStrategy = 'primary-key' | 'unique-key' | 'oracle-rowid' | 'none';
export type RowLocatorStrategy = 'primary-key' | 'unique-key' | 'oracle-rowid' | 'duckdb-rowid' | 'none';
export type EditRowLocator = {
strategy: RowLocatorStrategy;
@@ -22,6 +23,7 @@ export type ResolveEditRowLocatorParams = {
primaryKeys?: string[];
indexes?: IndexDefinition[];
allowOracleRowID?: boolean;
allowDuckDBRowID?: boolean;
};
export type ResolveRowLocatorValuesResult =
@@ -59,6 +61,7 @@ export const resolveEditRowLocator = ({
primaryKeys = [],
indexes,
allowOracleRowID = false,
allowDuckDBRowID = false,
}: ResolveEditRowLocatorParams): EditRowLocator => {
const columns = (resultColumns || []).map(normalizeColumnName).filter(Boolean);
const primaryKeyColumns = (primaryKeys || []).map(normalizeColumnName).filter(Boolean);
@@ -98,10 +101,25 @@ export const resolveEditRowLocator = ({
};
}
if (allowDuckDBRowID && String(dbType || '').trim().toLowerCase() === 'duckdb' && hasColumn(columns, DUCKDB_ROWID_LOCATOR_COLUMN)) {
const rowIDColumn = findColumn(columns, DUCKDB_ROWID_LOCATOR_COLUMN);
return {
strategy: 'duckdb-rowid',
columns: ['rowid'],
valueColumns: [rowIDColumn],
hiddenColumns: [rowIDColumn],
readOnly: false,
};
}
if (allowOracleRowID && isOracleLikeDialect(dbType)) {
return buildReadOnlyLocator('未检测到主键或可用唯一索引,且结果中缺少 Oracle ROWID无法安全提交修改。');
}
if (allowDuckDBRowID && String(dbType || '').trim().toLowerCase() === 'duckdb') {
return buildReadOnlyLocator('未检测到主键、可用唯一索引或 DuckDB rowid无法安全提交修改。');
}
return buildReadOnlyLocator('未检测到主键或可用唯一索引,无法安全提交修改。');
};

View File

@@ -0,0 +1,134 @@
import { describe, expect, it, vi } from 'vitest';
import type { SavedQuery } from '../types';
import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage';
import {
bootstrapSavedQueries,
readLegacySavedQueriesFromPayload,
stripLegacySavedQueries,
} from './savedQueryPersistence';
const createMemoryStorage = () => {
const data = new Map<string, string>();
return {
getItem: (key: string) => data.get(key) ?? null,
setItem: (key: string, value: string) => {
data.set(key, value);
},
};
};
describe('saved query persistence', () => {
it('imports legacy localStorage queries into backend and clears the legacy field', async () => {
const storage = createMemoryStorage();
storage.setItem(LEGACY_PERSIST_KEY, JSON.stringify({
state: {
connections: [
{
id: 'conn-1',
name: 'Primary',
config: {
id: 'conn-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'app',
password: 'secret',
},
},
],
theme: 'dark',
savedQueries: [
{
id: 'saved-1',
name: 'Orders',
sql: ' select * from orders;\n',
connectionId: 'conn-1',
dbName: 'app',
createdAt: 100,
},
],
},
version: 10,
}));
let backendQueries: SavedQuery[] = [];
const ImportSavedQueries = vi.fn(async (payload: { queries: SavedQuery[] }) => {
backendQueries = payload.queries;
return backendQueries;
});
const GetSavedQueries = vi.fn(async () => backendQueries);
const replaceSavedQueries = vi.fn();
const result = await bootstrapSavedQueries({
storage,
replaceSavedQueries,
backend: {
ImportSavedQueries,
GetSavedQueries,
},
});
expect(result).toEqual({ importedLegacyCount: 1, loadedCount: 1 });
expect(ImportSavedQueries).toHaveBeenCalledWith(expect.objectContaining({
queries: [
expect.objectContaining({
id: 'saved-1',
sql: ' select * from orders;\n',
}),
],
legacyConnections: [
expect.objectContaining({
id: 'conn-1',
config: expect.objectContaining({
host: 'db.local',
}),
}),
],
}));
expect(replaceSavedQueries).toHaveBeenCalledWith([
expect.objectContaining({
id: 'saved-1',
sql: ' select * from orders;\n',
}),
]);
const cleanedPayload = JSON.parse(storage.getItem(LEGACY_PERSIST_KEY) || '{}');
expect(cleanedPayload.state.theme).toBe('dark');
expect(cleanedPayload.state.savedQueries).toBeUndefined();
});
it('reads and strips legacy saved queries without altering other persisted state', () => {
const payload = JSON.stringify({
state: {
savedQueries: [
{
id: 'saved-1',
name: 'Analytics',
sql: '\nselect 1;',
connectionId: 'conn-1',
dbName: 'warehouse',
createdAt: 200,
},
{
id: 'invalid',
name: 'Missing context',
sql: 'select 2;',
},
],
sidebarWidth: 320,
},
});
expect(readLegacySavedQueriesFromPayload(payload)).toEqual([
expect.objectContaining({
id: 'saved-1',
sql: '\nselect 1;',
}),
]);
const stripped = JSON.parse(stripLegacySavedQueries(payload));
expect(stripped.state.savedQueries).toBeUndefined();
expect(stripped.state.sidebarWidth).toBe(320);
});
});

View File

@@ -0,0 +1,283 @@
import type { SavedConnection, SavedQuery } from '../types';
import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage';
type StorageLike = Pick<Storage, 'getItem' | 'setItem'>;
export interface SavedQueryImportPayload {
queries: SavedQuery[];
legacyConnections?: SavedConnection[];
}
export interface SavedQueryBackend {
GetSavedQueries?: () => Promise<SavedQuery[]>;
SaveQuery?: (query: SavedQuery) => Promise<SavedQuery | null | undefined>;
ImportSavedQueries?: (payload: SavedQueryImportPayload) => Promise<SavedQuery[]>;
DeleteQuery?: (id: string) => Promise<void>;
RebindSavedQuery?: (id: string, connectionId: string) => Promise<SavedQuery>;
}
export interface SavedQueryBootstrapArgs {
backend?: SavedQueryBackend;
replaceSavedQueries: (queries: SavedQuery[]) => void;
storage?: StorageLike;
}
export interface SavedQueryBootstrapResult {
importedLegacyCount: number;
loadedCount: number;
}
let capturedLegacySavedQuerySource: SavedQueryImportPayload = { queries: [] };
const toTrimmedString = (value: unknown, fallback = ''): string => {
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value).trim();
}
return fallback;
};
const unwrapPersistedAppState = (payload: unknown): Record<string, unknown> => {
if (!payload || typeof payload !== 'object') {
return {};
}
const raw = payload as Record<string, unknown>;
if (raw.state && typeof raw.state === 'object') {
return raw.state as Record<string, unknown>;
}
return raw;
};
const sanitizeSavedQuery = (value: unknown, index: number): SavedQuery | null => {
if (!value || typeof value !== 'object') {
return null;
}
const raw = value as Record<string, unknown>;
const id = toTrimmedString(raw.id, `query-${index + 1}`) || `query-${index + 1}`;
const sql = typeof raw.sql === 'string' ? raw.sql : toTrimmedString(raw.sql);
const connectionId = toTrimmedString(raw.connectionId);
const dbName = toTrimmedString(raw.dbName);
if (!sql.trim() || !connectionId || !dbName) {
return null;
}
const query: SavedQuery = {
id,
name: toTrimmedString(raw.name, `查询-${index + 1}`) || `查询-${index + 1}`,
sql,
connectionId,
dbName,
createdAt: Number.isFinite(Number(raw.createdAt)) ? Number(raw.createdAt) : Date.now(),
};
const connectionFingerprint = toTrimmedString(raw.connectionFingerprint);
const fingerprintVersion = toTrimmedString(raw.fingerprintVersion);
const bindingStatus = toTrimmedString(raw.bindingStatus);
const originalConnectionId = toTrimmedString(raw.originalConnectionId);
if (connectionFingerprint) query.connectionFingerprint = connectionFingerprint;
if (fingerprintVersion) query.fingerprintVersion = fingerprintVersion;
if (bindingStatus) query.bindingStatus = bindingStatus;
if (originalConnectionId) query.originalConnectionId = originalConnectionId;
return query;
};
export const sanitizeSavedQueries = (value: unknown): SavedQuery[] => {
if (!Array.isArray(value)) {
return [];
}
const result: SavedQuery[] = [];
const seen = new Set<string>();
value.forEach((item, index) => {
const query = sanitizeSavedQuery(item, index);
if (!query || seen.has(query.id)) {
return;
}
seen.add(query.id);
result.push(query);
});
return result;
};
const mergeSavedQueriesById = (...groups: SavedQuery[][]): SavedQuery[] => {
const result: SavedQuery[] = [];
const indexById = new Map<string, number>();
groups.flat().forEach((query) => {
if (!query.id) {
return;
}
const existingIndex = indexById.get(query.id);
if (existingIndex === undefined) {
indexById.set(query.id, result.length);
result.push(query);
return;
}
result[existingIndex] = query;
});
return result;
};
const sanitizeLegacyConnections = (value: unknown): SavedConnection[] => {
if (!Array.isArray(value)) {
return [];
}
return value.filter((item): item is SavedConnection => !!item && typeof item === 'object');
};
const mergeLegacyConnectionsById = (...groups: SavedConnection[][]): SavedConnection[] => {
const result: SavedConnection[] = [];
const indexById = new Map<string, number>();
groups.flat().forEach((connection) => {
const id = toTrimmedString((connection as { id?: unknown }).id);
if (!id) {
return;
}
const existingIndex = indexById.get(id);
if (existingIndex === undefined) {
indexById.set(id, result.length);
result.push(connection);
return;
}
result[existingIndex] = connection;
});
return result;
};
const mergeSavedQueryImportSources = (...sources: SavedQueryImportPayload[]): SavedQueryImportPayload => {
return {
queries: mergeSavedQueriesById(...sources.map((source) => source.queries || [])),
legacyConnections: mergeLegacyConnectionsById(...sources.map((source) => source.legacyConnections || [])),
};
};
export const captureLegacySavedQueriesSnapshot = (value: unknown, legacyConnections?: unknown): void => {
const nextQueries = sanitizeSavedQueries(value);
if (nextQueries.length === 0) {
return;
}
capturedLegacySavedQuerySource = mergeSavedQueryImportSources(capturedLegacySavedQuerySource, {
queries: nextQueries,
legacyConnections: sanitizeLegacyConnections(legacyConnections),
});
};
export const readLegacySavedQuerySourceFromPayload = (payload: string | null | undefined): SavedQueryImportPayload => {
if (!payload || typeof payload !== 'string') {
return { queries: [] };
}
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
const state = unwrapPersistedAppState(parsed);
return {
queries: sanitizeSavedQueries(state.savedQueries),
legacyConnections: sanitizeLegacyConnections(state.connections),
};
} catch {
return { queries: [] };
}
};
export const readLegacySavedQueriesFromPayload = (payload: string | null | undefined): SavedQuery[] => (
readLegacySavedQuerySourceFromPayload(payload).queries
);
export const stripLegacySavedQueries = (payload: string | null | undefined): string => {
if (!payload || typeof payload !== 'string') {
return '';
}
try {
const parsed = JSON.parse(payload) as Record<string, unknown>;
const state = unwrapPersistedAppState(parsed);
if (state.savedQueries === undefined) {
return payload;
}
delete state.savedQueries;
return JSON.stringify(parsed);
} catch {
return payload;
}
};
const resolveStorage = (storage?: StorageLike): StorageLike | undefined => {
if (storage) {
return storage;
}
if (typeof window === 'undefined') {
return undefined;
}
return window.localStorage;
};
const readLegacySavedQuerySourceFromStorage = (storage?: StorageLike): SavedQueryImportPayload => {
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
return readLegacySavedQuerySourceFromPayload(rawPayload);
};
const cleanupLegacySavedQueriesFromStorage = (storage?: StorageLike): void => {
const rawPayload = storage?.getItem(LEGACY_PERSIST_KEY) ?? null;
const sanitizedPayload = stripLegacySavedQueries(rawPayload);
if (sanitizedPayload && sanitizedPayload !== rawPayload) {
storage?.setItem(LEGACY_PERSIST_KEY, sanitizedPayload);
}
};
export const saveSavedQueryToBackend = async (
backend: SavedQueryBackend | undefined,
query: SavedQuery,
): Promise<SavedQuery> => {
const sanitized = sanitizeSavedQuery(query, 0);
if (!sanitized) {
throw new Error('保存查询缺少 SQL、连接或数据库上下文');
}
if (typeof backend?.SaveQuery !== 'function') {
return sanitized;
}
const saved = await backend.SaveQuery(sanitized);
return sanitizeSavedQuery(saved || sanitized, 0) || sanitized;
};
export const deleteSavedQueryFromBackend = async (
backend: SavedQueryBackend | undefined,
id: string,
): Promise<void> => {
if (typeof backend?.DeleteQuery === 'function') {
await backend.DeleteQuery(id);
}
};
export async function bootstrapSavedQueries(args: SavedQueryBootstrapArgs): Promise<SavedQueryBootstrapResult> {
const storage = resolveStorage(args.storage);
const storageLegacySource = readLegacySavedQuerySourceFromStorage(storage);
const legacySource = mergeSavedQueryImportSources(capturedLegacySavedQuerySource, storageLegacySource);
const legacyQueries = legacySource.queries;
let importedLegacyCount = 0;
if (legacyQueries.length > 0) {
if (typeof args.backend?.ImportSavedQueries === 'function') {
await args.backend.ImportSavedQueries(legacySource);
importedLegacyCount = legacyQueries.length;
capturedLegacySavedQuerySource = { queries: [] };
cleanupLegacySavedQueriesFromStorage(storage);
} else if (typeof args.backend?.SaveQuery === 'function') {
for (const query of legacyQueries) {
await args.backend.SaveQuery(query);
}
importedLegacyCount = legacyQueries.length;
capturedLegacySavedQuerySource = { queries: [] };
cleanupLegacySavedQueriesFromStorage(storage);
}
}
let loadedQueries: SavedQuery[] = [];
if (typeof args.backend?.GetSavedQueries === 'function') {
loadedQueries = sanitizeSavedQueries(await args.backend.GetSavedQueries());
}
if (loadedQueries.length === 0 && importedLegacyCount === 0 && legacyQueries.length > 0) {
loadedQueries = legacyQueries;
}
args.replaceSavedQueries(loadedQueries);
return {
importedLegacyCount,
loadedCount: loadedQueries.length,
};
}

View File

@@ -8,6 +8,7 @@ import {
startSecurityUpdateFromBootstrap,
} from './secureConfigBootstrap';
import { stripLegacyPersistedConnectionById } from './legacyConnectionStorage';
import { stripLegacySavedQueries } from './savedQueryPersistence';
const legacyPayload = JSON.stringify({
state: {
@@ -536,6 +537,69 @@ describe('secureConfigBootstrap', () => {
);
});
it('does not restore legacy saved queries when security cleanup runs after saved-query cleanup', async () => {
const args = createBaseArgs();
args.storage.setItem(LEGACY_PERSIST_KEY, JSON.stringify({
state: {
connections: [
{
id: 'legacy-1',
name: 'Legacy',
config: {
id: 'legacy-1',
type: 'postgres',
host: 'db.local',
port: 5432,
user: 'postgres',
password: 'secret',
},
},
],
globalProxy: {
enabled: true,
type: 'http',
host: '127.0.0.1',
port: 8080,
user: 'ops',
password: 'proxy-secret',
},
savedQueries: [
{
id: 'saved-1',
name: 'Orders',
sql: 'select * from orders',
connectionId: 'legacy-1',
dbName: 'app',
createdAt: 100,
},
],
},
}));
await startSecurityUpdateFromBootstrap({
...args,
backend: {
StartSecurityUpdate: vi.fn().mockImplementation(async () => {
args.storage.setItem(
LEGACY_PERSIST_KEY,
stripLegacySavedQueries(args.storage.getItem(LEGACY_PERSIST_KEY)),
);
return {
overallStatus: 'completed',
summary: { total: 3, updated: 3, pending: 0, skipped: 0, failed: 0 },
issues: [],
};
}),
GetSavedConnections: vi.fn().mockResolvedValue([]),
},
});
const cleaned = JSON.parse(args.storage.getItem(LEGACY_PERSIST_KEY) || '{}');
expect(cleaned.state.savedQueries).toBeUndefined();
expect(cleaned.state.connections).toEqual([]);
expect(cleaned.state.globalProxy).toBeUndefined();
});
it('refreshes backend config and strips source-side secrets when a later round finishes as completed', async () => {
const args = createBaseArgs();

View File

@@ -12,6 +12,7 @@ import {
readLegacyPersistedSecrets,
stripLegacyPersistedSecrets,
} from './legacyConnectionStorage';
import { stripLegacySavedQueries } from './savedQueryPersistence';
type StorageLike = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
@@ -312,8 +313,9 @@ const cleanupLegacySourceIfCompleted = (
if (!storage || !rawPayload || status.overallStatus !== 'completed') {
return;
}
const sanitizedPayload = stripLegacyPersistedSecrets(rawPayload);
if (sanitizedPayload && sanitizedPayload !== rawPayload) {
const currentPayload = storage.getItem(LEGACY_PERSIST_KEY) ?? rawPayload;
const sanitizedPayload = stripLegacySavedQueries(stripLegacyPersistedSecrets(currentPayload));
if (sanitizedPayload && sanitizedPayload !== currentPayload) {
storage.setItem(LEGACY_PERSIST_KEY, sanitizedPayload);
}
};

View File

@@ -190,6 +190,18 @@ describe('shortcut defaults', () => {
});
});
it('registers query results panel toggle as a query editor shortcut', () => {
expect(DEFAULT_SHORTCUT_OPTIONS.toggleQueryResultsPanel).toEqual({
mac: { combo: 'Meta+Shift+M', enabled: true },
windows: { combo: 'Ctrl+Shift+M', enabled: true },
});
expect(SHORTCUT_ACTION_META.toggleQueryResultsPanel).toMatchObject({
label: '切换结果区',
scope: 'queryEditor',
allowInEditable: true,
});
});
// Windows 任务栏恢复后字体异常变大的兜底入口(方案 3
// 自动 fix 路径9848b8b2刻意不再 toggle 以避免可见动画,由该快捷键给用户主动触发的修复入口。
it('registers reset window zoom shortcut with default Ctrl+Shift+0', () => {

View File

@@ -6,6 +6,7 @@ export type ShortcutAction =
| 'runQuery'
| 'selectCurrentStatement'
| 'saveQuery'
| 'toggleQueryResultsPanel'
| 'sendAIChatMessage'
| 'focusSidebarSearch'
| 'newQueryTab'
@@ -42,8 +43,10 @@ export interface ShortcutActionMeta {
}
interface ShortcutActionMetaDefinition extends Omit<ShortcutActionMeta, 'label' | 'description'> {
labelKey: string;
descriptionKey: string;
label?: string;
description?: string;
labelKey?: string;
descriptionKey?: string;
}
const MODIFIER_ORDER = ['Ctrl', 'Meta', 'Alt', 'Shift'] as const;
@@ -98,6 +101,7 @@ export const SHORTCUT_ACTION_ORDER: ShortcutAction[] = [
'runQuery',
'selectCurrentStatement',
'saveQuery',
'toggleQueryResultsPanel',
'sendAIChatMessage',
'focusSidebarSearch',
'newQueryTab',
@@ -118,10 +122,10 @@ const createShortcutActionMeta = (
definition: ShortcutActionMetaDefinition,
): ShortcutActionMeta => ({
get label() {
return localizeShortcut(definition.labelKey);
return definition.label ?? localizeShortcut(definition.labelKey || '');
},
get description() {
return localizeShortcut(definition.descriptionKey);
return definition.description ?? localizeShortcut(definition.descriptionKey || '');
},
allowInEditable: definition.allowInEditable,
allowWithoutModifier: definition.allowWithoutModifier,
@@ -147,6 +151,12 @@ const SHORTCUT_ACTION_META_DEFINITIONS: Record<ShortcutAction, ShortcutActionMet
scope: 'queryEditor',
allowInEditable: true,
},
toggleQueryResultsPanel: {
label: '切换结果区',
description: '在查询编辑器中显示或隐藏下方结果区域',
scope: 'queryEditor',
allowInEditable: true,
},
sendAIChatMessage: {
labelKey: 'app.shortcuts.action.sendAIChatMessage.label',
descriptionKey: 'app.shortcuts.action.sendAIChatMessage.description',
@@ -229,6 +239,10 @@ export const DEFAULT_SHORTCUT_OPTIONS: ShortcutOptions = {
mac: { combo: 'Meta+S', enabled: true },
windows: { combo: 'Ctrl+S', enabled: true },
},
toggleQueryResultsPanel: {
mac: { combo: 'Meta+Shift+M', enabled: true },
windows: { combo: 'Ctrl+Shift+M', enabled: true },
},
sendAIChatMessage: {
mac: { combo: 'Enter', enabled: true },
windows: { combo: 'Enter', enabled: true },

View File

@@ -96,6 +96,21 @@ describe('sidebarLocate', () => {
})).toBeNull();
});
it('keeps table-style view tabs on the views branch', () => {
expect(normalizeSidebarLocateObjectRequestFromTab({
id: 'legacy-view-tab-id',
type: 'table',
connectionId: 'conn-1',
dbName: 'GDB_APP',
tableName: 'V_ACCOUNT',
objectType: 'view',
})).toMatchObject({
tabId: 'legacy-view-tab-id',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
});
});
it('builds locate requests from trigger and routine tabs', () => {
expect(normalizeSidebarLocateObjectRequestFromTab({
id: 'trigger-conn-1-main-audit.users_bi',
@@ -122,6 +137,29 @@ describe('sidebarLocate', () => {
});
});
it('builds and resolves locate requests from external SQL file query tabs', () => {
const request = normalizeSidebarLocateObjectRequestFromTab({
id: 'external-sql-tab:conn-1:main:/Users/me/sql/report.sql',
type: 'query',
connectionId: 'conn-1',
dbName: 'main',
filePath: '/Users/me/sql/report.sql',
});
expect(request).toMatchObject({
connectionId: 'conn-1',
dbName: 'main',
filePath: '/Users/me/sql/report.sql',
objectGroup: 'externalSqlFiles',
});
expect(resolveSidebarLocateTarget(request!, { groupBySchema: false })).toMatchObject({
objectGroupKey: 'external-sql-root',
expectedAncestorKeys: ['external-sql-root'],
filePath: '/Users/me/sql/report.sql',
});
});
it('keeps StarRocks materialized view tabs on the materialized views branch', () => {
const request = normalizeSidebarLocateObjectRequestFromTab({
id: 'view-def-conn-1-main-sales.mv_daily',
@@ -285,4 +323,875 @@ describe('sidebarLocate', () => {
'conn-1-main-routine-reporting.refresh_stats',
]);
});
it('finds schema objects when tree nodes use unqualified names or different case', () => {
const viewTarget = resolveSidebarLocateTarget({
tabId: 'conn-1-main-view-reporting.active_users',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'reporting.active_users',
schemaName: 'reporting',
objectGroup: 'views',
}, { groupBySchema: true });
const routineTarget = resolveSidebarLocateTarget({
tabId: 'conn-1-main-routine-reporting.refresh_stats',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'reporting.refresh_stats',
schemaName: 'reporting',
objectGroup: 'routines',
}, { groupBySchema: true });
const triggerTarget = resolveSidebarLocateTarget({
tabId: 'conn-1-main-trigger-audit.users_bi-audit.users',
connectionId: 'conn-1',
dbName: 'main',
tableName: 'audit.users_bi',
schemaName: 'audit',
objectGroup: 'triggers',
}, { groupBySchema: true });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-main',
dataRef: { id: 'conn-1', dbName: 'main' },
children: [
{
key: 'conn-1-main-schema-REPORTING',
children: [
{
key: 'conn-1-main-schema-REPORTING-views',
children: [
{
key: 'conn-1-main-view-ACTIVE_USERS',
type: 'view',
dataRef: { id: 'conn-1', dbName: 'main', viewName: 'ACTIVE_USERS', schemaName: 'REPORTING' },
},
],
},
{
key: 'conn-1-main-schema-REPORTING-routines',
children: [
{
key: 'conn-1-main-routine-REFRESH_STATS',
type: 'routine',
dataRef: { id: 'conn-1', dbName: 'main', routineName: 'REFRESH_STATS', schemaName: 'REPORTING' },
},
],
},
],
},
{
key: 'conn-1-main-schema-AUDIT',
children: [
{
key: 'conn-1-main-schema-AUDIT-triggers',
children: [
{
key: 'conn-1-main-trigger-USERS_BI-AUDIT.USERS',
type: 'db-trigger',
dataRef: { id: 'conn-1', dbName: 'main', triggerName: 'USERS_BI', tableName: 'AUDIT.USERS', schemaName: 'AUDIT' },
},
],
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, viewTarget)).toEqual([
'conn-1',
'conn-1-main',
'conn-1-main-schema-REPORTING',
'conn-1-main-schema-REPORTING-views',
'conn-1-main-view-ACTIVE_USERS',
]);
expect(findSidebarNodePathForLocate(tree, routineTarget)).toEqual([
'conn-1',
'conn-1-main',
'conn-1-main-schema-REPORTING',
'conn-1-main-schema-REPORTING-routines',
'conn-1-main-routine-REFRESH_STATS',
]);
expect(findSidebarNodePathForLocate(tree, triggerTarget)).toEqual([
'conn-1',
'conn-1-main',
'conn-1-main-schema-AUDIT',
'conn-1-main-schema-AUDIT-triggers',
'conn-1-main-trigger-USERS_BI-AUDIT.USERS',
]);
});
it('finds a unique schema-qualified view when the locate request only has the view name', () => {
const target = resolveSidebarLocateTarget({
tabId: 'conn-1-SYSDBA-view-V_ACCOUNT',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
}, { groupBySchema: true });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-schema-SYSDBA',
children: [
{
key: 'conn-1-SYSDBA-schema-SYSDBA-views',
children: [
{
key: 'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT',
type: 'view',
dataRef: {
id: 'conn-1',
dbName: 'SYSDBA',
viewName: 'SYSDBA.V_ACCOUNT',
schemaName: 'SYSDBA',
},
},
],
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA-views',
'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT',
]);
});
it('finds a unique bare view node when metadata supplies schema separately', () => {
const target = resolveSidebarLocateTarget({
tabId: 'stale-view-tab-id',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
schemaName: 'SYSDBA',
objectGroup: 'views',
}, { groupBySchema: false });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-views',
children: [
{
key: 'conn-1-SYSDBA-view-V_ACCOUNT',
type: 'view',
dataRef: {
id: 'conn-1',
dbName: 'SYSDBA',
viewName: 'V_ACCOUNT',
},
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-views',
'conn-1-SYSDBA-view-V_ACCOUNT',
]);
});
it('finds a bare mysql-compatible view node when the locate request keeps a different schema name', () => {
const target = resolveSidebarLocateTarget({
tabId: 'stale-view-tab-id',
connectionId: 'conn-1',
dbName: 'GDB_APP',
tableName: 'V_ACCOUNT',
schemaName: 'SYSDBA',
objectGroup: 'views',
}, { groupBySchema: false });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-GDB_APP',
dataRef: { id: 'conn-1', dbName: 'GDB_APP' },
children: [
{
key: 'conn-1-GDB_APP-views',
children: [
{
key: 'conn-1-GDB_APP-view-V_ACCOUNT',
type: 'view',
dataRef: {
id: 'conn-1',
dbName: 'GDB_APP',
viewName: 'V_ACCOUNT',
},
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-GDB_APP',
'conn-1-GDB_APP-views',
'conn-1-GDB_APP-view-V_ACCOUNT',
]);
});
it('finds a mysql-compatible view node when objectType carries the view identity', () => {
const target = resolveSidebarLocateTarget({
tabId: 'stale-view-tab-id',
connectionId: 'conn-1',
dbName: 'GDB_APP',
tableName: 'V_ACCOUNT',
schemaName: 'SYSDBA',
objectGroup: 'views',
}, { groupBySchema: false });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-GDB_APP',
dataRef: { id: 'conn-1', dbName: 'GDB_APP' },
children: [
{
key: 'conn-1-GDB_APP-views',
children: [
{
key: 'opaque-view-node',
type: 'database-object',
dataRef: {
id: 'conn-1',
dbName: 'GDB_APP',
tableName: 'V_ACCOUNT',
objectType: 'view',
},
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-GDB_APP',
'conn-1-GDB_APP-views',
'opaque-view-node',
]);
});
it('falls back to a table-like node when a view is only present in the tables branch', () => {
const target = resolveSidebarLocateTarget({
tabId: 'stale-view-tab-id',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
}, { groupBySchema: false });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-tables',
children: [
{
key: 'conn-1-SYSDBA-V_ACCOUNT',
type: 'table',
dataRef: {
id: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
},
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-tables',
'conn-1-SYSDBA-V_ACCOUNT',
]);
});
it('falls back to a visual table-like node when view metadata is not present on the node', () => {
const target = resolveSidebarLocateTarget({
tabId: 'stale-view-tab-id',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
}, { groupBySchema: false });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-tables',
children: [
{
key: 'conn-1-SYSDBA-V_ACCOUNT',
title: 'V_ACCOUNT',
type: 'table',
dataRef: {},
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-tables',
'conn-1-SYSDBA-V_ACCOUNT',
]);
});
it('falls back to a visual table-like node with a table-prefixed key for a view request', () => {
const target = resolveSidebarLocateTarget({
tabId: 'stale-view-tab-id',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
}, { groupBySchema: false });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-tables',
children: [
{
key: 'conn-1-SYSDBA-table-V_ACCOUNT',
type: 'table',
dataRef: {},
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-tables',
'conn-1-SYSDBA-table-V_ACCOUNT',
]);
});
it('finds a view node by title when the tree node is missing object metadata', () => {
const target = resolveSidebarLocateTarget({
tabId: 'stale-view-tab-id',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
}, { groupBySchema: false });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-views',
children: [
{
key: 'conn-1-SYSDBA-view-generated-key',
title: 'V_ACCOUNT',
type: 'view',
dataRef: {
id: 'conn-1',
dbName: 'SYSDBA',
},
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-views',
'conn-1-SYSDBA-view-generated-key',
]);
});
it('finds a view node by title under the views group when node type metadata is missing', () => {
const target = resolveSidebarLocateTarget({
tabId: 'stale-view-tab-id',
connectionId: 'conn-1',
dbName: 'GDB_APP',
tableName: 'V_ACCOUNT',
schemaName: 'SYSDBA',
objectGroup: 'views',
}, { groupBySchema: false });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-GDB_APP',
dataRef: { id: 'conn-1', dbName: 'GDB_APP' },
children: [
{
key: 'conn-1-GDB_APP-views',
children: [
{
key: 'conn-1-GDB_APP-view-generated-key',
title: 'V_ACCOUNT',
dataRef: {},
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-GDB_APP',
'conn-1-GDB_APP-views',
'conn-1-GDB_APP-view-generated-key',
]);
});
it('finds a schema-qualified view request by visual title when the node has no schema metadata', () => {
const target = resolveSidebarLocateTarget({
tabId: 'stale-view-tab-id',
connectionId: 'conn-1',
dbName: 'GDB_APP',
tableName: 'SYSDBA.V_ACCOUNT',
schemaName: 'SYSDBA',
objectGroup: 'views',
}, { groupBySchema: false });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-GDB_APP',
dataRef: { id: 'conn-1', dbName: 'GDB_APP' },
children: [
{
key: 'conn-1-GDB_APP-views',
children: [
{
key: 'conn-1-GDB_APP-view-generated-key',
title: 'V_ACCOUNT',
type: 'view',
dataRef: {
id: 'conn-1',
dbName: 'GDB_APP',
},
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-GDB_APP',
'conn-1-GDB_APP-views',
'conn-1-GDB_APP-view-generated-key',
]);
});
it('falls back from a schema-qualified view request to a bare table-like node in the same database', () => {
const target = resolveSidebarLocateTarget({
tabId: 'stale-view-tab-id',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'SYSDBA.V_ACCOUNT',
schemaName: 'SYSDBA',
objectGroup: 'views',
}, { groupBySchema: false });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-tables',
children: [
{
key: 'conn-1-SYSDBA-V_ACCOUNT',
type: 'table',
dataRef: {
id: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
},
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-tables',
'conn-1-SYSDBA-V_ACCOUNT',
]);
});
it('falls back to a unique schema-qualified table-like node for an unqualified view request', () => {
const target = resolveSidebarLocateTarget({
tabId: 'stale-view-tab-id',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
}, { groupBySchema: true });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-schema-SYSDBA',
children: [
{
key: 'conn-1-SYSDBA-schema-SYSDBA-tables',
children: [
{
key: 'conn-1-SYSDBA-SYSDBA.V_ACCOUNT',
type: 'table',
dataRef: {
id: 'conn-1',
dbName: 'SYSDBA',
tableName: 'SYSDBA.V_ACCOUNT',
schemaName: 'SYSDBA',
},
},
],
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA-tables',
'conn-1-SYSDBA-SYSDBA.V_ACCOUNT',
]);
});
it('prefers the current database schema when an unqualified view request matches multiple schemas', () => {
const target = resolveSidebarLocateTarget({
tabId: 'conn-1-SYSDBA-view-V_ACCOUNT',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
}, { groupBySchema: true });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-schema-SYSDBA',
children: [
{
key: 'conn-1-SYSDBA-schema-SYSDBA-views',
children: [
{
key: 'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT',
type: 'view',
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'SYSDBA.V_ACCOUNT', schemaName: 'SYSDBA' },
},
],
},
],
},
{
key: 'conn-1-SYSDBA-schema-REPORT',
children: [
{
key: 'conn-1-SYSDBA-schema-REPORT-views',
children: [
{
key: 'conn-1-SYSDBA-view-REPORT.V_ACCOUNT',
type: 'view',
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'REPORT.V_ACCOUNT', schemaName: 'REPORT' },
},
],
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA-views',
'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT',
]);
});
it('prefers the current database schema when bare view nodes keep schema metadata separately', () => {
const target = resolveSidebarLocateTarget({
tabId: 'conn-1-SYSDBA-view-V_ACCOUNT',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
}, { groupBySchema: true });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-schema-REPORT',
children: [
{
key: 'conn-1-SYSDBA-schema-REPORT-views',
children: [
{
key: 'conn-1-SYSDBA-view-REPORT.V_ACCOUNT',
title: 'V_ACCOUNT',
type: 'view',
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'V_ACCOUNT', schemaName: 'REPORT' },
},
],
},
],
},
{
key: 'conn-1-SYSDBA-schema-SYSDBA',
children: [
{
key: 'conn-1-SYSDBA-schema-SYSDBA-views',
children: [
{
key: 'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT',
title: 'V_ACCOUNT',
type: 'view',
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'V_ACCOUNT', schemaName: 'SYSDBA' },
},
],
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'conn-1',
'conn-1-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA',
'conn-1-SYSDBA-schema-SYSDBA-views',
'conn-1-SYSDBA-view-SYSDBA.V_ACCOUNT',
]);
});
it('does not guess a schema-qualified view when no current-schema preference resolves ambiguity', () => {
const target = resolveSidebarLocateTarget({
tabId: 'conn-1-SYSDBA-view-V_ACCOUNT',
connectionId: 'conn-1',
dbName: 'SYSDBA',
tableName: 'V_ACCOUNT',
objectGroup: 'views',
}, { groupBySchema: true });
const tree = [
{
key: 'conn-1',
children: [
{
key: 'conn-1-SYSDBA',
dataRef: { id: 'conn-1', dbName: 'SYSDBA' },
children: [
{
key: 'conn-1-SYSDBA-schema-APP',
children: [
{
key: 'conn-1-SYSDBA-schema-APP-views',
children: [
{
key: 'conn-1-SYSDBA-view-APP.V_ACCOUNT',
type: 'view',
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'APP.V_ACCOUNT', schemaName: 'APP' },
},
],
},
],
},
{
key: 'conn-1-SYSDBA-schema-REPORT',
children: [
{
key: 'conn-1-SYSDBA-schema-REPORT-views',
children: [
{
key: 'conn-1-SYSDBA-view-REPORT.V_ACCOUNT',
type: 'view',
dataRef: { id: 'conn-1', dbName: 'SYSDBA', viewName: 'REPORT.V_ACCOUNT', schemaName: 'REPORT' },
},
],
},
],
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toBeNull();
});
it('finds external SQL file paths from loaded tree data', () => {
const target = resolveSidebarLocateTarget({
filePath: 'C:\\Users\\me\\sql\\report.sql',
objectGroup: 'externalSqlFiles',
}, { groupBySchema: false });
const tree = [
{
key: 'external-sql-root',
type: 'external-sql-root',
children: [
{
key: 'external-sql-directory:C:/Users/me/sql',
type: 'external-sql-directory',
dataRef: { path: 'C:/Users/me/sql' },
children: [
{
key: 'external-sql-file:C:/Users/me/sql/report.sql',
type: 'external-sql-file',
dataRef: { path: 'C:/Users/me/sql/report.sql' },
},
],
},
],
},
];
expect(findSidebarNodePathForLocate(tree, target)).toEqual([
'external-sql-root',
'external-sql-directory:C:/Users/me/sql',
'external-sql-file:C:/Users/me/sql/report.sql',
]);
});
});

View File

@@ -1,14 +1,28 @@
export type SidebarLocateObjectGroup = 'tables' | 'views' | 'materializedViews' | 'triggers' | 'routines';
import { splitQualifiedNameLast } from './qualifiedName';
export interface SidebarLocateObjectRequest {
export type SidebarLocateObjectGroup = 'tables' | 'views' | 'materializedViews' | 'triggers' | 'routines' | 'externalSqlFiles';
export type SidebarLocateDatabaseObjectGroup = Exclude<SidebarLocateObjectGroup, 'externalSqlFiles'>;
export interface SidebarLocateDatabaseObjectRequest {
tabId?: string;
connectionId: string;
dbName: string;
tableName: string;
schemaName?: string;
objectGroup: SidebarLocateObjectGroup;
objectGroup: SidebarLocateDatabaseObjectGroup;
}
export interface SidebarLocateExternalSQLFileRequest {
tabId?: string;
connectionId?: string;
dbName?: string;
filePath: string;
fileName?: string;
objectGroup: 'externalSqlFiles';
}
export type SidebarLocateObjectRequest = SidebarLocateDatabaseObjectRequest | SidebarLocateExternalSQLFileRequest;
export interface SidebarLocateTarget {
connectionKey: string;
databaseKey: string;
@@ -21,10 +35,12 @@ export interface SidebarLocateTarget {
dbName: string;
tableName: string;
schemaName: string;
filePath?: string;
}
export interface SidebarLocateTreeNodeLike {
key: string | number;
title?: unknown;
type?: string;
dataRef?: Record<string, any>;
children?: SidebarLocateTreeNodeLike[];
@@ -39,23 +55,30 @@ export interface SidebarLocateTabLike {
viewName?: string;
viewKind?: string;
triggerName?: string;
triggerTableName?: string;
routineName?: string;
schemaName?: string;
sidebarLocateKey?: string;
filePath?: string;
objectType?: string;
}
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const normalizeLocateName = (value: string): string => toTrimmedString(value).toLowerCase();
const normalizeExternalSQLLocatePath = (value: unknown): string => toTrimmedString(value).replace(/\\/g, '/');
export const splitSidebarQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = toTrimmedString(qualifiedName);
if (!raw) return { schemaName: '', objectName: '' };
const idx = raw.lastIndexOf('.');
if (idx <= 0 || idx >= raw.length - 1) return { schemaName: '', objectName: raw };
const parsed = splitQualifiedNameLast(raw);
return {
schemaName: raw.substring(0, idx).trim(),
objectName: raw.substring(idx + 1).trim(),
schemaName: parsed.parentPath,
objectName: parsed.objectName,
};
};
const inferObjectGroup = (detail: Record<string, unknown>, connectionId: string, dbName: string): SidebarLocateObjectGroup => {
const inferObjectGroup = (detail: Record<string, unknown>, connectionId: string, dbName: string): SidebarLocateDatabaseObjectGroup => {
const explicitGroup = toTrimmedString(detail.objectGroup);
if (explicitGroup === 'views' || explicitGroup === 'view') return 'views';
if (explicitGroup === 'materializedViews' || explicitGroup === 'materialized-view') return 'materializedViews';
@@ -80,6 +103,18 @@ const inferObjectGroup = (detail: Record<string, unknown>, connectionId: string,
export const normalizeSidebarLocateObjectRequest = (detail: unknown): SidebarLocateObjectRequest | null => {
const raw = (detail || {}) as Record<string, unknown>;
const filePath = normalizeExternalSQLLocatePath(raw.filePath);
if (filePath) {
return {
tabId: toTrimmedString(raw.tabId) || undefined,
connectionId: toTrimmedString(raw.connectionId) || undefined,
dbName: toTrimmedString(raw.dbName) || undefined,
filePath,
fileName: toTrimmedString(raw.fileName || raw.title) || undefined,
objectGroup: 'externalSqlFiles',
};
}
const connectionId = toTrimmedString(raw.connectionId);
const dbName = toTrimmedString(raw.dbName);
const tableName = toTrimmedString(raw.tableName || raw.objectName || raw.viewName || raw.triggerName || raw.routineName);
@@ -103,6 +138,17 @@ export const normalizeSidebarLocateObjectRequest = (detail: unknown): SidebarLoc
export const normalizeSidebarLocateObjectRequestFromTab = (tab: SidebarLocateTabLike | null | undefined): SidebarLocateObjectRequest | null => {
if (!tab) return null;
const filePath = normalizeExternalSQLLocatePath(tab.filePath);
if (tab.type === 'query' && filePath) {
return normalizeSidebarLocateObjectRequest({
tabId: tab.id,
connectionId: tab.connectionId,
dbName: tab.dbName,
filePath,
fileName: tab.id,
});
}
const objectName = tab.type === 'view-def'
? toTrimmedString(tab.viewName || tab.tableName)
: tab.type === 'trigger'
@@ -115,13 +161,18 @@ export const normalizeSidebarLocateObjectRequestFromTab = (tab: SidebarLocateTab
}
return normalizeSidebarLocateObjectRequest({
tabId: tab.id,
tabId: toTrimmedString(tab.sidebarLocateKey || tab.id) || undefined,
connectionId: tab.connectionId,
dbName: tab.dbName,
tableName: objectName,
schemaName: tab.schemaName,
objectGroup: tab.type === 'view-def'
? (tab.viewKind === 'materialized' ? 'materializedViews' : 'views')
: (tab.type === 'trigger' ? 'triggers' : (tab.type === 'routine-def' ? 'routines' : undefined)),
: (tab.type === 'trigger'
? 'triggers'
: (tab.type === 'routine-def'
? 'routines'
: (tab.objectType === 'materialized-view' ? 'materializedViews' : (tab.objectType === 'view' ? 'views' : undefined)))),
});
};
@@ -129,6 +180,23 @@ export const resolveSidebarLocateTarget = (
request: SidebarLocateObjectRequest,
options: { groupBySchema: boolean },
): SidebarLocateTarget => {
if (request.objectGroup === 'externalSqlFiles') {
const filePath = normalizeExternalSQLLocatePath(request.filePath);
return {
connectionKey: toTrimmedString(request.connectionId),
databaseKey: request.connectionId && request.dbName ? `${request.connectionId}-${request.dbName}` : '',
targetKey: request.tabId || filePath,
objectGroup: 'externalSqlFiles',
objectGroupKey: 'external-sql-root',
expectedAncestorKeys: ['external-sql-root'],
connectionId: toTrimmedString(request.connectionId),
dbName: toTrimmedString(request.dbName),
tableName: request.fileName || filePath.split('/').filter(Boolean).pop() || filePath,
schemaName: '',
filePath,
};
}
const connectionKey = request.connectionId;
const databaseKey = `${request.connectionId}-${request.dbName}`;
const fallbackTargetKey = request.objectGroup === 'materializedViews'
@@ -188,23 +256,63 @@ export const findSidebarNodePathByKey = (
return null;
};
const matchesLocateObjectName = (target: SidebarLocateTarget, nodeObjectName: string, nodeSchemaName: string): boolean => {
const matchesLocateObjectName = (
target: SidebarLocateTarget,
nodeObjectName: string,
nodeSchemaName: string,
options: { allowUnqualifiedSchemaMatch?: boolean } = {},
): boolean => {
const normalizedNodeName = toTrimmedString(nodeObjectName);
if (!normalizedNodeName) return false;
if (normalizedNodeName === target.tableName) return true;
if (!target.schemaName) return false;
const nodeParsed = splitSidebarQualifiedName(normalizedNodeName);
const targetParsed = splitSidebarQualifiedName(target.tableName);
const nodeObject = nodeParsed.objectName || normalizedNodeName;
const targetObject = targetParsed.objectName || target.tableName;
const resolvedNodeSchema = toTrimmedString(nodeSchemaName) || nodeParsed.schemaName;
return resolvedNodeSchema === target.schemaName && nodeObject === targetObject;
const resolvedTargetSchema = toTrimmedString(target.schemaName) || targetParsed.schemaName;
if (
resolvedTargetSchema
&& !resolvedNodeSchema
&& normalizeLocateName(resolvedTargetSchema) === normalizeLocateName(target.dbName)
&& normalizeLocateName(nodeObject) === normalizeLocateName(targetObject)
) {
return true;
}
if (
options.allowUnqualifiedSchemaMatch
&& !resolvedNodeSchema
&& normalizeLocateName(nodeObject) === normalizeLocateName(targetObject)
) {
return true;
}
if (!resolvedTargetSchema) {
if (options.allowUnqualifiedSchemaMatch) {
return normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
}
return !resolvedNodeSchema && normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
}
return normalizeLocateName(resolvedNodeSchema) === normalizeLocateName(resolvedTargetSchema)
&& normalizeLocateName(nodeObject) === normalizeLocateName(targetObject);
};
const matchesLocateObjectNode = (node: SidebarLocateTreeNodeLike, target: SidebarLocateTarget): boolean => {
const matchesLocateObjectNode = (
node: SidebarLocateTreeNodeLike,
target: SidebarLocateTarget,
options: { allowUnqualifiedSchemaMatch?: boolean } = {},
): boolean => {
const dataRef = node.dataRef || {};
const nodeObjectType = normalizeLocateName(toTrimmedString(dataRef.objectType || dataRef.objectKind));
if (target.objectGroup === 'externalSqlFiles') {
return node.type === 'external-sql-file'
&& normalizeExternalSQLLocatePath(dataRef.path) === normalizeExternalSQLLocatePath(target.filePath);
}
const nodeConnectionId = toTrimmedString(dataRef.id || dataRef.connectionId);
const nodeDbName = toTrimmedString(dataRef.dbName);
@@ -213,27 +321,190 @@ const matchesLocateObjectNode = (node: SidebarLocateTreeNodeLike, target: Sideba
}
if (target.objectGroup === 'views') {
if (node.type !== 'view') return false;
return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName));
if (node.type !== 'view' && nodeObjectType !== 'view' && nodeObjectType !== 'views') return false;
return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName), options);
}
if (target.objectGroup === 'materializedViews') {
if (node.type !== 'materialized-view') return false;
return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName));
if (node.type !== 'materialized-view' && nodeObjectType !== 'materialized-view' && nodeObjectType !== 'materializedviews') return false;
return matchesLocateObjectName(target, toTrimmedString(dataRef.viewName || dataRef.tableName), toTrimmedString(dataRef.schemaName), options);
}
if (target.objectGroup === 'triggers') {
if (node.type !== 'db-trigger') return false;
return matchesLocateObjectName(target, toTrimmedString(dataRef.triggerName || dataRef.tableName), toTrimmedString(dataRef.schemaName));
return matchesLocateObjectName(target, toTrimmedString(dataRef.triggerName || dataRef.tableName), toTrimmedString(dataRef.schemaName), options);
}
if (target.objectGroup === 'routines') {
if (node.type !== 'routine') return false;
return matchesLocateObjectName(target, toTrimmedString(dataRef.routineName || dataRef.tableName), toTrimmedString(dataRef.schemaName));
return matchesLocateObjectName(target, toTrimmedString(dataRef.routineName || dataRef.tableName), toTrimmedString(dataRef.schemaName), options);
}
if (node.type !== 'table') return false;
return matchesLocateObjectName(target, toTrimmedString(dataRef.tableName), toTrimmedString(dataRef.schemaName));
return matchesLocateObjectName(target, toTrimmedString(dataRef.tableName), toTrimmedString(dataRef.schemaName), options);
};
const findSidebarNodePathForLocateByObject = (
nodes: SidebarLocateTreeNodeLike[],
target: SidebarLocateTarget,
options: { allowUnqualifiedSchemaMatch?: boolean } = {},
): string[] | null => {
for (const node of nodes) {
const nodeKey = String(node.key);
if (matchesLocateObjectNode(node, target, options)) {
return [nodeKey];
}
if (node.children) {
const childPath = findSidebarNodePathForLocateByObject(node.children, target, options);
if (childPath) {
return [nodeKey, ...childPath];
}
}
}
return null;
};
const collectSidebarNodePathsForLocateByObject = (
nodes: SidebarLocateTreeNodeLike[],
target: SidebarLocateTarget,
options: { allowUnqualifiedSchemaMatch?: boolean } = {},
ancestorPath: string[] = [],
): string[][] => {
const paths: string[][] = [];
for (const node of nodes) {
const nodeKey = String(node.key);
const path = [...ancestorPath, nodeKey];
if (matchesLocateObjectNode(node, target, options)) {
paths.push(path);
}
if (node.children) {
paths.push(...collectSidebarNodePathsForLocateByObject(node.children, target, options, path));
}
}
return paths;
};
const getVisualNodeObjectName = (
node: SidebarLocateTreeNodeLike,
target: SidebarLocateTarget,
): string => {
const title = toTrimmedString(node.title);
if (title && title !== '[object Object]') return title;
const nodeKey = toTrimmedString(node.key);
const keyPrefixes = target.objectGroup === 'materializedViews'
? [`${target.databaseKey}-materialized-view-`]
: target.objectGroup === 'views'
? [`${target.databaseKey}-view-`]
: target.objectGroup === 'triggers'
? [`${target.databaseKey}-trigger-`]
: target.objectGroup === 'routines'
? [`${target.databaseKey}-routine-`]
: [`${target.databaseKey}-table-`, `${target.databaseKey}-`];
const matchedPrefix = keyPrefixes.find((prefix) => nodeKey.startsWith(prefix));
return matchedPrefix ? nodeKey.slice(matchedPrefix.length) : '';
};
const getLocateObjectGroupPathSuffix = (objectGroup: SidebarLocateObjectGroup): string => {
if (objectGroup === 'externalSqlFiles') return 'external-sql-root';
return objectGroup.toLowerCase();
};
const isPathInsideLocateObjectGroup = (
path: string[],
target: SidebarLocateTarget,
): boolean => {
if (target.objectGroup === 'externalSqlFiles') return false;
const normalizedObjectGroupKey = normalizeLocateName(target.objectGroupKey);
const groupSuffix = getLocateObjectGroupPathSuffix(target.objectGroup);
return path.some((key) => {
const normalizedKey = normalizeLocateName(key);
return normalizedKey === normalizedObjectGroupKey || normalizedKey.endsWith(`-${groupSuffix}`);
});
};
const matchesLocateObjectNodeByVisualIdentity = (
node: SidebarLocateTreeNodeLike,
target: SidebarLocateTarget,
path: string[],
): boolean => {
if (!path.includes(target.databaseKey)) return false;
const nodeObjectType = normalizeLocateName(toTrimmedString(node.dataRef?.objectType || node.dataRef?.objectKind));
const insideExpectedGroup = isPathInsideLocateObjectGroup(path, target);
if (target.objectGroup === 'views' && node.type !== 'view' && nodeObjectType !== 'view' && nodeObjectType !== 'views' && !insideExpectedGroup) return false;
if (target.objectGroup === 'materializedViews' && node.type !== 'materialized-view' && nodeObjectType !== 'materialized-view' && nodeObjectType !== 'materializedviews' && !insideExpectedGroup) return false;
if (target.objectGroup === 'triggers' && node.type !== 'db-trigger' && !insideExpectedGroup) return false;
if (target.objectGroup === 'routines' && node.type !== 'routine' && !insideExpectedGroup) return false;
if (target.objectGroup === 'tables' && node.type !== 'table' && !insideExpectedGroup) return false;
if (target.objectGroup === 'externalSqlFiles') return false;
const schemaName = toTrimmedString(node.dataRef?.schemaName);
return matchesLocateObjectName(target, getVisualNodeObjectName(node, target), schemaName, { allowUnqualifiedSchemaMatch: true });
};
const collectSidebarNodePathsForLocateByVisualIdentity = (
nodes: SidebarLocateTreeNodeLike[],
target: SidebarLocateTarget,
ancestorPath: string[] = [],
): string[][] => {
const paths: string[][] = [];
for (const node of nodes) {
const nodeKey = String(node.key);
const path = [...ancestorPath, nodeKey];
if (matchesLocateObjectNodeByVisualIdentity(node, target, path)) {
paths.push(path);
}
if (node.children) {
paths.push(...collectSidebarNodePathsForLocateByVisualIdentity(node.children, target, path));
}
}
return paths;
};
const hasLocateTargetSchema = (target: SidebarLocateTarget): boolean => {
if (target.objectGroup === 'externalSqlFiles') return true;
return Boolean(toTrimmedString(target.schemaName) || splitSidebarQualifiedName(target.tableName).schemaName);
};
const shouldFallbackViewLocateToTableNode = (target: SidebarLocateTarget): boolean => (
target.objectGroup === 'views' || target.objectGroup === 'materializedViews'
);
const selectPreferredSidebarLocatePath = (
paths: string[][],
target: SidebarLocateTarget,
): string[] | null => {
if (paths.length === 1) return paths[0];
if (paths.length === 0 || target.objectGroup === 'externalSqlFiles') return null;
const targetParsed = splitSidebarQualifiedName(target.tableName);
const targetObjectName = normalizeLocateName(targetParsed.objectName || target.tableName);
const schemaCandidates = [
toTrimmedString(target.schemaName),
targetParsed.schemaName,
target.dbName,
].filter(Boolean);
const normalizedSchemas = Array.from(new Set(schemaCandidates.map(normalizeLocateName)));
for (const normalizedSchema of normalizedSchemas) {
const preferredSchemaKey = `${normalizeLocateName(target.databaseKey)}-schema-${normalizedSchema}`;
const bySchemaGroup = paths.filter((path) =>
path.some((key) => normalizeLocateName(key) === preferredSchemaKey),
);
if (bySchemaGroup.length === 1) return bySchemaGroup[0];
const qualifiedSuffix = `${normalizedSchema}.${targetObjectName}`;
const byQualifiedLeafKey = paths.filter((path) => {
const leafKey = normalizeLocateName(path[path.length - 1] || '');
return leafKey.endsWith(qualifiedSuffix);
});
if (byQualifiedLeafKey.length === 1) return byQualifiedLeafKey[0];
}
return null;
};
export const findSidebarNodePathForLocate = (
@@ -243,18 +514,41 @@ export const findSidebarNodePathForLocate = (
const exactPath = findSidebarNodePathByKey(nodes, target.targetKey);
if (exactPath) return exactPath;
for (const node of nodes) {
const nodeKey = String(node.key);
if (matchesLocateObjectNode(node, target)) {
return [nodeKey];
}
const strictPath = findSidebarNodePathForLocateByObject(nodes, target);
if (strictPath) return strictPath;
if (node.children) {
const childPath = findSidebarNodePathForLocate(node.children, target);
if (childPath) {
return [nodeKey, ...childPath];
}
const visualIdentityPaths = collectSidebarNodePathsForLocateByVisualIdentity(nodes, target);
const visualIdentityPath = selectPreferredSidebarLocatePath(visualIdentityPaths, target);
if (visualIdentityPath) return visualIdentityPath;
if (shouldFallbackViewLocateToTableNode(target)) {
const tableLikeTarget = { ...target, objectGroup: 'tables' as const };
const tableLikePaths = collectSidebarNodePathsForLocateByObject(nodes, tableLikeTarget);
const tableLikePath = selectPreferredSidebarLocatePath(tableLikePaths, target);
if (tableLikePath) return tableLikePath;
const visualTableLikePaths = collectSidebarNodePathsForLocateByVisualIdentity(nodes, tableLikeTarget);
const visualTableLikePath = selectPreferredSidebarLocatePath(visualTableLikePaths, target);
if (visualTableLikePath) return visualTableLikePath;
if (!hasLocateTargetSchema(target)) {
const relaxedTableLikePaths = collectSidebarNodePathsForLocateByObject(
nodes,
tableLikeTarget,
{ allowUnqualifiedSchemaMatch: true },
);
const relaxedTableLikePath = selectPreferredSidebarLocatePath(relaxedTableLikePaths, target);
if (relaxedTableLikePath) return relaxedTableLikePath;
}
}
const relaxedPaths = collectSidebarNodePathsForLocateByObject(
nodes,
target,
{ allowUnqualifiedSchemaMatch: true },
);
const relaxedPath = selectPreferredSidebarLocatePath(relaxedPaths, target);
if (relaxedPath) return relaxedPath;
if (hasLocateTargetSchema(target)) return null;
return null;
};

View File

@@ -0,0 +1,50 @@
import { describe, expect, it } from 'vitest';
import {
buildMySQLCompatibleViewMetadataSqls,
isSidebarViewTableType,
normalizeSidebarViewMetadataEntry,
normalizeSidebarViewName,
resolveSidebarMetadataDialect,
} from './sidebarMetadata';
describe('sidebarMetadata', () => {
it('normalizes MySQL-compatible view names without schema prefixes', () => {
expect(normalizeSidebarViewName('mysql', 'SYSDBA', 'SYSDBA', 'SYSDBA.V_ACCOUNT')).toBe('V_ACCOUNT');
});
it('keeps MySQL-compatible view schema metadata after display-name normalization', () => {
expect(normalizeSidebarViewMetadataEntry('mysql', 'SYSDBA', 'SYSDBA', 'SYSDBA.V_ACCOUNT')).toEqual({
viewName: 'V_ACCOUNT',
schemaName: 'SYSDBA',
});
expect(normalizeSidebarViewMetadataEntry('mysql', 'GDB_APP', 'SYSDBA', 'V_ACCOUNT')).toEqual({
viewName: 'V_ACCOUNT',
schemaName: 'SYSDBA',
});
});
it('uses MySQL metadata queries for custom MySQL-compatible domestic drivers', () => {
expect(resolveSidebarMetadataDialect('goldendb')).toBe('mysql');
expect(resolveSidebarMetadataDialect('custom', 'gdb')).toBe('mysql');
expect(resolveSidebarMetadataDialect('custom', 'goldendb')).toBe('mysql');
expect(resolveSidebarMetadataDialect('custom', 'greatdb')).toBe('mysql');
expect(resolveSidebarMetadataDialect('custom', 'doris')).toBe('mysql');
});
it('accepts MySQL-compatible view type variants returned by domestic databases', () => {
expect(isSidebarViewTableType(undefined)).toBe(true);
expect(isSidebarViewTableType('VIEW')).toBe(true);
expect(isSidebarViewTableType('SYSTEM VIEW')).toBe(true);
expect(isSidebarViewTableType('BASE VIEW')).toBe(true);
expect(isSidebarViewTableType('BASE TABLE')).toBe(false);
expect(isSidebarViewTableType('MATERIALIZED VIEW')).toBe(false);
});
it('adds SHOW FULL TABLES view-only fallbacks for MySQL-compatible databases', () => {
expect(buildMySQLCompatibleViewMetadataSqls('GDB_APP')).toEqual(expect.arrayContaining([
"SHOW FULL TABLES FROM `GDB_APP` WHERE Table_type = 'VIEW'",
"SHOW FULL TABLES WHERE Table_type = 'VIEW'",
]));
});
});

View File

@@ -1,17 +1,9 @@
import { normalizeOceanBaseProtocol } from './oceanBaseProtocol';
import { splitQualifiedNameLast } from './qualifiedName';
import { resolveSqlDialect } from './sqlDialect';
const splitQualifiedName = (qualifiedName: string): { schemaName: string; objectName: string } => {
const raw = String(qualifiedName || '').trim();
if (!raw) return { schemaName: '', objectName: '' };
const idx = raw.lastIndexOf('.');
if (idx <= 0 || idx >= raw.length - 1) {
return { schemaName: '', objectName: raw };
}
return {
schemaName: raw.substring(0, idx),
objectName: raw.substring(idx + 1),
};
};
const escapeSQLLiteral = (raw: string): string => String(raw || '').replace(/'/g, "''");
const escapeBacktickIdentifier = (raw: string): string => String(raw || '').replace(/`/g, '``');
const normalizeSidebarConnectionDialect = (type: string, driver: string, oceanBaseProtocol?: string): string => {
const normalizedType = String(type || '').trim().toLowerCase();
@@ -19,6 +11,7 @@ const normalizeSidebarConnectionDialect = (type: string, driver: string, oceanBa
const normalizedDriver = String(driver || '').trim().toLowerCase();
if (normalizedDriver === 'postgresql' || normalizedDriver === 'postgres' || normalizedDriver === 'pg') return 'postgres';
if (normalizedDriver === 'opengauss' || normalizedDriver === 'open_gauss' || normalizedDriver === 'open-gauss') return 'opengauss';
if (normalizedDriver === 'gaussdb' || normalizedDriver === 'gauss_db' || normalizedDriver === 'gauss-db') return 'gaussdb';
if (normalizedDriver === 'dameng' || normalizedDriver === 'dm' || normalizedDriver === 'dm8') return 'dm';
if (normalizedDriver === 'oceanbase') {
return normalizeOceanBaseProtocol(oceanBaseProtocol) === 'oracle' ? 'oracle' : 'mysql';
@@ -30,10 +23,20 @@ const normalizeSidebarConnectionDialect = (type: string, driver: string, oceanBa
return normalizeOceanBaseProtocol(oceanBaseProtocol) === 'oracle' ? 'oracle' : 'mysql';
}
if (normalizedType === 'open_gauss' || normalizedType === 'open-gauss') return 'opengauss';
if (normalizedType === 'gauss_db' || normalizedType === 'gauss-db') return 'gaussdb';
if (normalizedType === 'dameng') return 'dm';
return normalizedType;
};
export const resolveSidebarMetadataDialect = (type: string, driver = '', oceanBaseProtocol?: unknown): string => {
const dialect = String(resolveSqlDialect(type, driver, { oceanBaseProtocol })).trim().toLowerCase();
if (dialect === 'diros' || dialect === 'sphinx' || dialect === 'mariadb' || dialect === 'oceanbase') {
return 'mysql';
}
if (dialect === 'dameng') return 'dm';
return dialect;
};
export const normalizeSidebarViewName = (dialect: string, dbName: string, schemaName: string, viewName: string): string => {
const normalizedDialect = String(dialect || '').trim().toLowerCase();
const normalizedDbName = String(dbName || '').trim();
@@ -45,7 +48,7 @@ export const normalizeSidebarViewName = (dialect: string, dbName: string, schema
}
if (normalizedDialect === 'mysql') {
const parsed = splitQualifiedName(normalizedViewName);
const parsed = splitQualifiedNameLast(normalizedViewName);
if (parsed.objectName) {
return parsed.objectName;
}
@@ -59,6 +62,51 @@ export const normalizeSidebarViewName = (dialect: string, dbName: string, schema
return `${normalizedSchemaName}.${normalizedViewName}`;
};
export interface SidebarViewMetadataEntry {
viewName: string;
schemaName: string;
}
export const normalizeSidebarViewMetadataEntry = (
dialect: string,
dbName: string,
schemaName: string,
viewName: string,
): SidebarViewMetadataEntry | null => {
const normalizedViewName = normalizeSidebarViewName(dialect, dbName, schemaName, viewName);
if (!normalizedViewName) return null;
const parsedViewName = splitQualifiedNameLast(viewName);
const parsedNormalizedViewName = splitQualifiedNameLast(normalizedViewName);
return {
viewName: normalizedViewName,
schemaName: String(schemaName || parsedNormalizedViewName.parentPath || parsedViewName.parentPath || '').trim(),
};
};
export const isSidebarViewTableType = (tableType: unknown): boolean => {
const normalizedType = String(tableType ?? '').trim().toUpperCase();
if (!normalizedType) return true;
return normalizedType.includes('VIEW') && !normalizedType.includes('MATERIALIZED');
};
export const buildMySQLCompatibleViewMetadataSqls = (dbName: string): string[] => {
const safeDbName = escapeSQLLiteral(dbName);
const dbIdent = escapeBacktickIdentifier(dbName).trim();
return [
safeDbName
? `SELECT TABLE_NAME AS view_name, TABLE_SCHEMA AS schema_name FROM information_schema.views WHERE table_schema = '${safeDbName}' ORDER BY TABLE_NAME`
: '',
safeDbName
? `SELECT TABLE_NAME AS view_name, TABLE_SCHEMA AS schema_name, TABLE_TYPE AS table_type FROM information_schema.tables WHERE table_schema = '${safeDbName}' AND UPPER(TABLE_TYPE) LIKE '%VIEW%' ORDER BY TABLE_NAME`
: '',
dbIdent ? `SHOW FULL TABLES FROM \`${dbIdent}\` WHERE Table_type = 'VIEW'` : '',
dbIdent ? `SHOW FULL TABLES FROM \`${dbIdent}\`` : '',
`SHOW FULL TABLES WHERE Table_type = 'VIEW'`,
`SHOW FULL TABLES`,
].filter(Boolean);
};
export const resolveSidebarRuntimeDatabase = (
type: string,
driver: string,

View File

@@ -0,0 +1,39 @@
export const SIDEBAR_SQL_EDITOR_DRAG_MIME = 'application/x-gonavi-sql-object';
export interface SidebarSqlEditorDragPayload {
text: string;
nodeType?: string;
connectionId?: string;
dbName?: string;
}
export const encodeSidebarSqlEditorDragPayload = (payload: SidebarSqlEditorDragPayload): string =>
JSON.stringify({
text: String(payload.text || '').trim(),
nodeType: payload.nodeType ? String(payload.nodeType) : undefined,
connectionId: payload.connectionId ? String(payload.connectionId) : undefined,
dbName: payload.dbName ? String(payload.dbName) : undefined,
});
export const hasSidebarSqlEditorDragPayload = (dataTransfer: Pick<DataTransfer, 'types'> | null | undefined): boolean => {
const rawTypes = dataTransfer?.types;
if (!rawTypes) return false;
const types = Array.from(rawTypes as any).map((type) => String(type || '').toLowerCase());
return types.includes(SIDEBAR_SQL_EDITOR_DRAG_MIME);
};
export const decodeSidebarSqlEditorDragPayload = (value: string): SidebarSqlEditorDragPayload | null => {
try {
const parsed = JSON.parse(String(value || '')) as SidebarSqlEditorDragPayload;
const text = String(parsed?.text || '').trim();
if (!text) return null;
return {
text,
nodeType: parsed?.nodeType ? String(parsed.nodeType) : undefined,
connectionId: parsed?.connectionId ? String(parsed.connectionId) : undefined,
dbName: parsed?.dbName ? String(parsed.dbName) : undefined,
};
} catch {
return null;
}
};

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest';
import { buildOrderBySQL, buildPaginatedSelectSQL, reverseOrderBySQL } from './sql';
import { buildOrderBySQL, buildPaginatedSelectSQL, quoteQualifiedIdent, reverseOrderBySQL } from './sql';
describe('buildOrderBySQL', () => {
it('does not add fallback ORDER BY for DuckDB without explicit sort', () => {
@@ -52,3 +52,45 @@ describe('reverseOrderBySQL', () => {
.toBe(' ORDER BY COALESCE([a], [b]) DESC, [id] ASC');
});
});
describe('quoteQualifiedIdent', () => {
it('quotes Apache IoTDB device paths with backticks per path segment', () => {
expect(quoteQualifiedIdent('iotdb', 'root.sg.d1'))
.toBe('`root`.`sg`.`d1`');
});
it('keeps RocketMQ topic names as one quoted identifier', () => {
expect(quoteQualifiedIdent('rocketmq', 'orders.events.v1'))
.toBe('"orders.events.v1"');
});
it('keeps MQTT topic filters as one quoted identifier', () => {
expect(quoteQualifiedIdent('mqtt', 'devices/+/telemetry.v1'))
.toBe('"devices/+/telemetry.v1"');
});
it('keeps Kafka topic names as one quoted identifier', () => {
expect(quoteQualifiedIdent('kafka', 'logs.app-1'))
.toBe('"logs.app-1"');
});
it('keeps RabbitMQ queue names as one quoted identifier', () => {
expect(quoteQualifiedIdent('rabbitmq', 'orders.events.v1'))
.toBe('"orders.events.v1"');
});
it('quotes GoldenDB identifiers with MySQL-style backticks', () => {
expect(quoteQualifiedIdent('goldendb', 'ledger.entries'))
.toBe('`ledger`.`entries`');
});
it('does not split dots inside quoted DuckDB identifiers', () => {
expect(quoteQualifiedIdent('duckdb', '"daily.events"."2026.06"'))
.toBe('"daily.events"."2026.06"');
});
it('preserves three-part DuckDB names with quoted dots', () => {
expect(quoteQualifiedIdent('duckdb', '"analytics.catalog"."main.schema"."daily.events"'))
.toBe('"analytics.catalog"."main.schema"."daily.events"');
});
});

View File

@@ -1,3 +1,5 @@
import { splitQualifiedNameSegments, stripIdentifierQuotes } from './qualifiedName';
export type FilterCondition = {
id?: number;
enabled?: boolean;
@@ -8,17 +10,7 @@ export type FilterCondition = {
value2?: string;
};
const normalizeIdentPart = (ident: string) => {
let raw = (ident || '').trim();
if (!raw) return raw;
const first = raw[0];
const last = raw[raw.length - 1];
if ((first === '"' && last === '"') || (first === '`' && last === '`')) {
raw = raw.slice(1, -1).trim();
}
raw = raw.replace(/["`]/g, '').trim();
return raw;
};
const normalizeIdentPart = (ident: string) => stripIdentifierQuotes(ident);
// 检查标识符是否需要引号(包含特殊字符或是保留字)
const needsQuote = (ident: string): boolean => {
@@ -37,12 +29,12 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
if (!raw) return raw;
const dbTypeLower = (dbType || '').toLowerCase();
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'starrocks' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'clickhouse') {
if (dbTypeLower === 'mysql' || dbTypeLower === 'goldendb' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'starrocks' || dbTypeLower === 'sphinx' || dbTypeLower === 'tdengine' || dbTypeLower === 'iotdb' || dbTypeLower === 'clickhouse') {
return `\`${raw.replace(/`/g, '``')}\``;
}
// 对于 KingBase/PostgreSQL只在必要时加引号
if (dbTypeLower === 'kingbase' || dbTypeLower === 'postgres' || dbTypeLower === 'opengauss') {
if (dbTypeLower === 'kingbase' || dbTypeLower === 'postgres' || dbTypeLower === 'opengauss' || dbTypeLower === 'gaussdb') {
if (needsQuote(raw)) {
return `"${raw.replace(/"/g, '""')}"`;
}
@@ -62,9 +54,13 @@ export const quoteIdentPart = (dbType: string, ident: string) => {
export const quoteQualifiedIdent = (dbType: string, ident: string) => {
const raw = (ident || '').trim();
if (!raw) return raw;
const parts = raw.split('.').map(normalizeIdentPart).filter(Boolean);
if (parts.length <= 1) return quoteIdentPart(dbType, raw);
return parts.map(p => quoteIdentPart(dbType, p)).join('.');
if (['rocketmq', 'mqtt', 'kafka', 'rabbitmq'].includes((dbType || '').trim().toLowerCase())) {
return quoteIdentPart(dbType, raw);
}
const parts = splitQualifiedNameSegments(raw).filter(Boolean);
if (parts.length === 0) return quoteIdentPart(dbType, raw);
if (parts.length === 1 && parts[0] === normalizeIdentPart(raw)) return quoteIdentPart(dbType, raw);
return parts.map((part) => quoteIdentPart(dbType, part)).join('.');
};
export const escapeLiteral = (val: string) => (val || '').replace(/'/g, "''");
@@ -153,7 +149,7 @@ export const buildOrderBySQL = (
// 部分数据源在无显式排序需求时强制 ORDER BY即使按主键会显著放大大表预览成本
// MySQL/MariaDB 可能触发 filesort 和 sort memory 错误DuckDB 大文件可能被排序拖到连接超时。
// 因此仅在用户主动点击排序时下发 ORDER BY默认分页查询不加兜底排序。
if (dbTypeLower === 'mysql' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'starrocks' || dbTypeLower === 'duckdb') {
if (dbTypeLower === 'mysql' || dbTypeLower === 'goldendb' || dbTypeLower === 'mariadb' || dbTypeLower === 'oceanbase' || dbTypeLower === 'diros' || dbTypeLower === 'starrocks' || dbTypeLower === 'duckdb') {
return '';
}

View File

@@ -19,6 +19,7 @@ describe('sqlDialect', () => {
it('normalizes datasource aliases without collapsing all dialects to mysql', () => {
expect(resolveSqlDialect('postgresql')).toBe('postgres');
expect(resolveSqlDialect('OpenGauss')).toBe('opengauss');
expect(resolveSqlDialect('GaussDB')).toBe('gaussdb');
expect(resolveSqlDialect('OceanBase')).toBe('oceanbase');
expect(resolveSqlDialect('doris')).toBe('diros');
expect(resolveSqlDialect('StarRocks')).toBe('starrocks');
@@ -28,7 +29,27 @@ describe('sqlDialect', () => {
expect(resolveSqlDialect('custom', 'kingbase8')).toBe('kingbase');
expect(resolveSqlDialect('custom', 'dm8')).toBe('dameng');
expect(resolveSqlDialect('custom', 'mariadb')).toBe('mariadb');
expect(resolveSqlDialect('custom', 'gdb')).toBe('mysql');
expect(resolveSqlDialect('custom', 'goldendb')).toBe('mysql');
expect(resolveSqlDialect('custom', 'greatdb')).toBe('mysql');
expect(resolveSqlDialect('custom', 'open_gauss')).toBe('opengauss');
expect(resolveSqlDialect('custom', 'gauss_db')).toBe('gaussdb');
expect(resolveSqlDialect('Elasticsearch')).toBe('elasticsearch');
expect(resolveSqlDialect('custom', 'elastic')).toBe('elasticsearch');
expect(resolveSqlDialect('ChromaDB')).toBe('chroma');
expect(resolveSqlDialect('custom', 'chroma-db')).toBe('chroma');
expect(resolveSqlDialect('QdrantDB')).toBe('qdrant');
expect(resolveSqlDialect('custom', 'qdrant-db')).toBe('qdrant');
expect(resolveSqlDialect('Apache-IoTDB')).toBe('iotdb');
expect(resolveSqlDialect('custom', 'apache_iotdb')).toBe('iotdb');
expect(resolveSqlDialect('Rocket-MQ')).toBe('rocketmq');
expect(resolveSqlDialect('custom', 'rmq')).toBe('rocketmq');
expect(resolveSqlDialect('MQTTS')).toBe('mqtt');
expect(resolveSqlDialect('custom', 'mqtts')).toBe('mqtt');
expect(resolveSqlDialect('Apache-Kafka')).toBe('kafka');
expect(resolveSqlDialect('custom', 'apache_kafka')).toBe('kafka');
expect(resolveSqlDialect('Rabbit-MQ')).toBe('rabbitmq');
expect(resolveSqlDialect('custom', 'rabbit_mq')).toBe('rabbitmq');
expect(resolveSqlDialect('OceanBase', '', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
expect(resolveSqlDialect('custom', 'oceanbase', { oceanBaseProtocol: 'oracle' })).toBe('oracle');
expect(isMysqlFamilyDialect('mariadb')).toBe(true);
@@ -43,6 +64,7 @@ describe('sqlDialect', () => {
expect(values(resolveColumnTypeOptions('dameng'))).toContain('VARCHAR2(255)');
expect(values(resolveColumnTypeOptions('kingbase'))).toContain('integer');
expect(values(resolveColumnTypeOptions('opengauss'))).toContain('integer');
expect(values(resolveColumnTypeOptions('gaussdb'))).toContain('integer');
expect(values(resolveColumnTypeOptions('oceanbase'))).toContain('varchar(255)');
expect(values(resolveColumnTypeOptions('kingbase'))).not.toContain('tinyint(1)');
expect(values(resolveColumnTypeOptions('diros'))).toContain('LARGEINT');
@@ -51,9 +73,42 @@ describe('sqlDialect', () => {
expect(values(resolveColumnTypeOptions('clickhouse'))).toContain('DateTime64(3)');
expect(values(resolveColumnTypeOptions('iris'))).toContain('varchar(255)');
expect(values(resolveColumnTypeOptions('tdengine'))).toContain('TIMESTAMP');
expect(values(resolveColumnTypeOptions('iotdb'))).toContain('INT64');
expect(values(resolveColumnTypeOptions('duckdb'))).toContain('STRUCT');
});
it('resolves Apache IoTDB completion keywords and functions independently', () => {
expect(resolveSqlKeywords('iotdb')).toEqual(expect.arrayContaining(['ALIGN BY DEVICE', 'SHOW TIMESERIES', 'WITH DATATYPE']));
expect(names(resolveSqlFunctions('iotdb'))).toEqual(expect.arrayContaining(['DATE_BIN', 'DIFF', 'TOP_K']));
expect(resolveSqlKeywords('iotdb')).not.toEqual(expect.arrayContaining(['TAGS', 'USING']));
});
it('resolves RocketMQ completion keywords for topic discovery and consume syntax', () => {
expect(resolveSqlKeywords('rocketmq')).toEqual(expect.arrayContaining(['SHOW TOPICS', 'DESCRIBE TOPIC', 'CONSUME']));
expect(resolveSqlKeywords('rocketmq')).not.toEqual(expect.arrayContaining(['ALIGN BY DEVICE', 'AUTO_INCREMENT']));
});
it('resolves MQTT completion keywords for topic discovery and consume syntax', () => {
expect(resolveSqlKeywords('mqtt')).toEqual(expect.arrayContaining(['SHOW TOPICS', 'DESCRIBE TOPIC', 'CONSUME']));
expect(resolveSqlKeywords('mqtt')).not.toEqual(expect.arrayContaining(['ALIGN BY DEVICE', 'AUTO_INCREMENT']));
});
it('resolves Kafka completion keywords for topic discovery and consume syntax', () => {
expect(resolveSqlKeywords('kafka')).toEqual(expect.arrayContaining(['SHOW TOPICS', 'DESCRIBE TOPIC', 'CONSUME']));
expect(resolveSqlKeywords('kafka')).not.toEqual(expect.arrayContaining(['ALIGN BY DEVICE', 'AUTO_INCREMENT']));
});
it('resolves RabbitMQ completion keywords for queue and exchange discovery', () => {
expect(resolveSqlKeywords('rabbitmq')).toEqual(expect.arrayContaining(['SHOW VHOSTS', 'SHOW QUEUES', 'SHOW EXCHANGES', 'DESCRIBE QUEUE']));
expect(resolveSqlKeywords('rabbitmq')).not.toEqual(expect.arrayContaining(['ALIGN BY DEVICE', 'AUTO_INCREMENT']));
});
it('resolves GaussDB completion keywords and functions as a PostgreSQL-like dialect', () => {
expect(resolveSqlKeywords('gaussdb')).toEqual(expect.arrayContaining(['RETURNING', 'SERIAL', 'JSONB']));
expect(names(resolveSqlFunctions('gaussdb'))).toEqual(expect.arrayContaining(['STRING_AGG', 'TO_CHAR', 'CURRENT_DATABASE']));
expect(resolveSqlKeywords('gaussdb')).not.toEqual(expect.arrayContaining(['AUTO_INCREMENT', 'CHANGE']));
});
it('resolves oracle completion keywords and functions without mysql-only suggestions', () => {
expect(resolveSqlKeywords('oracle')).toEqual(expect.arrayContaining(['ROWNUM', 'FETCH', 'VARCHAR2', 'NUMBER']));
expect(resolveSqlKeywords('oracle')).not.toEqual(expect.arrayContaining(['AUTO_INCREMENT', 'CHANGE', 'LIMIT']));

View File

@@ -20,6 +20,7 @@ export type SqlDialect =
| 'highgo'
| 'vastbase'
| 'opengauss'
| 'gaussdb'
| 'oracle'
| 'dameng'
| 'sqlserver'
@@ -28,8 +29,16 @@ export type SqlDialect =
| 'duckdb'
| 'clickhouse'
| 'tdengine'
| 'iotdb'
| 'rocketmq'
| 'mqtt'
| 'kafka'
| 'rabbitmq'
| 'mongodb'
| 'redis'
| 'elasticsearch'
| 'chroma'
| 'qdrant'
| 'unknown'
| string;
@@ -66,6 +75,10 @@ export const resolveSqlDialect = (
case 'open_gauss':
case 'open-gauss':
return 'opengauss';
case 'gaussdb':
case 'gauss_db':
case 'gauss-db':
return 'gaussdb';
case 'mssql':
case 'sql_server':
case 'sql-server':
@@ -94,6 +107,10 @@ export const resolveSqlDialect = (
case 'kingbasees':
case 'kingbasev8':
return 'kingbase';
case 'gdb':
case 'goldendb':
case 'greatdb':
return 'mysql';
case 'mariadb':
case 'oceanbase':
case 'mysql':
@@ -105,17 +122,52 @@ export const resolveSqlDialect = (
case 'duckdb':
case 'clickhouse':
case 'tdengine':
case 'iotdb':
case 'mongodb':
case 'redis':
case 'elasticsearch':
return source;
case 'elastic':
return 'elasticsearch';
case 'chromadb':
case 'chroma-db':
case 'chroma':
return 'chroma';
case 'qdrantdb':
case 'qdrant-db':
case 'qdrant':
return 'qdrant';
case 'apache-iotdb':
case 'apache_iotdb':
return 'iotdb';
case 'rocketmq':
case 'rocket-mq':
case 'rocket_mq':
case 'apache-rocketmq':
case 'apache_rocketmq':
case 'rmq':
return 'rocketmq';
case 'mqtt':
case 'mqtts':
return 'mqtt';
case 'kafka':
case 'apache-kafka':
case 'apache_kafka':
return 'kafka';
case 'rabbitmq':
case 'rabbit-mq':
case 'rabbit_mq':
return 'rabbitmq';
default:
break;
}
if (source.includes('opengauss') || source.includes('open_gauss') || source.includes('open-gauss')) return 'opengauss';
if (source.includes('gaussdb') || source.includes('gauss_db') || source.includes('gauss-db')) return 'gaussdb';
if (source.includes('postgres')) return 'postgres';
if (source.includes('oceanbase')) return 'oceanbase';
if (source.includes('mariadb')) return 'mariadb';
if (source.includes('goldendb') || source.includes('greatdb')) return 'mysql';
if (source.includes('mysql')) return 'mysql';
if (source.includes('doris') || source.includes('diros')) return 'diros';
if (source.includes('starrocks')) return 'starrocks';
@@ -129,8 +181,16 @@ export const resolveSqlDialect = (
if (source.includes('duckdb')) return 'duckdb';
if (source.includes('clickhouse')) return 'clickhouse';
if (source.includes('tdengine')) return 'tdengine';
if (source.includes('iotdb')) return 'iotdb';
if (source.includes('rocketmq') || source.includes('rocket-mq') || source.includes('rocket_mq') || source === 'rmq') return 'rocketmq';
if (source.includes('mqtt')) return 'mqtt';
if (source.includes('kafka')) return 'kafka';
if (source.includes('rabbitmq') || source.includes('rabbit-mq') || source.includes('rabbit_mq')) return 'rabbitmq';
if (source.includes('sqlserver') || source.includes('mssql')) return 'sqlserver';
if (source.includes('iris') || source.includes('intersystems')) return 'iris';
if (source.includes('elastic')) return 'elasticsearch';
if (source.includes('chroma')) return 'chroma';
if (source.includes('qdrant')) return 'qdrant';
return source;
};
@@ -140,7 +200,7 @@ export const isMysqlFamilyDialect = (dbType: string): boolean => (
);
export const isPgLikeDialect = (dbType: string): boolean => (
['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss'].includes(resolveSqlDialect(dbType))
['postgres', 'kingbase', 'highgo', 'vastbase', 'opengauss', 'gaussdb'].includes(resolveSqlDialect(dbType))
);
export const isOracleLikeDialect = (dbType: string): boolean => (
@@ -150,7 +210,7 @@ export const isOracleLikeDialect = (dbType: string): boolean => (
export const isSqlServerDialect = (dbType: string): boolean => resolveSqlDialect(dbType) === 'sqlserver';
export const isBacktickIdentifierDialect = (dbType: string): boolean => (
isMysqlFamilyDialect(dbType) || ['clickhouse', 'tdengine'].includes(resolveSqlDialect(dbType))
isMysqlFamilyDialect(dbType) || ['clickhouse', 'tdengine', 'iotdb'].includes(resolveSqlDialect(dbType))
);
const stripIdentifierQuotes = (part: string): string => {
@@ -449,6 +509,19 @@ const TDENGINE_TYPES = optionValues([
'GEOMETRY',
]);
const IOTDB_TYPES = optionValues([
'BOOLEAN',
'INT32',
'INT64',
'FLOAT',
'DOUBLE',
'TEXT',
'STRING',
'BLOB',
'TIMESTAMP',
'DATE',
]);
const DUCKDB_TYPES = optionValues([
'BOOLEAN',
'TINYINT',
@@ -493,6 +566,7 @@ export const resolveColumnTypeOptions = (dbType: string): ColumnTypeOption[] =>
if (dialect === 'duckdb') return DUCKDB_TYPES;
if (dialect === 'clickhouse') return CLICKHOUSE_TYPES;
if (dialect === 'tdengine') return TDENGINE_TYPES;
if (dialect === 'iotdb') return IOTDB_TYPES;
return COMMON_TYPES;
};
@@ -544,6 +618,67 @@ const STARROCKS_KEYWORDS = [
const TDENGINE_KEYWORDS = ['LIMIT', 'SLIMIT', 'SOFFSET', 'TAGS', 'USING', 'INTERVAL', 'FILL', 'PARTITION BY'];
const IOTDB_KEYWORDS = [
'LIMIT',
'OFFSET',
'ALIGN BY DEVICE',
'DISABLE ALIGN',
'GROUP BY',
'LEVEL',
'FILL',
'SLIMIT',
'SOFFSET',
'CREATE TIMESERIES',
'SHOW TIMESERIES',
'SHOW DEVICES',
'SHOW DATABASES',
'STORAGE GROUP',
'WITH DATATYPE',
'ENCODING',
'COMPRESSION',
];
const ROCKETMQ_KEYWORDS = [
'SHOW TOPICS',
'DESCRIBE TOPIC',
'CONSUME',
'FROM',
'LIMIT',
'OFFSET',
];
const MQTT_KEYWORDS = [
'SHOW TOPICS',
'DESCRIBE TOPIC',
'CONSUME',
'FROM',
'LIMIT',
'OFFSET',
];
const KAFKA_KEYWORDS = [
'SHOW TOPICS',
'SHOW TOPIC',
'DESCRIBE TOPIC',
'CONSUME',
'GROUP',
'FROM',
'LIMIT',
'OFFSET',
];
const RABBITMQ_KEYWORDS = [
'SHOW VHOSTS',
'SHOW QUEUES',
'SHOW EXCHANGES',
'DESCRIBE QUEUE',
'DESCRIBE EXCHANGE',
'CONSUME',
'FROM',
'LIMIT',
'OFFSET',
];
export const resolveSqlKeywords = (dbType: string): string[] => {
const dialect = resolveSqlDialect(dbType);
if (dialect === 'starrocks') return unique([...COMMON_KEYWORDS, ...MYSQL_KEYWORDS, ...STARROCKS_KEYWORDS]);
@@ -555,6 +690,11 @@ export const resolveSqlKeywords = (dbType: string): string[] => {
if (dialect === 'duckdb') return unique([...COMMON_KEYWORDS, ...DUCKDB_KEYWORDS]);
if (dialect === 'clickhouse') return unique([...COMMON_KEYWORDS, ...CLICKHOUSE_KEYWORDS]);
if (dialect === 'tdengine') return unique([...COMMON_KEYWORDS, ...TDENGINE_KEYWORDS]);
if (dialect === 'iotdb') return unique([...COMMON_KEYWORDS, ...IOTDB_KEYWORDS]);
if (dialect === 'rocketmq') return unique([...COMMON_KEYWORDS, ...ROCKETMQ_KEYWORDS]);
if (dialect === 'mqtt') return unique([...COMMON_KEYWORDS, ...MQTT_KEYWORDS]);
if (dialect === 'kafka') return unique([...COMMON_KEYWORDS, ...KAFKA_KEYWORDS]);
if (dialect === 'rabbitmq') return unique([...COMMON_KEYWORDS, ...RABBITMQ_KEYWORDS]);
return COMMON_KEYWORDS;
};
@@ -790,6 +930,19 @@ const TDENGINE_FUNCTIONS = [
fn('IRATE', vendorDetail('TDengine', 'query_editor.completion.action.instant_rate_of_change')),
];
const IOTDB_FUNCTIONS = [
fn('NOW', vendorDetail('IoTDB', 'query_editor.completion.action.current_time')),
fn('DATE_BIN', vendorDetail('IoTDB', 'query_editor.completion.action.date_truncation')),
fn('DIFF', vendorDetail('IoTDB', 'query_editor.completion.action.time_difference')),
fn('TIME_DIFFERENCE', vendorDetail('IoTDB', 'query_editor.completion.action.time_difference')),
fn('DERIVATIVE', vendorDetail('IoTDB', 'query_editor.completion.action.rate_of_change')),
fn('NON_NEGATIVE_DERIVATIVE', vendorDetail('IoTDB', 'query_editor.completion.action.rate_of_change')),
fn('TOP_K', vendorDetail('IoTDB', 'query_editor.completion.action.maximum')),
fn('BOTTOM_K', vendorDetail('IoTDB', 'query_editor.completion.action.minimum')),
fn('M4', vendorDetail('IoTDB', 'query_editor.completion.action.approximate_quantile')),
fn('EQUAL_SIZE_BUCKET_RANDOM_SAMPLE', vendorDetail('IoTDB', 'query_editor.completion.action.random_number')),
];
const mergeFunctions = (items: SqlFunctionDefinition[]): SqlFunctionCompletion[] => {
const seen = new Set<string>();
const result: SqlFunctionCompletion[] = [];
@@ -816,5 +969,6 @@ export const resolveSqlFunctions = (dbType: string): SqlFunctionCompletion[] =>
if (dialect === 'duckdb') return mergeFunctions([...COMMON_FUNCTIONS, ...DUCKDB_FUNCTIONS]);
if (dialect === 'clickhouse') return mergeFunctions([...COMMON_FUNCTIONS, ...CLICKHOUSE_FUNCTIONS]);
if (dialect === 'tdengine') return mergeFunctions([...COMMON_FUNCTIONS, ...TDENGINE_FUNCTIONS]);
if (dialect === 'iotdb') return mergeFunctions([...COMMON_FUNCTIONS, ...IOTDB_FUNCTIONS]);
return mergeFunctions(COMMON_FUNCTIONS);
};

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import {
resolveSqlEditorOperationKeyword,
shouldUseSqlEditorManagedTransaction,
} from './sqlEditorTransaction';
describe('sqlEditorTransaction', () => {
it('keeps regular DML in a managed transaction', () => {
expect(shouldUseSqlEditorManagedTransaction(['UPDATE users SET name = "n" WHERE id = 1'])).toBe(true);
expect(shouldUseSqlEditorManagedTransaction(['INSERT INTO users(id) VALUES (1)'])).toBe(true);
expect(shouldUseSqlEditorManagedTransaction(['DELETE FROM users WHERE id = 1'])).toBe(true);
});
it('classifies WITH statements by their top-level operation', () => {
expect(resolveSqlEditorOperationKeyword('WITH target AS (SELECT id FROM users) SELECT * FROM target')).toBe('select');
expect(resolveSqlEditorOperationKeyword('WITH target AS (SELECT id FROM users) UPDATE users SET synced = 1')).toBe('update');
expect(resolveSqlEditorOperationKeyword('WITH target AS (SELECT id FROM users) DELETE FROM users WHERE id IN (SELECT id FROM target)')).toBe('delete');
});
it('uses managed transactions for WITH DML but not WITH SELECT', () => {
expect(shouldUseSqlEditorManagedTransaction([
'WITH target AS (SELECT id FROM users) UPDATE users SET synced = 1 WHERE id IN (SELECT id FROM target)',
])).toBe(true);
expect(shouldUseSqlEditorManagedTransaction([
'WITH target AS (SELECT id FROM users) SELECT * FROM target',
])).toBe(false);
});
it('uses managed transactions for data-changing CTEs even when the top-level operation is SELECT', () => {
const sql = 'WITH moved AS (DELETE FROM audit_logs WHERE created_at < NOW() RETURNING id) SELECT * FROM moved';
expect(resolveSqlEditorOperationKeyword(sql)).toBe('select');
expect(shouldUseSqlEditorManagedTransaction([sql])).toBe(true);
});
it('does not wrap user-authored explicit transactions', () => {
expect(shouldUseSqlEditorManagedTransaction([
'BEGIN',
'UPDATE users SET name = "n" WHERE id = 1',
'COMMIT',
])).toBe(false);
expect(shouldUseSqlEditorManagedTransaction([
'START TRANSACTION',
'DELETE FROM users WHERE id = 1',
])).toBe(false);
});
});

View File

@@ -0,0 +1,267 @@
const SQL_EDITOR_DML_KEYWORDS = new Set(['insert', 'update', 'delete', 'replace', 'merge', 'upsert']);
const SQL_EDITOR_READ_KEYWORDS = new Set(['select', 'with', 'show', 'describe', 'desc', 'explain', 'pragma', 'values']);
const SQL_EDITOR_TRANSACTION_CONTROL_KEYWORDS = new Set(['begin', 'commit', 'rollback', 'savepoint', 'release']);
type SqlEditorWithAnalysis = {
keyword: string;
cteHasManagedWrite: boolean;
};
const isSqlEditorKeywordChar = (char: string | undefined): boolean => !!char && /[A-Za-z0-9_]/.test(char);
const skipSqlEditorTrivia = (text: string, start: number): number => {
let pos = start;
while (pos < text.length) {
const char = text[pos];
if (/\s/.test(char || '')) {
pos++;
continue;
}
if (text.startsWith('--', pos) || text.startsWith('#', pos)) {
const nextLine = text.indexOf('\n', pos);
if (nextLine < 0) return text.length;
pos = nextLine + 1;
continue;
}
if (text.startsWith('/*', pos)) {
const blockEnd = text.indexOf('*/', pos + 2);
if (blockEnd < 0) return text.length;
pos = blockEnd + 2;
continue;
}
return pos;
}
return pos;
};
const readSqlEditorKeyword = (text: string, start: number): { keyword: string; end: number } => {
const pos = skipSqlEditorTrivia(text, start);
if (!isSqlEditorKeywordChar(text[pos])) {
return { keyword: '', end: pos };
}
let end = pos + 1;
while (isSqlEditorKeywordChar(text[end])) {
end++;
}
return { keyword: text.slice(pos, end).toLowerCase(), end };
};
const skipSqlEditorDelimited = (text: string, start: number, delimiter: string): number => {
let pos = start + 1;
while (pos < text.length) {
if (text[pos] === delimiter) {
if (text[pos + 1] === delimiter) {
pos += 2;
continue;
}
return pos + 1;
}
pos++;
}
return text.length;
};
const resolveSqlEditorDollarQuoteTag = (text: string, start: number): string => {
if (text[start] !== '$') return '';
let end = start + 1;
while (isSqlEditorKeywordChar(text[end])) {
end++;
}
return text[end] === '$' ? text.slice(start, end + 1) : '';
};
const skipSqlEditorQuotedOrComment = (text: string, start: number): number | null => {
if (text.startsWith('--', start) || text.startsWith('#', start)) {
const nextLine = text.indexOf('\n', start);
return nextLine < 0 ? text.length : nextLine + 1;
}
if (text.startsWith('/*', start)) {
const blockEnd = text.indexOf('*/', start + 2);
return blockEnd < 0 ? text.length : blockEnd + 2;
}
const char = text[start];
if (char === '\'' || char === '"' || char === '`') {
return skipSqlEditorDelimited(text, start, char);
}
if (char === '[') {
const bracketEnd = text.indexOf(']', start + 1);
return bracketEnd < 0 ? text.length : bracketEnd + 1;
}
const dollarTag = resolveSqlEditorDollarQuoteTag(text, start);
if (dollarTag) {
const dollarEnd = text.indexOf(dollarTag, start + dollarTag.length);
return dollarEnd < 0 ? text.length : dollarEnd + dollarTag.length;
}
return null;
};
const skipBalancedSqlEditorParens = (text: string, start: number): number => {
if (text[start] !== '(') return -1;
let depth = 0;
let pos = start;
while (pos < text.length) {
const skipped = skipSqlEditorQuotedOrComment(text, pos);
if (skipped !== null) {
pos = skipped;
continue;
}
if (text[pos] === '(') {
depth++;
pos++;
continue;
}
if (text[pos] === ')') {
depth--;
pos++;
if (depth === 0) return pos;
continue;
}
pos++;
}
return -1;
};
const skipSqlEditorIdentifierToken = (text: string, start: number): number => {
if (start >= text.length) return -1;
const char = text[start];
if (char === '"' || char === '`') return skipSqlEditorDelimited(text, start, char);
if (char === '[') {
const bracketEnd = text.indexOf(']', start + 1);
return bracketEnd < 0 ? text.length : bracketEnd + 1;
}
if (!isSqlEditorKeywordChar(char)) return -1;
let end = start + 1;
while (isSqlEditorKeywordChar(text[end])) {
end++;
}
return end;
};
const findTopLevelSqlEditorKeyword = (text: string, start: number, keyword: string): number => {
let depth = 0;
let pos = start;
while (pos < text.length) {
const skipped = skipSqlEditorQuotedOrComment(text, pos);
if (skipped !== null) {
pos = skipped;
continue;
}
if (text[pos] === '(') {
depth++;
pos++;
continue;
}
if (text[pos] === ')') {
if (depth > 0) depth--;
pos++;
continue;
}
if (depth === 0 && isSqlEditorKeywordChar(text[pos])) {
let end = pos + 1;
while (isSqlEditorKeywordChar(text[end])) {
end++;
}
if (text.slice(pos, end).toLowerCase() === keyword) {
return end;
}
pos = end;
continue;
}
pos++;
}
return -1;
};
const resolveSqlEditorWithAnalysis = (text: string, start: number): SqlEditorWithAnalysis => {
let pos = skipSqlEditorTrivia(text, start);
let cteHasManagedWrite = false;
const recursive = readSqlEditorKeyword(text, pos);
if (recursive.keyword === 'recursive') {
pos = recursive.end;
}
while (pos < text.length) {
pos = skipSqlEditorTrivia(text, pos);
const identifierEnd = skipSqlEditorIdentifierToken(text, pos);
if (identifierEnd < 0) return { keyword: '', cteHasManagedWrite };
pos = skipSqlEditorTrivia(text, identifierEnd);
if (text[pos] === '(') {
const columnsEnd = skipBalancedSqlEditorParens(text, pos);
if (columnsEnd < 0) return { keyword: '', cteHasManagedWrite };
pos = skipSqlEditorTrivia(text, columnsEnd);
}
const asEnd = findTopLevelSqlEditorKeyword(text, pos, 'as');
if (asEnd < 0) return { keyword: '', cteHasManagedWrite };
pos = skipSqlEditorTrivia(text, asEnd);
const materialized = readSqlEditorKeyword(text, pos);
if (materialized.keyword === 'not') {
const next = readSqlEditorKeyword(text, materialized.end);
if (next.keyword === 'materialized') {
pos = next.end;
}
} else if (materialized.keyword === 'materialized') {
pos = materialized.end;
}
pos = skipSqlEditorTrivia(text, pos);
if (text[pos] !== '(') return { keyword: '', cteHasManagedWrite };
const cteBodyStart = pos + 1;
const cteEnd = skipBalancedSqlEditorParens(text, pos);
if (cteEnd < 0) return { keyword: '', cteHasManagedWrite };
const cteBody = text.slice(cteBodyStart, Math.max(cteBodyStart, cteEnd - 1));
if (sqlEditorStatementHasManagedWrite(cteBody)) {
cteHasManagedWrite = true;
}
pos = skipSqlEditorTrivia(text, cteEnd);
if (text[pos] === ',') {
pos++;
continue;
}
return { keyword: readSqlEditorKeyword(text, pos).keyword, cteHasManagedWrite };
}
return { keyword: '', cteHasManagedWrite };
};
export const resolveSqlEditorOperationKeyword = (statement: string): string => {
const text = String(statement || '');
const leading = readSqlEditorKeyword(text, 0);
if (leading.keyword !== 'with') {
return leading.keyword;
}
return resolveSqlEditorWithAnalysis(text, leading.end).keyword || leading.keyword;
};
const sqlEditorStatementHasManagedWrite = (statement: string): boolean => {
const text = String(statement || '');
const leading = readSqlEditorKeyword(text, 0);
if (leading.keyword === 'with') {
const analysis = resolveSqlEditorWithAnalysis(text, leading.end);
return analysis.cteHasManagedWrite || SQL_EDITOR_DML_KEYWORDS.has(analysis.keyword);
}
return SQL_EDITOR_DML_KEYWORDS.has(leading.keyword);
};
const isSqlEditorTransactionControlStatement = (statement: string): boolean => {
const keyword = readSqlEditorKeyword(String(statement || ''), 0).keyword;
if (SQL_EDITOR_TRANSACTION_CONTROL_KEYWORDS.has(keyword)) return true;
return keyword === 'start' && /\btransaction\b/i.test(statement);
};
export const shouldUseSqlEditorManagedTransaction = (statements: string[]): boolean => {
let hasManagedWrite = false;
for (const statement of statements) {
const trimmed = String(statement || '').trim();
if (!trimmed) continue;
if (isSqlEditorTransactionControlStatement(trimmed)) return false;
if (sqlEditorStatementHasManagedWrite(trimmed)) {
hasManagedWrite = true;
continue;
}
const keyword = resolveSqlEditorOperationKeyword(trimmed);
if (SQL_EDITOR_READ_KEYWORDS.has(keyword)) continue;
return false;
}
return hasManagedWrite;
};

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import { formatSqlExecutionError } from './sqlErrorSemantics';
describe('formatSqlExecutionError', () => {
it('adds Chinese semantic explanation for SQL syntax errors and keeps raw text', () => {
const formatted = formatSqlExecutionError('pq: syntax error at or near "from"');
expect(formatted).toContain('中文语义SQL 语法错误');
expect(formatted).toContain('处理建议:');
expect(formatted).toContain('原始错误pq: syntax error at or near "from"');
});
it('recognizes missing table errors', () => {
const formatted = formatSqlExecutionError('ERROR: relation "orders" does not exist');
expect(formatted).toContain('中文语义:表或对象不存在');
expect(formatted).toContain('原始错误ERROR: relation "orders" does not exist');
});
it('recognizes duplicate key errors with statement prefix', () => {
const formatted = formatSqlExecutionError('Duplicate entry "1" for key "PRIMARY"', {
prefix: '第 2 条语句执行失败:',
});
expect(formatted.startsWith('第 2 条语句执行失败:\n中文语义唯一约束或主键冲突')).toBe(true);
expect(formatted).toContain('原始错误Duplicate entry "1" for key "PRIMARY"');
});
it('falls back to a generic database execution error', () => {
const formatted = formatSqlExecutionError('driver returned unexpected status 123');
expect(formatted).toContain('中文语义:数据库执行错误');
expect(formatted).toContain('原始错误driver returned unexpected status 123');
});
it('does not format an already formatted message again', () => {
const raw = [
'中文语义SQL 语法错误。通常是关键字、逗号、括号、引号、语句顺序或当前数据库方言不匹配。',
'处理建议:检查报错位置附近的 SQL 片段,并确认当前连接的数据源类型与 SQL 方言一致。',
'原始错误pq: syntax error at or near "from"',
].join('\n');
expect(formatSqlExecutionError(raw)).toBe(raw);
});
});

View File

@@ -0,0 +1,177 @@
export type SqlExecutionErrorFormatOptions = {
prefix?: string;
};
type SqlErrorSemanticRule = {
label: string;
explanation: string;
suggestion: string;
patterns: RegExp[];
};
const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
{
label: 'SQL 语法错误',
explanation: '通常是关键字、逗号、括号、引号、语句顺序或当前数据库方言不匹配。',
suggestion: '检查报错位置附近的 SQL 片段,并确认当前连接的数据源类型与 SQL 方言一致。',
patterns: [
/syntax error/i,
/sql syntax/i,
/sqlstate\s*42601/i,
/near\s+["'`].+["'`]\s*:?\s*syntax error/i,
/ora-00933/i,
/ora-00936/i,
/you have an error in your sql syntax/i,
],
},
{
label: '表或对象不存在',
explanation: 'SQL 引用了当前库或 schema 中找不到的表、视图、序列或其他数据库对象。',
suggestion: '确认对象名称、大小写、schema/database 前缀,以及当前查询所选数据库是否正确。',
patterns: [
/relation\s+["'`].+["'`]\s+does not exist/i,
/table\s+.+doesn'?t exist/i,
/no such table/i,
/invalid object name/i,
/ora-00942/i,
/object\s+.+does not exist/i,
],
},
{
label: '字段不存在',
explanation: 'SQL 引用了结果集中不存在、拼写不一致或当前表没有的字段。',
suggestion: '检查字段名、别名、大小写、引用表别名,以及字段是否属于当前 FROM/JOIN 的对象。',
patterns: [
/column\s+["'`].+["'`]\s+does not exist/i,
/unknown column/i,
/invalid column name/i,
/ora-00904/i,
/no such column/i,
],
},
{
label: '唯一约束或主键冲突',
explanation: '插入或更新的数据与唯一索引、主键或唯一约束中的已有数据重复。',
suggestion: '检查重复键值,必要时改为 UPDATE、UPSERT或调整唯一键字段值。',
patterns: [
/duplicate key/i,
/duplicate entry/i,
/unique constraint failed/i,
/violates unique constraint/i,
/ora-00001/i,
],
},
{
label: '权限不足',
explanation: '当前数据库账号没有执行该 SQL 或访问相关对象的权限。',
suggestion: '确认账号权限、schema 授权、只读连接限制,以及是否需要由管理员授权。',
patterns: [
/permission denied/i,
/access denied/i,
/not authorized/i,
/insufficient privileges/i,
/ora-01031/i,
],
},
{
label: '数据类型或格式不匹配',
explanation: '写入、比较或转换的数据格式不符合目标字段或表达式要求。',
suggestion: '检查日期、数字、布尔值、枚举值、隐式转换和字段类型,必要时显式 CAST。',
patterns: [
/invalid input syntax/i,
/incorrect\s+.+\s+value/i,
/data truncated/i,
/truncated incorrect/i,
/conversion failed/i,
/invalid number/i,
/ora-01722/i,
],
},
{
label: '约束校验失败',
explanation: '数据不满足外键、非空、检查约束或引用完整性规则。',
suggestion: '检查关联父表记录、必填字段、CHECK 条件,以及写入顺序是否正确。',
patterns: [
/foreign key constraint/i,
/violates foreign key constraint/i,
/cannot be null/i,
/not null constraint failed/i,
/check constraint/i,
/constraint failed/i,
],
},
{
label: '查询超时或被取消',
explanation: 'SQL 执行时间超过超时限制,或执行过程被手动取消。',
suggestion: '检查 SQL 执行计划、过滤条件和索引,必要时缩小查询范围或调整超时时间。',
patterns: [
/context deadline exceeded/i,
/statement canceled/i,
/statement cancelled/i,
/context canceled/i,
/context cancelled/i,
/timeout/i,
/timed out/i,
],
},
{
label: '数据库连接或认证失败',
explanation: '客户端无法连接数据库,或认证信息、网络、实例状态存在问题。',
suggestion: '检查主机、端口、账号密码、网络连通性、代理/SSH 隧道和数据库服务状态。',
patterns: [
/password authentication failed/i,
/connection refused/i,
/no route to host/i,
/server has gone away/i,
/too many connections/i,
/connection reset/i,
/connection timeout/i,
],
},
];
const normalizeErrorText = (raw: unknown): string => {
if (raw instanceof Error) {
return raw.message || String(raw);
}
if (typeof raw === 'string') {
return raw;
}
if (raw == null) {
return '';
}
try {
return JSON.stringify(raw);
} catch {
return String(raw);
}
};
const findSqlErrorSemantic = (message: string): SqlErrorSemanticRule | null => {
const text = String(message || '');
return SQL_ERROR_RULES.find((rule) => rule.patterns.some((pattern) => pattern.test(text))) || null;
};
export const formatSqlExecutionError = (
raw: unknown,
options: SqlExecutionErrorFormatOptions = {},
): string => {
const rawMessage = normalizeErrorText(raw).trim() || '未知错误';
if (/中文语义:/.test(rawMessage) && /原始错误:/.test(rawMessage)) {
return rawMessage;
}
const semantic = findSqlErrorSemantic(rawMessage) || {
label: '数据库执行错误',
explanation: '数据库返回了执行失败信息,当前未匹配到更具体的错误类型。',
suggestion: '结合原始错误、SQL 片段和当前数据库方言继续排查。',
};
const prefix = String(options.prefix || '').trim();
return [
prefix,
`中文语义:${semantic.label}${semantic.explanation}`,
`处理建议:${semantic.suggestion}`,
`原始错误:${rawMessage}`,
].filter(Boolean).join('\n');
};

View File

@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';
import {
getSQLFileTabPath,
hasSQLFileTabUnsavedChanges,
isSQLFileMissingErrorMessage,
isSQLFileMissingReadResult,
isSQLFileQueryTab,
normalizeSQLFileReadContent,
} from './sqlFileTabDirty';
describe('sqlFileTabDirty', () => {
it('only treats query tabs with filePath as SQL file tabs', () => {
expect(isSQLFileQueryTab({ type: 'query', filePath: '/tmp/a.sql' })).toBe(true);
expect(isSQLFileQueryTab({ type: 'query', filePath: ' ' })).toBe(false);
expect(isSQLFileQueryTab({ type: 'table', filePath: '/tmp/a.sql' } as any)).toBe(false);
expect(getSQLFileTabPath({ type: 'query', filePath: ' /tmp/a.sql ' })).toBe('/tmp/a.sql');
});
it('normalizes old and new SQL file read payloads', () => {
expect(normalizeSQLFileReadContent('select 1;')).toBe('select 1;');
expect(normalizeSQLFileReadContent({ content: 'select 2;', filePath: '/tmp/a.sql' })).toBe('select 2;');
expect(normalizeSQLFileReadContent({ isLargeFile: true, filePath: '/tmp/a.sql' })).toBe('');
});
it('detects unsaved changes by comparing tab query with disk content', () => {
expect(hasSQLFileTabUnsavedChanges({
type: 'query',
filePath: '/tmp/a.sql',
query: 'select 1;',
} as any, 'select 1;')).toBe(false);
expect(hasSQLFileTabUnsavedChanges({
type: 'query',
filePath: '/tmp/a.sql',
query: 'select 2;',
} as any, 'select 1;')).toBe(true);
});
it('detects missing SQL file read failures by structured error code', () => {
expect(isSQLFileMissingReadResult({
success: false,
message: '无法读取文件信息: stat /tmp/missing.sql: no such file or directory',
data: { errorCode: 'file_not_found', filePath: '/tmp/missing.sql' },
})).toBe(true);
expect(isSQLFileMissingReadResult({
success: false,
message: '无法读取文件信息: permission denied',
data: { filePath: '/tmp/report.sql' },
})).toBe(false);
});
it('keeps platform-specific missing file messages as a fallback', () => {
expect(isSQLFileMissingErrorMessage('GetFileAttributesEx C:\\Users\\me\\missing.sql: The system cannot find the file specified.')).toBe(true);
expect(isSQLFileMissingErrorMessage('stat /Users/me/missing.sql: no such file or directory')).toBe(true);
expect(isSQLFileMissingErrorMessage('无法读取文件信息: 权限不足')).toBe(false);
});
});

View File

@@ -0,0 +1,64 @@
import type { TabData } from '../types';
const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
export const SQL_FILE_NOT_FOUND_ERROR_CODE = 'file_not_found';
export const getSQLFileTabPath = (tab: Pick<TabData, 'type' | 'filePath'> | null | undefined): string => {
if (!tab || tab.type !== 'query') return '';
return toTrimmedString(tab.filePath);
};
export const isSQLFileQueryTab = (tab: Pick<TabData, 'type' | 'filePath'> | null | undefined): boolean =>
Boolean(getSQLFileTabPath(tab));
export const normalizeSQLFileReadContent = (data: unknown): string => {
if (data && typeof data === 'object') {
const payload = data as Record<string, unknown>;
if ('content' in payload) {
return String(payload.content ?? '');
}
return '';
}
return String(data ?? '');
};
export const hasSQLFileTabUnsavedChanges = (
tab: Pick<TabData, 'type' | 'filePath' | 'query'>,
diskContent: string,
): boolean => {
if (!isSQLFileQueryTab(tab)) return false;
return String(tab.query ?? '') !== diskContent;
};
const SQL_FILE_MISSING_MESSAGE_PATTERNS = [
'no such file or directory',
'cannot find the file specified',
'system cannot find the file specified',
'does not exist',
'not exist',
'系统找不到指定的文件',
'文件不存在',
];
export const isSQLFileMissingErrorMessage = (message: unknown): boolean => {
const normalizedMessage = toTrimmedString(message).toLowerCase();
if (!normalizedMessage) return false;
return SQL_FILE_MISSING_MESSAGE_PATTERNS.some((pattern) => normalizedMessage.includes(pattern));
};
export const isSQLFileMissingReadResult = (result: unknown): boolean => {
if (!result || typeof result !== 'object') return false;
const payload = result as Record<string, unknown>;
if (payload.success === true) return false;
const data = payload.data;
if (data && typeof data === 'object') {
const errorCode = toTrimmedString((data as Record<string, unknown>).errorCode).toLowerCase();
if (errorCode === SQL_FILE_NOT_FOUND_ERROR_CODE) {
return true;
}
}
return isSQLFileMissingErrorMessage(payload.message);
};

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest';
import {
clearQueryTabDraft,
clearSQLFileTabDraft,
getQueryTabDraft,
getSQLFileTabDraft,
hasQueryTabDraft,
hasSQLFileTabDraft,
setQueryTabDraft,
setSQLFileTabDraft,
} from './sqlFileTabDrafts';
describe('sqlFileTabDrafts', () => {
it('stores query editor drafts outside the persisted tab state', () => {
clearQueryTabDraft('query-tab-1');
expect(hasQueryTabDraft('query-tab-1')).toBe(false);
expect(getQueryTabDraft('query-tab-1', 'fallback')).toBe('fallback');
setQueryTabDraft('query-tab-1', 'select * from large_table;');
expect(hasQueryTabDraft('query-tab-1')).toBe(true);
expect(getQueryTabDraft('query-tab-1', 'fallback')).toBe('select * from large_table;');
clearQueryTabDraft('query-tab-1');
expect(hasQueryTabDraft('query-tab-1')).toBe(false);
});
it('stores external SQL file editor drafts outside the persisted tab state', () => {
clearSQLFileTabDraft('tab-1');
expect(hasSQLFileTabDraft('tab-1')).toBe(false);
expect(getSQLFileTabDraft('tab-1', 'fallback')).toBe('fallback');
setSQLFileTabDraft('tab-1', 'select 1;');
expect(hasSQLFileTabDraft('tab-1')).toBe(true);
expect(getSQLFileTabDraft('tab-1', 'fallback')).toBe('select 1;');
clearSQLFileTabDraft('tab-1');
expect(hasSQLFileTabDraft('tab-1')).toBe(false);
});
});

View File

@@ -0,0 +1,44 @@
const drafts = new Map<string, string>();
const toTabId = (value: unknown): string => String(value ?? '').trim();
export const setQueryTabDraft = (tabId: string, content: string): void => {
const id = toTabId(tabId);
if (!id) return;
drafts.set(id, String(content ?? ''));
};
export const getQueryTabDraft = (tabId: string, fallback = ''): string => {
const id = toTabId(tabId);
if (!id || !drafts.has(id)) {
return fallback;
}
return drafts.get(id) ?? fallback;
};
export const clearQueryTabDraft = (tabId: string): void => {
const id = toTabId(tabId);
if (!id) return;
drafts.delete(id);
};
export const hasQueryTabDraft = (tabId: string): boolean => {
const id = toTabId(tabId);
return Boolean(id && drafts.has(id));
};
export const setSQLFileTabDraft = (tabId: string, content: string): void => {
setQueryTabDraft(tabId, content);
};
export const getSQLFileTabDraft = (tabId: string, fallback = ''): string => {
return getQueryTabDraft(tabId, fallback);
};
export const clearSQLFileTabDraft = (tabId: string): void => {
clearQueryTabDraft(tabId);
};
export const hasSQLFileTabDraft = (tabId: string): boolean => {
return hasQueryTabDraft(tabId);
};

View File

@@ -0,0 +1,47 @@
import { describe, expect, it } from 'vitest';
import { buildSqlServerObjectDefinitionQueries } from './sqlServerObjectDefinition';
describe('buildSqlServerObjectDefinitionQueries', () => {
it('builds schema-aware SQL Server routine definition queries', () => {
const queries = buildSqlServerObjectDefinitionQueries('routine', 'dbo.p_get_select', 'BizDB', 'routine_definition');
expect(queries).toHaveLength(2);
expect(queries[0]).toContain('FROM [BizDB].sys.all_sql_modules AS m');
expect(queries[0]).toContain('JOIN [BizDB].sys.all_objects AS o ON o.object_id = m.object_id');
expect(queries[0]).toContain("WHERE o.name = N'p_get_select'");
expect(queries[0]).toContain("AND s.name = N'dbo'");
expect(queries[0]).toContain("o.type IN ('P', 'PC', 'RF', 'FN', 'FS', 'FT', 'IF', 'TF')");
expect(queries[0]).not.toContain('OBJECT_DEFINITION');
expect(queries[1]).toBe("EXEC [BizDB].sys.sp_helptext @objname = N'[dbo].[p_get_select]'");
});
it('uses the database segment from a three-part SQL Server object name', () => {
const queries = buildSqlServerObjectDefinitionQueries('view', 'Archive.reporting.active_users', 'BizDB', 'view_definition');
expect(queries[0]).toContain('FROM [Archive].sys.all_sql_modules AS m');
expect(queries[0]).toContain("WHERE o.name = N'active_users'");
expect(queries[0]).toContain("AND s.name = N'reporting'");
expect(queries[0]).toContain("o.type IN ('V')");
expect(queries[1]).toBe("EXEC [Archive].sys.sp_helptext @objname = N'[reporting].[active_users]'");
});
it('falls back to all schemas when SQL Server object name is unqualified', () => {
const queries = buildSqlServerObjectDefinitionQueries('routine', 'sp_helptext', 'master', 'routine_definition');
expect(queries[0]).toContain('FROM [master].sys.all_sql_modules AS m');
expect(queries[0]).toContain("WHERE o.name = N'sp_helptext'");
expect(queries[0]).not.toContain('AND s.name = N');
expect(queries[0]).toContain("CASE WHEN s.name = N'dbo' THEN 0 WHEN s.name = N'sys' THEN 1 ELSE 2 END");
expect(queries[1]).toBe("EXEC [master].sys.sp_helptext @objname = N'sp_helptext'");
});
it('escapes SQL Server literals and bracket identifiers', () => {
const queries = buildSqlServerObjectDefinitionQueries('trigger', "audit]x.o'clock", 'Biz]DB', 'trigger_definition');
expect(queries[0]).toContain('FROM [Biz]]DB].sys.all_sql_modules AS m');
expect(queries[0]).toContain("WHERE o.name = N'o''clock'");
expect(queries[0]).toContain("AND s.name = N'audit]x'");
expect(queries[1]).toBe("EXEC [Biz]]DB].sys.sp_helptext @objname = N'[audit]]x].[o''clock]'");
});
});

Some files were not shown because too many files have changed in this diff Show More