mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-09 16:23:27 +08:00
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:
319
frontend/src/utils/aiBuiltinDatabaseToolInfo.ts
Normal file
319
frontend/src/utils/aiBuiltinDatabaseToolInfo.ts
Normal 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"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
226
frontend/src/utils/aiBuiltinInspectionContextToolInfo.ts
Normal file
226
frontend/src/utils/aiBuiltinInspectionContextToolInfo.ts
Normal 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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
206
frontend/src/utils/aiBuiltinInspectionCoreToolInfo.ts
Normal file
206
frontend/src/utils/aiBuiltinInspectionCoreToolInfo.ts
Normal 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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
240
frontend/src/utils/aiBuiltinInspectionDiagnosticsToolInfo.ts
Normal file
240
frontend/src/utils/aiBuiltinInspectionDiagnosticsToolInfo.ts
Normal 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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
176
frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts
Normal file
176
frontend/src/utils/aiBuiltinInspectionMcpToolInfo.ts
Normal 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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
110
frontend/src/utils/aiBuiltinInspectionSqlToolInfo.ts
Normal file
110
frontend/src/utils/aiBuiltinInspectionSqlToolInfo.ts
Normal 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" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
14
frontend/src/utils/aiBuiltinInspectionToolInfo.ts
Normal file
14
frontend/src/utils/aiBuiltinInspectionToolInfo.ts
Normal 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,
|
||||
];
|
||||
97
frontend/src/utils/aiBuiltinToolCatalog.test.ts
Normal file
97
frontend/src/utils/aiBuiltinToolCatalog.test.ts
Normal 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('治理前端大文件');
|
||||
});
|
||||
});
|
||||
349
frontend/src/utils/aiBuiltinToolCatalog.ts
Normal file
349
frontend/src/utils/aiBuiltinToolCatalog.ts
Normal 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(' '),
|
||||
]),
|
||||
]);
|
||||
});
|
||||
};
|
||||
12
frontend/src/utils/aiBuiltinToolInfo.ts
Normal file
12
frontend/src/utils/aiBuiltinToolInfo.ts
Normal 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,
|
||||
];
|
||||
17
frontend/src/utils/aiBuiltinToolInfo.types.ts
Normal file
17
frontend/src/utils/aiBuiltinToolInfo.types.ts
Normal 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;
|
||||
}
|
||||
28
frontend/src/utils/aiChatRuntime.test.ts
Normal file
28
frontend/src/utils/aiChatRuntime.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
90
frontend/src/utils/aiChatRuntime.ts
Normal file
90
frontend/src/utils/aiChatRuntime.ts
Normal 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;
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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('收入下降');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
310
frontend/src/utils/aiToolRegistry.test.ts
Normal file
310
frontend/src/utils/aiToolRegistry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
39
frontend/src/utils/aiToolRegistry.ts
Normal file
39
frontend/src/utils/aiToolRegistry.ts
Normal 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)];
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
|
||||
57
frontend/src/utils/connectionDriverType.test.ts
Normal file
57
frontend/src/utils/connectionDriverType.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
frontend/src/utils/connectionDriverType.ts
Normal file
64
frontend/src/utils/connectionDriverType.ts
Normal 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))
|
||||
);
|
||||
@@ -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({
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
96
frontend/src/utils/connectionTypeCapabilities.test.ts
Normal file
96
frontend/src/utils/connectionTypeCapabilities.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
180
frontend/src/utils/connectionTypeCapabilities.ts
Normal file
180
frontend/src/utils/connectionTypeCapabilities.ts
Normal 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";
|
||||
72
frontend/src/utils/connectionTypeCatalog.test.ts
Normal file
72
frontend/src/utils/connectionTypeCatalog.test.ts
Normal 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('标准连接配置');
|
||||
});
|
||||
});
|
||||
189
frontend/src/utils/connectionTypeCatalog.ts
Normal file
189
frontend/src/utils/connectionTypeCatalog.ts
Normal 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);
|
||||
@@ -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',
|
||||
|
||||
@@ -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),
|
||||
|
||||
22
frontend/src/utils/ddlFormat.test.ts
Normal file
22
frontend/src/utils/ddlFormat.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
52
frontend/src/utils/ddlFormat.ts
Normal file
52
frontend/src/utils/ddlFormat.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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()));
|
||||
|
||||
82
frontend/src/utils/driverProgress.test.ts
Normal file
82
frontend/src/utils/driverProgress.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
59
frontend/src/utils/driverProgress.ts
Normal file
59
frontend/src/utils/driverProgress.ts
Normal 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;
|
||||
};
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
33
frontend/src/utils/fontFamilies.test.ts
Normal file
33
frontend/src/utils/fontFamilies.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
281
frontend/src/utils/mcpArgumentDetailHints.ts
Normal file
281
frontend/src/utils/mcpArgumentDetailHints.ts
Normal 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;
|
||||
};
|
||||
166
frontend/src/utils/mcpArgumentHints.test.ts
Normal file
166
frontend/src/utils/mcpArgumentHints.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
555
frontend/src/utils/mcpArgumentHints.ts
Normal file
555
frontend/src/utils/mcpArgumentHints.ts
Normal 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 只填 docker,run、-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),
|
||||
};
|
||||
};
|
||||
163
frontend/src/utils/mcpClientInstallStatus.test.ts
Normal file
163
frontend/src/utils/mcpClientInstallStatus.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
300
frontend/src/utils/mcpClientInstallStatus.ts
Normal file
300
frontend/src/utils/mcpClientInstallStatus.ts
Normal 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: '填一段随机长 token,Windows 启动命令和云端 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 Server,transport 选择 Streamable HTTP,URL 填隧道/反向代理后的 /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 MCP,URL 指向隧道或反向代理后的 /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。',
|
||||
],
|
||||
};
|
||||
};
|
||||
90
frontend/src/utils/mcpCommandDraft.test.ts
Normal file
90
frontend/src/utils/mcpCommandDraft.test.ts
Normal 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: '命令中存在未闭合的引号,请检查后重试。',
|
||||
});
|
||||
});
|
||||
});
|
||||
182
frontend/src/utils/mcpCommandDraft.ts
Normal file
182
frontend/src/utils/mcpCommandDraft.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
};
|
||||
31
frontend/src/utils/mcpEnvDraft.test.ts
Normal file
31
frontend/src/utils/mcpEnvDraft.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
47
frontend/src/utils/mcpEnvDraft.ts
Normal file
47
frontend/src/utils/mcpEnvDraft.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
63
frontend/src/utils/mcpEnvHints.test.ts
Normal file
63
frontend/src/utils/mcpEnvHints.test.ts
Normal 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('不会自动进入容器');
|
||||
});
|
||||
});
|
||||
287
frontend/src/utils/mcpEnvHints.ts
Normal file
287
frontend/src/utils/mcpEnvHints.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
54
frontend/src/utils/mcpServerDraftSeed.test.ts
Normal file
54
frontend/src/utils/mcpServerDraftSeed.test.ts
Normal 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: '***' },
|
||||
});
|
||||
});
|
||||
});
|
||||
111
frontend/src/utils/mcpServerDraftSeed.ts
Normal file
111
frontend/src/utils/mcpServerDraftSeed.ts
Normal 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,
|
||||
});
|
||||
32
frontend/src/utils/mcpServerGuidance.test.ts
Normal file
32
frontend/src/utils/mcpServerGuidance.test.ts
Normal 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 &&');
|
||||
});
|
||||
});
|
||||
172
frontend/src/utils/mcpServerGuidance.ts
Normal file
172
frontend/src/utils/mcpServerGuidance.ts
Normal 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 和 --stdio;docker 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 填 npx,args 逐项填 -y、包名和 --stdio;不要把整行 npx 命令放进 command。',
|
||||
'README 给 Docker 示例时,command 填 docker,args 逐项填 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(' ');
|
||||
90
frontend/src/utils/mcpServerTemplates.ts
Normal file
90
frontend/src/utils/mcpServerTemplates.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
];
|
||||
96
frontend/src/utils/mcpServerValidation.test.ts
Normal file
96
frontend/src/utils/mcpServerValidation.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
243
frontend/src/utils/mcpServerValidation.ts
Normal file
243
frontend/src/utils/mcpServerValidation.ts
Normal 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=docker,args 里单独填写 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,
|
||||
};
|
||||
};
|
||||
172
frontend/src/utils/messagePublish.test.ts
Normal file
172
frontend/src/utils/messagePublish.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
443
frontend/src/utils/messagePublish.ts
Normal file
443
frontend/src/utils/messagePublish.ts
Normal 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'}`);
|
||||
};
|
||||
@@ -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;');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)};`;
|
||||
};
|
||||
|
||||
126
frontend/src/utils/qualifiedName.ts
Normal file
126
frontend/src/utils/qualifiedName.ts
Normal 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;
|
||||
@@ -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'],
|
||||
|
||||
@@ -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;
|
||||
|
||||
47
frontend/src/utils/queryResultPagination.test.ts
Normal file
47
frontend/src/utils/queryResultPagination.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
162
frontend/src/utils/queryResultPagination.ts
Normal file
162
frontend/src/utils/queryResultPagination.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
85
frontend/src/utils/redisConnectionUri.test.ts
Normal file
85
frontend/src/utils/redisConnectionUri.test.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
});
|
||||
451
frontend/src/utils/redisConnectionUri.ts
Normal file
451
frontend/src/utils/redisConnectionUri.ts
Normal 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),
|
||||
};
|
||||
};
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('未检测到主键或可用唯一索引,无法安全提交修改。');
|
||||
};
|
||||
|
||||
|
||||
134
frontend/src/utils/savedQueryPersistence.test.ts
Normal file
134
frontend/src/utils/savedQueryPersistence.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
283
frontend/src/utils/savedQueryPersistence.ts
Normal file
283
frontend/src/utils/savedQueryPersistence.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
50
frontend/src/utils/sidebarMetadata.test.ts
Normal file
50
frontend/src/utils/sidebarMetadata.test.ts
Normal 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'",
|
||||
]));
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
39
frontend/src/utils/sidebarSqlDrag.ts
Normal file
39
frontend/src/utils/sidebarSqlDrag.ts
Normal 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;
|
||||
}
|
||||
};
|
||||
@@ -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"');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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']));
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
47
frontend/src/utils/sqlEditorTransaction.test.ts
Normal file
47
frontend/src/utils/sqlEditorTransaction.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
267
frontend/src/utils/sqlEditorTransaction.ts
Normal file
267
frontend/src/utils/sqlEditorTransaction.ts
Normal 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;
|
||||
};
|
||||
46
frontend/src/utils/sqlErrorSemantics.test.ts
Normal file
46
frontend/src/utils/sqlErrorSemantics.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
177
frontend/src/utils/sqlErrorSemantics.ts
Normal file
177
frontend/src/utils/sqlErrorSemantics.ts
Normal 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');
|
||||
};
|
||||
59
frontend/src/utils/sqlFileTabDirty.test.ts
Normal file
59
frontend/src/utils/sqlFileTabDirty.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
64
frontend/src/utils/sqlFileTabDirty.ts
Normal file
64
frontend/src/utils/sqlFileTabDirty.ts
Normal 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);
|
||||
};
|
||||
46
frontend/src/utils/sqlFileTabDrafts.test.ts
Normal file
46
frontend/src/utils/sqlFileTabDrafts.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
44
frontend/src/utils/sqlFileTabDrafts.ts
Normal file
44
frontend/src/utils/sqlFileTabDrafts.ts
Normal 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);
|
||||
};
|
||||
47
frontend/src/utils/sqlServerObjectDefinition.test.ts
Normal file
47
frontend/src/utils/sqlServerObjectDefinition.test.ts
Normal 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
Reference in New Issue
Block a user