feat(i18n): 收口多语言功能业务代码

This commit is contained in:
tianqijiuyun-latiao
2026-06-22 15:12:42 +08:00
parent eba689754c
commit f282da3bcb
433 changed files with 39626 additions and 6973 deletions

View File

@@ -1,319 +1,279 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_DATABASE_TOOL_INFO: AIBuiltinToolInfo[] = [
type DatabaseToolInfoTranslator = (key: string) => string;
interface DatabaseToolInfoCopy {
name: string;
icon: string;
desc: string;
detail: string;
params: string;
toolDescription: string;
parameters: Record<string, { type: string; description: string }>;
required?: string[];
}
const DATABASE_TOOL_INFO_KEY_PREFIX = "ai_chat.builtin_tools.database";
const translateDatabaseToolInfo = (
t: DatabaseToolInfoTranslator | undefined,
key: string,
fallback: string,
): string => {
if (!t) return fallback;
const translated = t(key);
return translated && translated !== key ? translated : fallback;
};
const DATABASE_TOOL_INFO_COPY: DatabaseToolInfoCopy[] = [
{
name: "get_connections",
icon: "🔗",
desc: "获取所有可用的数据库连接",
desc: "Get all available database connections",
detail:
"返回连接 ID、名称、类型 (MySQL/PostgreSQL 等) 和 Host 地址。AI 根据返回信息决定优先探索哪个连接。",
params: "无参数",
tool: {
type: "function",
function: {
name: "get_connections",
description:
"当需要查询、操作数据库但用户没有选择任何连接上下文时获取当前软件中可用的所有数据库连接信息。返回的数据包含连接ID(id)和名称(name)。",
parameters: { type: "object", properties: {} },
},
},
"Returns connection ID, name, type (such as MySQL or PostgreSQL), and Host address. AI uses the returned data to decide which connection to explore first.",
params: "No parameters",
toolDescription:
"When database querying or operations are needed but the user has not selected any connection context, get all database connections available in the current app. Returned data includes connection ID (id) and name (name).",
parameters: {},
},
{
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"],
},
},
desc: "Get all databases under a specified connection",
detail: "Pass connectionId and return the database or Schema name list under that connection.",
params: "connectionId: connection ID",
toolDescription: "Get all database (Database/Schema) names under the specified connectionId.",
parameters: {
connectionId: { type: "string", description: "Connection ID (from get_connections)" },
},
required: ["connectionId"],
},
{
name: "get_tables",
icon: "📋",
desc: "获取指定数据库下的所有表名",
desc: "Get all table names under a specified database",
detail:
"传入 connectionId dbName返回表名列表。AI 用它来定位用户提到的目标表。",
"Pass connectionId and dbName, then return a table name list. AI uses it to locate the target table mentioned by the user.",
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"],
},
},
toolDescription:
"After the target connection and database name are known, if the user asks about a table or implicitly mentions one but the exact table name is unknown, call this tool to get all table names in that database (table names only) and infer the target table.",
parameters: {
connectionId: { type: "string", description: "Connection ID" },
dbName: { type: "string", description: "Database name" },
},
required: ["connectionId", "dbName"],
},
{
name: "get_all_columns",
icon: "🧱",
desc: "获取指定数据库下所有表的字段摘要",
desc: "Get field summaries for all tables in a database",
detail:
"传入 connectionId dbName,返回跨表字段列表(表名、字段名、类型、注释)。适合用户只知道业务字段、不知道具体在哪张表时快速定位目标表。",
"Pass connectionId and dbName, then return a cross-table field list including table name, field name, type, and comment. Useful when the user knows a business field but not which table contains it.",
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"],
},
},
toolDescription:
"Get field summaries for all tables in the specified database, returning table names, field names, types, and comments. Use it for field-to-table lookup, cross-table field comparison, and data map exploration.",
parameters: {
connectionId: { type: "string", description: "Connection ID" },
dbName: { type: "string", description: "Database name" },
},
required: ["connectionId", "dbName"],
},
{
name: "get_columns",
icon: "🔍",
desc: "获取指定表的字段结构",
desc: "Get the field structure of a specified table",
detail:
"传入 connectionIddbName tableName返回每个字段的名称、类型、是否可空、默认值和注释。AI 在生成 SQL 前必须调用此工具确认真实字段名。",
"Pass connectionId, dbName, and tableName, then return each field's name, type, nullability, default value, and comment. AI must call this before generating SQL to confirm real field names.",
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"],
},
},
toolDescription:
"Get the field list of the specified table, including field name, type, nullability, default value, comment, and related metadata. Before generating SQL, call this tool to confirm real field names and do not guess field names.",
parameters: {
connectionId: { type: "string", description: "Connection ID" },
dbName: { type: "string", description: "Database name" },
tableName: { type: "string", description: "Table name" },
},
required: ["connectionId", "dbName", "tableName"],
},
{
name: "get_indexes",
icon: "🧭",
desc: "获取指定表的索引定义",
desc: "Get index definitions for a specified table",
detail:
"传入 connectionIddbName tableName返回索引名、索引列、唯一性和索引类型。AI 在做慢 SQL 分析、索引优化和执行计划推断时应优先调用。",
"Pass connectionId, dbName, and tableName, then return index name, index columns, uniqueness, and index type. AI should prefer this for slow SQL analysis, index optimization, and execution-plan inference.",
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"],
},
},
toolDescription:
"Get index definitions for the specified table, including index name, column order, uniqueness, and index type. Use it for slow SQL analysis, index optimization suggestions, and confirming existing index coverage.",
parameters: {
connectionId: { type: "string", description: "Connection ID" },
dbName: { type: "string", description: "Database name" },
tableName: { type: "string", description: "Table name" },
},
required: ["connectionId", "dbName", "tableName"],
},
{
name: "get_foreign_keys",
icon: "🧬",
desc: "获取指定表的外键关系",
desc: "Get foreign-key relationships for a specified table",
detail:
"传入 connectionIddbName tableName返回当前表到其他表的外键映射。AI 在推断表关系、生成联表 SQL 和评审数据一致性时可直接使用。",
"Pass connectionId, dbName, and tableName, then return foreign-key mappings from the current table to other tables. AI can use it directly for relationship inference, join SQL generation, and data consistency review.",
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"],
},
},
toolDescription:
"Get foreign-key relationships for the specified table, including local fields, referenced table, referenced fields, and constraint names. Use it for join-path analysis, ER relationship mapping, and constraint checks.",
parameters: {
connectionId: { type: "string", description: "Connection ID" },
dbName: { type: "string", description: "Database name" },
tableName: { type: "string", description: "Table name" },
},
required: ["connectionId", "dbName", "tableName"],
},
{
name: "get_triggers",
icon: "⏱️",
desc: "获取指定表的触发器定义",
desc: "Get trigger definitions for a specified table",
detail:
"传入 connectionIddbName tableName返回触发器名、触发时机、事件类型和语句体。AI 在分析隐式写入、副作用和审计逻辑时可直接查看。",
"Pass connectionId, dbName, and tableName, then return trigger name, timing, event type, and statement body. AI can inspect it directly when analyzing implicit writes, side effects, and audit logic.",
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"],
},
},
toolDescription:
"Get trigger definitions for the specified table, including timing, event, and trigger statement. Use it to investigate implicit data changes, audit logic, and table-level side effects.",
parameters: {
connectionId: { type: "string", description: "Connection ID" },
dbName: { type: "string", description: "Database name" },
tableName: { type: "string", description: "Table name" },
},
required: ["connectionId", "dbName", "tableName"],
},
{
name: "get_table_ddl",
icon: "📝",
desc: "获取表的建表语句 (DDL)",
desc: "Get the table creation statement (DDL)",
detail:
"传入 connectionIddbName tableName,返回完整的 CREATE TABLE 语句,包含字段定义、索引、约束等信息。",
"Pass connectionId, dbName, and tableName, then return the complete CREATE TABLE statement, including field definitions, indexes, constraints, and related structure details.",
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"],
},
},
toolDescription:
"Get the complete table creation statement (CREATE TABLE DDL) for the specified table, including fields, indexes, constraints, and complete structure information.",
parameters: {
connectionId: { type: "string", description: "Connection ID" },
dbName: { type: "string", description: "Database name" },
tableName: { type: "string", description: "Table name" },
},
required: ["connectionId", "dbName", "tableName"],
},
{
name: "preview_table_rows",
icon: "👀",
desc: "抽样预览指定表的前几行数据",
desc: "Preview the first rows of a specified table",
detail:
"传入 connectionIddbNametableName 和可选 limit返回该表的前几行真实样例数据。适合先看数据形态、空值分布和枚举值再决定怎么写 SQL",
"Pass connectionId, dbName, tableName, and optional limit, then return real sample rows from the table. Use it to inspect data shape, null distribution, and enum values before deciding how to write 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"],
},
},
toolDescription:
"Preview sample rows from the specified table. Use it to quickly understand field value shapes, nulls, time formats, and status enums, reducing blind SQL generation by the model.",
parameters: {
connectionId: { type: "string", description: "Connection ID" },
dbName: { type: "string", description: "Database name" },
tableName: { type: "string", description: "Table name" },
limit: { type: "number", description: "Optional. Preview row count. Default 20, maximum 100." },
},
required: ["connectionId", "dbName", "tableName"],
},
{
name: "inspect_table_bundle",
icon: "🧰",
desc: "一次抓取指定表的结构快照",
desc: "Capture a structure snapshot for a specified table",
detail:
"传入 connectionIddbName tableName,返回字段、索引、外键、触发器和 DDL还可以附带前几行样例数据。适合在写 SQL、评审表设计或排查副作用前先做完整摸底。",
"Pass connectionId, dbName, and tableName, then return columns, indexes, foreign keys, triggers, and DDL; sample rows can also be included. Useful before writing SQL, reviewing table design, or investigating side effects.",
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"],
},
},
toolDescription:
"Get a complete structure snapshot for the specified table, returning columns, indexes, foreign keys, triggers, DDL, and optional sample rows. Use it for full table-design exploration, quickly understanding table relationships, and reducing repeated round trips.",
parameters: {
connectionId: { type: "string", description: "Connection ID" },
dbName: { type: "string", description: "Database name" },
tableName: { type: "string", description: "Table name" },
includeSampleRows: { type: "boolean", description: "Optional. Whether to include sample rows." },
sampleLimit: { type: "number", description: "Optional. Sample row count. Default 10, maximum 100." },
},
required: ["connectionId", "dbName", "tableName"],
},
{
name: "inspect_database_bundle",
icon: "🗂️",
desc: "一次抓取指定数据库的结构总览",
desc: "Capture a structure overview for a specified database",
detail:
"传入 connectionId dbName,返回库内表清单、表数量、总字段数,以及按表聚合的字段摘要预览。适合刚接手陌生库时先做全局摸底,再决定深入哪张表。",
"Pass connectionId and dbName, then return table list, table count, total field count, and per-table field summary preview. Useful for first-pass exploration of an unfamiliar database before drilling into target tables.",
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"],
},
},
toolDescription:
"Get a structure overview for the specified database, returning table name list, total field count, and per-table field summary preview. Use it for unfamiliar database exploration, data mapping, and quickly choosing the next table to analyze deeply.",
parameters: {
connectionId: { type: "string", description: "Connection ID" },
dbName: { type: "string", description: "Database name" },
includeColumns: { type: "boolean", description: "Optional. Whether to include per-table field summaries. Default true." },
tableLimit: { type: "number", description: "Optional. Maximum tables to return. Default 80, maximum 200." },
perTableColumnLimit: { type: "number", description: "Optional. Maximum field summaries per table. Default 8, maximum 30." },
},
required: ["connectionId", "dbName"],
},
{
name: "execute_sql",
icon: "▶️",
desc: "执行 SQL 查询并返回结果",
desc: "Execute a SQL query and return results",
detail:
"传入 connectionIddbName 和 sql在目标数据库上执行 SQL 并返回结果(最多 50 行)。受安全级别控制,只读模式下仅允许 SELECT/SHOW/DESCRIBE",
"Pass connectionId, dbName, and sql, then execute SQL on the target database and return results (up to 50 rows). Controlled by safety level; read-only mode only allows SELECT/SHOW/DESCRIBE.",
params: "connectionId, dbName, sql",
toolDescription:
"Execute SQL on the specified connection and database and return results. Controlled by safety level; read-only mode only allows query operations such as SELECT/SHOW/DESCRIBE. Results return at most 50 rows.",
parameters: {
connectionId: { type: "string", description: "Connection ID" },
dbName: { type: "string", description: "Database name" },
sql: { type: "string", description: "SQL statement to execute" },
},
required: ["connectionId", "dbName", "sql"],
},
];
const buildDatabaseToolInfo = (
copy: DatabaseToolInfoCopy,
t?: DatabaseToolInfoTranslator,
): AIBuiltinToolInfo => {
const keyPrefix = `${DATABASE_TOOL_INFO_KEY_PREFIX}.${copy.name}`;
const translatedProperties = Object.fromEntries(
Object.entries(copy.parameters).map(([paramName, schema]) => [
paramName,
{
type: schema.type,
description: translateDatabaseToolInfo(
t,
`${keyPrefix}.parameters.${paramName}.description`,
schema.description,
),
},
]),
);
return {
name: copy.name,
icon: copy.icon,
desc: translateDatabaseToolInfo(t, `${keyPrefix}.desc`, copy.desc),
detail: translateDatabaseToolInfo(t, `${keyPrefix}.detail`, copy.detail),
params: translateDatabaseToolInfo(t, `${keyPrefix}.params`, copy.params),
tool: {
type: "function",
function: {
name: "execute_sql",
description:
"在指定连接和数据库上执行 SQL 查询并返回结果。受安全级别控制,只读模式下只能执行 SELECT/SHOW/DESCRIBE 等查询操作。结果最多返回 50 行。",
name: copy.name,
description: translateDatabaseToolInfo(t, `${keyPrefix}.tool_description`, copy.toolDescription),
parameters: {
type: "object",
properties: {
connectionId: { type: "string", description: "连接ID" },
dbName: { type: "string", description: "数据库名" },
sql: { type: "string", description: "要执行的 SQL 语句" },
},
required: ["connectionId", "dbName", "sql"],
properties: translatedProperties,
...(copy.required ? { required: copy.required } : {}),
},
},
},
},
];
};
};
export const localizeBuiltinDatabaseToolInfo = (
t?: DatabaseToolInfoTranslator,
): AIBuiltinToolInfo[] =>
DATABASE_TOOL_INFO_COPY.map((copy) => buildDatabaseToolInfo(copy, t));
export const BUILTIN_AI_DATABASE_TOOL_INFO: AIBuiltinToolInfo[] =
localizeBuiltinDatabaseToolInfo();

View File

@@ -1,226 +1,278 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_INSPECTION_CONTEXT_TOOL_INFO: AIBuiltinToolInfo[] = [
type InspectionToolInfoTranslator = (key: string) => string;
type ToolParameterSchema = { description?: string } & Record<string, unknown>;
const CONTEXT_TOOL_INFO_KEY_PREFIX = "ai_chat.inspection.tool_info";
const translateToolInfo = (
t: InspectionToolInfoTranslator | undefined,
key: string,
fallback: string,
): string => {
if (!t) return fallback;
const translated = t(key);
return translated && translated !== key ? translated : fallback;
};
const CONTEXT_TOOL_INFO_COPY: Record<
string,
{
name: "inspect_ai_guidance",
icon: string;
desc: string;
detail: string;
paramsSummary: string;
toolDescription: string;
params?: Record<string, string>;
required?: string[];
}
> = {
inspect_ai_guidance: {
icon: "🧠",
desc: "查看当前 AI 提示词与 Skills 配置",
desc: "Inspect current AI prompts and Skills configuration",
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: {} },
},
},
"Returns current user-defined global, database, and JVM prompts, plus enabled Skills, scopes, dependency tools, and skill prompt content. Use it when users ask which prompts are currently attached, why AI answers this way, or which Skills are active.",
paramsSummary: "No parameters",
toolDescription:
"Read the current AI prompt and skill configuration snapshot, including user-defined prompts, enabled Skills, scopes, dependency tools, and each system prompt.",
},
{
name: "inspect_ai_context",
inspect_ai_context: {
icon: "🧷",
desc: "查看当前 AI 已关联的表结构上下文",
desc: "Inspect currently attached AI table-schema context",
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" },
},
},
},
"Returns the tables currently attached to the AI conversation context, their connection and database, and optional DDL previews. Use it when users ask which table structures are attached or what the current AI context contains.",
paramsSummary: "includeDDL?(default false), ddlLimit?(default 4000)",
toolDescription:
"Read the table-schema snapshot currently attached to the AI conversation context, including connection, database, table name, and optional DDL content.",
params: {
includeDDL: "Optional. Whether to include each table's DDL content. Default false.",
ddlLimit: "Optional. DDL truncation length. Default 4000, maximum 12000.",
},
},
{
name: "inspect_current_connection",
inspect_current_connection: {
icon: "🛰️",
desc: "查看当前活动连接/数据源摘要",
desc: "Inspect the current active connection or data source summary",
detail:
"返回当前活动连接的类型、地址、端口、当前数据库、是否启用 SSH/代理/HTTP 隧道,以及当前活动页签绑定的表信息。适合用户问“我现在连的是哪个库”“这个连接走没走 SSH”“当前数据源是什么类型”时先读取真实连接状态。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_current_connection",
description:
"读取当前活动连接或当前页签对应数据源的真实摘要包括连接类型、地址、端口、当前数据库、SSH/代理/HTTP 隧道状态,以及当前页签绑定的表上下文。适用于用户提到当前连接、当前数据源、当前库地址、是否走 SSH、当前连的是哪种数据库时先读取真实界面上下文避免模型猜测。",
parameters: { type: "object", properties: {} },
},
},
"Returns current active connection type, address, port, current database, SSH/proxy/HTTP tunnel state, and table information bound to the active tab. Use it when users ask which database is connected, whether SSH is used, or what type the current data source is.",
paramsSummary: "No parameters",
toolDescription:
"Read the real summary of the current active connection or active-tab data source, including connection type, address, port, current database, SSH/proxy/HTTP tunnel state, and table context bound to the active tab.",
},
{
name: "inspect_connection_capabilities",
inspect_connection_capabilities: {
icon: "🧱",
desc: "查看当前连接支持哪些前端能力",
desc: "Inspect frontend capabilities supported by the current connection",
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不传时默认读取当前活动连接" },
},
},
},
"Returns the data-source capability matrix for the current or specified connection, including query editor support, SQL export, copy INSERT, create/rename/delete database support, forced read-only result state, and whether manual or approximate counts are preferred. Use it when users ask why database creation, deletion, result editing, or other actions are unavailable.",
paramsSummary: "connectionId?(default current active connection)",
toolDescription:
"Read the frontend capability matrix for the current active connection or specified saved connection, including query editor support, SQL export, copy INSERT, create/rename/delete database support, forced read-only result state, and count strategy preferences.",
params: {
connectionId: "Optional. Connection ID to inspect. If omitted, the current active connection is used.",
},
},
{
name: "inspect_saved_connections",
inspect_saved_connections: {
icon: "🧭",
desc: "查看本地已保存连接清单",
desc: "Inspect locally saved connections",
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" },
},
},
},
"Filters local saved data sources by keyword or database type and returns the data-source list, type distribution, address, current database, and SSH/proxy/HTTP tunnel state. Use it when users ask which connections are saved locally, want to find mysql or postgres connections, or need to know which connection has SSH configured.",
paramsSummary: "keyword?, type?, limit?",
toolDescription:
"Read locally saved connections, optionally filtered by keyword and database type, and return each connection's type, address, current database, SSH/proxy/HTTP tunnel summary, and related metadata.",
params: {
keyword: "Optional. Filter by connection name, ID, type, host, database name, SSH address, or proxy address.",
type: "Optional. Only inspect one database type, such as mysql, postgres, redis, or mongodb.",
limit: "Optional. Maximum number of connections to return. Default 20, maximum 100.",
},
},
{
name: "inspect_redis_topology",
inspect_redis_topology: {
icon: "🧰",
desc: "诊断 Redis 单机/哨兵/集群配置",
desc: "Diagnose Redis standalone, Sentinel, and Cluster configuration",
detail:
"读取本地 Redis 连接拓扑摘要,返回单机、SentinelCluster 的节点、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" },
},
},
},
"Reads local Redis connection topology summaries and returns standalone, Sentinel, and Cluster nodes, master, authentication state, DB range, redacted URI examples, status level, and next actions. Use it when users ask how to configure Redis Sentinel or Cluster, why DB switching fails, or how Cluster multi-DB behavior works.",
paramsSummary: "connectionId?, keyword?, limit?, includeRecommendations?(default true)",
toolDescription:
"Read local Redis standalone, Sentinel, and Cluster topology summaries, returning nodes, Sentinel master, authentication state, DB selection, TLS/SSH/proxy state, backend adapter, redacted URI examples, status level, blockers, potential configuration risks, and recommendations. Results do not echo Redis or Sentinel passwords.",
params: {
connectionId: "Optional. Diagnose only one Redis connection ID.",
keyword: "Optional. Filter by connection name, address, topology, Sentinel master, or node address.",
limit: "Optional. Maximum number of Redis connections to return. Default 20, maximum 100.",
includeRecommendations: "Optional. Whether to return repair recommendations. Default true.",
},
},
{
name: "inspect_external_sql_directories",
inspect_external_sql_directories: {
icon: "🗂️",
desc: "查看本地外部 SQL 目录资产",
desc: "Inspect local external SQL directory assets",
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" },
},
},
},
"Filters local external SQL directories by keyword, connection, or database, and returns directory path, bound connection/database, and whether SQL files from those directories are currently open. Use it when users mention external SQL directories, ask which directory contains a script, or need to identify the external directory for an open SQL file.",
paramsSummary: "keyword?, connectionId?, dbName?, limit?",
toolDescription:
"Read locally configured external SQL directories, optionally filtered by keyword, connection, and database, and return directory path, bound connection/database, and summaries of currently open external SQL file tabs.",
params: {
keyword: "Optional. Filter by directory name, path, connection name, or database name.",
connectionId: "Optional. Only inspect external SQL directories bound to one connection.",
dbName: "Optional. Only inspect external SQL directories bound to one database.",
limit: "Optional. Maximum number of directories to return. Default 20, maximum 100.",
},
},
{
name: "inspect_external_sql_file",
inspect_external_sql_file: {
icon: "📄",
desc: "读取外部 SQL 文件内容",
desc: "Read external SQL file content",
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"],
},
},
"Reads a specific SQL file inside a configured external SQL directory and returns its directory, bound connection/database, whether it already has an open tab, and a truncated content preview. Use it when users ask to inspect a script in a directory or explain what report.sql does.",
paramsSummary: "filePath, previewCharLimit?",
toolDescription:
"Read the content preview of a specified external SQL file, only for SQL files inside configured external SQL directories. Return file path, owning directory, bound connection/database, whether it is already open in the workspace, and truncated body content.",
params: {
filePath: "Required. Absolute path of the SQL file to read, usually found with inspect_external_sql_directories first.",
previewCharLimit: "Optional. Maximum characters returned in the content preview. Default 12000, maximum 40000.",
},
required: ["filePath"],
},
{
name: "inspect_active_tab",
inspect_active_tab: {
icon: "📍",
desc: "查看当前活动页签上下文",
desc: "Inspect the current active tab context",
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" },
},
},
},
"Returns the current active tab type, connection, database, table name, and draft content in the current SQL or command tab, truncated when long. Use it when users mention the current SQL, ask to optimize the editor statement, or refer to the current tab.",
paramsSummary: "includeContent?(default true)",
toolDescription:
"Get the current active tab context snapshot, including tab type, connection, database, table name, and draft content from the current SQL or command tab.",
params: {
includeContent: "Optional. Whether to include SQL or command draft content from the tab. Default true.",
},
},
{
name: "inspect_workspace_tabs",
inspect_workspace_tabs: {
icon: "🗃️",
desc: "查看当前工作区打开的页签总览",
desc: "Inspect currently open workspace tabs",
detail:
"返回当前工作区里打开的页签列表、哪个是活动页签,以及每个页签对应的连接、数据库、表名等上下文。适合用户说“我现在开了哪些 SQL”“看看我工作区里有哪些页签”“帮我对比这几个查询页签”时先读取真实工作区布局再继续分析。",
params: "limit?(默认 12), includeContent?(默认 false)",
"Returns the list of tabs open in the current workspace, which one is active, and each tab's connection, database, table name, and related context. Use it when users ask which SQL tabs are open, what exists in the workspace, or want to compare several query tabs.",
paramsSummary: "limit?(default 12), includeContent?(default false)",
toolDescription:
"Get an overview of currently open workspace tabs, including active tab, tab type, connection, database, table name, and optional SQL or command draft content.",
params: {
limit: "Optional. Maximum number of tabs to return. Default 12, maximum 30.",
includeContent: "Optional. Whether to include SQL or command draft content from tabs. Default false.",
},
},
};
const createContextToolInfo = (
name: keyof typeof CONTEXT_TOOL_INFO_COPY,
properties: Record<string, any> = {},
): AIBuiltinToolInfo => {
const copy = CONTEXT_TOOL_INFO_COPY[name];
const translatedProperties = Object.fromEntries(
Object.entries(properties).map(([paramName, schema]) => [
paramName,
{
...schema,
description: copy.params?.[paramName] || schema.description || "",
},
]),
);
return {
name,
icon: copy.icon,
desc: copy.desc,
detail: copy.detail,
params: copy.paramsSummary,
tool: {
type: "function",
function: {
name: "inspect_workspace_tabs",
description:
"获取当前工作区已打开页签的总览,包括活动页签、页签类型、连接、数据库、表名,以及可选的 SQL / 命令草稿内容。适用于用户提到当前工作区、打开了哪些页签、哪几个查询页签、想对比多个编辑器内容时,先读取真实界面状态,避免模型猜测。",
name,
description: copy.toolDescription,
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "可选,最多返回多少个页签,默认 12最大 30" },
includeContent: { type: "boolean", description: "可选,是否附带页签中的 SQL / 命令草稿内容,默认 false" },
},
properties: translatedProperties,
...(copy.required ? { required: copy.required } : {}),
},
},
},
},
];
};
};
export const localizeBuiltinInspectionContextToolInfo = (
t?: InspectionToolInfoTranslator,
): AIBuiltinToolInfo[] =>
([
createContextToolInfo("inspect_ai_guidance"),
createContextToolInfo("inspect_ai_context", {
includeDDL: { type: "boolean" },
ddlLimit: { type: "number" },
}),
createContextToolInfo("inspect_current_connection"),
createContextToolInfo("inspect_connection_capabilities", {
connectionId: { type: "string" },
}),
createContextToolInfo("inspect_saved_connections", {
keyword: { type: "string" },
type: { type: "string" },
limit: { type: "number" },
}),
createContextToolInfo("inspect_redis_topology", {
connectionId: { type: "string" },
keyword: { type: "string" },
limit: { type: "number" },
includeRecommendations: { type: "boolean" },
}),
createContextToolInfo("inspect_external_sql_directories", {
keyword: { type: "string" },
connectionId: { type: "string" },
dbName: { type: "string" },
limit: { type: "number" },
}),
createContextToolInfo("inspect_external_sql_file", {
filePath: { type: "string" },
previewCharLimit: { type: "number" },
}),
createContextToolInfo("inspect_active_tab", {
includeContent: { type: "boolean" },
}),
createContextToolInfo("inspect_workspace_tabs", {
limit: { type: "number" },
includeContent: { type: "boolean" },
}),
]).map((tool) => {
const keyPrefix = `${CONTEXT_TOOL_INFO_KEY_PREFIX}.${tool.name}`;
const properties = tool.tool.function.parameters.properties as
| Record<string, ToolParameterSchema>
| undefined;
const translatedProperties = Object.fromEntries(
Object.entries(properties || {}).map(([paramName, schema]) => [
paramName,
{
...schema,
description: translateToolInfo(
t,
`${keyPrefix}.param.${paramName}`,
schema.description || "",
),
},
]),
);
return {
...tool,
desc: translateToolInfo(t, `${keyPrefix}.desc`, tool.desc),
detail: translateToolInfo(t, `${keyPrefix}.detail`, tool.detail),
params: translateToolInfo(t, `${keyPrefix}.params`, tool.params),
tool: {
...tool.tool,
function: {
...tool.tool.function,
description: translateToolInfo(
t,
`${keyPrefix}.tool_description`,
tool.tool.function.description,
),
parameters: {
...tool.tool.function.parameters,
properties: translatedProperties,
},
},
},
};
});
export const BUILTIN_AI_INSPECTION_CONTEXT_TOOL_INFO: AIBuiltinToolInfo[] =
localizeBuiltinInspectionContextToolInfo();

View File

@@ -1,206 +1,283 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_INSPECTION_CORE_TOOL_INFO: AIBuiltinToolInfo[] = [
type InspectionToolInfoTranslator = (key: string) => string;
type ToolParameterSchema = { description?: string } & Record<string, unknown>;
const CORE_TOOL_INFO_KEY_PREFIX = "ai_chat.inspection.tool_info";
const translateToolInfo = (
t: InspectionToolInfoTranslator | undefined,
key: string,
fallback: string,
): string => {
if (!t) return fallback;
const translated = t(key);
return translated && translated !== key ? translated : fallback;
};
const CORE_TOOL_INFO_COPY: Record<
string,
{
name: "inspect_app_health",
icon: string;
desc: string;
detail: string;
paramsSummary: string;
toolDescription: string;
params?: Record<string, string>;
}
> = {
inspect_app_health: {
icon: "🧭",
desc: "一键查看 AI 应用健康总览",
desc: "Inspect the overall AI application health",
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需要引用原文时再开启" },
},
},
},
"Summarizes AI configuration, provider send prerequisites, MCP access, application log ERROR/WARN signals, recent connection failures and cooldowns, AI reply bubble render errors, and current workspace tabs. Use it first when users report AI instability, ask for an overall check, or need connection and MCP issues diagnosed together.",
paramsSummary: "keyword?, connectionKeyword?, lineLimit?(default 120), includeLogLines?(default false)",
toolDescription:
"Read the GoNavi AI application health overview, including AI provider and send prerequisites, MCP access, application log ERROR/WARN signals, recent connection failures and cooldowns, AI reply bubble render errors, and current workspace tabs, then return blockers, runtime anomaly signals, and suggested next probes.",
params: {
keyword: "Optional. Filter application logs by keyword, such as ai, mcp, mysql, or error. If omitted, the recent log window is read.",
connectionKeyword: "Optional. Keyword used when analyzing connection failure logs by type, address, or error. If omitted, keyword is reused.",
lineLimit: "Optional. Maximum number of log lines to analyze per probe. Default 120, maximum 240.",
includeLogLines: "Optional. Whether to include original log lines in the result. Default false; enable only when lines need to be quoted.",
},
},
{
name: "inspect_ai_support_bundle",
inspect_ai_support_bundle: {
icon: "📦",
desc: "导出 AI 排障支持包",
desc: "Export an AI troubleshooting support bundle",
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 会返回鉴权告警" },
},
},
},
"Aggregates AI application health, provider and MCP status, application log summary, connection failure summary, message flow structure, context size, remote MCP access, and tool catalog index. Use it when users report AI instability, need MCP, connection, and logs reviewed together, or need development troubleshooting material without secrets or database passwords.",
paramsSummary:
"keyword?, sessionId?, lineLimit?(default 120), includeLogLines?(default false), includeMessageContent?(default false), publicUrl?, tokenConfigured?",
toolDescription:
"Generate a GoNavi AI troubleshooting support bundle that summarizes AI application health, provider and send prerequisites, MCP configuration and remote access, application log summary, database connection failure summary, current AI message flow, context-size risk, and tool catalog index. By default it does not include database passwords, provider keys, MCP environment variable values, original log lines, or full message content.",
params: {
keyword: "Optional. Filter logs and tool catalog entries by keyword, such as ai, mcp, mysql, error, or openclaw.",
connectionKeyword: "Optional. Keyword used to analyze connection failure logs. If omitted, keyword is reused.",
sessionId: "Optional. AI session ID to diagnose. If omitted, the current active session is used.",
lineLimit: "Optional. Maximum number of application log lines to analyze. Default 120, maximum 240.",
includeLogLines: "Optional. Whether to include original log lines. Default false; enable only when lines need to be quoted.",
includeMessageContent: "Optional. Whether to include message content previews. Default false; enable only when troubleshooting bubble content.",
includeDetails: "Optional. Whether to include context-size details. Default false.",
publicUrl: "Optional. Public or tunnel URL used by a cloud Agent to access GoNavi MCP for the remote MCP support bundle.",
localAddr: "Optional. Windows local HTTP MCP listen address. Default 127.0.0.1:8765.",
path: "Optional. Streamable HTTP MCP path. Default /mcp.",
exposeStrategy: "Optional. Remote exposure strategy used to generate matching safety reminders.",
tokenConfigured: "Optional. Whether a random Bearer Token is already prepared. Passing false returns an authentication warning.",
},
},
{
name: "inspect_ai_setup_health",
inspect_ai_setup_health: {
icon: "🩺",
desc: "一键体检当前 AI 配置健康度",
desc: "Run a one-shot health check for the current AI setup",
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: {} },
},
},
"Summarizes the current AI provider, chat send prerequisites, MCP services and external client access, prompts and Skills, and attached context, then returns blockers, warnings, and next actions. Use it when users ask why AI is hard to use, whether the current AI setup has problems, or what is still missing.",
paramsSummary: "No parameters",
toolDescription:
"Inspect current AI setup health, returning provider, model, chat send prerequisites, MCP access, prompts and Skills, attached table context, blockers, suggestions, and next actions.",
},
{
name: "inspect_ai_runtime",
inspect_ai_runtime: {
icon: "🎛️",
desc: "查看当前 AI 自身运行状态",
desc: "Inspect current AI runtime status",
detail:
"返回当前启用的模型供应商、模型名、安全级别、上下文级别、启用的 Skills以及当前已暴露的内置工具和 MCP 工具。适合用户问“你现在能调用什么”“当前用的哪个模型”“为什么不能执行写操作”时,先读真实运行状态再回答。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_ai_runtime",
description:
"读取当前 AI 运行时快照,包括当前供应商、模型、安全级别、上下文级别、启用的 Skills、当前可用的内置工具与 MCP 工具。适用于用户询问当前 AI 能力边界、当前使用哪个模型、为什么不能执行某些操作时,先读取真实运行状态,避免模型猜测。",
parameters: { type: "object", properties: {} },
},
},
"Returns the active model provider, model name, safety level, context level, enabled Skills, and currently exposed built-in and MCP tools. Use it before answering questions about available tools, the active model, or why write operations are unavailable.",
paramsSummary: "No parameters",
toolDescription:
"Read the current AI runtime snapshot, including provider, model, safety level, context level, enabled Skills, available built-in tools, and MCP tools. Use it before answering AI capability-boundary questions.",
},
{
name: "inspect_ai_safety",
inspect_ai_safety: {
icon: "🛡️",
desc: "查看当前 AI 写入安全边界",
desc: "Inspect current AI write safety boundaries",
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: {} },
},
},
"Returns the SQL scope allowed by the current AI safety level, whether non-read-only statements still require confirmation or allowMutating, and whether the active connection, tab, or JVM diagnostic permission adds read-only restrictions. Use it when users ask why writes are blocked, whether DDL can run, or whether allowMutating is required.",
paramsSummary: "No parameters",
toolDescription:
"Read the current AI safety-boundary snapshot, including SQL scope allowed by the active safety level, confirmation requirements for non-query statements, MCP execute_sql allowMutating requirements, and any additional read-only restrictions from the active connection, result tab, or JVM diagnostic permissions.",
},
{
name: "inspect_ai_providers",
inspect_ai_providers: {
icon: "🪪",
desc: "查看当前 AI 供应商与模型配置",
desc: "Inspect current AI providers and model configuration",
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: {} },
},
},
"Returns configured AI providers, the active provider, baseUrl values, selected models, declared model lists, whether keys exist, custom request header keys, and missing key, model, or endpoint checks. Use it when users ask why there are no models, whether an API Key is configured, or which providers are currently configured.",
paramsSummary: "No parameters",
toolDescription:
"Read the current AI provider configuration snapshot, including provider list, active provider, endpoint, selected model, declared model list, key presence, custom request header keys, and missing key, model, or endpoint checks.",
},
{
name: "inspect_ai_chat_readiness",
inspect_ai_chat_readiness: {
icon: "🚦",
desc: "查看当前 AI 聊天是否具备发送条件",
desc: "Inspect whether current AI chat can send",
detail:
"返回当前聊天输入区是否已经具备发送条件,包括有没有活动供应商、当前供应商是否缺密钥或接口地址、是否已选模型、当前连接/表结构上下文是否已挂载,以及下一步建议动作。适合用户问“为什么现在不能发送”“输入框到底缺什么配置”“当前 AI 聊天准备好了没有”时先读真实状态。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_ai_chat_readiness",
description:
"读取当前 AI 聊天输入区的发送前置状态,包括活动供应商、密钥和接口地址是否完整、是否已选模型、当前连接上下文和已挂载表结构数量,以及建议的下一步动作。适用于用户提到为什么现在不能发送、为什么输入区还没准备好、当前到底缺什么配置时,先读取真实状态再回答。",
parameters: { type: "object", properties: {} },
},
},
"Returns whether the current chat input has all prerequisites to send, including active provider, missing key or endpoint on the current provider, selected model, current connection context, attached table context, and next actions. Use it when users ask why sending is disabled or what the chat input is missing.",
paramsSummary: "No parameters",
toolDescription:
"Read the send-prerequisite state of the current AI chat input, including active provider, key and endpoint completeness, selected model, current connection context, attached table schema count, and suggested next actions.",
},
{
name: "inspect_ai_upstream_logs",
inspect_ai_upstream_logs: {
icon: "📡",
desc: "查看 AI 上游请求入参与状态",
desc: "Inspect AI upstream request payloads and status",
detail:
"从 gonavi.log 读取最近的 AI 上游请求开始/完成/失败记录,按 providerrequestId 或关键词过滤,返回请求体 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" },
},
},
},
"Reads recent AI upstream request start, completion, and failure records from gonavi.log, filtered by provider, requestId, or keyword, then returns request body preview, payload structure summary, endpoint, status code, latency, and error summary. Use it when users need to verify the real payload sent upstream, diagnose request parameter compatibility, confirm whether tools were sent, or inspect redacted request logs.",
paramsSummary:
"provider?, requestId?, keyword?, lineLimit?(default 160), requestLimit?(default 12), includeBody?(default true), includePayloadSummary?(default true), includeLines?(default false)",
toolDescription:
"Read AI upstream request records from GoNavi application logs and return requestId, provider, method, endpoint, request body preview, redacted payload structure summary, status code, latency, and error summary. Use it when users mention upstream request payloads, requestId, provider parameters, missing tool calls, model API errors, or need to verify the real payload just sent upstream.",
params: {
provider: "Optional. Inspect only one provider, such as openai, anthropic, or gemini. Case-insensitive.",
requestId: "Optional. Filter by the exact requestId in logs, useful when continuing from an error log.",
keyword: "Optional. Further filter by requestId, provider, endpoint, bodyPreview, or error, such as model name, API path, or parameter name.",
lineLimit: "Optional. Maximum number of tail log lines to read. Default 160, maximum 300.",
requestLimit: "Optional. Maximum number of request summaries to return. Default 12, maximum 40.",
includeBody: "Optional. Whether to return the redacted request body preview. Default true; set false when only status is needed.",
includePayloadSummary: "Optional. Whether to parse the request body and return model, message role distribution, tool count/name list, stream, and tool_choice summary. Default true; message bodies and keys are not returned.",
includeLines: "Optional. Whether to include redacted raw log lines. Default false; enable only when original lines need to be quoted.",
bodyPreviewLimit: "Optional. Maximum characters for one body preview. Default 6000, maximum 12000.",
},
},
{
name: "inspect_ai_tool_catalog",
inspect_ai_tool_catalog: {
icon: "🧭",
desc: "查看 AI 内置工具目录和参数提示",
desc: "Inspect AI built-in tool catalog and argument hints",
detail:
"按关键词或工具名返回 GoNavi AI 内置工具、推荐探针流程、参数说明和当前 MCP 工具摘要。适合用户问“你该用哪个工具”“这个工具参数怎么填”“有哪些内置工具”或 AI 需要先选择探针路线时调用。",
params: "keyword?, toolName?, includeMCPTools?(默认 true), limit?(默认 12)",
"Returns GoNavi AI built-in tools, recommended probe flows, argument descriptions, and current MCP tool summaries by keyword or tool name. Use it when users ask which tool should be used, how to fill arguments, which built-in tools exist, or when AI needs to choose a probe route first.",
paramsSummary: "keyword?, toolName?, includeMCPTools?(default true), limit?(default 12)",
toolDescription:
"Read the GoNavi AI tool catalog snapshot, filterable by keyword or tool name, and return recommended tool-call flows, built-in tool descriptions, argument hints, and currently discovered MCP tool summaries.",
params: {
keyword: "Optional. Filter tools and flows by problem keyword, such as mcp, connection failure, transaction, shortcut, schema, or log.",
toolName: "Optional. Query by exact built-in tool name, such as inspect_mcp_draft or inspect_sql_risk.",
includeMCPTools: "Optional. Whether to include currently discovered MCP tool summaries. Default true.",
limit: "Optional. Maximum number of flows, built-in tools, and MCP tools to return. Default 12, maximum 40.",
},
},
};
const createCoreToolInfo = (
name: keyof typeof CORE_TOOL_INFO_COPY,
properties: Record<string, any> = {},
): AIBuiltinToolInfo => {
const copy = CORE_TOOL_INFO_COPY[name];
const keyPrefix = `${CORE_TOOL_INFO_KEY_PREFIX}.${name}`;
const translatedProperties = Object.fromEntries(
Object.entries(properties).map(([paramName, schema]) => [
paramName,
{
...schema,
description: translateToolInfo(
undefined,
`${keyPrefix}.param.${paramName}`,
copy.params?.[paramName] || schema.description || "",
),
},
]),
);
return {
name,
icon: copy.icon,
desc: copy.desc,
detail: copy.detail,
params: copy.paramsSummary,
tool: {
type: "function",
function: {
name: "inspect_ai_tool_catalog",
description:
"读取 GoNavi AI 工具目录快照,可按关键词或工具名筛选,返回推荐工具调用流程、内置工具说明、参数提示和当前已发现 MCP 工具摘要。适用于用户询问当前有哪些内置工具、某类问题该先调用哪个探针、工具 arguments 怎么填、或 AI 在处理复杂问题前需要先选择工具路线时优先调用。",
name,
description: copy.toolDescription,
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" },
},
properties: translatedProperties,
},
},
},
},
];
};
};
export const localizeBuiltinInspectionCoreToolInfo = (
t?: InspectionToolInfoTranslator,
): AIBuiltinToolInfo[] =>
([
createCoreToolInfo("inspect_app_health", {
keyword: { type: "string" },
connectionKeyword: { type: "string" },
lineLimit: { type: "number" },
includeLogLines: { type: "boolean" },
}),
createCoreToolInfo("inspect_ai_support_bundle", {
keyword: { type: "string" },
connectionKeyword: { type: "string" },
sessionId: { type: "string" },
lineLimit: { type: "number" },
includeLogLines: { type: "boolean" },
includeMessageContent: { type: "boolean" },
includeDetails: { type: "boolean" },
publicUrl: { type: "string" },
localAddr: { type: "string" },
path: { type: "string" },
exposeStrategy: {
type: "string",
enum: ["reverse_proxy", "ssh_reverse_tunnel", "cloudflare_tunnel", "tailscale", "custom"],
},
tokenConfigured: { type: "boolean" },
}),
createCoreToolInfo("inspect_ai_setup_health"),
createCoreToolInfo("inspect_ai_runtime"),
createCoreToolInfo("inspect_ai_safety"),
createCoreToolInfo("inspect_ai_providers"),
createCoreToolInfo("inspect_ai_chat_readiness"),
createCoreToolInfo("inspect_ai_upstream_logs", {
provider: { type: "string" },
requestId: { type: "string" },
keyword: { type: "string" },
lineLimit: { type: "number" },
requestLimit: { type: "number" },
includeBody: { type: "boolean" },
includePayloadSummary: { type: "boolean" },
includeLines: { type: "boolean" },
bodyPreviewLimit: { type: "number" },
}),
createCoreToolInfo("inspect_ai_tool_catalog", {
keyword: { type: "string" },
toolName: { type: "string" },
includeMCPTools: { type: "boolean" },
limit: { type: "number" },
}),
]).map((tool) => {
const keyPrefix = `${CORE_TOOL_INFO_KEY_PREFIX}.${tool.name}`;
const properties = tool.tool.function.parameters.properties as
| Record<string, ToolParameterSchema>
| undefined;
const translatedProperties = Object.fromEntries(
Object.entries(properties || {}).map(([paramName, schema]) => [
paramName,
{
...schema,
description: translateToolInfo(
t,
`${keyPrefix}.param.${paramName}`,
schema.description || "",
),
},
]),
);
return {
...tool,
desc: translateToolInfo(t, `${keyPrefix}.desc`, tool.desc),
detail: translateToolInfo(t, `${keyPrefix}.detail`, tool.detail),
params: translateToolInfo(t, `${keyPrefix}.params`, tool.params),
tool: {
...tool.tool,
function: {
...tool.tool.function,
description: translateToolInfo(
t,
`${keyPrefix}.tool_description`,
tool.tool.function.description,
),
parameters: {
...tool.tool.function.parameters,
properties: translatedProperties,
},
},
},
};
});
export const BUILTIN_AI_INSPECTION_CORE_TOOL_INFO: AIBuiltinToolInfo[] =
localizeBuiltinInspectionCoreToolInfo();

View File

@@ -1,240 +1,297 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_INSPECTION_DIAGNOSTICS_TOOL_INFO: AIBuiltinToolInfo[] = [
type InspectionToolInfoTranslator = (key: string) => string;
const DIAGNOSTICS_TOOL_INFO_KEY_PREFIX = "ai_chat.inspection.tool_info";
const translateToolInfo = (
t: InspectionToolInfoTranslator | undefined,
key: string,
fallback: string,
): string => {
if (!t) return fallback;
const translated = t(key);
return translated && translated !== key ? translated : fallback;
};
const DIAGNOSTICS_TOOL_INFO_COPY: Record<
string,
{
name: "inspect_app_logs",
icon: string;
desc: string;
detail: string;
paramsSummary: string;
toolDescription: string;
params?: Record<string, string>;
}
> = {
inspect_app_logs: {
icon: "🪵",
desc: "查看 GoNavi 应用日志尾部",
desc: "Inspect GoNavi application log tail",
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" },
},
},
},
"Filters recent GoNavi application log INFO/WARN/ERROR lines by optional keyword and returns level distribution, log file path, and truncation status. Use it first when users mention gonavi.log, startup errors, MCP startup failures, or database connection failures.",
paramsSummary: "keyword?, lineLimit?(default 80)",
toolDescription:
"Read the GoNavi application log tail, optionally filtered by keyword, and return recent log lines, level distribution, log path, and truncation status. Use it when users mention gonavi.log, application startup errors, MCP startup failures, database connection errors, or ask to inspect recent logs.",
params: {
keyword: "Optional. Filter log content by keyword, such as mcp, mysql, timeout, or error.",
lineLimit: "Optional. Maximum number of log lines to return. Default 80, maximum 200.",
},
},
{
name: "inspect_recent_connection_failures",
inspect_recent_connection_failures: {
icon: "🧯",
desc: "总结最近数据库连接失败与冷却原因",
desc: "Summarize recent database connection failures and cooldowns",
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" },
},
},
},
"Extracts database connection failures, validation failures, SSH tunnel errors, and connection cooldown hits from recent gonavi.log lines, then classifies main issue type, latest address, latest root cause, and next actions. Use it before manually reading long logs when users ask why a connection fails or whether SSH tunneling is involved.",
paramsSummary: "keyword?, lineLimit?(default 120)",
toolDescription:
"Summarize recent database connection failures, validation failures, SSH tunnel failures, and cooldown hits from GoNavi application logs, returning main failure category, latest address, latest root cause, and recommended actions.",
params: {
keyword: "Optional. Filter by connection type, address, or failure keyword, such as mysql, ssh, timeout, or 127.0.0.1.",
lineLimit: "Optional. Maximum number of log lines to analyze. Default 120, maximum 240.",
},
},
{
name: "inspect_ai_last_render_error",
inspect_ai_last_render_error: {
icon: "🧯",
desc: "查看最近一次 AI 消息渲染异常记录",
desc: "Inspect the latest AI message render error",
detail:
"返回最近一次被前端隔离下来的 AI 消息渲染异常包括是哪条消息、消息内容预览、错误摘要和组件栈摘要。适合用户提到“AI 某条回复空白了”“某个气泡渲染失败”“消息块报错但面板没全挂”时,先读这份真实前端异常快照。",
params: "无参数",
tool: {
type: "function",
function: {
name: "inspect_ai_last_render_error",
description:
"读取最近一次 AI 消息渲染异常的本地快照,包括消息 ID、角色、内容预览、错误摘要、组件栈摘要和下一步排查建议。适用于用户提到 AI 消息空白、某条回复渲染失败、气泡局部报错但面板仍然存活时,先读取真实前端异常记录,不要只凭现象猜测。",
parameters: { type: "object", properties: {} },
},
},
"Returns the latest isolated AI message render error, including message identity, content preview, error summary, and component stack summary. Use it when users report that one AI reply is blank, a message bubble failed to render, or a message block errored without crashing the whole panel.",
paramsSummary: "No parameters",
toolDescription:
"Read the latest local AI message render error snapshot, including message ID, role, content preview, error summary, component stack summary, and next diagnostic suggestions.",
},
{
name: "inspect_saved_queries",
inspect_saved_queries: {
icon: "💾",
desc: "查看本地已保存的 SQL 查询",
desc: "Inspect locally saved SQL queries",
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" },
},
},
},
"Filters locally saved queries by keyword, connection, or database and returns query name, connection, database, and SQL preview. Use it when users mention a previously saved query, want to find an old SQL script, or want to reuse a saved statement.",
paramsSummary: "keyword?, connectionId?, dbName?, limit?, includeSql?(default true)",
toolDescription:
"Read locally saved SQL queries, optionally filtered by keyword, connection, and database, and return each query name, connection, database, and SQL preview.",
params: {
keyword: "Optional. Filter by query name, SQL text, connection name, or database name.",
connectionId: "Optional. Only inspect saved queries under one connection.",
dbName: "Optional. Only inspect saved queries under one database.",
limit: "Optional. Maximum number of queries to return. Default 12, maximum 50.",
includeSql: "Optional. Whether to include SQL preview. Default true.",
},
},
{
name: "inspect_ai_sessions",
inspect_ai_sessions: {
icon: "🗂️",
desc: "查看本地 AI 历史会话清单",
desc: "Inspect local AI conversation history",
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" },
},
},
},
"Filters local AI sessions by keyword and returns session title, update time, message count, whether it is current, first user question, and latest message preview. Use it when users want to find a previous AI conversation or recent session that discussed a topic.",
paramsSummary: "keyword?, limit?, includePreview?(default true)",
toolDescription:
"Read local AI conversation history, optionally filtered by keyword, and return session title, update time, message count, current-session flag, first user question, and latest message preview.",
params: {
keyword: "Optional. Filter by session title, session ID, first user question, or latest message content.",
limit: "Optional. Maximum number of sessions to return. Default 10, maximum 50.",
includePreview: "Optional. Whether to include first user question and latest message preview. Default true.",
},
},
{
name: "inspect_ai_message_flow",
inspect_ai_message_flow: {
icon: "🧬",
desc: "诊断当前 AI 会话消息流",
desc: "Diagnose the current AI conversation message flow",
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" },
},
},
},
"Reads recent messages from the current or specified AI session, counts user/assistant/tool messages, checks whether tool calls have results, and detects consecutive assistant bubbles, empty assistant placeholders, or uncleared loading state. Use it when users report split replies, missing follow-up after tool calls, or abnormal message flow.",
paramsSummary: "sessionId?(default current session), limit?(default 24), includeContent?(default true), previewLimit?(default 180)",
toolDescription:
"Read recent message-flow diagnostics for the current or specified AI session, including role sequence, assistant/tool counts, tool-call to tool-result matching, consecutive assistant messages, empty assistant messages, and loading leftovers.",
params: {
sessionId: "Optional. AI session ID to diagnose. If omitted, the current active session is used.",
limit: "Optional. Maximum number of recent messages to return. Default 24, maximum 80.",
includeContent: "Optional. Whether to include message content previews. Default true.",
previewLimit: "Optional. Character limit for each message preview. Default 180, maximum 1000.",
},
},
{
name: "inspect_ai_context_budget",
inspect_ai_context_budget: {
icon: "📦",
desc: "诊断 AI 上下文体量与稳定性风险",
desc: "Diagnose AI context size and stability risk",
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" },
},
},
},
"Estimates recent messages, tool results, attached table DDL, MCP tool schemas, user prompts, and Skills in the current or specified AI session, then returns low/medium/high/critical risk, main expansion sources, and narrowing suggestions. Use it when AI slows down, answers erratically, context is too large, tool results are long, or too many table schemas are attached.",
paramsSummary: "sessionId?(default current session), messageLimit?(default 40), includeDetails?(default true)",
toolDescription:
"Read an AI context-size and stability-risk snapshot, including recent message window, tool result length, attached table DDL, MCP tool schemas, user prompts, and enabled Skills, then return risk level, warnings, and narrowing suggestions.",
params: {
sessionId: "Optional. AI session ID to diagnose. If omitted, the current active session is used.",
messageLimit: "Optional. Maximum number of recent messages to count. Default 40, maximum 120.",
includeDetails: "Optional. Whether to return largest message, largest DDL table, and largest MCP schema details. Default true.",
},
},
{
name: "inspect_codebase_hotspots",
inspect_codebase_hotspots: {
icon: "🧱",
desc: "查看前端大文件和拆分热点",
desc: "Inspect large frontend files and split hotspots",
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" },
},
},
},
"Returns large frontend file hotspots in GoNavi, including line counts, risk level, split maturity, safety boundaries, suggested split slices, and regression tests to run. Use it when users ask to continue large-file governance, choose the next component to split, or assess modification risk before changing UI, AI, or MCP code.",
paramsSummary: "keyword?, minLines?(default 1000), limit?(default 8), includeRecommendations?(default true)",
toolDescription:
"Read the GoNavi frontend large-file and split-hotspot snapshot, returning file path, line count, risk level, split maturity, preferred slice, safe split boundary, suggested slices, test targets, and verification plan.",
params: {
keyword: "Optional. Filter by path, module, risk, split slice, or test target, such as Sidebar, DataGrid, Redis, transaction, or connection.",
minLines: "Optional. Only return hotspot files with at least this many lines. Default 1000, maximum 20000.",
limit: "Optional. Maximum number of hotspots to return. Default 8, maximum 30.",
includeRecommendations: "Optional. Whether to include suggestedSlices, testTargets, and nextActions. Default true.",
},
},
{
name: "inspect_sql_snippets",
inspect_sql_snippets: {
icon: "🧩",
desc: "查看 SQL 片段模板",
desc: "Inspect SQL snippet templates",
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" },
},
},
},
"Returns local SQL snippet prefix, name, description, and template preview, optionally filtered by keyword. Use it when users want to find existing templates, completion snippets, or team SQL conventions.",
paramsSummary: "keyword?, limit?, includeBody?(default true)",
toolDescription:
"Read local SQL snippet templates, optionally filtered by keyword, and return prefix, name, description, and template preview.",
params: {
keyword: "Optional. Filter by prefix, name, description, or template content.",
limit: "Optional. Maximum number of snippets to return. Default 20, maximum 80.",
includeBody: "Optional. Whether to include template body preview. Default true.",
},
},
{
name: "inspect_shortcuts",
inspect_shortcuts: {
icon: "⌨️",
desc: "查看当前快捷键配置与平台差异",
desc: "Inspect current shortcut configuration and platform differences",
detail:
"返回当前快捷键动作、当前平台绑定、Win/Mac 双平台组合键、是否被用户改过以及默认值对照。适合用户问“当前这个快捷键是什么”“Win 和 Mac 分别怎么按”“我是不是改过默认快捷键”时先读真实配置。",
params: "action?, keyword?, includeDisabled?(默认 true), includeAllPlatforms?(默认 true)",
"Returns shortcut actions, current platform binding, Windows/macOS combinations, whether the user changed a shortcut, and default-value comparison. Use it when users ask what a shortcut is, how to press it on Windows or Mac, or whether defaults were changed.",
paramsSummary: "action?, keyword?, includeDisabled?(default true), includeAllPlatforms?(default true)",
toolDescription:
"Read the current GoNavi shortcut configuration snapshot, optionally filtered by action name or keyword, and return current platform binding, Windows/macOS bindings, defaults, and whether shortcuts were customized.",
params: {
action: "Optional. Filter by exact action key, such as toggleQueryResultsPanel, sendAIChatMessage, or toggleAIPanel.",
keyword: "Optional. Filter by action name, description, scope, key combination, or default value.",
includeDisabled: "Optional. Whether to include currently disabled shortcuts. Default true.",
includeAllPlatforms: "Optional. Whether to include both Windows and macOS platform bindings. Default true.",
},
},
};
const createDiagnosticsToolInfo = (
name: keyof typeof DIAGNOSTICS_TOOL_INFO_COPY,
properties: Record<string, any> = {},
): AIBuiltinToolInfo => {
const copy = DIAGNOSTICS_TOOL_INFO_COPY[name];
const translatedProperties = Object.fromEntries(
Object.entries(properties).map(([paramName, schema]) => [
paramName,
{
...schema,
description: copy.params?.[paramName],
},
]),
);
return {
name,
icon: copy.icon,
desc: copy.desc,
detail: copy.detail,
params: copy.paramsSummary,
tool: {
type: "function",
function: {
name: "inspect_shortcuts",
description:
"读取当前 GoNavi 快捷键配置快照可按动作名或关键词过滤并返回当前平台绑定、Win/Mac 双平台组合键、默认值和是否被用户改过。适用于用户提到快捷键、Win/Mac 键位差异、当前结果区/AI/查询相关快捷键是什么时,先读取真实配置,不要凭记忆回答默认值。",
name,
description: copy.toolDescription,
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" },
},
properties: translatedProperties,
},
},
},
},
};
};
export const BUILTIN_AI_INSPECTION_DIAGNOSTICS_TOOL_INFO: AIBuiltinToolInfo[] = [
createDiagnosticsToolInfo("inspect_app_logs", {
keyword: { type: "string" },
lineLimit: { type: "number" },
}),
createDiagnosticsToolInfo("inspect_recent_connection_failures", {
keyword: { type: "string" },
lineLimit: { type: "number" },
}),
createDiagnosticsToolInfo("inspect_ai_last_render_error"),
createDiagnosticsToolInfo("inspect_saved_queries", {
keyword: { type: "string" },
connectionId: { type: "string" },
dbName: { type: "string" },
limit: { type: "number" },
includeSql: { type: "boolean" },
}),
createDiagnosticsToolInfo("inspect_ai_sessions", {
keyword: { type: "string" },
limit: { type: "number" },
includePreview: { type: "boolean" },
}),
createDiagnosticsToolInfo("inspect_ai_message_flow", {
sessionId: { type: "string" },
limit: { type: "number" },
includeContent: { type: "boolean" },
previewLimit: { type: "number" },
}),
createDiagnosticsToolInfo("inspect_ai_context_budget", {
sessionId: { type: "string" },
messageLimit: { type: "number" },
includeDetails: { type: "boolean" },
}),
createDiagnosticsToolInfo("inspect_codebase_hotspots", {
keyword: { type: "string" },
minLines: { type: "number" },
limit: { type: "number" },
includeRecommendations: { type: "boolean" },
}),
createDiagnosticsToolInfo("inspect_sql_snippets", {
keyword: { type: "string" },
limit: { type: "number" },
includeBody: { type: "boolean" },
}),
createDiagnosticsToolInfo("inspect_shortcuts", {
action: { type: "string" },
keyword: { type: "string" },
includeDisabled: { type: "boolean" },
includeAllPlatforms: { type: "boolean" },
}),
];
export const localizeBuiltinInspectionDiagnosticsToolInfo = (
t?: InspectionToolInfoTranslator,
): AIBuiltinToolInfo[] =>
BUILTIN_AI_INSPECTION_DIAGNOSTICS_TOOL_INFO.map((tool) => {
const copy = DIAGNOSTICS_TOOL_INFO_COPY[tool.name];
if (!copy) return tool;
const keyPrefix = `${DIAGNOSTICS_TOOL_INFO_KEY_PREFIX}.${tool.name}`;
const originalProperties = tool.tool.function.parameters?.properties || {};
const translatedProperties = Object.fromEntries(
Object.entries(originalProperties).map(([paramName, schema]) => {
const fallback = copy.params?.[paramName];
if (!fallback || !schema || typeof schema !== "object") {
return [paramName, schema];
}
return [
paramName,
{
...schema,
description: translateToolInfo(t, `${keyPrefix}.param.${paramName}`, fallback),
},
];
}),
);
return {
...tool,
desc: translateToolInfo(t, `${keyPrefix}.desc`, copy.desc),
detail: translateToolInfo(t, `${keyPrefix}.detail`, copy.detail),
params: translateToolInfo(t, `${keyPrefix}.params`, copy.paramsSummary),
tool: {
...tool.tool,
function: {
...tool.tool.function,
description: translateToolInfo(t, `${keyPrefix}.tool_description`, copy.toolDescription),
parameters: {
...tool.tool.function.parameters,
properties: translatedProperties,
},
},
},
};
});

View File

@@ -1,176 +1,251 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
export const BUILTIN_AI_INSPECTION_MCP_TOOL_INFO: AIBuiltinToolInfo[] = [
type InspectionToolInfoTranslator = (key: string) => string;
const MCP_TOOL_INFO_KEY_PREFIX = "ai_chat.inspection.tool_info";
const translateToolInfo = (
t: InspectionToolInfoTranslator | undefined,
key: string,
fallback: string,
): string => {
if (!t) return fallback;
const translated = t(key);
return translated && translated !== key ? translated : fallback;
};
const MCP_TOOL_INFO_COPY: Record<
string,
{
name: "inspect_mcp_setup",
icon: string;
desc: string;
detail: string;
paramsSummary: string;
toolDescription: string;
params?: Record<string, string>;
}
> = {
inspect_mcp_setup: {
icon: "🪛",
desc: "查看当前 MCP 配置与外部接入状态",
desc: "Inspect current MCP configuration and external access",
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: {} },
},
},
"Returns the local MCP services, enabled state, declared startup commands, Claude Code / Codex local client write status, OpenClaw / Hermans remote Agent boundaries, and command detection results. Use it first when the user asks which MCP services are configured, why external clients cannot use them, or whether MCP was written into client configs.",
paramsSummary: "No parameters",
toolDescription:
"Read the current local MCP configuration snapshot, including MCP service list, enabled state, startup commands, environment variable keys, discovered tools, external client GoNavi MCP write status, local CLI detection results, and remote Agent access boundaries. Use it when the user mentions MCP service configuration, Claude/Codex/OpenClaw/Hermans access, external clients not working, or which MCP services are enabled.",
},
{
name: "inspect_mcp_remote_access",
inspect_mcp_remote_access: {
icon: "🌉",
desc: "查看 OpenClaw/Hermans 远程 MCP 接入方式",
desc: "Inspect OpenClaw/Hermans remote MCP access",
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 会返回鉴权告警" },
},
},
},
"Returns GoNavi Streamable HTTP MCP local startup commands, remote URL and authentication guidance, OpenClaw/Hermans cloud Agent access boundaries, optional bridging approaches, and safety reminders. Use it when the user asks how cloud OpenClaw connects to Windows GoNavi, how to keep database passwords away from Agents, or how to expose HTTP MCP.",
paramsSummary: "publicUrl?, localAddr?, path?, exposeStrategy?, tokenConfigured?",
toolDescription:
"Read the GoNavi MCP remote Agent access snapshot, including Streamable HTTP mode startup commands, /mcp URL, Bearer Token authentication requirements, OpenClaw/Hermans cloud access steps, the boundary that keeps database passwords on the Windows host, and risk reminders for tunnel, reverse proxy, Tailscale, or other exposure strategies.",
params: {
publicUrl: "Optional. HTTPS or private-network URL reachable by the remote Agent. If /mcp is missing, the tool appends the configured path.",
localAddr: "Optional. Windows local HTTP MCP listen address. Default 127.0.0.1:8765. Binding directly to 0.0.0.0 is not recommended.",
path: "Optional. Streamable HTTP MCP path. Default /mcp.",
exposeStrategy: "Optional. Planned remote exposure strategy used to return matching risk reminders.",
tokenConfigured: "Optional. Whether a random Bearer Token is already configured. Passing false returns an authentication warning.",
},
},
{
name: "inspect_mcp_runtime_failures",
inspect_mcp_runtime_failures: {
icon: "🧯",
desc: "诊断 MCP 启动与调用失败",
desc: "Diagnose MCP startup and tool-call failures",
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需要引用原文时再开启" },
},
},
},
"Reads recent MCP startup, tool discovery, tool-call, and HTTP MCP subprocess failures from gonavi.log, combines them with saved MCP services and discovered tools, and returns failure types, likely causes, involved services, and next repair actions. Use it first when users report failed MCP tests, 0 discovered tools, MCP tool-call failures, or HTTP MCP startup failures.",
paramsSummary: "serverName?, keyword?, lineLimit?(default 160), includeLines?(default false)",
toolDescription:
"Read MCP runtime failure signals from GoNavi application logs, classify MCP service startup failures, tool discovery failures, tool-call failures, and HTTP MCP subprocess exits, then combine current MCP service configuration and discovered tool counts to return likely causes and nextActions.",
params: {
serverName: "Optional. Inspect only one MCP service name or server= name from logs, such as GitHub, Browser, or DockerFetch.",
keyword: "Optional. Filter MCP-related logs by keyword, such as timeout, stdio, permission, 401, or docker.",
lineLimit: "Optional. Maximum number of tail log lines to read. Default 160, maximum 200.",
includeLines: "Optional. Whether to include redacted raw MCP log lines. Default false; enable only when original lines need to be quoted.",
},
},
{
name: "inspect_mcp_authoring_guide",
inspect_mcp_authoring_guide: {
icon: "🧭",
desc: "查看新增 MCP 的填写指引",
desc: "Inspect the add-MCP authoring guide",
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: {} },
},
},
"Returns the purpose of each add-MCP form field, recommended filling order, full-command auto-splitting rules, and npx / Node / uvx / Python / Docker / EXE templates. Use it before answering questions about command, args, env, templates, or why a full startup command should not be pasted into one field.",
paramsSummary: "No parameters",
toolDescription:
"Read GoNavi's current built-in MCP authoring guide, including recommended field order, field purpose, common command examples, full-command auto-splitting rules, and npx / Node / uvx / Python / Docker / EXE template examples.",
},
{
name: "inspect_mcp_docker_setup",
inspect_mcp_docker_setup: {
icon: "🐳",
desc: "检查 Docker MCP 启动配置",
desc: "Inspect Docker MCP startup configuration",
detail:
"读取当前已保存的 Docker MCP 服务,检查 command/args 是否正确拆成 dockerrun--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" },
},
},
},
"Reads saved Docker MCP services, checks whether command and args are split into docker, run, --rm, -i, image name, and container arguments correctly, then returns missing arguments, discovered tool count, timeout advice, and next repair actions. Use it when Docker README setup discovers 0 tools, the container exits immediately, or docker run arguments may be filled incorrectly.",
paramsSummary: "serverId?, includeDisabled?(default true)",
toolDescription:
"Inspect startup arguments for saved Docker MCP services and return docker run/-i/image/--rm/env/timeout status, discovered tool counts, configuration warnings, and nextActions. Use it before guiding users through Docker MCP repairs.",
params: {
serverId: "Optional. Inspect only one MCP serverId. If omitted, all Docker MCP services are inspected.",
includeDisabled: "Optional. Whether to include disabled Docker MCP services. Default true.",
},
},
{
name: "inspect_mcp_draft",
inspect_mcp_draft: {
icon: "🧪",
desc: "校验 MCP 新增草稿",
desc: "Validate an add-MCP draft",
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" },
},
},
},
"Simulates GoNavi add-MCP configuration from a full startup command or per-field draft, returning auto-split results, startup preview, applicable draft, command argument hints, environment variable hints, validation issues, recommended templates, and next repair suggestions. Sensitive values in command arguments are redacted.",
paramsSummary: "fullCommand?, command?, args?, envText?, timeoutSeconds?, templateKey?, name?",
toolDescription:
"Validate a pending MCP service draft. Supports fullCommand/rawCommand/commandLine for automatic splitting, or command, args, envText, timeoutSeconds, and templateKey for per-field validation. Returns parsed fields, redacted startup preview, suggestedServerSeed, command argument hints, environment variable key purpose and risk hints, errors, warnings, recommended templates, and nextActions without echoing api-key/token/password values.",
params: {
fullCommand: "Optional. A full MCP startup command from README or the user, such as $env:GITHUB_TOKEN=...; uvx mcp-server-github --stdio.",
command: "Optional. Startup command in a per-field draft. It should be only npx, node, uvx, python, or an exe path.",
args: "Optional. Command arguments in a per-field draft. Arrays are more accurate, but comma-separated or newline-separated strings are also accepted.",
envText: "Optional. Environment variable draft, one KEY=VALUE per line. Do not pass export, set, or $env: prefixes.",
timeoutSeconds: "Optional. Timeout seconds for one tool discovery or call. Recommended 20; slow-start services can use 45 or 60.",
templateKey: "Optional. Apply a built-in template first, then override it with user-supplied fields.",
name: "Optional. MCP service name, such as GitHub, Filesystem, or Browser.",
},
},
{
name: "inspect_mcp_tool_schema",
inspect_mcp_tool_schema: {
icon: "🧩",
desc: "查看 MCP 工具参数怎么传",
desc: "Inspect MCP tool argument schema",
detail:
" aliasserverId 或关键词查看当前已发现 MCP 工具的 inputSchema返回必填参数、字段类型、枚举值、嵌套对象路径和调用前提示。适合新增 MCP 成功后,用户或 AI 不知道某个 MCP 工具到底该传哪些参数时先读真实 schema。",
params: "alias?, serverId?, keyword?, includeSchema?(默认 false), limit?(默认 8)",
"Reads the inputSchema for currently discovered MCP tools by alias, serverId, or keyword, returning required parameters, field types, enum values, nested object paths, and pre-call hints. Use it after MCP discovery succeeds when a user or AI needs to know what arguments an MCP tool accepts.",
paramsSummary: "alias?, serverId?, keyword?, includeSchema?(default false), limit?(default 8)",
toolDescription:
"Read parameter schema summaries for currently discovered MCP tools, filterable by alias, serverId, or keyword, and return required fields, types, enum values, nested parameter paths, and pre-call hints. Use it before writing arguments JSON for external MCP tool calls or after parameter-related tool-call errors.",
params: {
alias: "Optional. Query by exact MCP tool alias, such as github_create_issue. Prefer reading the real alias from inspect_mcp_setup first.",
serverId: "Optional. Only inspect tools discovered under one MCP serverId.",
keyword: "Optional. Filter by tool alias, original name, title, description, or service name.",
includeSchema: "Optional. Whether to include the full raw inputSchema. Default false; enable only for complex nested schema inspection.",
limit: "Optional. Maximum number of matching tools to return. Default 8, maximum 30.",
},
},
};
const createMcpToolInfo = (
name: keyof typeof MCP_TOOL_INFO_COPY,
properties: Record<string, any> = {},
parameterExtras: Record<string, any> = {},
): AIBuiltinToolInfo => {
const copy = MCP_TOOL_INFO_COPY[name];
const translatedProperties = Object.fromEntries(
Object.entries(properties).map(([paramName, schema]) => [
paramName,
{
...schema,
description: copy.params?.[paramName],
},
]),
);
return {
name,
icon: copy.icon,
desc: copy.desc,
detail: copy.detail,
params: copy.paramsSummary,
tool: {
type: "function",
function: {
name: "inspect_mcp_tool_schema",
description:
"读取当前已发现 MCP 工具的参数 schema 摘要,可按 alias、serverId 或关键词过滤,并返回必填字段、类型、枚举值、嵌套参数路径和调用前提示。适用于用户问某个 MCP 工具参数怎么填、AI 准备调用外部 MCP 工具但不确定 arguments JSON 怎么写、或工具调用报参数错误时,先读取真实 inputSchema 再继续。",
name,
description: copy.toolDescription,
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" },
},
properties: translatedProperties,
...parameterExtras,
},
},
},
},
};
};
export const BUILTIN_AI_INSPECTION_MCP_TOOL_INFO: AIBuiltinToolInfo[] = [
createMcpToolInfo("inspect_mcp_setup"),
createMcpToolInfo("inspect_mcp_remote_access", {
publicUrl: { type: "string" },
localAddr: { type: "string" },
path: { type: "string" },
exposeStrategy: {
type: "string",
enum: ["reverse_proxy", "ssh_reverse_tunnel", "cloudflare_tunnel", "tailscale", "custom"],
},
tokenConfigured: { type: "boolean" },
}),
createMcpToolInfo("inspect_mcp_runtime_failures", {
serverName: { type: "string" },
keyword: { type: "string" },
lineLimit: { type: "number" },
includeLines: { type: "boolean" },
}),
createMcpToolInfo("inspect_mcp_authoring_guide"),
createMcpToolInfo("inspect_mcp_docker_setup", {
serverId: { type: "string" },
includeDisabled: { type: "boolean" },
}),
createMcpToolInfo("inspect_mcp_draft", {
fullCommand: { type: "string" },
command: { type: "string" },
args: {
oneOf: [
{ type: "array", items: { type: "string" } },
{ type: "string" },
],
},
envText: { type: "string" },
timeoutSeconds: { type: "number" },
templateKey: { type: "string", enum: ["npx", "uvx", "node", "python", "docker", "exe"] },
name: { type: "string" },
}),
createMcpToolInfo("inspect_mcp_tool_schema", {
alias: { type: "string" },
serverId: { type: "string" },
keyword: { type: "string" },
includeSchema: { type: "boolean" },
limit: { type: "number" },
}),
];
export const localizeBuiltinInspectionMcpToolInfo = (
t?: InspectionToolInfoTranslator,
): AIBuiltinToolInfo[] =>
BUILTIN_AI_INSPECTION_MCP_TOOL_INFO.map((tool) => {
const copy = MCP_TOOL_INFO_COPY[tool.name];
if (!copy) return tool;
const keyPrefix = `${MCP_TOOL_INFO_KEY_PREFIX}.${tool.name}`;
const originalProperties = tool.tool.function.parameters?.properties || {};
const translatedProperties = Object.fromEntries(
Object.entries(originalProperties).map(([paramName, schema]) => {
const fallback = copy.params?.[paramName];
if (!fallback || !schema || typeof schema !== "object") {
return [paramName, schema];
}
return [
paramName,
{
...schema,
description: translateToolInfo(t, `${keyPrefix}.param.${paramName}`, fallback),
},
];
}),
);
return {
...tool,
desc: translateToolInfo(t, `${keyPrefix}.desc`, copy.desc),
detail: translateToolInfo(t, `${keyPrefix}.detail`, copy.detail),
params: translateToolInfo(t, `${keyPrefix}.params`, copy.paramsSummary),
tool: {
...tool.tool,
function: {
...tool.tool.function,
description: translateToolInfo(t, `${keyPrefix}.tool_description`, copy.toolDescription),
parameters: {
...tool.tool.function.parameters,
properties: translatedProperties,
},
},
},
};
});

View File

@@ -1,26 +1,95 @@
import type { AIBuiltinToolInfo } from "./aiBuiltinToolInfo.types";
type InspectionToolInfoTranslator = (key: string) => string;
const translateToolInfo = (
t: InspectionToolInfoTranslator | undefined,
key: string,
fallback: string,
): string => {
if (!t) return fallback;
const translated = t(key);
return translated && translated !== key ? translated : fallback;
};
const SQL_TOOL_INFO_KEY_PREFIX = "ai_chat.inspection.tool_info";
const SQL_TOOL_INFO_COPY: Record<
string,
{
desc: string;
detail: string;
toolDescription: string;
params?: Record<string, string>;
}
> = {
inspect_recent_sql_logs: {
desc: "View recent SQL execution logs",
detail:
"Accepts optional limit and status filters, then returns recent SQL execution records including database, duration, success or failure, error, affected rows, and SQL text. Use it to trace failed statements, locate slow queries, and let AI explain or optimize based on real execution history.",
toolDescription:
"Get a summary of recent SQL execution logs, optionally filtered by success or failure. Use it to review recently executed SQL, diagnose failures, locate slow queries, and let AI explain or optimize from real execution history.",
params: {
limit: "Optional. Number of log entries to return. Default 20, maximum 100.",
status: "Optional. Filter by execution status: all, success, or error. Default all.",
},
},
inspect_recent_sql_activity: {
desc: "Summarize recent SQL activity distribution",
detail:
"Can filter by status, activityKind, dbName, and keyword, then returns a structured summary of recent SQL activity including read/write/DDL ratio, statement type distribution, database distribution, recent errors, recent writes, and slowest statements. Use it when the user asks what ran recently, whether data may have been deleted, which database is failing most, or whether recent activity is mostly reads or writes.",
toolDescription:
"Summarize the structured profile of recent SQL activity, optionally filtered by execution status, activity type, database name, and keyword. Use it to inspect recent read/write operations, concentrated errors in a database, DELETE or DDL activity, and let AI judge from the real execution scene first.",
params: {
limit: "Optional. Maximum number of recent activity samples to return. Default 30, maximum 100.",
status: "Optional. Filter by execution status: all, success, or error. Default all.",
activityKind: "Optional. Filter by activity type: all, read, write, ddl, transaction, session, or other. Default all.",
dbName: "Optional. Only include logs whose database name contains this keyword.",
keyword: "Optional. Filter by SQL text, error message, statement type, or database name.",
},
},
inspect_sql_editor_transaction: {
desc: "View SQL editor transaction commit state",
detail:
"Returns SQL editor managed-DML transaction semantics, current manual or auto commit setting, whether the active SQL tab will enter a managed transaction, pending transactions, and recent write or transaction execution records. Use it when the user asks what manual or auto commit means, whether there are uncommitted transactions, or whether update/insert/delete will commit automatically.",
toolDescription:
"Read a SQL editor transaction state snapshot, including the real semantics that DML always enters a managed transaction, current commit mode, auto-commit delay, whether the active SQL tab triggers a managed transaction, pending transactions, and recent write or transaction logs. Use it when the user asks about SQL editor manual commit, auto commit, uncommitted transactions, or whether DML commits after execution.",
params: {
includeSqlPreview: "Optional. Whether to return a SQL preview from the active SQL tab. Default true.",
},
},
inspect_sql_risk: {
desc: "Check execution risk for current or specified SQL",
detail:
"Reads supplied SQL or the current active query tab content, detects multiple statements, writes, DDL, DELETE/UPDATE without WHERE, DROP/TRUNCATE, and other risks, then combines the result with current AI safety policy to say whether execution is allowed. Use it before AI executes SQL, explains risk, or confirms whether a SQL statement can run.",
toolDescription:
"Check execution risk for supplied SQL or the current active query tab SQL, returning statement count, activity type, risk level, risk points, whether user confirmation is required, and the current AI safety policy result. Use it before answering or continuing when the user asks to execute, delete, update, run DDL, run batch SQL, or asks whether a SQL statement can run.",
params: {
sql: "Optional. SQL to inspect. If omitted, the current active query tab SQL draft is read by default.",
previewCharLimit: "Optional. Maximum number of characters to return in the SQL preview. Default 12000, maximum 40000.",
},
},
};
export const BUILTIN_AI_INSPECTION_SQL_TOOL_INFO: AIBuiltinToolInfo[] = [
{
name: "inspect_recent_sql_logs",
icon: "🧾",
desc: "查看最近 SQL 执行日志",
detail:
"传入可选 limit 和 status返回最近 SQL 执行记录,包括数据库、耗时、成功/失败、报错、受影响行数和 SQL 文本。适合追查刚执行失败的语句、定位慢查询,并让 AI 基于真实执行历史给出解释或优化建议。",
desc: SQL_TOOL_INFO_COPY.inspect_recent_sql_logs.desc,
detail: SQL_TOOL_INFO_COPY.inspect_recent_sql_logs.detail,
params: "limit?, status?(all|success|error)",
tool: {
type: "function",
function: {
name: "inspect_recent_sql_logs",
description:
"获取最近 SQL 执行日志摘要,可按成功/失败过滤。适用于回看刚执行过的 SQL、排查失败原因、定位慢查询以及让 AI 基于真实执行历史给出解释和优化建议。",
description: SQL_TOOL_INFO_COPY.inspect_recent_sql_logs.toolDescription,
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "可选,返回多少条日志,默认 20最大 100" },
limit: { type: "number", description: SQL_TOOL_INFO_COPY.inspect_recent_sql_logs.params?.limit },
status: {
type: "string",
description: "可选,按执行状态过滤,支持 all、success、error默认 all",
description: SQL_TOOL_INFO_COPY.inspect_recent_sql_logs.params?.status,
enum: ["all", "success", "error"],
},
},
@@ -31,32 +100,30 @@ export const BUILTIN_AI_INSPECTION_SQL_TOOL_INFO: AIBuiltinToolInfo[] = [
{
name: "inspect_recent_sql_activity",
icon: "📊",
desc: "总结最近 SQL 活动分布",
detail:
"可按 status、activityKind、dbName 和 keyword 过滤,返回最近 SQL 活动的结构化总结,包括读写/DDL 比例、语句类型分布、数据库分布、最近报错、最近写操作和最慢语句。适合用户提到“最近都执行了什么”“是不是刚删过数据”“哪个库最近报错最多”“最近主要在跑查询还是写入”时先读真实执行画像。",
desc: SQL_TOOL_INFO_COPY.inspect_recent_sql_activity.desc,
detail: SQL_TOOL_INFO_COPY.inspect_recent_sql_activity.detail,
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 基于真实执行现场先做全局判断。",
description: SQL_TOOL_INFO_COPY.inspect_recent_sql_activity.toolDescription,
parameters: {
type: "object",
properties: {
limit: { type: "number", description: "可选,最近活动样例最多返回多少条,默认 30最大 100" },
limit: { type: "number", description: SQL_TOOL_INFO_COPY.inspect_recent_sql_activity.params?.limit },
status: {
type: "string",
description: "可选,按执行状态过滤,支持 all、success、error默认 all",
description: SQL_TOOL_INFO_COPY.inspect_recent_sql_activity.params?.status,
enum: ["all", "success", "error"],
},
activityKind: {
type: "string",
description: "可选,按活动类型过滤,支持 all、read、write、ddl、transaction、session、other默认 all",
description: SQL_TOOL_INFO_COPY.inspect_recent_sql_activity.params?.activityKind,
enum: ["all", "read", "write", "ddl", "transaction", "session", "other"],
},
dbName: { type: "string", description: "可选,只看数据库名里包含该关键词的日志" },
keyword: { type: "string", description: "可选,按 SQL 文本、报错信息、语句类型或数据库名做关键词筛选" },
dbName: { type: "string", description: SQL_TOOL_INFO_COPY.inspect_recent_sql_activity.params?.dbName },
keyword: { type: "string", description: SQL_TOOL_INFO_COPY.inspect_recent_sql_activity.params?.keyword },
},
},
},
@@ -65,20 +132,21 @@ export const BUILTIN_AI_INSPECTION_SQL_TOOL_INFO: AIBuiltinToolInfo[] = [
{
name: "inspect_sql_editor_transaction",
icon: "🔁",
desc: "查看 SQL 编辑器事务提交状态",
detail:
"返回 SQL 编辑器 DML 托管事务语义、当前手动/自动提交设置、活动 SQL 页签是否会进入托管事务、待提交事务以及最近写入/事务执行记录。适合用户问“手动/自动提交到底是什么意思”“当前有没有事务没提交”“执行 update/insert/delete 会不会自动提交”时先读真实状态。",
params: "includeSqlPreview?(默认 true)",
desc: SQL_TOOL_INFO_COPY.inspect_sql_editor_transaction.desc,
detail: SQL_TOOL_INFO_COPY.inspect_sql_editor_transaction.detail,
params: "includeSqlPreview?(default true)",
tool: {
type: "function",
function: {
name: "inspect_sql_editor_transaction",
description:
"读取 SQL 编辑器事务状态快照,包括 DML 始终进入托管事务的真实语义、当前提交模式、自动提交延迟、活动 SQL 页签是否会触发托管事务、待提交事务列表和最近写入/事务日志。适用于用户提到 SQL 编辑器手动提交、自动提交、未提交事务、DML 执行后是否提交或事务语义不清时,先读取真实状态再解释。",
description: SQL_TOOL_INFO_COPY.inspect_sql_editor_transaction.toolDescription,
parameters: {
type: "object",
properties: {
includeSqlPreview: { type: "boolean", description: "可选,是否返回活动 SQL 页签的 SQL 预览,默认 true" },
includeSqlPreview: {
type: "boolean",
description: SQL_TOOL_INFO_COPY.inspect_sql_editor_transaction.params?.includeSqlPreview,
},
},
},
},
@@ -87,24 +155,65 @@ export const BUILTIN_AI_INSPECTION_SQL_TOOL_INFO: AIBuiltinToolInfo[] = [
{
name: "inspect_sql_risk",
icon: "🛑",
desc: "检查当前或指定 SQL 的执行风险",
detail:
"读取传入 SQL 或当前活动查询页签内容识别多语句、写入、DDL、DELETE/UPDATE 无 WHERE、DROP/TRUNCATE 等风险,并结合当前 AI 安全策略返回是否允许执行。适合用户让 AI 执行、解释风险、确认能不能跑某条 SQL 前先做一次安全体检。",
params: "sql?(默认读取当前活动查询页签), previewCharLimit?",
desc: SQL_TOOL_INFO_COPY.inspect_sql_risk.desc,
detail: SQL_TOOL_INFO_COPY.inspect_sql_risk.detail,
params: "sql?(default current active query tab), previewCharLimit?",
tool: {
type: "function",
function: {
name: "inspect_sql_risk",
description:
"检查传入 SQL 或当前活动查询页签 SQL 的执行风险,返回语句数量、活动类型、风险级别、危险点、是否需要用户确认,以及当前 AI 安全策略检查结果。适用于用户要求执行、删除、更新、DDL、批量 SQL、或询问某条 SQL 能不能跑时,先读取这份风险快照再回答或继续执行。",
description: SQL_TOOL_INFO_COPY.inspect_sql_risk.toolDescription,
parameters: {
type: "object",
properties: {
sql: { type: "string", description: "可选,要检查的 SQL不传时默认读取当前活动查询页签的 SQL 草稿" },
previewCharLimit: { type: "number", description: "可选SQL 预览最多返回多少字符,默认 12000最大 40000" },
sql: { type: "string", description: SQL_TOOL_INFO_COPY.inspect_sql_risk.params?.sql },
previewCharLimit: { type: "number", description: SQL_TOOL_INFO_COPY.inspect_sql_risk.params?.previewCharLimit },
},
},
},
},
},
];
export const localizeBuiltinInspectionSqlToolInfo = (
t?: InspectionToolInfoTranslator,
): AIBuiltinToolInfo[] =>
BUILTIN_AI_INSPECTION_SQL_TOOL_INFO.map((tool) => {
const copy = SQL_TOOL_INFO_COPY[tool.name];
if (!copy) return tool;
const keyPrefix = `${SQL_TOOL_INFO_KEY_PREFIX}.${tool.name}`;
const originalProperties = tool.tool.function.parameters?.properties || {};
const translatedProperties = Object.fromEntries(
Object.entries(originalProperties).map(([paramName, schema]) => {
const fallback = copy.params?.[paramName];
if (!fallback || !schema || typeof schema !== "object") {
return [paramName, schema];
}
return [
paramName,
{
...schema,
description: translateToolInfo(t, `${keyPrefix}.param.${paramName}`, fallback),
},
];
}),
);
return {
...tool,
desc: translateToolInfo(t, `${keyPrefix}.desc`, copy.desc),
detail: translateToolInfo(t, `${keyPrefix}.detail`, copy.detail),
tool: {
...tool.tool,
function: {
...tool.tool.function,
description: translateToolInfo(t, `${keyPrefix}.tool_description`, copy.toolDescription),
parameters: {
...tool.tool.function.parameters,
properties: translatedProperties,
},
},
},
};
});

View File

@@ -1,9 +1,24 @@
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";
import {
BUILTIN_AI_INSPECTION_CONTEXT_TOOL_INFO,
localizeBuiltinInspectionContextToolInfo,
} from "./aiBuiltinInspectionContextToolInfo";
import {
BUILTIN_AI_INSPECTION_CORE_TOOL_INFO,
localizeBuiltinInspectionCoreToolInfo,
} from "./aiBuiltinInspectionCoreToolInfo";
import {
BUILTIN_AI_INSPECTION_DIAGNOSTICS_TOOL_INFO,
localizeBuiltinInspectionDiagnosticsToolInfo,
} from "./aiBuiltinInspectionDiagnosticsToolInfo";
import {
BUILTIN_AI_INSPECTION_MCP_TOOL_INFO,
localizeBuiltinInspectionMcpToolInfo,
} from "./aiBuiltinInspectionMcpToolInfo";
import {
BUILTIN_AI_INSPECTION_SQL_TOOL_INFO,
localizeBuiltinInspectionSqlToolInfo,
} from "./aiBuiltinInspectionSqlToolInfo";
export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [
...BUILTIN_AI_INSPECTION_CORE_TOOL_INFO,
@@ -12,3 +27,13 @@ export const BUILTIN_AI_INSPECTION_TOOL_INFO: AIBuiltinToolInfo[] = [
...BUILTIN_AI_INSPECTION_SQL_TOOL_INFO,
...BUILTIN_AI_INSPECTION_DIAGNOSTICS_TOOL_INFO,
];
export const localizeBuiltinInspectionToolInfo = (
t?: (key: string) => string,
): AIBuiltinToolInfo[] => [
...localizeBuiltinInspectionCoreToolInfo(t),
...localizeBuiltinInspectionMcpToolInfo(t),
...localizeBuiltinInspectionContextToolInfo(t),
...localizeBuiltinInspectionSqlToolInfo(t),
...localizeBuiltinInspectionDiagnosticsToolInfo(t),
];

View File

@@ -5,9 +5,10 @@ import {
describeBuiltinToolParameters,
filterBuiltinToolFlows,
filterBuiltinTools,
localizeBuiltinToolFlows,
} from './aiBuiltinToolCatalog';
import type { AIBuiltinToolInfo } from './aiBuiltinToolInfo.types';
import { BUILTIN_AI_TOOL_INFO } from './aiToolRegistry';
import { BUILTIN_AI_TOOL_INFO, localizeBuiltinAIToolInfo } from './aiToolRegistry';
describe('describeBuiltinToolParameters', () => {
it('extracts type, required, enum, default, and example hints from builtin tool schemas', () => {
@@ -82,16 +83,161 @@ describe('describeBuiltinToolParameters', () => {
expect(allowMutatingTools).toContain('inspect_ai_safety');
expect(allowMutatingTools).not.toContain('inspect_mcp_runtime_failures');
const executeSqlTools = filterBuiltinTools(BUILTIN_AI_TOOL_INFO, '要执行的 SQL 语句')
const executeSqlTools = filterBuiltinTools(BUILTIN_AI_TOOL_INFO, 'SQL statement to execute')
.map((tool) => tool.name);
expect(executeSqlTools).toContain('execute_sql');
const mcpFlows = filterBuiltinToolFlows(BUILTIN_TOOL_FLOWS, '运行期失败日志')
const mcpFlows = filterBuiltinToolFlows(BUILTIN_TOOL_FLOWS, 'runtime failure logs')
.map((flow) => flow.title);
expect(mcpFlows).toContain('排查 MCP 接入状态');
expect(mcpFlows).toContain('Troubleshoot MCP access status');
const codebaseFlows = filterBuiltinToolFlows(BUILTIN_TOOL_FLOWS, '拆分热点')
const codebaseFlows = filterBuiltinToolFlows(BUILTIN_TOOL_FLOWS, 'split hotspots')
.map((flow) => flow.title);
expect(codebaseFlows).toContain('治理前端大文件');
expect(codebaseFlows).toContain('Govern large frontend files');
});
it('localizes builtin tool flows while preserving raw tool-call steps', () => {
const translations: Record<string, string> = {
'ai_chat.builtin_tools.flows.locate_table_fields.title': 'Locate tables and fields',
'ai_chat.builtin_tools.flows.locate_table_fields.description': 'Find the connection, database, and table first, then confirm fields before writing SQL.',
};
const t = (key: string) => translations[key] || `missing:${key}`;
const [flow] = localizeBuiltinToolFlows(t);
expect(flow.title).toBe('Locate tables and fields');
expect(flow.steps).toBe('get_connections -> get_databases -> get_tables -> get_columns');
expect(flow.description).toContain('writing SQL');
});
it('localizes database builtin tool copy while preserving raw tool and parameter names', () => {
const translations: Record<string, string> = {
'ai_chat.builtin_tools.database.execute_sql.desc': 'Execute a SQL query and return results',
'ai_chat.builtin_tools.database.execute_sql.detail': 'Runs SQL on the target database; read-only mode only allows SELECT/SHOW/DESCRIBE.',
'ai_chat.builtin_tools.database.execute_sql.params': 'connectionId, dbName, sql',
'ai_chat.builtin_tools.database.execute_sql.tool_description': 'Run SQL on the selected connection and database. SELECT/SHOW/DESCRIBE stay raw.',
'ai_chat.builtin_tools.database.execute_sql.parameters.connectionId.description': 'Connection ID',
'ai_chat.builtin_tools.database.execute_sql.parameters.dbName.description': 'Database name',
'ai_chat.builtin_tools.database.execute_sql.parameters.sql.description': 'SQL statement to execute',
};
const t = (key: string) => translations[key] || `missing:${key}`;
const executeSql = localizeBuiltinAIToolInfo(t).find((tool) => tool.name === 'execute_sql');
expect(executeSql?.name).toBe('execute_sql');
expect(executeSql?.desc).toBe('Execute a SQL query and return results');
expect(executeSql?.detail).toContain('SELECT/SHOW/DESCRIBE');
expect(executeSql?.params).toBe('connectionId, dbName, sql');
expect(executeSql?.tool.function.name).toBe('execute_sql');
expect(executeSql?.tool.function.description).toContain('SELECT/SHOW/DESCRIBE');
expect(Object.keys(executeSql?.tool.function.parameters.properties || {})).toEqual([
'connectionId',
'dbName',
'sql',
]);
expect(executeSql?.tool.function.parameters.properties.sql.description).toBe('SQL statement to execute');
});
it('localizes MCP inspection tool copy while preserving raw tool and parameter names', () => {
const translations: Record<string, string> = {
'ai_chat.inspection.tool_info.inspect_mcp_remote_access.desc': 'Inspect remote MCP access',
'ai_chat.inspection.tool_info.inspect_mcp_remote_access.detail': 'Returns GoNavi Streamable HTTP MCP access guidance for remote Agents.',
'ai_chat.inspection.tool_info.inspect_mcp_remote_access.params': 'publicUrl?, localAddr?, path?, exposeStrategy?, tokenConfigured?',
'ai_chat.inspection.tool_info.inspect_mcp_remote_access.tool_description': 'Read the GoNavi MCP remote Agent access snapshot.',
'ai_chat.inspection.tool_info.inspect_mcp_remote_access.param.publicUrl': 'Optional HTTPS or private-network URL reachable by the remote Agent.',
'ai_chat.inspection.tool_info.inspect_mcp_remote_access.param.localAddr': 'Optional local HTTP MCP listen address.',
'ai_chat.inspection.tool_info.inspect_mcp_remote_access.param.path': 'Optional Streamable HTTP MCP path.',
'ai_chat.inspection.tool_info.inspect_mcp_remote_access.param.exposeStrategy': 'Optional remote exposure strategy.',
'ai_chat.inspection.tool_info.inspect_mcp_remote_access.param.tokenConfigured': 'Optional. Whether a random Bearer Token is already configured.',
};
const t = (key: string) => translations[key] || `missing:${key}`;
const remoteAccess = localizeBuiltinAIToolInfo(t).find((tool) => tool.name === 'inspect_mcp_remote_access');
expect(remoteAccess?.name).toBe('inspect_mcp_remote_access');
expect(remoteAccess?.desc).toBe('Inspect remote MCP access');
expect(remoteAccess?.detail).toContain('Streamable HTTP MCP');
expect(remoteAccess?.params).toBe('publicUrl?, localAddr?, path?, exposeStrategy?, tokenConfigured?');
expect(remoteAccess?.tool.function.name).toBe('inspect_mcp_remote_access');
expect(remoteAccess?.tool.function.description).toBe('Read the GoNavi MCP remote Agent access snapshot.');
expect(Object.keys(remoteAccess?.tool.function.parameters.properties || {})).toEqual([
'publicUrl',
'localAddr',
'path',
'exposeStrategy',
'tokenConfigured',
]);
expect(remoteAccess?.tool.function.parameters.properties.publicUrl.description).toContain('remote Agent');
});
it('localizes diagnostics inspection tool copy while preserving raw tool and parameter names', () => {
const translations: Record<string, string> = {
'ai_chat.inspection.tool_info.inspect_app_logs.desc': 'Inspect GoNavi application logs',
'ai_chat.inspection.tool_info.inspect_app_logs.detail': 'Reads recent GoNavi application log lines with optional keyword filtering.',
'ai_chat.inspection.tool_info.inspect_app_logs.params': 'keyword?, lineLimit?(default 80)',
'ai_chat.inspection.tool_info.inspect_app_logs.tool_description': 'Read recent GoNavi application logs before diagnosing startup or MCP failures.',
'ai_chat.inspection.tool_info.inspect_app_logs.param.keyword': 'Optional keyword used to filter log content.',
'ai_chat.inspection.tool_info.inspect_app_logs.param.lineLimit': 'Optional maximum number of log lines to return.',
};
const t = (key: string) => translations[key] || `missing:${key}`;
const appLogs = localizeBuiltinAIToolInfo(t).find((tool) => tool.name === 'inspect_app_logs');
expect(appLogs?.name).toBe('inspect_app_logs');
expect(appLogs?.desc).toBe('Inspect GoNavi application logs');
expect(appLogs?.detail).toContain('keyword filtering');
expect(appLogs?.params).toBe('keyword?, lineLimit?(default 80)');
expect(appLogs?.tool.function.name).toBe('inspect_app_logs');
expect(appLogs?.tool.function.description).toBe('Read recent GoNavi application logs before diagnosing startup or MCP failures.');
expect(Object.keys(appLogs?.tool.function.parameters.properties || {})).toEqual([
'keyword',
'lineLimit',
]);
expect(appLogs?.tool.function.parameters.properties.keyword.description).toBe('Optional keyword used to filter log content.');
});
it('localizes core and context inspection tool copy while preserving raw names', () => {
const translations: Record<string, string> = {
'ai_chat.inspection.tool_info.inspect_ai_runtime.desc': 'Inspect current AI runtime',
'ai_chat.inspection.tool_info.inspect_ai_runtime.detail': 'Returns provider, model, safety level, enabled Skills, and available tools.',
'ai_chat.inspection.tool_info.inspect_ai_runtime.params': 'No parameters',
'ai_chat.inspection.tool_info.inspect_ai_runtime.tool_description': 'Read the current AI runtime snapshot before answering capability questions.',
'ai_chat.inspection.tool_info.inspect_ai_safety.desc': 'Inspect AI write safety boundaries',
'ai_chat.inspection.tool_info.inspect_ai_safety.detail': 'Returns SQL write boundaries and whether allowMutating is required.',
'ai_chat.inspection.tool_info.inspect_ai_safety.params': 'No parameters',
'ai_chat.inspection.tool_info.inspect_ai_safety.tool_description': 'Read the current AI safety boundary including allowMutating requirements.',
'ai_chat.inspection.tool_info.inspect_current_connection.desc': 'Inspect the current connection',
'ai_chat.inspection.tool_info.inspect_current_connection.detail': 'Returns the active data source, database, address, and SSH or proxy state.',
'ai_chat.inspection.tool_info.inspect_current_connection.params': 'No parameters',
'ai_chat.inspection.tool_info.inspect_current_connection.tool_description': 'Read the active connection summary before database exploration.',
'ai_chat.inspection.tool_info.inspect_connection_capabilities.desc': 'Inspect data-source capabilities',
'ai_chat.inspection.tool_info.inspect_connection_capabilities.detail': 'Returns capability flags for the current or specified connection.',
'ai_chat.inspection.tool_info.inspect_connection_capabilities.params': 'connectionId?(default current active connection)',
'ai_chat.inspection.tool_info.inspect_connection_capabilities.tool_description': 'Read the frontend capability matrix for a saved connection.',
'ai_chat.inspection.tool_info.inspect_connection_capabilities.param.connectionId': 'Optional connection ID to inspect.',
};
const t = (key: string) => translations[key] || `missing:${key}`;
const runtime = localizeBuiltinAIToolInfo(t).find((tool) => tool.name === 'inspect_ai_runtime');
const safety = localizeBuiltinAIToolInfo(t).find((tool) => tool.name === 'inspect_ai_safety');
const currentConnection = localizeBuiltinAIToolInfo(t).find((tool) => tool.name === 'inspect_current_connection');
const capabilities = localizeBuiltinAIToolInfo(t).find((tool) => tool.name === 'inspect_connection_capabilities');
expect(runtime?.name).toBe('inspect_ai_runtime');
expect(runtime?.desc).toBe('Inspect current AI runtime');
expect(runtime?.tool.function.name).toBe('inspect_ai_runtime');
expect(runtime?.tool.function.description).toBe('Read the current AI runtime snapshot before answering capability questions.');
expect(safety?.desc).toBe('Inspect AI write safety boundaries');
expect(safety?.detail).toContain('allowMutating');
expect(safety?.tool.function.description).toContain('allowMutating');
expect(currentConnection?.name).toBe('inspect_current_connection');
expect(currentConnection?.desc).toBe('Inspect the current connection');
expect(currentConnection?.detail).toContain('SSH');
expect(capabilities?.params).toBe('connectionId?(default current active connection)');
expect(Object.keys(capabilities?.tool.function.parameters.properties || {})).toEqual(['connectionId']);
expect(capabilities?.tool.function.parameters.properties.connectionId.description).toBe('Optional connection ID to inspect.');
});
});

View File

@@ -16,234 +16,311 @@ export interface AIBuiltinToolParameterHint {
exampleValue: string;
}
export const BUILTIN_TOOL_FLOWS: AIBuiltinToolFlow[] = [
type BuiltinToolFlowTranslator = (key: string) => string;
interface BuiltinToolFlowCopy extends AIBuiltinToolFlow {
key: string;
}
const BUILTIN_TOOL_FLOW_KEY_PREFIX = 'ai_chat.builtin_tools.flows';
const translateBuiltinToolFlow = (
t: BuiltinToolFlowTranslator | undefined,
key: string,
fallback: string,
): string => {
if (!t) return fallback;
const translated = t(key);
return translated && translated !== key ? translated : fallback;
};
const BUILTIN_TOOL_FLOW_COPY: BuiltinToolFlowCopy[] = [
{
title: '定位表与字段',
key: 'locate_table_fields',
title: 'Locate tables and fields',
steps: 'get_connections -> get_databases -> get_tables -> get_columns',
description: '适合先找连接、找库、找表,再确认真实字段名后生成 SQL',
description: 'Find the connection, database, and table first, then confirm real field names before generating SQL.',
},
{
title: '字段反查表',
key: 'field_lookup_table',
title: 'Find tables by field',
steps: 'get_databases -> get_all_columns',
description: '适合只知道字段名、业务含义或注释关键词,但还不确定具体落在哪张表。',
description: 'Use when only a field name, business meaning, or comment keyword is known, but the exact table is still unclear.',
},
{
title: '结构深挖',
key: 'deep_structure',
title: 'Deep-dive structure',
steps: 'get_columns -> get_indexes -> get_foreign_keys -> get_triggers -> get_table_ddl',
description: '适合做索引优化、关系梳理、隐式副作用排查和 DDL 审查。',
description: 'Use for index optimization, relationship mapping, implicit side-effect investigation, and DDL review.',
},
{
title: '一键结构快照',
key: 'table_snapshot',
title: 'One-shot table snapshot',
steps: 'inspect_table_bundle',
description: '适合一次带回字段、索引、外键、触发器和 DDL必要时还能附带样例行减少来回调用。',
description: 'Return columns, indexes, foreign keys, triggers, and DDL in one call; sample rows can be included when needed to reduce round trips.',
},
{
title: '全库快速摸底',
key: 'database_overview',
title: 'Quick database overview',
steps: 'inspect_database_bundle -> inspect_table_bundle',
description: '适合先看整库有哪些表、每张表大概有哪些字段,再对目标表继续做深挖快照。',
description: 'Start by seeing which tables exist and what fields they roughly contain, then drill into target tables with snapshots.',
},
{
title: 'AI 应用健康总览',
key: 'app_health_overview',
title: 'AI app health overview',
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 问题交织、回复气泡显示异常,或需要先看整体健康状态时,一次汇总配置、日志、连接失败、渲染异常、消息流和工作区现场。',
description: 'Use when AI instability, connection issues, MCP issues, or message rendering problems overlap and an overall health snapshot is needed first.',
},
{
title: '导出 AI 排障支持包',
key: 'support_bundle',
title: 'Export AI troubleshooting support bundle',
steps: 'inspect_ai_support_bundle -> inspect_app_health / inspect_ai_context_budget / inspect_ai_message_flow / inspect_mcp_remote_access',
description: '适合需要一次性带走排障证据,或用户反馈 AI 不成熟、不稳定、MCP/连接/日志/上下文都可能相关时,先生成不含密钥和数据库密码的支持包。',
description: 'Use when troubleshooting evidence needs to be collected at once, without secrets or database passwords.',
},
{
title: '选择 AI 工具路线',
key: 'choose_tool_route',
title: 'Choose an AI tool route',
steps: 'inspect_ai_tool_catalog -> inspect_ai_runtime / inspect_mcp_setup',
description: '适合先按关键词确认该用哪些内置探针、每个工具 arguments 怎么填,以及当前有没有外部 MCP 工具可用。',
description: 'Use keywords to decide which built-in probes to call, how to fill tool arguments, and whether external MCP tools are available.',
},
{
title: '一键体检 AI 配置',
key: 'ai_setup_health',
title: 'One-shot AI setup health check',
steps: 'inspect_ai_setup_health -> inspect_ai_providers / inspect_mcp_setup / inspect_ai_guidance',
description: '适合先拿到一份 AI 配置健康快照看清当前是供应商没配好、聊天发送前置没满足、MCP 没接入,还是提示词 / Skills / 上下文还不完整,再决定往哪条探针继续下钻。',
description: 'Get an AI configuration health snapshot first, then decide whether to drill into providers, chat readiness, MCP, prompts, Skills, or context.',
},
{
title: '查看 AI 当前能力',
key: 'ai_runtime',
title: 'Inspect current AI capabilities',
steps: 'inspect_ai_runtime -> inspect_ai_context / inspect_current_connection',
description: '适合先确认当前模型、安全级别、上下文级别、Skills 和 MCP 工具,再决定让 AI 走哪条探针链路。',
description: 'Confirm the current model, safety level, context level, Skills, and MCP tools before choosing a probe chain.',
},
{
title: '核对写入安全边界',
key: 'safety_boundary',
title: 'Check write safety boundaries',
steps: 'inspect_ai_safety -> inspect_ai_runtime -> inspect_current_connection',
description: '适合先确认当前是不是只读、DDL/DML 到底允不允许、MCP 写操作是否还需要 allowMutating再决定后续该走查询、改数据还是改结构。',
description: 'Check whether the current state is read-only, whether DDL/DML is allowed, and whether MCP writes require allowMutating.',
},
{
title: '排查供应商与模型',
key: 'providers_models',
title: 'Troubleshoot providers and models',
steps: 'inspect_ai_providers -> inspect_ai_runtime',
description: '适合先确认当前到底配置了哪些供应商、哪个在生效、有没有缺密钥或没选模型,再解释为什么 AI 不能发送、为什么模型列表为空。',
description: 'Confirm which providers are configured and active, whether keys or models are missing, and why chat cannot send or model lists are empty.',
},
{
title: '排查聊天发送状态',
key: 'chat_readiness',
title: 'Troubleshoot chat send readiness',
steps: 'inspect_ai_chat_readiness -> inspect_ai_providers',
description: '适合先确认当前聊天输入区到底缺什么前置条件,例如没选活动供应商、缺密钥、缺接口地址、没选模型,避免只凭界面现象猜测。',
description: 'Check which chat input prerequisites are missing, such as active provider, key, endpoint, or selected model, instead of guessing from UI symptoms.',
},
{
title: '追踪 AI 上游请求',
key: 'upstream_request',
title: 'Trace AI upstream requests',
steps: 'inspect_ai_upstream_logs -> inspect_ai_providers / inspect_ai_message_flow',
description: '适合用户想看发给上游模型的真实入参、requestId、状态码、耗时或请求体预览时先读脱敏后的 gonavi.log 请求记录,再结合供应商配置和当前消息流继续排查。',
description: 'Read redacted gonavi.log request records when the user needs upstream payloads, requestId, status codes, latency, or request body previews.',
},
{
title: '排查 MCP 接入状态',
key: 'mcp_setup',
title: 'Troubleshoot MCP access status',
steps: 'inspect_mcp_setup -> inspect_mcp_runtime_failures -> inspect_ai_runtime',
description: '适合先确认当前配置了哪些 MCP 服务、哪些已启用、外部客户端有没有写入当前 GoNavi 路径,再结合 MCP 运行期失败日志判断为什么某个工具没暴露出来。',
description: 'Confirm configured and enabled MCP services and external client write status, then use MCP runtime failure logs to explain missing tools.',
},
{
title: '远程 Agent 接入 GoNavi MCP',
key: 'remote_agent_mcp',
title: 'Connect remote Agents to GoNavi MCP',
steps: 'inspect_mcp_remote_access -> inspect_mcp_setup -> inspect_ai_safety',
description: '适合 OpenClaw/Hermans 部署在云端 Linux但数据库连接和密码只在 Windows GoNavi 本机时,先生成 HTTP MCP、Bearer Token、隧道和安全边界指引。',
description: 'Use when OpenClaw/Hermans run on cloud Linux while database connections and passwords stay on the Windows GoNavi machine.',
},
{
title: '新增 MCP 填写指引',
key: 'mcp_authoring',
title: 'New MCP authoring guide',
steps: 'inspect_mcp_authoring_guide -> inspect_mcp_draft -> inspect_mcp_setup',
description: '适合先读真实字段说明、模板样例和整行命令拆分规则,再把用户贴出的命令或草稿交给真实校验器试算,最后结合当前 MCP 配置现状判断应该新增哪种启动方式。',
description: 'Read real field descriptions, templates, and full-command splitting rules before validating pasted commands or drafts.',
},
{
title: '排查 Docker MCP 启动',
key: 'docker_mcp',
title: 'Troubleshoot Docker MCP startup',
steps: 'inspect_mcp_runtime_failures -> inspect_mcp_docker_setup -> inspect_mcp_draft',
description: '适合用户按 Docker README 新增 MCP 后发现 0 个工具、容器一启动就退出,或不确定 docker run 参数是否拆对时,先看运行期失败原因,再检查 run、-i、镜像名和超时设置。',
description: 'Use when Docker README setup discovers 0 tools, containers exit immediately, or docker run arguments may be split incorrectly.',
},
{
title: '查看 MCP 工具参数',
key: 'mcp_tool_parameters',
title: 'Inspect MCP tool parameters',
steps: 'inspect_mcp_setup -> inspect_mcp_tool_schema',
description: '适合先找到当前真实发现到的 MCP 工具 alias,再读取对应 inputSchema、必填字段、枚举和嵌套参数路径避免调用外部 MCP 工具时乱填 arguments。',
description: 'Find the real discovered MCP tool alias first, then read inputSchema, required fields, enums, and nested parameter paths.',
},
{
title: '查看当前提示与 Skills',
key: 'prompts_skills',
title: 'Inspect current prompts and Skills',
steps: 'inspect_ai_guidance -> inspect_ai_runtime',
description: '适合先确认当前自定义提示词、启用的 Skills、依赖工具和生效范围再解释为什么 AI 当前会这样回答或为什么某个规则没有触发。',
description: 'Confirm current custom prompts, enabled Skills, dependency tools, and effective scope before explaining current AI behavior.',
},
{
title: '查看当前 AI 上下文',
key: 'ai_context',
title: 'Inspect current AI context',
steps: 'inspect_ai_context -> inspect_table_bundle / get_columns',
description: '适合先确认这轮对话当前到底挂了哪些表结构,再继续做字段核对、表设计评审或 SQL 生成。',
description: 'Confirm which table structures are attached to the current conversation before field checks, table design review, or SQL generation.',
},
{
title: '查看当前连接',
key: 'current_connection',
title: 'Inspect current connection',
steps: 'inspect_current_connection -> get_databases / get_tables',
description: '适合先确认当前活动数据源的类型、地址、当前库和 SSH/代理状态,再继续做库表探索或连接问题排查。',
description: 'Confirm the active data source type, address, current database, and SSH/proxy status before database exploration or connection troubleshooting.',
},
{
title: '核对数据源能力边界',
key: 'connection_capabilities',
title: 'Check data-source capability boundaries',
steps: 'inspect_connection_capabilities -> inspect_current_connection',
description: '适合先确认当前连接到底支不支持建库、删库、结果编辑、SQL 导出或近似计数,再解释为什么某些按钮没出现或某类操作只能只读。',
description: 'Check whether the current connection supports database creation/deletion, result editing, SQL export, or approximate counts.',
},
{
title: '盘点本地连接资产',
key: 'saved_connections',
title: 'Inventory local connection assets',
steps: 'inspect_saved_connections -> inspect_current_connection / get_databases',
description: '适合先按关键词或类型筛出本地保存的数据源,再挑目标连接继续看当前状态或库表结构。',
description: 'Filter locally saved data sources by keyword or type, then inspect the chosen connection state or database structure.',
},
{
title: '诊断 Redis 拓扑',
key: 'redis_topology',
title: 'Diagnose Redis topology',
steps: 'inspect_redis_topology -> inspect_current_connection / inspect_app_logs',
description: '适合用户问 Redis 哨兵、Cluster、多节点、切库失败或 SSH 隧道不可用时,先拿到状态分级、脱敏 URI、后端适配器、DB 语义和下一步动作。',
description: 'Use for Redis Sentinel, Cluster, multi-node, DB switch failures, or SSH tunnel issues to get status, redacted URI, adapter, DB semantics, and next actions.',
},
{
title: '盘点外部 SQL 目录',
key: 'external_sql_dirs',
title: 'Inventory external SQL directories',
steps: 'inspect_external_sql_directories -> inspect_workspace_tabs / inspect_active_tab',
description: '适合先确认本地配置了哪些外部 SQL 目录、目录绑定到哪个连接/库,以及当前打开的 SQL 文件来自哪里,再继续分析脚本内容。',
description: 'Confirm configured external SQL directories, their connection/database bindings, and where an opened SQL file comes from before analyzing scripts.',
},
{
title: '读取外部 SQL 文件',
key: 'external_sql_file',
title: 'Read external SQL files',
steps: 'inspect_external_sql_directories -> inspect_external_sql_file -> inspect_active_tab',
description: '适合先定位具体脚本路径,再直接读取目录中的 SQL 文件内容;如果这个文件已经在编辑器里打开,再继续结合当前页签草稿一起分析。',
description: 'Locate a script path, read SQL file content from the directory, and combine it with the active tab draft if already opened.',
},
{
title: '读取当前页签',
key: 'active_tab',
title: 'Read the current tab',
steps: 'inspect_active_tab -> get_columns / get_indexes / execute_sql',
description: '适合先读取当前编辑器里的 SQL 草稿或当前表页签,再继续做字段核对、索引分析和只读验证。',
description: 'Read the current editor SQL draft or table tab before field checks, index analysis, and read-only verification.',
},
{
title: '盘点当前工作区',
key: 'workspace_tabs',
title: 'Inventory the current workspace',
steps: 'inspect_workspace_tabs -> inspect_active_tab -> get_columns / execute_sql',
description: '适合先看当前打开了哪些 SQL / 表 / 命令页签,再切到目标页签继续做字段核对、对比分析和只读验证。',
description: 'See which SQL, table, or command tabs are open, then inspect the target tab for field checks, comparisons, and read-only validation.',
},
{
title: '查看当前快捷键配置',
key: 'shortcuts',
title: 'Inspect current shortcut configuration',
steps: 'inspect_shortcuts -> inspect_active_tab / inspect_workspace_tabs',
description: '适合先确认当前 Win / Mac 快捷键、是否改过默认值以及结果区、AI 面板、查询执行等动作到底该怎么按,再结合当前页签解释具体使用场景。',
description: 'Confirm current Win/Mac shortcuts, customizations, and how to trigger result panel, AI panel, query execution, and related actions.',
},
{
title: '回看最近执行记录',
key: 'recent_sql_logs',
title: 'Review recent execution records',
steps: 'inspect_recent_sql_logs -> get_columns / get_indexes / execute_sql',
description: '适合追查刚刚执行失败的 SQL、慢查询耗时或基于真实执行历史继续让 AI 给解释和优化建议。',
description: 'Trace recently failed SQL, slow query duration, or let AI explain and optimize based on real execution history.',
},
{
title: '总结最近 SQL 活动',
key: 'recent_sql_activity',
title: 'Summarize recent SQL activity',
steps: 'inspect_recent_sql_activity -> inspect_recent_sql_logs -> inspect_current_connection',
description: '适合先看最近到底以读还是写为主、有没有 DDL 或删除、哪个库最近报错最多,再决定继续下钻哪条日志或哪个连接。',
description: 'Check whether recent activity is mostly read or write, whether DDL or deletes occurred, and which database has the most recent errors.',
},
{
title: '核对 SQL 编辑器事务',
key: 'sql_editor_transaction',
title: 'Check SQL editor transactions',
steps: 'inspect_sql_editor_transaction -> inspect_recent_sql_activity -> inspect_sql_risk',
description: '适合先确认 SQL 编辑器 DML 是否会进入托管事务、当前是手动还是自动提交、有没有待提交事务,再解释 update/insert/delete 执行后的提交语义。',
description: 'Confirm whether SQL editor DML enters a managed transaction, current commit mode, pending transactions, and commit semantics after update/insert/delete.',
},
{
title: 'SQL 风险预检',
key: 'sql_risk',
title: 'Pre-check SQL risk',
steps: 'inspect_sql_risk -> inspect_ai_safety -> execute_sql',
description: '适合用户要求执行、删除、更新、DDL 或批量 SQL 前,先检查语句数量、写入/DDL 风险、WHERE 条件和当前安全策略,再决定是否需要用户确认。',
description: 'Before execution, deletion, update, DDL, or batch SQL, check statement count, write/DDL risk, WHERE clauses, and current safety policy.',
},
{
title: '排查应用日志',
key: 'app_logs',
title: 'Troubleshoot application logs',
steps: 'inspect_app_logs -> inspect_mcp_setup / inspect_saved_connections / inspect_current_connection',
description: '适合先回看 gonavi.log 尾部的 ERROR/WARN再结合 MCP、连接和当前数据源状态继续定位启动异常、连接失败或外部工具拉起问题。',
description: 'Review ERROR/WARN lines from the gonavi.log tail, then combine MCP, connection, and current data source state for diagnosis.',
},
{
title: '排查连接失败与冷却',
key: 'connection_failures',
title: 'Troubleshoot connection failures and cooldown',
steps: 'inspect_recent_connection_failures -> inspect_current_connection / inspect_saved_connections / inspect_app_logs',
description: '适合用户直接问“为什么连接不上”或已经看到冷却/验证失败提示时,先拿到结构化根因、最新地址和下一步建议,再决定回到连接配置还是看更长日志。',
description: 'When connection failures, cooldown, or validation failures appear, get structured root cause, latest address, and next actions first.',
},
{
title: '排查 AI 气泡渲染异常',
key: 'render_error',
title: 'Troubleshoot AI bubble render errors',
steps: 'inspect_ai_last_render_error -> inspect_active_tab / inspect_ai_runtime',
description: '适合用户反馈 AI 某条消息空白、气泡局部报错但整个面板没挂时,先拿到最近一次被隔离的渲染异常快照,再回到具体会话和运行时上下文继续缩小范围。',
description: 'Use when an AI message is blank or a bubble fails locally while the panel stays alive; read the isolated render-error snapshot first.',
},
{
title: '诊断 AI 消息流',
key: 'message_flow',
title: 'Diagnose AI message flow',
steps: 'inspect_ai_message_flow -> inspect_ai_last_render_error / inspect_app_logs',
description: '适合用户反馈回复被拆成多个气泡、工具调用后没继续回答、消息流状态不对时,先读取当前会话的真实消息结构和异常信号。',
description: 'Read the real current-session message structure and anomaly signals when replies split into bubbles, tool calls do not close, or flow state looks wrong.',
},
{
title: '诊断 AI 上下文体量',
key: 'context_budget',
title: 'Diagnose AI context size',
steps: 'inspect_ai_context_budget -> inspect_ai_context / inspect_ai_message_flow / inspect_ai_tool_catalog',
description: '适合用户反馈 AI 变慢、乱答、上下文太大、工具结果过长或表结构挂太多时,先看消息、DDLMCP schema、提示词和 Skills 的体量来源,再决定收窄上下文或拆任务。',
description: 'When AI slows down, answers poorly, or context is too large, inspect messages, DDL, MCP schema, prompts, and Skills before narrowing context.',
},
{
title: '治理前端大文件',
key: 'codebase_hotspots',
title: 'Govern large frontend files',
steps: 'inspect_codebase_hotspots -> inspect_ai_tool_catalog',
description: '适合用户要求继续拆分几千行组件、评估下一步重构切入点,或 AI 修改 UI/AI/MCP 前先判断大文件拆分热点、风险和验证范围。',
description: 'Use before splitting thousand-line components, choosing the next refactor slice, or changing UI/AI/MCP code to inspect split hotspots, risk, and validation scope.',
},
{
title: '复用历史 SQL',
key: 'saved_queries',
title: 'Reuse saved SQL',
steps: 'inspect_saved_queries -> get_columns / execute_sql',
description: '适合先找本地保存过的查询脚本,再核对字段和只读验证,避免把之前写过的 SQL 重新手打一遍。',
description: 'Find locally saved query scripts first, then check fields and run read-only validation instead of rewriting old SQL manually.',
},
{
title: '回看 AI 历史对话',
key: 'ai_sessions',
title: 'Review AI chat history',
steps: 'inspect_ai_sessions -> inspect_active_tab / inspect_saved_queries',
description: '适合先定位之前聊过的 AI 会话、首条问题和最近回复,再继续复用当前页签或历史 SQL 上下文。',
description: 'Locate previous AI sessions, first user questions, and recent replies before reusing the current tab or historical SQL context.',
},
{
title: '查找模板片段',
key: 'sql_snippets',
title: 'Find SQL snippet templates',
steps: 'inspect_sql_snippets',
description: '适合先找团队已有的 SQL 片段模板、补全前缀和常用骨架,再决定是否继续改写。',
description: 'Find team SQL snippet templates, completion prefixes, and common skeletons before deciding whether to rewrite.',
},
{
title: '理解样例数据',
key: 'sample_data',
title: 'Understand sample data',
steps: 'get_columns -> preview_table_rows',
description: '适合先确认字段,再直接查看前几行真实样例数据和空值形态。',
description: 'Confirm fields first, then inspect the first real sample rows and null patterns.',
},
{
title: '只读验证',
key: 'readonly_validation',
title: 'Read-only validation',
steps: 'get_columns -> preview_table_rows -> execute_sql',
description: '适合生成 SQL 后做小范围结果核对,仍会受 AI 安全级别控制。',
description: 'After generating SQL, validate results on a small scope while still respecting the AI safety level.',
},
];
export const localizeBuiltinToolFlows = (
t?: BuiltinToolFlowTranslator,
): AIBuiltinToolFlow[] =>
BUILTIN_TOOL_FLOW_COPY.map((flow) => {
const keyPrefix = `${BUILTIN_TOOL_FLOW_KEY_PREFIX}.${flow.key}`;
return {
title: translateBuiltinToolFlow(t, `${keyPrefix}.title`, flow.title),
steps: flow.steps,
description: translateBuiltinToolFlow(t, `${keyPrefix}.description`, flow.description),
};
});
export const BUILTIN_TOOL_FLOWS: AIBuiltinToolFlow[] = localizeBuiltinToolFlows();
const stringifyHintValue = (value: unknown): string => {
if (value === undefined) return '';
if (value === null) return 'null';
@@ -273,12 +350,12 @@ const readDefaultValue = (schema: Record<string, any>, description: string): str
if (Object.prototype.hasOwnProperty.call(schema, 'default')) {
return stringifyHintValue(schema.default);
}
const match = description.match(/\s*([^\s,;)]+)/u);
const match = description.match(/\u9ed8\u8ba4\s*([^\s\uff0c,;\u3002)\uff09]+)/u);
return match?.[1]?.trim() || '';
};
const readExampleValue = (description: string): string => {
const match = description.match(/(?:|?[:])\s*([^;\n]+)/u);
const match = description.match(/(?:\u4f8b\u5982|\u793a\u4f8b\u503c?[:\uff1a])\s*([^\u3002;\n]+)/u);
return match?.[1]?.trim() || '';
};

View File

@@ -1,5 +1,11 @@
import { BUILTIN_AI_DATABASE_TOOL_INFO } from "./aiBuiltinDatabaseToolInfo";
import { BUILTIN_AI_INSPECTION_TOOL_INFO } from "./aiBuiltinInspectionToolInfo";
import {
BUILTIN_AI_DATABASE_TOOL_INFO,
localizeBuiltinDatabaseToolInfo,
} from "./aiBuiltinDatabaseToolInfo";
import {
BUILTIN_AI_INSPECTION_TOOL_INFO,
localizeBuiltinInspectionToolInfo,
} from "./aiBuiltinInspectionToolInfo";
export type {
AIChatToolDefinition,
@@ -10,3 +16,10 @@ export const BUILTIN_AI_TOOL_INFO = [
...BUILTIN_AI_DATABASE_TOOL_INFO,
...BUILTIN_AI_INSPECTION_TOOL_INFO,
];
export const localizeBuiltinAIToolInfo = (
t?: (key: string) => string,
) => [
...localizeBuiltinDatabaseToolInfo(t),
...localizeBuiltinInspectionToolInfo(t),
];

View File

@@ -1,8 +1,14 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { afterEach, describe, expect, it } from 'vitest';
import { compressContextIfNeeded, getDynamicMaxContextChars, sanitizeErrorMsg } from './aiChatRuntime';
import { setCurrentLanguage } from '../i18n';
describe('aiChatRuntime', () => {
afterEach(() => {
setCurrentLanguage('zh-CN');
});
it('maps modern model families to practical context windows', () => {
expect(getDynamicMaxContextChars('gemini-2.5-pro')).toBe(5000000);
expect(getDynamicMaxContextChars('gpt-5')).toBe(1000000);
@@ -17,6 +23,34 @@ describe('aiChatRuntime', () => {
expect(sanitizeErrorMsg('permission denied')).toBe('permission denied');
});
it('localizes runtime fallback errors while preserving raw details', () => {
setCurrentLanguage('en-US');
expect(sanitizeErrorMsg('')).toBe('Unknown error');
expect(sanitizeErrorMsg('<html><body>HTTP 503</body></html>')).toBe('HTTP 503 server error');
expect(sanitizeErrorMsg('<html><head><title>502 Bad Gateway</title></head></html>')).toBe('HTTP 502: 502 Bad Gateway');
expect(sanitizeErrorMsg('<html><body>gateway timeout</body></html>')).toBe(
'The server returned an abnormal HTML response, possibly a gateway timeout or unavailable service',
);
expect(sanitizeErrorMsg('x'.repeat(320))).toBe(`${'x'.repeat(280)}...(truncated)`);
expect(sanitizeErrorMsg('permission denied')).toBe('permission denied');
});
it('keeps aiChatRuntime user-facing fallback copy behind i18n keys', () => {
const source = readFileSync(new URL('./aiChatRuntime.ts', import.meta.url), 'utf8');
expect(source).toContain("'ai_chat.panel.prompt.memory_summary'");
expect(source).not.toContain('这是一段超长对话的历史记录');
expect(source).not.toContain('注意:');
expect(source).not.toContain('客观准确,不能遗漏关键业务逻辑或探索出的表名/字段');
expect(source).not.toContain('⚙️ 对话已超载,正在启动记忆压缩...');
expect(source).not.toContain('❌ 记忆压缩失败,将尝试原样接续...');
expect(source).not.toContain("'未知错误'");
expect(source).not.toContain('服务端返回了异常 HTML 响应');
expect(source).not.toContain('服务端错误');
expect(source).not.toContain('已截断');
});
it('skips compression when the payload is still within the configured limit', async () => {
const result = await compressContextIfNeeded('session-1', [
{ role: 'user', content: 'short prompt' },

View File

@@ -1,6 +1,18 @@
import { useStore } from '../store';
import { t as translateCatalog, type I18nParams } from '../i18n';
const genCompressionMessageId = () => `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
type AIChatRuntimeTranslator = (key: string, params?: I18nParams) => string;
const translateRuntimeCopy = (
translate: AIChatRuntimeTranslator | undefined,
key: string,
fallback: string,
params?: I18nParams,
): string => {
const resolved = (translate || translateCatalog)(key, params);
return resolved && resolved !== key ? resolved : fallback;
};
export const getDynamicMaxContextChars = (modelName?: string) => {
if (!modelName) return 258000;
@@ -27,7 +39,12 @@ export const getDynamicMaxContextChars = (modelName?: string) => {
return 258000;
};
export const compressContextIfNeeded = async (sid: string, messagesPayload: any[], maxLimit: number) => {
export const compressContextIfNeeded = async (
sid: string,
messagesPayload: any[],
maxLimit: number,
translate?: AIChatRuntimeTranslator,
) => {
try {
const chars = messagesPayload.reduce((sum, message) =>
sum + (message.content?.length || 0) + (message.reasoning_content?.length || 0) + JSON.stringify(message.tool_calls || []).length, 0);
@@ -41,17 +58,25 @@ export const compressContextIfNeeded = async (sid: string, messagesPayload: any[
id: connectingMsgId,
role: 'assistant',
phase: 'connecting',
content: '⚙️ 对话已超载,正在启动记忆压缩...',
content: translateRuntimeCopy(
translate,
'ai_chat.panel.status.memory_compressing',
'⚙️ Conversation is overloaded. Starting memory compression...',
),
timestamp: Date.now(),
loading: true,
});
const summaryPrompt = `这是一段超长对话的历史记录。为了释放上下文空间同时保留你的记忆核心,请你仔细阅读并以“技术事实、已探索出的数据结构状态、用户的中心诉求、当前进展”为准则,进行高度浓缩的结构化总结。
注意:
1. 客观准确,不能遗漏关键业务逻辑或探索出的表名/字段。
2. 剔除无效执行过程、客套话、JSON返回值本身。
3. 请控制在 1000-2000 字左右,输出纯干货 Markdown。
4. 开头直接输出总结,不要带寒暄。`;
const summaryPrompt = translateRuntimeCopy(
translate,
'ai_chat.panel.prompt.memory_summary',
`This is the history of an overlong conversation. To free context space while preserving the core memory, read it carefully and produce a highly condensed structured summary based on technical facts, explored data-structure state, the user's central request, and current progress.
Notes:
1. Be objective and accurate; do not omit key business logic or discovered table names/fields.
2. Remove ineffective execution process, pleasantries, and the JSON return values themselves.
3. Keep it around 1000-2000 words and output concise Markdown only.
4. Start directly with the summary; do not include greetings.`,
);
const result = await Service.AIChatSend([
{ role: 'system', content: summaryPrompt },
@@ -66,7 +91,11 @@ export const compressContextIfNeeded = async (sid: string, messagesPayload: any[
useStore.getState().updateAIChatMessage(sid, connectingMsgId, {
loading: false,
phase: 'idle',
content: '❌ 记忆压缩失败,将尝试原样接续...',
content: translateRuntimeCopy(
translate,
'ai_chat.panel.status.memory_compress_failed',
'❌ Memory compression failed. Continuing with the original context...',
),
});
} catch (error) {
console.error('Compression exception:', error);
@@ -74,17 +103,36 @@ export const compressContextIfNeeded = async (sid: string, messagesPayload: any[
return null;
};
export const sanitizeErrorMsg = (raw: string): string => {
if (!raw || typeof raw !== 'string') return '未知错误';
export const sanitizeErrorMsg = (raw: string, translate?: AIChatRuntimeTranslator): string => {
if (!raw || typeof raw !== 'string') {
return translateRuntimeCopy(translate, 'ai_chat.panel.error.unknown', 'Unknown error');
}
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 (code) {
return translateRuntimeCopy(
translate,
'ai_chat.panel.error.http_server',
`HTTP ${code} server error`,
{ code },
);
}
return translateRuntimeCopy(
translate,
'ai_chat.panel.error.html_response',
'The server returned an abnormal HTML response, possibly a gateway timeout or unavailable service',
);
}
if (raw.length > 300) {
return `${raw.substring(0, 280)}${translateRuntimeCopy(
translate,
'ai_chat.panel.error.truncated_suffix',
'...(truncated)',
)}`;
}
if (raw.length > 300) return `${raw.substring(0, 280)}...(已截断)`;
return raw;
};

View File

@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it, vi } from 'vitest';
import {
@@ -14,6 +15,7 @@ import {
} from './aiChatSendShortcut';
const binding = (combo: string, enabled = true): ShortcutPlatformBinding => ({ combo, enabled });
const source = readFileSync(new URL('./aiChatSendShortcut.ts', import.meta.url), 'utf8');
describe('aiChatSendShortcut', () => {
it('registers AI chat send in the shared shortcut center with Enter default', () => {
@@ -23,7 +25,7 @@ describe('aiChatSendShortcut', () => {
windows: { combo: 'Enter', enabled: true },
});
expect(SHORTCUT_ACTION_META.sendAIChatMessage).toMatchObject({
label: 'AI 聊天发送',
label: 'Send AI Chat',
allowInEditable: true,
allowWithoutModifier: true,
scope: 'aiComposer',
@@ -73,9 +75,25 @@ describe('aiChatSendShortcut', () => {
it('does not allow Shift to become an AI send shortcut even if a stale binding exists', () => {
expect(shouldSendAIChatOnKeyDown(binding('Shift+Enter'), { key: 'Enter', shiftKey: true })).toBe(false);
expect(getAIChatSendShortcutLabel(binding('Meta+Enter'))).toBe('Meta+Enter 发送');
expect(getAIChatSendShortcutLabel(binding('Meta+Enter'), 'mac')).toBe('⌘↵ 发送');
expect(getAIChatSendShortcutLabel(binding('Enter', false))).toBe('快捷键发送已关闭');
expect(getAIChatSendShortcutLabel(binding('Meta+Enter'))).toBe('Meta+Enter to send');
expect(getAIChatSendShortcutLabel(binding('Meta+Enter'), 'mac')).toBe('⌘↵ to send');
expect(getAIChatSendShortcutLabel(binding('Enter', false))).toBe('Shortcut sending disabled');
});
it('uses the provided translator for the shortcut hint chrome while keeping the shortcut raw', () => {
const translate = (key: string, params?: Record<string, string>) => `t:${key}:${params?.shortcut || ''}`;
expect(getAIChatSendShortcutLabel(binding('Meta+Enter'), 'windows', translate)).toBe(
't:ai_chat.input.shortcut.send_with_combo:Meta+Enter',
);
expect(getAIChatSendShortcutLabel(binding('Enter', false), 'windows', translate)).toBe(
't:ai_chat.input.shortcut.disabled:',
);
});
it('does not keep legacy Chinese shortcut hint chrome in production source', () => {
expect(source).not.toContain('快捷键发送已关闭');
expect(source).not.toMatch(/return\s+`[^`]*发送`/);
});
it('stops propagation after consuming the configured AI send shortcut', () => {

View File

@@ -18,15 +18,22 @@ export interface AIChatSendShortcutKeyEventLike {
stopPropagation?: () => void;
}
export type AIChatSendShortcutTranslate = (
key: string,
params?: Record<string, string>,
) => string;
export const getAIChatSendShortcutLabel = (
binding: ShortcutPlatformBinding | undefined,
platform: ShortcutPlatform = 'windows',
translate?: AIChatSendShortcutTranslate,
): string => {
if (binding?.enabled === false) {
return '快捷键发送已关闭';
return translate?.('ai_chat.input.shortcut.disabled') || 'Shortcut sending disabled';
}
const combo = binding?.combo || DEFAULT_SHORTCUT_OPTIONS.sendAIChatMessage.windows.combo;
return `${getShortcutDisplayLabel(combo, platform)} 发送`;
const shortcut = getShortcutDisplayLabel(combo, platform);
return translate?.('ai_chat.input.shortcut.send_with_combo', { shortcut }) || `${shortcut} to send`;
};
export const shouldSendAIChatOnKeyDown = (

View File

@@ -1,5 +1,7 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { t as catalogTranslate } from '../i18n/catalog';
import {
buildAIComposerNotice,
buildIncompleteProviderNotice,
@@ -8,80 +10,95 @@ import {
buildMissingProviderNotice,
} from './aiComposerNotice';
const t = (key: string, params?: Record<string, unknown>) => {
const suffix = params?.detail ? `:${String(params.detail)}` : '';
return `${key}${suffix}`;
};
const source = readFileSync(new URL('./aiComposerNotice.ts', import.meta.url), 'utf8');
const en = (key: string, params?: Record<string, unknown>) =>
catalogTranslate('en-US', key, params as Record<string, string | number | boolean | null | undefined> | undefined);
describe('ai composer notice helpers', () => {
it('builds a translated compact notice for missing provider with an action', () => {
expect(buildMissingProviderNotice(t)).toEqual({
it('keeps English fallback copy in source instead of legacy Chinese notice defaults', () => {
expect(source).toContain("catalogTranslate('en-US'");
expect(source).not.toContain('还没有可用供应商');
expect(source).not.toContain('先在 AI 设置里添加并启用一个模型供应商。');
expect(source).not.toContain('先选择一个模型');
expect(source).not.toContain('打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。');
expect(source).not.toContain('当前供应商配置还不完整');
expect(source).not.toContain('先补全供应商配置再发送,避免请求刚发起就失败。');
expect(source).not.toContain('模型列表加载失败');
expect(source).not.toContain('请检查供应商入口、API Key 或账号权限,然后重新打开模型下拉。');
expect(source).not.toContain('打开 AI 设置');
expect(source).not.toContain('修复供应商配置');
expect(source).not.toContain('重新加载模型');
expect(source).not.toContain('当前供应商还缺少 ${missingLabels.join');
});
it('builds a localized missing-provider notice and falls back to English action copy when needed', () => {
expect(buildMissingProviderNotice(en)).toEqual({
tone: 'warning',
title: 'ai_chat.composer_notice.missing_provider.title',
description: 'ai_chat.composer_notice.missing_provider.description',
title: 'No provider available',
description: 'Add and enable a model provider in AI settings first.',
action: {
key: 'open-settings',
label: '打开 AI 设置',
label: 'Open AI settings',
},
});
});
it('builds a translated compact notice for missing model selection with an action', () => {
expect(buildMissingModelNotice(t)).toEqual({
it('builds a localized missing-model notice and falls back to English action copy when needed', () => {
expect(buildMissingModelNotice(en)).toEqual({
tone: 'warning',
title: 'ai_chat.composer_notice.missing_model.title',
description: 'ai_chat.composer_notice.missing_model.description',
title: 'Select a model first',
description: 'Open the model dropdown below and select a model. If the list is empty, check the provider endpoint and API Key.',
action: {
key: 'reload-models',
label: '重新加载模型',
label: 'Reload models',
},
});
});
it('builds a translated incomplete provider notice from readiness issues', () => {
expect(buildIncompleteProviderNotice(['missing_secret', 'missing_base_url'], t)).toEqual({
it('builds an incomplete-provider notice with English fallback wrapper copy instead of mixed Chinese', () => {
expect(buildIncompleteProviderNotice(['missing_secret', 'missing_base_url'], en)).toEqual({
tone: 'error',
title: '当前供应商还缺少 密钥、接口地址',
description: '先补全供应商配置再发送,避免请求刚发起就失败。',
title: 'Current provider is missing API key, endpoint URL',
description: 'Complete the provider configuration before sending to avoid immediate request failures.',
action: {
key: 'open-settings',
label: '修复供应商配置',
label: 'Fix provider configuration',
},
});
});
it('builds a translated inline notice for model fetch failures with raw detail', () => {
expect(buildModelFetchFailedNotice(t, '当前接口未返回可用模型')).toEqual({
it('builds a localized inline notice for model fetch failures while preserving raw detail', () => {
expect(buildModelFetchFailedNotice(en, 'HTTP 401 raw error')).toEqual({
tone: 'error',
title: 'ai_chat.composer_notice.model_fetch_failed.title',
description: 'ai_chat.composer_notice.model_fetch_failed.detail_description:当前接口未返回可用模型',
title: 'Model list failed to load',
description: 'Provider detail: HTTP 401 raw error',
action: {
key: 'reload-models',
label: '重新加载模型',
label: 'Reload models',
},
});
});
it('uses the translated default description when model fetch failure detail is empty', () => {
expect(buildModelFetchFailedNotice(t, ' ')).toEqual({
it('uses the English default description when model fetch failure detail is empty', () => {
expect(buildModelFetchFailedNotice(en, ' ')).toEqual({
tone: 'error',
title: 'ai_chat.composer_notice.model_fetch_failed.title',
description: 'ai_chat.composer_notice.model_fetch_failed.default_description',
title: 'Model list failed to load',
description: 'Check the provider endpoint, API Key, or account permissions, then reopen the model dropdown.',
action: {
key: 'reload-models',
label: '重新加载模型',
label: 'Reload models',
},
});
});
it('keeps a non-translated compatibility path for direct notices', () => {
expect(buildModelFetchFailedNotice('当前接口未返回可用模型')).toEqual({
it('keeps the direct compatibility path raw-detail only while falling back to English chrome', () => {
expect(buildModelFetchFailedNotice('HTTP 401 raw error')).toEqual({
tone: 'error',
title: '模型列表加载失败',
description: '当前接口未返回可用模型',
title: 'Model list failed to load',
description: 'HTTP 401 raw error',
action: {
key: 'reload-models',
label: '重新加载模型',
label: 'Reload models',
},
});
});
@@ -103,7 +120,7 @@ describe('ai composer notice helpers', () => {
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',
label: 'zh:ai_chat.input.status.action.reload_models',
},
});
expect(relocalized).toEqual({
@@ -112,12 +129,12 @@ describe('ai composer notice helpers', () => {
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',
label: 'en:ai_chat.input.status.action.reload_models',
},
});
});
it('returns null when there is no composer notice descriptor', () => {
expect(buildAIComposerNotice(t, null)).toBeNull();
expect(buildAIComposerNotice(en, null)).toBeNull();
});
});

View File

@@ -1,3 +1,4 @@
import { t as catalogTranslate } from '../i18n/catalog';
import type { AIChatReadinessIssue } from '../components/ai/aiChatReadiness';
import { formatAIChatProviderIssueLabels } from '../components/ai/aiChatReadiness';
@@ -25,19 +26,10 @@ export type AIComposerNoticeTranslator = (
params?: Record<string, string | number | boolean | null | undefined>
) => string;
const defaultCopy = {
missingProviderTitle: '还没有可用供应商',
missingProviderDescription: '先在 AI 设置里添加并启用一个模型供应商。',
missingModelTitle: '先选择一个模型',
missingModelDescription: '打开下方模型下拉并选择模型;如果列表为空,请检查供应商入口和 API Key。',
incompleteProviderTitle: '当前供应商配置还不完整',
incompleteProviderDescription: '先补全供应商配置再发送,避免请求刚发起就失败。',
modelFetchFailedTitle: '模型列表加载失败',
modelFetchFailedDescription: '请检查供应商入口、API Key 或账号权限,然后重新打开模型下拉。',
openSettingsAction: '打开 AI 设置',
fixProviderAction: '修复供应商配置',
reloadModelsAction: '重新加载模型',
} as const;
const catalogTranslateEn = (
key: string,
params?: Record<string, string | number | boolean | null | undefined>,
) => catalogTranslate('en-US', key, params);
const translateWithFallback = (
t: AIComposerNoticeTranslator | undefined,
@@ -52,6 +44,23 @@ const translateWithFallback = (
return translated && translated !== key ? translated : fallback;
};
const getProviderFallbackLabel = (t?: AIComposerNoticeTranslator): string =>
translateWithFallback(
t,
'ai_chat.input.status.provider_fallback_name',
catalogTranslateEn('ai_chat.input.status.provider_fallback_name'),
);
const getIssueSeparator = (t?: AIComposerNoticeTranslator): string =>
translateWithFallback(
t,
'ai_chat.input.status.issue.separator',
catalogTranslateEn('ai_chat.input.status.issue.separator'),
);
const joinIssueLabels = (labels: string[], t?: AIComposerNoticeTranslator): string =>
labels.join(getIssueSeparator(t));
const buildNoticeAction = (
key: AIComposerNoticeAction,
labelKey: string,
@@ -64,32 +73,40 @@ const buildNoticeAction = (
export const buildMissingProviderNotice = (t?: AIComposerNoticeTranslator): AIComposerNotice => ({
tone: 'warning',
title: t
? t('ai_chat.composer_notice.missing_provider.title')
: defaultCopy.missingProviderTitle,
description: t
? t('ai_chat.composer_notice.missing_provider.description')
: defaultCopy.missingProviderDescription,
title: translateWithFallback(
t,
'ai_chat.composer_notice.missing_provider.title',
catalogTranslateEn('ai_chat.composer_notice.missing_provider.title'),
),
description: translateWithFallback(
t,
'ai_chat.composer_notice.missing_provider.description',
catalogTranslateEn('ai_chat.composer_notice.missing_provider.description'),
),
action: buildNoticeAction(
'open-settings',
'ai_chat.composer_notice.action.open_settings',
defaultCopy.openSettingsAction,
'ai_chat.input.status.action.open_settings',
catalogTranslateEn('ai_chat.input.status.action.open_settings'),
t,
),
});
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,
title: translateWithFallback(
t,
'ai_chat.composer_notice.missing_model.title',
catalogTranslateEn('ai_chat.composer_notice.missing_model.title'),
),
description: translateWithFallback(
t,
'ai_chat.composer_notice.missing_model.description',
catalogTranslateEn('ai_chat.composer_notice.missing_model.description'),
),
action: buildNoticeAction(
'reload-models',
'ai_chat.composer_notice.action.reload_models',
defaultCopy.reloadModelsAction,
'ai_chat.input.status.action.reload_models',
catalogTranslateEn('ai_chat.input.status.action.reload_models'),
t,
),
});
@@ -98,28 +115,40 @@ export const buildIncompleteProviderNotice = (
issues: AIChatReadinessIssue[] = [],
t?: AIComposerNoticeTranslator,
): AIComposerNotice => {
const missingLabels = formatAIChatProviderIssueLabels(issues.filter((issue) => issue !== 'missing_selected_model'));
const filteredIssues = issues.filter((issue) => issue !== 'missing_selected_model');
const missingLabels = formatAIChatProviderIssueLabels(filteredIssues, t);
const providerFallbackLabel = getProviderFallbackLabel(t);
const issuesText = joinIssueLabels(missingLabels, t);
const fallbackTitle = missingLabels.length > 0
? `当前供应商还缺少 ${missingLabels.join('、')}`
: defaultCopy.incompleteProviderTitle;
? catalogTranslateEn('ai_chat.input.status.provider_incomplete.title', {
provider: catalogTranslateEn('ai_chat.input.status.provider_fallback_name'),
issues: joinIssueLabels(
formatAIChatProviderIssueLabels(filteredIssues),
undefined,
),
})
: catalogTranslateEn('ai_chat.input.status.provider_fallback_name');
return {
tone: 'error',
title: translateWithFallback(
t,
'ai_chat.composer_notice.provider_incomplete.title',
'ai_chat.input.status.provider_incomplete.title',
fallbackTitle,
{ labels: missingLabels.join('、') },
{
provider: providerFallbackLabel,
issues: issuesText,
},
),
description: translateWithFallback(
t,
'ai_chat.composer_notice.provider_incomplete.description',
defaultCopy.incompleteProviderDescription,
'ai_chat.input.status.provider_incomplete.description',
catalogTranslateEn('ai_chat.input.status.provider_incomplete.description'),
),
action: buildNoticeAction(
'open-settings',
'ai_chat.composer_notice.action.fix_provider',
defaultCopy.fixProviderAction,
'ai_chat.input.status.action.fix_provider',
catalogTranslateEn('ai_chat.input.status.action.fix_provider'),
t,
),
};
@@ -143,18 +172,29 @@ export function buildModelFetchFailedNotice(
return {
tone: 'error',
title: t
? t('ai_chat.composer_notice.model_fetch_failed.title')
: defaultCopy.modelFetchFailedTitle,
title: translateWithFallback(
t,
'ai_chat.composer_notice.model_fetch_failed.title',
catalogTranslateEn('ai_chat.composer_notice.model_fetch_failed.title'),
),
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,
? translateWithFallback(
t,
'ai_chat.composer_notice.model_fetch_failed.detail_description',
catalogTranslateEn('ai_chat.composer_notice.model_fetch_failed.detail_description', { detail }),
{ detail },
)
: translateWithFallback(
t,
'ai_chat.composer_notice.model_fetch_failed.default_description',
catalogTranslateEn('ai_chat.composer_notice.model_fetch_failed.default_description'),
)
: detail || catalogTranslateEn('ai_chat.composer_notice.model_fetch_failed.default_description'),
action: buildNoticeAction(
'reload-models',
'ai_chat.composer_notice.action.reload_models',
defaultCopy.reloadModelsAction,
'ai_chat.input.status.action.reload_models',
catalogTranslateEn('ai_chat.input.status.action.reload_models'),
t,
),
};

View File

@@ -19,6 +19,21 @@ const message = (overrides: Partial<AIChatMessage>): AIChatMessage => ({
...overrides,
});
const translateAttachmentPrompt = (
key: string,
params?: Record<string, string | number | boolean | null | undefined>,
): string => ({
'ai_chat.input.attachment.kind.markdown': 'Markdown',
'ai_chat.input.attachment.prompt.heading': `### Attachment ${params?.index}: ${params?.name}`,
'ai_chat.input.attachment.prompt.kind': `- Type: ${params?.kind}`,
'ai_chat.input.attachment.prompt.mime': `- MIME: ${params?.mimeType}`,
'ai_chat.input.attachment.prompt.size': `- Size: ${params?.size}`,
'ai_chat.input.attachment.prompt.no_text': 'No readable attachment body was extracted.',
'ai_chat.input.attachment.prompt.default_user_content': 'Continue based on the following attachment content.',
'ai_chat.input.attachment.prompt.wrapper_start': '<User Uploaded Attachments>',
'ai_chat.input.attachment.prompt.wrapper_end': '</User Uploaded Attachments>',
}[key] || key);
describe('toAIRequestMessage', () => {
it('keeps reasoning_content on assistant tool-call messages', () => {
const payload = toAIRequestMessage(message({
@@ -88,11 +103,13 @@ describe('toAIRequestMessage', () => {
kind: 'markdown',
text: '# 周报\n收入下降',
}],
}));
}), translateAttachmentPrompt as any);
expect(payload.content).toContain('帮我看附件');
expect(payload.content).toContain('<用户上传附件>');
expect(payload.content).toContain('<User Uploaded Attachments>');
expect(payload.content).toContain('### Attachment 1: report.md');
expect(payload.content).toContain('report.md');
expect(payload.content).toContain('收入下降');
expect(payload.content).not.toContain('<用户上传附件>');
});
});

View File

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

View File

@@ -2,6 +2,13 @@ import { describe, expect, it, vi } from 'vitest';
import { resolveAITableSchemaToolResult } from './aiTableSchemaTool';
const translate = (key: string, params?: Record<string, unknown>) => {
const renderedParams = params
? Object.entries(params).map(([name, value]) => `${name}=${value}`).join('|')
: '';
return `T:${key}${renderedParams ? ` ${renderedParams}` : ''}`;
};
describe('resolveAITableSchemaToolResult', () => {
it('returns DDL directly when DDL fetch succeeds', async () => {
const fetchColumns = vi.fn();
@@ -27,14 +34,23 @@ describe('resolveAITableSchemaToolResult', () => {
{ Name: 'NAME', Type: 'VARCHAR2(64)', Nullable: 'YES' },
],
}),
translate,
});
expect(result.success).toBe(true);
expect(result.content).toContain('DDL 获取失败,已降级为字段元数据摘要');
expect(result.content).toContain('ORA-31603');
expect(result.content).toContain('可用字段ID, NAME');
expect(result.content).toContain(
'T:ai_chat.inspection.table_schema.warning.ddl_fallback tableName=USERS',
);
expect(result.content).toContain(
'T:ai_chat.inspection.table_schema.warning.ddl_error detail=ORA-31603: object not found or insufficient privileges',
);
expect(result.content).toContain(
'T:ai_chat.inspection.table_schema.warning.available_fields fields=ID, NAME',
);
expect(result.content).toContain('"field":"ID"');
expect(result.content).toContain('"type":"NUMBER"');
expect(result.content).not.toContain('DDL 获取失败');
expect(result.content).not.toContain('可用字段');
});
it('returns a combined failure when both DDL and column metadata fail', async () => {
@@ -42,10 +58,21 @@ describe('resolveAITableSchemaToolResult', () => {
tableName: 'USERS',
fetchDDL: vi.fn().mockResolvedValue({ success: false, message: 'DDL permission denied' }),
fetchColumns: vi.fn().mockResolvedValue({ success: false, message: 'columns permission denied' }),
translate,
});
expect(result.success).toBe(false);
expect(result.content).toBe(
'T:ai_chat.inspection.table_schema.error.ddl_and_columns_failed ddlDetail=DDL permission denied|columnDetail=columns permission denied',
);
expect(result.content).toContain('DDL permission denied');
expect(result.content).toContain('columns permission denied');
expect(result.content).not.toContain('获取建表语句失败');
});
it('keeps legacy Chinese table schema wrappers out of the source', async () => {
const { readFileSync } = await import('node:fs');
const source = readFileSync(new URL('./aiTableSchemaTool.ts', import.meta.url), 'utf8');
expect(source).not.toMatch(/DDL 获取失败|DDL 错误|该结果不包含完整索引|可用字段|详细信息|获取建表语句失败|未知错误|降级获取字段列表/);
});
});

View File

@@ -1,13 +1,18 @@
import { t as translateCatalog, type I18nParams } from '../i18n';
type ToolQueryResult = {
success?: boolean;
data?: unknown;
message?: string;
};
type TableSchemaTranslate = (key: string, params?: I18nParams) => string;
type ResolveAITableSchemaToolResultParams = {
tableName: string;
fetchDDL: () => Promise<ToolQueryResult>;
fetchColumns: () => Promise<ToolQueryResult>;
translate?: TableSchemaTranslate;
};
const stringifyToolData = (data: unknown): string => (
@@ -36,15 +41,61 @@ const normalizeAIColumn = (raw: unknown) => {
};
};
const buildColumnFallbackContent = (tableName: string, ddlError: string, columns: unknown[]): string => {
const translateTableSchemaCopy = (
translate: TableSchemaTranslate | undefined,
key: string,
fallback: string,
params?: I18nParams,
): string => {
const t = translate || ((catalogKey, catalogParams) => translateCatalog(catalogKey, catalogParams, 'en-US'));
const translated = t(key, params);
return translated && translated !== key ? translated : fallback;
};
const buildColumnFallbackContent = (
tableName: string,
ddlError: string,
columns: unknown[],
translate?: TableSchemaTranslate,
): string => {
const normalizedColumns = columns.map(normalizeAIColumn).filter((column) => column.field.trim());
const fieldNames = normalizedColumns.map((column) => column.field).join(', ');
const fieldsText = fieldNames || translateTableSchemaCopy(
translate,
'ai_chat.inspection.table_schema.value.none',
'none',
);
const detail = JSON.stringify(normalizedColumns);
return [
`⚠️ 表 ${tableName} 的 DDL 获取失败,已降级为字段元数据摘要。`,
`DDL 错误:${ddlError || '未知错误'}`,
'该结果不包含完整索引、约束、触发器等 DDL 信息;请基于字段列表继续分析,不要因为 DDL 权限失败而停止。',
`可用字段:${fieldNames || '无'}`,
`详细信息:${JSON.stringify(normalizedColumns)}`,
translateTableSchemaCopy(
translate,
'ai_chat.inspection.table_schema.warning.ddl_fallback',
`DDL fetch failed for table ${tableName}; fell back to column metadata summary.`,
{ tableName },
),
translateTableSchemaCopy(
translate,
'ai_chat.inspection.table_schema.warning.ddl_error',
`DDL error: ${ddlError || 'Unknown error'}`,
{ detail: ddlError || translateTableSchemaCopy(translate, 'ai_chat.inspection.table_schema.error.unknown', 'Unknown error') },
),
translateTableSchemaCopy(
translate,
'ai_chat.inspection.table_schema.warning.fallback_limitation',
'This result does not include complete index, constraint, trigger, or other DDL information; continue analysis from the column list and do not stop solely because DDL permissions failed.',
),
translateTableSchemaCopy(
translate,
'ai_chat.inspection.table_schema.warning.available_fields',
`Available fields: ${fieldsText}`,
{ fields: fieldsText },
),
translateTableSchemaCopy(
translate,
'ai_chat.inspection.table_schema.warning.detail',
`Details: ${detail}`,
{ detail },
),
].join('\n');
};
@@ -52,6 +103,7 @@ export const resolveAITableSchemaToolResult = async ({
tableName,
fetchDDL,
fetchColumns,
translate,
}: ResolveAITableSchemaToolResultParams): Promise<{ success: boolean; content: string }> => {
const ddlResult = await fetchDDL();
if (ddlResult?.success) {
@@ -61,9 +113,17 @@ export const resolveAITableSchemaToolResult = async ({
const ddlError = ddlResult?.message || 'Failed to fetch DDL';
const columnResult = await fetchColumns();
if (columnResult?.success && Array.isArray(columnResult.data)) {
return { success: true, content: buildColumnFallbackContent(tableName, ddlError, columnResult.data) };
return { success: true, content: buildColumnFallbackContent(tableName, ddlError, columnResult.data, translate) };
}
const columnError = columnResult?.message || 'Failed to fetch columns';
return { success: false, content: `获取建表语句失败:${ddlError};降级获取字段列表也失败:${columnError}` };
return {
success: false,
content: translateTableSchemaCopy(
translate,
'ai_chat.inspection.table_schema.error.ddl_and_columns_failed',
`Failed to fetch table DDL: ${ddlError}; fallback column metadata also failed: ${columnError}`,
{ ddlDetail: ddlError, columnDetail: columnError },
),
};
};

View File

@@ -9,37 +9,38 @@ 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('当前供应商');
expect(info?.desc).toContain('AI runtime status');
expect(info?.tool.function.description).toContain('provider');
expect(info?.tool.function.description).toContain('safety level');
});
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('聊天发送前置');
expect(info?.desc).toContain('current AI setup');
expect(info?.tool.function.description).toContain('chat send prerequisites');
});
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');
expect(info?.desc).toContain('troubleshooting support bundle');
expect(info?.tool.function.description).toContain('does not include database passwords');
expect(info?.tool.function.parameters?.properties?.includeMessageContent?.description).toContain('Default 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?.desc).toContain('write safety boundaries');
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('外部客户端');
expect(info?.desc).toContain('MCP configuration');
expect(info?.tool.function.description).toContain('external client');
});
it('registers the mcp-remote-access inspector as a builtin tool', () => {
@@ -53,24 +54,24 @@ describe('aiToolRegistry', () => {
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 服务名');
expect(info?.desc).toContain('startup and tool-call failures');
expect(info?.tool.function.description).toContain('tool discovery failures');
expect(info?.tool.function.parameters?.properties?.serverName?.description).toContain('MCP service name');
});
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');
expect(info?.desc).toContain('add-MCP');
expect(info?.tool.function.description).toContain('full-command auto-splitting');
});
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?.desc).toContain('add-MCP draft');
expect(info?.tool.function.description).toContain('automatic splitting');
expect(info?.tool.function.parameters?.properties?.fullCommand?.description).toContain('full MCP startup command');
expect(info?.tool.function.parameters?.properties?.templateKey?.enum).toContain('docker');
});
@@ -79,149 +80,152 @@ describe('aiToolRegistry', () => {
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');
expect(info?.tool.function.parameters?.properties?.includeDisabled?.description).toContain('Default 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');
expect(info?.desc).toContain('MCP tool argument schema');
expect(info?.tool.function.description).toContain('parameter schema');
expect(info?.tool.function.parameters?.properties?.alias?.description).toContain('real 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('模型列表为空');
expect(info?.desc).toContain('providers and model configuration');
expect(info?.tool.function.description).toContain('model list');
});
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 聊天输入区');
expect(info?.desc).toContain('AI chat can send');
expect(info?.tool.function.description).toContain('current AI chat input');
});
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?.desc).toContain('upstream request payloads');
expect(info?.tool.function.description).toContain('request body preview');
expect(info?.tool.function.parameters?.properties?.requestId?.description).toContain('requestId');
expect(info?.tool.function.parameters?.properties?.includePayloadSummary?.description).toContain('工具数量');
expect(info?.tool.function.parameters?.properties?.includePayloadSummary?.description).toContain('tool count');
expect(info?.tool.function.parameters?.properties?.includePayloadSummary?.description).toContain('tool_choice');
});
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 工具摘要');
expect(info?.desc).toContain('built-in tool catalog');
expect(info?.tool.function.description).toContain('recommended tool-call flows');
expect(info?.tool.function.parameters?.properties?.keyword?.description).toContain('connection failure');
expect(info?.tool.function.parameters?.properties?.includeMCPTools?.description).toContain('MCP tool summaries');
});
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('自定义提示词');
expect(info?.desc).toContain('prompts and Skills');
expect(info?.tool.function.description).toContain('user-defined prompts');
});
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 隧道状态');
expect(info?.desc).toContain('active connection');
expect(info?.tool.function.description).toContain('SSH/proxy/HTTP tunnel state');
});
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('结果是否强制只读');
expect(info?.desc).toContain('frontend capabilities');
expect(info?.tool.function.description).toContain('forced read-only result state');
});
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('本地已保存连接清单');
expect(info?.desc).toContain('saved connections');
expect(info?.tool.function.description).toContain('locally saved connections');
});
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?.desc).toContain('Redis standalone, Sentinel, and Cluster');
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');
expect(info?.tool.function.description).toContain('do not echo Redis or Sentinel passwords');
expect(info?.tool.function.parameters?.properties?.connectionId?.description).toContain('Redis connection 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 文件页签');
expect(info?.desc).toContain('external SQL directory');
expect(info?.tool.function.description).toContain('currently open external SQL file tabs');
});
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 脚本');
expect(info?.desc).toContain('external SQL file content');
expect(info?.tool.function.description).toContain('specified external SQL file');
expect(info?.tool.function.parameters?.properties?.filePath?.description).toContain('Absolute path');
});
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');
expect(info?.desc).toContain('shortcut configuration');
expect(info?.tool.function.description).toContain('Windows/macOS');
});
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?.desc).toContain('application log');
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');
expect(info?.desc).toContain('connection failures');
expect(info?.tool.function.description).toContain('SSH tunnel failures');
expect(info?.tool.function.parameters?.properties?.keyword?.description).toContain('127.0.0.1');
});
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('消息渲染异常');
expect(info?.desc).toContain('render error');
expect(info?.tool.function.description).toContain('AI message render error');
});
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 消息');
expect(info?.desc).toContain('message flow');
expect(info?.tool.function.description).toContain('consecutive assistant messages');
});
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');
expect(info?.desc).toContain('context size');
expect(info?.tool.function.description).toContain('MCP tool schemas');
expect(info?.tool.function.parameters?.properties?.messageLimit?.description).toContain('maximum 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');
expect(info?.desc).toContain('large frontend files');
expect(info?.tool.function.description).toContain('split-hotspot snapshot');
expect(info?.tool.function.parameters?.properties?.minLines?.description).toContain('Default 1000');
});
it('registers the recent-sql-activity, saved-query, and sql-snippet inspectors as builtin tools', () => {
@@ -236,26 +240,26 @@ describe('aiToolRegistry', () => {
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('片段模板');
expect(recentActivityTool?.desc).toContain('recent SQL activity');
expect(recentActivityTool?.tool.function.description).toContain('recent SQL activity');
expect(sqlEditorTransactionTool?.desc).toContain('SQL editor transaction');
expect(sqlEditorTransactionTool?.tool.function.description).toContain('managed transaction');
expect(sqlRiskTool?.desc).toContain('execution risk');
expect(sqlRiskTool?.tool.function.description).toContain('risk points');
expect(appLogTool?.desc).toContain('GoNavi application log');
expect(appLogTool?.tool.function.description).toContain('application log');
expect(connectionFailureTool?.desc).toContain('connection failures');
expect(connectionFailureTool?.tool.function.description).toContain('cooldown');
expect(renderErrorTool?.desc).toContain('message render error');
expect(renderErrorTool?.tool.function.description).toContain('render error snapshot');
expect(messageFlowTool?.desc).toContain('message flow');
expect(messageFlowTool?.tool.function.description).toContain('tool-call to tool-result matching');
expect(savedQueryTool?.desc).toContain('saved SQL queries');
expect(savedQueryTool?.tool.function.description).toContain('SQL preview');
expect(aiSessionsTool?.desc).toContain('AI conversation history');
expect(aiSessionsTool?.tool.function.description).toContain('latest message preview');
expect(snippetTool?.desc).toContain('SQL snippet templates');
expect(snippetTool?.tool.function.description).toContain('snippet templates');
});
it('keeps builtin tools and MCP tools in the unified runtime tool chain', () => {
@@ -333,4 +337,47 @@ describe('aiToolRegistry', () => {
'ai_chat.tools.mcp_fallback_description: raw_tool_title @ raw-server.local',
);
});
it('localizes SQL inspection tool schema copy while preserving raw tool and parameter names', () => {
const tools = buildAvailableAIChatTools([], (key) => `T:${key}`);
const recentLogsTool = tools.find((item) => item.function.name === 'inspect_recent_sql_logs');
const sqlRiskTool = tools.find((item) => item.function.name === 'inspect_sql_risk');
expect(recentLogsTool?.function.name).toBe('inspect_recent_sql_logs');
expect(recentLogsTool?.function.description).toBe(
'T:ai_chat.inspection.tool_info.inspect_recent_sql_logs.tool_description',
);
expect(recentLogsTool?.function.parameters?.properties?.limit?.description).toBe(
'T:ai_chat.inspection.tool_info.inspect_recent_sql_logs.param.limit',
);
expect(recentLogsTool?.function.parameters?.properties?.status?.enum).toEqual(['all', 'success', 'error']);
expect(sqlRiskTool?.function.name).toBe('inspect_sql_risk');
expect(sqlRiskTool?.function.description).toBe(
'T:ai_chat.inspection.tool_info.inspect_sql_risk.tool_description',
);
expect(sqlRiskTool?.function.parameters?.properties?.sql?.description).toBe(
'T:ai_chat.inspection.tool_info.inspect_sql_risk.param.sql',
);
expect(sqlRiskTool?.function.parameters?.properties?.previewCharLimit?.description).toBe(
'T:ai_chat.inspection.tool_info.inspect_sql_risk.param.previewCharLimit',
);
});
it('keeps SQL inspection tool info source free of legacy Chinese copy', () => {
const sqlToolInfoSource = readFileSync(
new URL('./aiBuiltinInspectionSqlToolInfo.ts', import.meta.url),
'utf8',
);
expect(sqlToolInfoSource).toContain('const SQL_TOOL_INFO_KEY_PREFIX = "ai_chat.inspection.tool_info";');
expect(sqlToolInfoSource).toContain('inspect_recent_sql_logs');
expect(sqlToolInfoSource).toContain('`${keyPrefix}.desc`');
expect(sqlToolInfoSource).not.toContain('查看最近 SQL 执行日志');
expect(sqlToolInfoSource).not.toContain('总结最近 SQL 活动分布');
expect(sqlToolInfoSource).not.toContain('查看 SQL 编辑器事务提交状态');
expect(sqlToolInfoSource).not.toContain('检查当前或指定 SQL 的执行风险');
expect(sqlToolInfoSource).not.toContain('可选,返回多少条日志,默认 20最大 100');
expect(sqlToolInfoSource).not.toContain('可选,要检查的 SQL不传时默认读取当前活动查询页签的 SQL 草稿');
});
});

View File

@@ -1,12 +1,14 @@
import type { AIMCPToolDescriptor } from "../types";
import {
BUILTIN_AI_TOOL_INFO,
localizeBuiltinAIToolInfo,
type AIChatToolDefinition,
type AIBuiltinToolInfo,
} from "./aiBuiltinToolInfo";
export {
BUILTIN_AI_TOOL_INFO,
localizeBuiltinAIToolInfo,
type AIChatToolDefinition,
type AIBuiltinToolInfo,
} from "./aiBuiltinToolInfo";
@@ -48,4 +50,7 @@ export const buildMCPAIChatTools = (
export const buildAvailableAIChatTools = (
tools: AIMCPToolDescriptor[],
t?: AIChatToolTranslator,
): AIChatToolDefinition[] => [...BUILTIN_AI_TOOLS, ...buildMCPAIChatTools(tools, t)];
): AIChatToolDefinition[] => [
...localizeBuiltinAIToolInfo(t).map((item) => item.tool),
...buildMCPAIChatTools(tools, t),
];

View File

@@ -1,7 +1,10 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolveAboutDisplayVersion } from './appVersionDisplay';
const source = readFileSync(new URL('./appVersionDisplay.ts', import.meta.url), 'utf8');
describe('resolveAboutDisplayVersion', () => {
it('shows fixed dev version for development build', () => {
expect(resolveAboutDisplayVersion('development', '0.6.5')).toBe('0.0.1-dev');
@@ -16,6 +19,10 @@ describe('resolveAboutDisplayVersion', () => {
});
it('falls back to unknown when version is empty outside development', () => {
expect(resolveAboutDisplayVersion('production', '')).toBe('未知');
expect(resolveAboutDisplayVersion('production', '', 'T:unknown')).toBe('T:unknown');
});
it('does not keep the old Chinese unknown fallback in production source', () => {
expect(source).not.toContain("'未知'");
});
});

View File

@@ -3,6 +3,7 @@ const DEV_ABOUT_VERSION = '0.0.1-dev';
export const resolveAboutDisplayVersion = (
buildType: string,
version: string | undefined,
unknownLabel = 'Unknown',
): string => {
const normalizedBuildType = String(buildType || '').trim().toLowerCase();
if (normalizedBuildType === 'development' || normalizedBuildType === 'dev') {
@@ -10,5 +11,5 @@ export const resolveAboutDisplayVersion = (
}
const normalizedVersion = String(version || '').trim();
return normalizedVersion || '未知';
return normalizedVersion || unknownLabel;
};

View File

@@ -1,3 +1,5 @@
import { readFileSync } from 'node:fs';
import { beforeEach, describe, expect, it } from 'vitest';
import { setCurrentLanguage } from '../i18n';
@@ -9,6 +11,8 @@ import {
normalizeConnectionPackagePassword,
} from './connectionExport';
const source = readFileSync(new URL('./connectionExport.ts', import.meta.url), 'utf8');
describe('connectionExport', () => {
beforeEach(() => {
setCurrentLanguage('en-US');
@@ -140,6 +144,11 @@ describe('connectionExport', () => {
expect(isConnectionPackagePasswordRequiredError(undefined)).toBe(false);
});
it('keeps the backend password-required sentinel keyed instead of hard-coded in source', () => {
expect(source).not.toContain('恢复包密码不能为空');
expect(source).toContain('file.backend.error.connection_package_password_required');
});
it('treats export cancel as a non-error backend result', () => {
expect(isConnectionPackageExportCanceled({ success: false, message: '已取消' })).toBe(true);
expect(isConnectionPackageExportCanceled({ success: false, message: '导出失败' })).toBe(false);

View File

@@ -27,7 +27,11 @@ const CONNECTION_PACKAGE_SCHEMA_VERSION_V2 = 2;
const CONNECTION_PACKAGE_PROTECTION_APP_MANAGED = 1;
const CONNECTION_PACKAGE_PROTECTION_FILE_PASSWORD = 2;
export const BACKEND_CANCELLED_MESSAGE = '已取消';
const CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE = '恢复包密码不能为空';
const CONNECTION_PACKAGE_PASSWORD_REQUIRED_MESSAGE = t(
'file.backend.error.connection_package_password_required',
undefined,
'zh-CN',
);
const isJsonObject = (value: unknown): value is JsonObject => (
typeof value === 'object' && value !== null && !Array.isArray(value)

View File

@@ -1,4 +1,5 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { setCurrentLanguage } from '../i18n';
import {
@@ -11,6 +12,8 @@ import {
summarizeConnectionTestFailureMessage,
} from './connectionModalPresentation';
const source = readFileSync(new URL('./connectionModalPresentation.ts', import.meta.url), 'utf8');
const sectionKeyEntries = [
['identity', 'identity'],
['uri', 'uri'],
@@ -36,6 +39,9 @@ const layoutKindEntries = [
['postgres-compatible', 'postgresCompatible'],
['oracle', 'oracle'],
['file', 'file'],
['search', 'search'],
['vector', 'vector'],
['timeseries', 'timeseries'],
['custom', 'custom'],
['jvm', 'jvm'],
['generic-sql', 'genericSql'],
@@ -266,6 +272,9 @@ describe('connectionModalPresentation', () => {
description: 'Name the connection and define the basic metadata shown in the connection tree.',
});
expect(getConnectionConfigLayoutKindLabel('mysql-compatible')).toBe('MySQL-compatible');
expect(getConnectionConfigLayoutKindLabel('search')).toBe('Search engines');
expect(getConnectionConfigLayoutKindLabel('vector')).toBe('Vector databases');
expect(getConnectionConfigLayoutKindLabel('timeseries')).toBe('Time-series databases');
expect(getStoredSecretPlaceholder({
hasStoredSecret: true,
emptyPlaceholder: 'Password',
@@ -313,4 +322,14 @@ describe('connectionModalPresentation', () => {
});
});
});
it('keeps layout kind labels out of hard-coded Chinese UI copy', () => {
[
"return '搜索引擎'",
"return '向量数据库'",
"return '时序数据库'",
].forEach((snippet) => {
expect(source).not.toContain(snippet);
});
});
});

View File

@@ -125,11 +125,11 @@ export const getConnectionConfigLayoutKindLabel = (
case 'file':
return t('connection.modal.layoutKind.file');
case 'search':
return '搜索引擎';
return t('connection.modal.layoutKind.search');
case 'vector':
return '向量数据库';
return t('connection.modal.layoutKind.vector');
case 'timeseries':
return '时序数据库';
return t('connection.modal.layoutKind.timeseries');
case 'custom':
return t('connection.modal.layoutKind.custom');
case 'jvm':

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { connection } from '../../wailsjs/go/models';
import { buildRpcConnectionConfig } from './connectionRpcConfig';
import { describeUnsupportedOceanBaseProtocol } from './oceanBaseProtocol';
describe('buildRpcConnectionConfig', () => {
it('preserves the saved connection id while normalizing numeric fields', () => {
@@ -119,7 +120,7 @@ describe('buildRpcConnectionConfig', () => {
user: 'root@test',
database: 'app',
connectionParams: 'protocol=native',
} as any)).toThrow(/不支持.*native/);
} as any)).toThrow('OceanBase only supports MySQL/Oracle tenant protocols; "native" is not supported. Switch to MySQL or Oracle.');
});
it('rejects unsupported OceanBase protocol even when form protocol is explicit MySQL', () => {
@@ -132,7 +133,13 @@ describe('buildRpcConnectionConfig', () => {
database: 'app',
oceanBaseProtocol: 'mysql',
connectionParams: 'protocol=native',
} as any)).toThrow(/不支持.*native/);
} as any)).toThrow('OceanBase only supports MySQL/Oracle tenant protocols; "native" is not supported. Switch to MySQL or Oracle.');
});
it('localizes unsupported OceanBase protocol wrappers while preserving the raw protocol value', () => {
expect(describeUnsupportedOceanBaseProtocol('native', (key, params) => (
`${key}:${params?.value}`
))).toBe('connection.oceanbase.error.unsupported_protocol:native');
});
it('preserves extra connection params for RPC calls', () => {

View File

@@ -1,23 +1,64 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import {
buildConnectionTypeGroups,
CONNECTION_TYPE_GROUPS,
getAllConnectionTypeCatalogItems,
getConnectionTypeDefaultPort,
getConnectionTypeHint,
} from './connectionTypeCatalog';
const source = readFileSync(new URL('./connectionTypeCatalog.ts', import.meta.url), 'utf8');
const translatedCopy: Record<string, string> = {
'connection_modal.step1.group.relational': 'T:relational',
'connection_modal.step1.group.domestic': 'T:domestic',
'connection_modal.step1.group.nosql': 'T:nosql',
'connection_modal.step1.group.vector': 'T:vector',
'connection_modal.step1.group.timeseries': 'T:timeseries',
'connection_modal.step1.group.message_queue': 'T:message-queue',
'connection_modal.step1.group.other': 'T:other',
'connection_modal.step1.hint.redis': 'T:redis',
'connection_modal.step1.hint.mongodb': 'T:mongodb',
'connection_modal.step1.hint.elasticsearch': 'T:elasticsearch',
'connection_modal.step1.hint.chroma': 'T:chroma',
'connection_modal.step1.hint.qdrant': 'T:qdrant',
'connection_modal.step1.hint.oceanBase': 'T:oceanbase',
'connection_modal.step1.hint.goldendb': 'T:goldendb',
'connection_modal.step1.hint.file': 'T:file',
'connection_modal.step1.hint.standard': 'T:standard',
'connection_modal.db_icon_label.custom': 'T:custom',
};
const translate = (key: string) => translatedCopy[key] || key;
describe('connectionTypeCatalog', () => {
it('keeps supported connection types grouped for the creation modal', () => {
expect(CONNECTION_TYPE_GROUPS.map((group) => group.label)).toEqual([
'关系型数据库',
'国产数据库',
'NoSQL',
'向量数据库',
'时序数据库',
'消息队列',
'其他',
expect(CONNECTION_TYPE_GROUPS.map((group) => group.labelKey)).toEqual([
'connection_modal.step1.group.relational',
'connection_modal.step1.group.domestic',
'connection_modal.step1.group.nosql',
'connection_modal.step1.group.vector',
'connection_modal.step1.group.timeseries',
'connection_modal.step1.group.message_queue',
'connection_modal.step1.group.other',
]);
expect(buildConnectionTypeGroups(translate).map((group) => group.label)).toEqual([
'T:relational',
'T:domestic',
'T:nosql',
'T:vector',
'T:timeseries',
'T:message-queue',
'T:other',
]);
expect(
buildConnectionTypeGroups(translate)
.flatMap((group) => group.items)
.find((item) => item.key === 'custom')?.name,
).toBe('T:custom');
const keys = getAllConnectionTypeCatalogItems().map((item) => item.key);
expect(keys).toContain('mysql');
@@ -57,16 +98,40 @@ describe('connectionTypeCatalog', () => {
});
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('redis', translate)).toBe('T:redis');
expect(getConnectionTypeHint('mongodb', translate)).toBe('T:mongodb');
expect(getConnectionTypeHint('elasticsearch', translate)).toBe('T:elasticsearch');
expect(getConnectionTypeHint('chroma', translate)).toBe('T:chroma');
expect(getConnectionTypeHint('qdrant', translate)).toBe('T:qdrant');
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('标准连接配置');
expect(getConnectionTypeHint('oceanbase', translate)).toBe('T:oceanbase');
expect(getConnectionTypeHint('goldendb', translate)).toBe('T:goldendb');
expect(getConnectionTypeHint('duckdb', translate)).toBe('T:file');
expect(getConnectionTypeHint('mysql', translate)).toBe('T:standard');
});
it('keeps connection type group labels and hints out of hard-coded Chinese UI copy', () => {
[
'关系型数据库',
'国产数据库',
'向量数据库',
'时序数据库',
'消息队列',
'其他',
'自定义驱动与 DSN',
'单机 / 哨兵 / 集群',
'单机 / 副本集',
'支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询',
'Collection 浏览、向量检索和元数据过滤',
'Collection 浏览、向量搜索和 Payload 过滤',
'MySQL / Oracle 租户',
'MySQL 兼容 / 分布式事务',
'本地文件连接',
'标准连接配置',
'Custom (自定义)',
].forEach((snippet) => {
expect(source).not.toContain(snippet);
});
});
});

View File

@@ -1,16 +1,34 @@
export type ConnectionTypeCatalogItem = {
key: string;
name: string;
nameKey?: string;
};
export type ConnectionTypeCatalogTranslator = (
key: string,
params?: Record<string, string | number | boolean | null | undefined>,
) => string;
export type ConnectionTypeCatalogGroup = {
labelKey: string;
label: string;
items: ConnectionTypeCatalogItem[];
};
const translateCatalogCopy = (
translate: ConnectionTypeCatalogTranslator | undefined,
key: string,
fallback: string,
): string => {
if (!translate) return fallback;
const translated = translate(key);
return translated && translated !== key ? translated : fallback;
};
export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
{
label: '关系型数据库',
labelKey: 'connection_modal.step1.group.relational',
label: 'Relational databases',
items: [
{ key: 'mysql', name: 'MySQL' },
{ key: 'mariadb', name: 'MariaDB' },
@@ -27,7 +45,8 @@ export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
],
},
{
label: '国产数据库',
labelKey: 'connection_modal.step1.group.domestic',
label: 'Domestic databases',
items: [
{ key: 'oceanbase', name: 'OceanBase' },
{ key: 'dameng', name: 'Dameng (达梦)' },
@@ -40,6 +59,7 @@ export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
],
},
{
labelKey: 'connection_modal.step1.group.nosql',
label: 'NoSQL',
items: [
{ key: 'mongodb', name: 'MongoDB' },
@@ -48,21 +68,24 @@ export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
],
},
{
label: '向量数据库',
labelKey: 'connection_modal.step1.group.vector',
label: 'Vector databases',
items: [
{ key: 'chroma', name: 'Chroma' },
{ key: 'qdrant', name: 'Qdrant' },
],
},
{
label: '时序数据库',
labelKey: 'connection_modal.step1.group.timeseries',
label: 'Time-series databases',
items: [
{ key: 'tdengine', name: 'TDengine' },
{ key: 'iotdb', name: 'Apache IoTDB' },
],
},
{
label: '消息队列',
labelKey: 'connection_modal.step1.group.message_queue',
label: 'Message queues',
items: [
{ key: 'rocketmq', name: 'RocketMQ' },
{ key: 'mqtt', name: 'MQTT' },
@@ -71,14 +94,29 @@ export const CONNECTION_TYPE_GROUPS: ConnectionTypeCatalogGroup[] = [
],
},
{
label: '其他',
labelKey: 'connection_modal.step1.group.other',
label: 'Other',
items: [
{ key: 'jvm', name: 'JVM Runtime' },
{ key: 'custom', name: 'Custom (自定义)' },
{ key: 'jvm', name: 'JVM Runtime', nameKey: 'connection_modal.layoutKind.jvm' },
{ key: 'custom', name: 'Custom', nameKey: 'connection_modal.db_icon_label.custom' },
],
},
];
export const buildConnectionTypeGroups = (
translate?: ConnectionTypeCatalogTranslator,
): ConnectionTypeCatalogGroup[] =>
CONNECTION_TYPE_GROUPS.map((group) => ({
...group,
label: translateCatalogCopy(translate, group.labelKey, group.label),
items: group.items.map((item) => ({
...item,
name: item.nameKey
? translateCatalogCopy(translate, item.nameKey, item.name)
: item.name,
})),
}));
export const getConnectionTypeDefaultPort = (type: string): number => {
switch (String(type || '').trim().toLowerCase()) {
case 'jvm':
@@ -147,22 +185,37 @@ export const getConnectionTypeDefaultPort = (type: string): number => {
}
};
export const getConnectionTypeHint = (type: string): string => {
export const getConnectionTypeHint = (
type: string,
translate?: ConnectionTypeCatalogTranslator,
): string => {
switch (String(type || '').trim().toLowerCase()) {
case 'jvm':
return 'JMX / Endpoint / Agent';
return translateCatalogCopy(translate, 'connection_modal.step1.hint.jvm', 'JMX / Endpoint / Agent');
case 'custom':
return '自定义驱动与 DSN';
return translateCatalogCopy(translate, 'connection_modal.step1.hint.custom', 'Custom driver and DSN');
case 'redis':
return '单机 / 哨兵 / 集群';
return translateCatalogCopy(translate, 'connection_modal.step1.hint.redis', 'Single node / cluster');
case 'mongodb':
return '单机 / 副本集';
return translateCatalogCopy(translate, 'connection_modal.step1.hint.mongodb', 'Single node / replica set');
case 'elasticsearch':
return '支持索引浏览、Mapping 检查、JSON DSL 和 query_string 查询';
return translateCatalogCopy(
translate,
'connection_modal.step1.hint.elasticsearch',
'Index browsing, Mapping inspection, JSON DSL, and query_string queries',
);
case 'chroma':
return 'Collection 浏览、向量检索和元数据过滤';
return translateCatalogCopy(
translate,
'connection_modal.step1.hint.chroma',
'Collection browsing, vector retrieval, and metadata filtering',
);
case 'qdrant':
return 'Collection 浏览、向量搜索和 Payload 过滤';
return translateCatalogCopy(
translate,
'connection_modal.step1.hint.qdrant',
'Collection browsing, vector search, and Payload filtering',
);
case 'iotdb':
return 'Storage Group / Device / Timeseries';
case 'rocketmq':
@@ -174,14 +227,22 @@ export const getConnectionTypeHint = (type: string): string => {
case 'rabbitmq':
return 'Management API / Virtual Host / Queue';
case 'oceanbase':
return 'MySQL / Oracle 租户';
return translateCatalogCopy(translate, 'connection_modal.step1.hint.oceanBase', 'MySQL / Oracle tenant');
case 'goldendb':
return 'MySQL 兼容 / 分布式事务';
return translateCatalogCopy(
translate,
'connection_modal.step1.hint.goldendb',
'MySQL compatible / distributed transactions',
);
case 'sqlite':
case 'duckdb':
return '本地文件连接';
return translateCatalogCopy(translate, 'connection_modal.step1.hint.file', 'Local file connection');
default:
return '标准连接配置';
return translateCatalogCopy(
translate,
'connection_modal.step1.hint.standard',
'Standard connection configuration',
);
}
};

View File

@@ -2,6 +2,8 @@ import { describe, expect, it } from 'vitest';
import {
DEFAULT_DATA_GRID_DISPLAY_SETTINGS,
DENSITY_OPTIONS,
createDensityOptions,
resolveDataTableColumnWidth,
resolveDataTableDefaultColumnWidth,
resolveDataTableVerticalBorderColor,
@@ -20,6 +22,21 @@ describe('dataGridDisplay helpers', () => {
expect(resolveDataTableDefaultColumnWidth('compact')).toBe(100);
});
it('creates density option labels from i18n keys while keeping density values raw', () => {
const options = createDensityOptions((key) => `T(${key})`);
expect(options).toEqual([
{ label: 'T(app.theme.data_table.density.comfortable)', value: 'comfortable' },
{ label: 'T(app.theme.data_table.density.standard)', value: 'standard' },
{ label: 'T(app.theme.data_table.density.compact)', value: 'compact' },
]);
expect(DENSITY_OPTIONS).toEqual([
{ label: 'Comfortable', value: 'comfortable' },
{ label: 'Standard', value: 'standard' },
{ label: 'Compact', value: 'compact' },
]);
});
it('keeps manual column widths ahead of density defaults', () => {
expect(resolveDataTableColumnWidth({ manualWidth: 320, density: 'compact' })).toBe(320);
expect(resolveDataTableColumnWidth({ manualWidth: undefined, density: 'compact' })).toBe(100);

View File

@@ -1,3 +1,5 @@
import { t as translateCatalog } from '../i18n/catalog';
export type DataTableDensity = 'comfortable' | 'standard' | 'compact';
export interface DataGridDisplaySettings {
@@ -22,6 +24,8 @@ export const MAX_DATA_TABLE_FONT_SIZE = 18;
export const MIN_SIDEBAR_TREE_FONT_SIZE = 10;
export const MAX_SIDEBAR_TREE_FONT_SIZE = 18;
type DensityOptionTranslator = (key: string) => string;
interface DensityParams {
defaultColumnWidth: number;
cellPadding: string;
@@ -58,11 +62,20 @@ const DENSITY_PARAMS: Record<DataTableDensity, DensityParams> = {
},
};
export const DENSITY_OPTIONS = [
{ label: '舒适', value: 'comfortable' as const },
{ label: '标准', value: 'standard' as const },
{ label: '紧凑', value: 'compact' as const },
];
const DENSITY_OPTION_VALUES = [
'comfortable',
'standard',
'compact',
] as const;
export const createDensityOptions = (
translate: DensityOptionTranslator = (key) => translateCatalog('en-US', key),
) => DENSITY_OPTION_VALUES.map((value) => ({
label: translate(`app.theme.data_table.density.${value}`),
value,
}));
export const DENSITY_OPTIONS = createDensityOptions();
export const sanitizeDataTableDensity = (value: unknown): DataTableDensity => {
if (value === 'standard' || value === 'compact') return value;

View File

@@ -1,5 +1,7 @@
import { describe, expect, it } from 'vitest';
import { beforeEach, describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { setCurrentLanguage } from '../i18n';
import {
applyWhereConditionSuggestion,
buildEffectiveFilterConditions,
@@ -11,7 +13,13 @@ import {
validateQuickWhereCondition,
} from './dataGridWhereFilter';
const source = readFileSync(new URL('./dataGridWhereFilter.ts', import.meta.url), 'utf8');
describe('dataGridWhereFilter', () => {
beforeEach(() => {
setCurrentLanguage('zh-CN');
});
it('normalizes pasted WHERE clauses to condition bodies', () => {
expect(normalizeQuickWhereCondition(' WHERE status = 1; ')).toBe('status = 1');
expect(normalizeQuickWhereCondition('\nwhere name like \'A%\'\n')).toBe("name like 'A%'");
@@ -29,6 +37,36 @@ describe('dataGridWhereFilter', () => {
});
});
it('switches quick where validation and suggestion labels to English', () => {
setCurrentLanguage('en-US');
expect(validateQuickWhereCondition('status = 1 -- bypass')).toEqual({
ok: false,
message: 'WHERE condition cannot contain semicolons or SQL comments',
});
const [columnSuggestion] = resolveWhereConditionSuggestions({
input: 'sta',
columnNames: ['status'],
dbType: 'mysql',
});
expect(columnSuggestion.detail).toBe('Column');
const [operatorSuggestion] = resolveWhereConditionSuggestions({
input: 'status ',
columnNames: ['status'],
dbType: 'mysql',
});
expect(operatorSuggestion.detail).toBe('Operator');
const [keywordSuggestion] = resolveWhereConditionSuggestions({
input: 'status = 1 a',
columnNames: ['status'],
dbType: 'mysql',
});
expect(keywordSuggestion.detail).toBe('Keyword');
});
it('merges structured filters with a quick custom where condition', () => {
const effective = buildEffectiveFilterConditions(
[{ id: 1, column: 'status', op: '=', value: 'A', logic: 'AND' }],
@@ -137,4 +175,15 @@ describe('dataGridWhereFilter', () => {
suggestionCount: 0,
})).toBe(false);
});
it('keeps quick where UI copy out of hard-coded Chinese strings', () => {
[
'WHERE 条件不能包含分号或 SQL 注释',
"detail: '操作符'",
"detail: '关键字'",
"detail: '字段'",
].forEach((snippet) => {
expect(source).not.toContain(snippet);
});
});
});

View File

@@ -1,4 +1,5 @@
import { quoteIdentPart, type FilterCondition } from './sql';
import { t } from '../i18n';
export type WhereConditionSuggestionKind = 'column' | 'operator' | 'keyword';
@@ -146,7 +147,7 @@ export const validateQuickWhereCondition = (
if (/[;]/.test(text) || /--|\/\*/.test(text)) {
return {
ok: false,
message: 'WHERE 条件不能包含分号或 SQL 注释',
message: t('data_grid.filter.invalid_quick_where'),
};
}
return { ok: true };
@@ -257,7 +258,7 @@ export const resolveWhereConditionSuggestions = ({
label: operator,
insertText,
value: applyWhereConditionSuggestion(text, insertText),
detail: '操作符',
detail: t('data_grid.filter.suggestion.operator'),
kind: 'operator',
});
});
@@ -280,7 +281,7 @@ export const resolveWhereConditionSuggestions = ({
label: keyword,
insertText,
value: applyWhereConditionSuggestion(text, insertText),
detail: '关键字',
detail: t('data_grid.filter.suggestion.keyword'),
kind: 'keyword',
});
});
@@ -298,7 +299,7 @@ export const resolveWhereConditionSuggestions = ({
label: column,
insertText,
value: applyWhereConditionSuggestion(text, insertText),
detail: '字段',
detail: t('data_grid.filter.suggestion.column'),
kind: 'column',
});
});
@@ -311,7 +312,7 @@ export const resolveWhereConditionSuggestions = ({
label: keyword,
insertText,
value: applyWhereConditionSuggestion(text, insertText),
detail: '关键字',
detail: t('data_grid.filter.suggestion.keyword'),
kind: 'keyword',
});
});

View File

@@ -1,11 +1,19 @@
import { describe, expect, it } from 'vitest';
import {
buildFontFamilyOptions,
DEFAULT_MONO_FONT_FAMILY,
DEFAULT_UI_FONT_FAMILY,
getLinuxCJKFontInstallHint,
hasInstalledCJKFontFamily,
} from './fontFamilies';
describe('fontFamilies helpers', () => {
const translateFontLabel = (key: string): string => ({
'app.theme.font_family.default_ui_option': 'Default UI font',
'app.theme.font_family.default_mono_option': 'Default code font',
}[key] ?? key);
it('detects installed CJK font families on Linux', () => {
expect(hasInstalledCJKFontFamily([
{ family: 'Ubuntu' },
@@ -30,4 +38,26 @@ describe('fontFamilies helpers', () => {
{ family: 'DejaVu Sans' },
])).toBeNull();
});
it('localizes default UI font labels without relying on the Chinese label for sorting', () => {
const [defaultOption] = buildFontFamilyOptions('linux', 'ui', [
{ family: 'Zulu Sans' },
], translateFontLabel);
expect(defaultOption).toMatchObject({
value: DEFAULT_UI_FONT_FAMILY,
label: 'Default UI font',
});
});
it('localizes default mono font labels without relying on the Chinese label for sorting', () => {
const [defaultOption] = buildFontFamilyOptions('linux', 'mono', [
{ family: 'Zulu Mono' },
], translateFontLabel);
expect(defaultOption).toMatchObject({
value: DEFAULT_MONO_FONT_FAMILY,
label: 'Default code font',
});
});
});

View File

@@ -35,9 +35,13 @@ const CJK_FONT_KEYWORDS = [
export type FontFamilyOption = {
value: string;
label: string;
labelKey?: string;
isDefault?: boolean;
keywords?: string[];
};
type FontFamilyLabelTranslator = (key: string) => string;
export type InstalledFontFamily = {
family: string;
path?: string;
@@ -132,7 +136,13 @@ const LINUX_MONO_FONTS: FontFamilyOption[] = [
];
const SHARED_UI_FONTS: FontFamilyOption[] = [
{ value: DEFAULT_UI_FONT_FAMILY, label: '默认 UI 字体', keywords: ['default', 'system'] },
{
value: DEFAULT_UI_FONT_FAMILY,
label: 'Default UI font',
labelKey: 'app.theme.font_family.default_ui_option',
isDefault: true,
keywords: ['default', 'system'],
},
{ value: '"Inter", sans-serif', label: 'Inter', keywords: ['shared'] },
{ value: '"PingFang SC", sans-serif', label: 'PingFang SC', keywords: ['shared', '苹方'] },
{ value: '"Microsoft YaHei", sans-serif', label: 'Microsoft YaHei', keywords: ['shared', '雅黑'] },
@@ -141,7 +151,13 @@ const SHARED_UI_FONTS: FontFamilyOption[] = [
];
const SHARED_MONO_FONTS: FontFamilyOption[] = [
{ value: DEFAULT_MONO_FONT_FAMILY, label: '默认代码字体', keywords: ['default', 'system', 'mono'] },
{
value: DEFAULT_MONO_FONT_FAMILY,
label: 'Default code font',
labelKey: 'app.theme.font_family.default_mono_option',
isDefault: true,
keywords: ['default', 'system', 'mono'],
},
{ value: '"JetBrains Mono", monospace', label: 'JetBrains Mono', keywords: ['shared'] },
{ value: '"Cascadia Code", monospace', label: 'Cascadia Code', keywords: ['shared'] },
{ value: '"Fira Code", monospace', label: 'Fira Code', keywords: ['shared'] },
@@ -170,17 +186,32 @@ const dedupeFontOptions = (options: FontFamilyOption[]): FontFamilyOption[] => {
result.push({
value: normalizedValue,
label: option.label,
labelKey: option.labelKey,
isDefault: option.isDefault,
keywords: option.keywords,
});
});
return result;
};
const localizeFontOptions = (
options: FontFamilyOption[],
translate?: FontFamilyLabelTranslator,
): FontFamilyOption[] => {
if (!translate) {
return options;
}
return options.map((option) => ({
...option,
label: option.labelKey ? translate(option.labelKey) : option.label,
}));
};
const sortFontOptions = (options: FontFamilyOption[]): FontFamilyOption[] => {
const defaultOptions: FontFamilyOption[] = [];
const regularOptions: FontFamilyOption[] = [];
options.forEach((option) => {
if (option.label.startsWith('默认')) {
if (option.isDefault) {
defaultOptions.push(option);
return;
}
@@ -328,6 +359,7 @@ export const getLinuxCJKFontInstallHint = (
export const getPlatformFontFamilyOptions = (
platform: string,
kind: "ui" | "mono",
translate?: FontFamilyLabelTranslator,
): FontFamilyOption[] => {
const normalizedPlatform = String(platform || "").toLowerCase();
const platformOptions =
@@ -338,21 +370,22 @@ export const getPlatformFontFamilyOptions = (
: normalizedPlatform === "linux"
? (kind === "ui" ? LINUX_UI_FONTS : LINUX_MONO_FONTS)
: [];
return sortFontOptions(dedupeFontOptions([
return localizeFontOptions(sortFontOptions(dedupeFontOptions([
...platformOptions,
...(kind === "ui" ? SHARED_UI_FONTS : SHARED_MONO_FONTS),
]));
])), translate);
};
export const buildFontFamilyOptions = (
platform: string,
kind: 'ui' | 'mono',
installedFamilies: Array<string | InstalledFontFamily>,
translate?: FontFamilyLabelTranslator,
): FontFamilyOption[] => {
return sortFontOptions(dedupeFontOptions([
return localizeFontOptions(sortFontOptions(dedupeFontOptions([
...buildInstalledFontOptions(installedFamilies, kind),
...getPlatformFontFamilyOptions(platform, kind),
]));
])), translate);
};
export const matchFontFamilyOption = (

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import {
buildJVMChangeDraftFromAIPlan,
@@ -8,6 +9,44 @@ import {
resolveJVMAIPlanTargetTabId,
} from './jvmAiPlan';
const translatedCopy: Record<string, string> = {
'jvm_ai_plan.error.payload_json_object_required': 'Translated payload object required',
'jvm_ai_plan.snapshot.unavailable': 'Translated snapshot unavailable.',
'jvm_ai_plan.actions.none': 'Translated actions none.',
'jvm_ai_plan.actions.label': ' <{{label}}>',
'jvm_ai_plan.actions.description': ' :: {{description}}',
'jvm_ai_plan.actions.payload_fields': ' :: fields={{fields}}',
'jvm_ai_plan.actions.field_separator': '|',
'jvm_ai_plan.actions.required_suffix': '(req)',
'jvm_ai_plan.prompt.resource_path_missing': '(translated missing path)',
'jvm_ai_plan.prompt.environment_unknown': 'translated-unknown',
'jvm_ai_plan.prompt.intro': 'Translated JVM prompt intro.',
'jvm_ai_plan.prompt.connection_name': 'Conn={{connectionName}}',
'jvm_ai_plan.prompt.target_host': 'Host={{host}}',
'jvm_ai_plan.prompt.provider_mode': 'Mode={{providerMode}}',
'jvm_ai_plan.prompt.environment': 'Env={{environmentLabel}}',
'jvm_ai_plan.prompt.connection_policy.read_only': 'Translated read only policy',
'jvm_ai_plan.prompt.connection_policy.writable': 'Translated writable policy',
'jvm_ai_plan.prompt.connection_policy': 'Policy={{policy}}',
'jvm_ai_plan.prompt.resource_path': 'Path={{resourcePath}}',
'jvm_ai_plan.prompt.snapshot_title': 'Translated snapshot title:',
'jvm_ai_plan.prompt.supported_actions_title': 'Translated actions title:',
'jvm_ai_plan.prompt.output_requirements_title': 'Translated requirements:',
'jvm_ai_plan.prompt.requirement.single_json_block': 'Translated requirement 1.',
'jvm_ai_plan.prompt.requirement.fields': 'Translated requirement 2.',
'jvm_ai_plan.prompt.requirement.resource_path': 'Translated requirement path {{resourcePath}}.',
'jvm_ai_plan.prompt.requirement.action': 'Translated requirement action.',
'jvm_ai_plan.prompt.requirement.payload': 'Translated requirement payload.',
'jvm_ai_plan.prompt.requirement.no_execute': 'Translated requirement no execute.',
'jvm_ai_plan.prompt.example_title': 'Translated JSON example:',
'jvm_ai_plan.prompt.example_reason': 'Translated cache reason',
};
const translate = (key: string, params?: Record<string, unknown>) => {
const template = translatedCopy[key] || key;
return template.replace(/\{\{(\w+)\}\}/g, (_match, name) => String(params?.[name] ?? ''));
};
describe('extractJVMChangePlan', () => {
it('parses fenced json plan with namespace and key selector', () => {
const message = [
@@ -87,7 +126,16 @@ describe('buildJVMChangeDraftFromAIPlan', () => {
);
expect(plan).not.toBeNull();
expect(() => buildJVMChangeDraftFromAIPlan(plan!)).toThrow('当前 JVM 预览要求 payload 仍然是 JSON 对象');
expect(() => buildJVMChangeDraftFromAIPlan(plan!)).toThrow(/payload.*JSON object/i);
});
it('uses translated error copy for invalid AI plan payloads', () => {
const plan = extractJVMChangePlan(
'```json\n{"targetType":"cacheEntry","selector":{"resourcePath":"/cache/orders"},"action":"updateValue","payload":{"format":"text","value":"ACTIVE"},"reason":"修复缓存脏值"}\n```',
);
expect(plan).not.toBeNull();
expect(() => buildJVMChangeDraftFromAIPlan(plan!, translate)).toThrow('Translated payload object required');
});
it('keeps generic action for managed bean payload updates', () => {
@@ -134,6 +182,43 @@ describe('buildJVMAIPlanPrompt', () => {
expect(prompt).toContain('********');
expect(prompt).not.toContain('secret-token');
});
it('builds the prompt from translated JVM AI plan copy', () => {
const prompt = buildJVMAIPlanPrompt({
connectionName: 'orders-jvm',
providerMode: 'endpoint',
resourcePath: '',
readOnly: true,
snapshot: null,
}, translate);
expect(prompt).toContain('Translated JVM prompt intro.');
expect(prompt).toContain('Conn=orders-jvm');
expect(prompt).toContain('Path=(translated missing path)');
expect(prompt).toContain('Policy=Translated read only policy');
expect(prompt).toContain('Translated snapshot unavailable.');
expect(prompt).toContain('Translated actions none.');
expect(prompt).toContain('Translated cache reason');
expect(prompt).not.toContain('请分析下面这个 JVM 资源');
expect(prompt).not.toContain('当前资源快照尚未加载成功');
});
it('keeps JVM AI plan prompt and error copy behind catalog keys', () => {
const source = fs.readFileSync(new URL('./jvmAiPlan.ts', import.meta.url), 'utf8');
[
'AI 计划缺少可用的资源定位信息',
'当前资源快照尚未加载成功',
'当前资源未声明支持动作',
'请分析下面这个 JVM 资源',
'输出要求:',
'JSON 示例:',
].forEach((literal) => {
expect(source).not.toContain(literal);
});
expect(source).toContain('jvm_ai_plan.prompt.intro');
expect(source).toContain('jvm_ai_plan.error.payload_json_object_required');
});
});
describe('resolveJVMAIPlanTargetTabId', () => {

View File

@@ -1,4 +1,5 @@
import type { JVMActionDefinition, JVMChangeRequest, JVMAIPlanContext, JVMValueSnapshot, TabData } from '../types';
import { t as translateCatalog, type I18nParams } from '../i18n';
import { JVM_SENSITIVE_VALUE_MASK } from './jvmResourcePresentation';
export type JVMAIChangePlan = {
@@ -28,6 +29,8 @@ type JVMAIPlanPromptContext = {
snapshot?: JVMValueSnapshot | null;
};
type JVMAIPlanTranslator = (key: string, params?: I18nParams) => string;
const planFencePattern = /```json\s*([\s\S]*?)```/gi;
const allowedTargetTypes = new Set<JVMAIChangePlan['targetType']>(['cacheEntry', 'managedBean', 'attribute', 'operation']);
const allowedPayloadFormats = new Set<NonNullable<JVMAIChangePlan['payload']>['format']>(['json', 'text']);
@@ -37,6 +40,15 @@ const asTrimmedString = (value: unknown): string => String(value ?? '').trim();
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === 'object' && !Array.isArray(value);
const translatePlanCopy = (
translate: JVMAIPlanTranslator | undefined,
key: string,
params?: I18nParams,
): string => {
const resolved = (translate || translateCatalog)(key, params);
return resolved && resolved !== key ? resolved : key;
};
const normalizeSelector = (value: unknown): JVMAIChangePlan['selector'] | null => {
if (!isRecord(value)) {
return null;
@@ -103,9 +115,12 @@ const normalizePlan = (value: unknown): JVMAIChangePlan | null => {
};
};
const formatSnapshotValue = (snapshot?: JVMValueSnapshot | null): string => {
const formatSnapshotValue = (
snapshot?: JVMValueSnapshot | null,
translate?: JVMAIPlanTranslator,
): string => {
if (!snapshot) {
return '当前资源快照尚未加载成功。';
return translatePlanCopy(translate, 'jvm_ai_plan.snapshot.unavailable');
}
if (snapshot.sensitive) {
return JVM_SENSITIVE_VALUE_MASK;
@@ -181,26 +196,29 @@ export const resolveJVMAIPlanTargetTabId = (tabs: TabData[], context?: JVMAIPlan
return fallbackMatch?.id || '';
};
export const buildJVMChangeDraftFromAIPlan = (plan: JVMAIChangePlan): JVMAIChangeDraft => {
export const buildJVMChangeDraftFromAIPlan = (
plan: JVMAIChangePlan,
translate?: JVMAIPlanTranslator,
): JVMAIChangeDraft => {
const resourceId = resolveJVMAIPlanResourceId(plan);
if (!resourceId) {
throw new Error('AI 计划缺少可用的资源定位信息');
throw new Error(translatePlanCopy(translate, 'jvm_ai_plan.error.resource_locator_missing'));
}
const reason = asTrimmedString(plan.reason);
if (!reason) {
throw new Error('AI 计划缺少变更原因');
throw new Error(translatePlanCopy(translate, 'jvm_ai_plan.error.reason_missing'));
}
const action = asTrimmedString(plan.action);
if (!action) {
throw new Error('AI 计划缺少可执行 action');
throw new Error(translatePlanCopy(translate, 'jvm_ai_plan.error.action_missing'));
}
if (plan.action === 'updateValue') {
const value = plan.payload?.value;
if (plan.payload?.format !== 'json' || !isRecord(value)) {
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
throw new Error(translatePlanCopy(translate, 'jvm_ai_plan.error.payload_json_object_required'));
}
return {
resourceId,
@@ -214,7 +232,7 @@ export const buildJVMChangeDraftFromAIPlan = (plan: JVMAIChangePlan): JVMAIChang
const payloadValue = plan.payload?.value;
if (plan.payload && plan.payload.format === 'json') {
if (!isRecord(payloadValue)) {
throw new Error('当前 JVM 预览要求 payload 仍然是 JSON 对象');
throw new Error(translatePlanCopy(translate, 'jvm_ai_plan.error.payload_json_object_required'));
}
return {
resourceId,
@@ -246,16 +264,23 @@ export const buildJVMChangeDraftFromAIPlan = (plan: JVMAIChangePlan): JVMAIChang
};
};
const formatSupportedActions = (actions?: JVMActionDefinition[]): string => {
const formatSupportedActions = (
actions?: JVMActionDefinition[],
translate?: JVMAIPlanTranslator,
): string => {
if (!actions || actions.length === 0) {
return '当前资源未声明支持动作。若要生成计划,请仅在你能从快照内容中明确推断时给出 action并保持 payload 为 JSON 对象。';
return translatePlanCopy(translate, 'jvm_ai_plan.actions.none');
}
return actions
.map((item) => {
const payloadFields = Array.isArray(item.payloadFields) && item.payloadFields.length > 0
? `payload 字段:${item.payloadFields.map((field) => `${field.name}${field.required ? '(required)' : ''}`).join('、')}`
? translatePlanCopy(translate, 'jvm_ai_plan.actions.payload_fields', {
fields: item.payloadFields
.map((field) => `${field.name}${field.required ? translatePlanCopy(translate, 'jvm_ai_plan.actions.required_suffix') : ''}`)
.join(translatePlanCopy(translate, 'jvm_ai_plan.actions.field_separator')),
})
: '';
return `- ${item.action}${item.label ? ` (${item.label})` : ''}${item.description ? `${item.description}` : ''}${payloadFields}`;
return `- ${item.action}${item.label ? translatePlanCopy(translate, 'jvm_ai_plan.actions.label', { label: item.label }) : ''}${item.description ? translatePlanCopy(translate, 'jvm_ai_plan.actions.description', { description: item.description }) : ''}${payloadFields}`;
})
.join('\n');
};
@@ -268,39 +293,43 @@ export const buildJVMAIPlanPrompt = ({
readOnly,
environment,
snapshot,
}: JVMAIPlanPromptContext): string => {
const normalizedPath = asTrimmedString(resourcePath) || '(未提供资源路径)';
}: JVMAIPlanPromptContext, translate?: JVMAIPlanTranslator): string => {
const normalizedPath = asTrimmedString(resourcePath) || translatePlanCopy(translate, 'jvm_ai_plan.prompt.resource_path_missing');
const snapshotFormat = asTrimmedString(snapshot?.format) || 'json';
const environmentLabel = asTrimmedString(environment) || 'unknown';
const supportedActionsText = formatSupportedActions(snapshot?.supportedActions);
const environmentLabel = asTrimmedString(environment) || translatePlanCopy(translate, 'jvm_ai_plan.prompt.environment_unknown');
const connectionPolicy = translatePlanCopy(
translate,
readOnly ? 'jvm_ai_plan.prompt.connection_policy.read_only' : 'jvm_ai_plan.prompt.connection_policy.writable',
);
const supportedActionsText = formatSupportedActions(snapshot?.supportedActions, translate);
return [
'请分析下面这个 JVM 资源,并生成一个可用于 GoNavi “预览变更” 的结构化修改计划。',
translatePlanCopy(translate, 'jvm_ai_plan.prompt.intro'),
'',
`连接名称:${connectionName}`,
`目标主机:${asTrimmedString(host) || '-'}`,
`Provider 模式:${providerMode}`,
`运行环境:${environmentLabel}`,
`连接策略:${readOnly ? '只读连接,当前只能生成计划和风险分析,不能假设已执行' : '可写连接,但仍必须先预览再人工确认'}`,
`当前资源路径:${normalizedPath}`,
translatePlanCopy(translate, 'jvm_ai_plan.prompt.connection_name', { connectionName }),
translatePlanCopy(translate, 'jvm_ai_plan.prompt.target_host', { host: asTrimmedString(host) || '-' }),
translatePlanCopy(translate, 'jvm_ai_plan.prompt.provider_mode', { providerMode }),
translatePlanCopy(translate, 'jvm_ai_plan.prompt.environment', { environmentLabel }),
translatePlanCopy(translate, 'jvm_ai_plan.prompt.connection_policy', { policy: connectionPolicy }),
translatePlanCopy(translate, 'jvm_ai_plan.prompt.resource_path', { resourcePath: normalizedPath }),
'',
'当前资源快照:',
translatePlanCopy(translate, 'jvm_ai_plan.prompt.snapshot_title'),
`\`\`\`${snapshotFormat}`,
formatSnapshotValue(snapshot),
formatSnapshotValue(snapshot, translate),
'```',
'',
'当前资源支持动作:',
translatePlanCopy(translate, 'jvm_ai_plan.prompt.supported_actions_title'),
supportedActionsText,
'',
'输出要求:',
'1. 可以先给一小段分析,但必须包含且只包含一个 ```json 代码块。',
'2. 代码块里的 JSON 字段必须严格是targetType、selector、action、payload、reason。',
`3. selector.resourcePath 优先使用当前资源路径 ${normalizedPath},不要凭空编造其他路径。`,
'4. action 优先从“当前资源支持动作”里选择;如果当前资源未声明支持动作,才允许基于快照内容推断。',
'5. payload 只能使用 JSON 对象包装,不要输出脚本、命令或原始二进制。若需要纯文本值,也请包装成 {"format":"text","value":"..."}。',
'6. 不要声称已经执行修改,也不要输出脚本或命令。',
translatePlanCopy(translate, 'jvm_ai_plan.prompt.output_requirements_title'),
translatePlanCopy(translate, 'jvm_ai_plan.prompt.requirement.single_json_block'),
translatePlanCopy(translate, 'jvm_ai_plan.prompt.requirement.fields'),
translatePlanCopy(translate, 'jvm_ai_plan.prompt.requirement.resource_path', { resourcePath: normalizedPath }),
translatePlanCopy(translate, 'jvm_ai_plan.prompt.requirement.action'),
translatePlanCopy(translate, 'jvm_ai_plan.prompt.requirement.payload'),
translatePlanCopy(translate, 'jvm_ai_plan.prompt.requirement.no_execute'),
'',
'JSON 示例:',
translatePlanCopy(translate, 'jvm_ai_plan.prompt.example_title'),
'```json',
JSON.stringify(
{
@@ -315,7 +344,7 @@ export const buildJVMAIPlanPrompt = ({
status: 'ACTIVE',
},
},
reason: '修复缓存脏值',
reason: translatePlanCopy(translate, 'jvm_ai_plan.prompt.example_reason'),
},
null,
2,

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from "vitest";
import fs from "node:fs";
import {
parseJVMDiagnosticPlan,
@@ -42,6 +43,28 @@ describe("jvmDiagnosticPlan", () => {
it("returns null for malformed diagnostic payload", () => {
expect(parseJVMDiagnosticPlan('{"command":1}')).toBeNull();
});
it("localizes the fallback reason when the AI plan omits reason", () => {
const plan = parseJVMDiagnosticPlan(
'{"intent":"generic_diagnostic","command":"thread -n 5"}',
(key, params) => {
expect(key).toBe("jvm_diagnostic.ai_plan.default_reason");
return `AI diagnostic plan: ${params?.intent}`;
},
);
expect(plan?.reason).toBe("AI diagnostic plan: generic_diagnostic");
});
it("keeps AIMessageBubble diagnostic plan parsing wired to the active translator", () => {
const source = fs.readFileSync(
new URL("../components/ai/AIMessageBubble.tsx", import.meta.url),
"utf8",
);
expect(source).toContain("parseJVMDiagnosticPlan(displayContent, copy)");
expect(source).not.toContain("parseJVMDiagnosticPlan(displayContent);");
});
});
describe("resolveJVMDiagnosticPlanTargetTabId", () => {

View File

@@ -18,6 +18,11 @@ const allowedRiskLevels = new Set<JVMDiagnosticPlan["riskLevel"]>([
const asTrimmedString = (value: unknown): string => String(value ?? "").trim();
export type JVMDiagnosticPlanTranslator = (
key: string,
params?: Record<string, string>,
) => string;
const isRecord = (value: unknown): value is Record<string, unknown> =>
!!value && typeof value === "object" && !Array.isArray(value);
@@ -31,7 +36,17 @@ const normalizeRiskLevel = (value: unknown): JVMDiagnosticPlan["riskLevel"] => {
return allowedRiskLevels.has(riskLevel) ? riskLevel : "low";
};
const normalizePlan = (value: unknown): JVMDiagnosticPlan | null => {
const getDefaultReason = (
intent: string,
translate?: JVMDiagnosticPlanTranslator,
): string =>
translate?.("jvm_diagnostic.ai_plan.default_reason", { intent })
|| `AI diagnostic plan: ${intent}`;
const normalizePlan = (
value: unknown,
translate?: JVMDiagnosticPlanTranslator,
): JVMDiagnosticPlan | null => {
if (!isRecord(value)) {
return null;
}
@@ -45,7 +60,7 @@ const normalizePlan = (value: unknown): JVMDiagnosticPlan | null => {
}
const intent = asTrimmedString(value.intent) || "generic_diagnostic";
const reason = asTrimmedString(value.reason) || `AI 诊断计划:${intent}`;
const reason = asTrimmedString(value.reason) || getDefaultReason(intent, translate);
return {
intent,
@@ -61,9 +76,12 @@ const normalizePlan = (value: unknown): JVMDiagnosticPlan | null => {
};
};
const tryParsePlan = (content: string): JVMDiagnosticPlan | null => {
const tryParsePlan = (
content: string,
translate?: JVMDiagnosticPlanTranslator,
): JVMDiagnosticPlan | null => {
try {
return normalizePlan(JSON.parse(content));
return normalizePlan(JSON.parse(content), translate);
} catch {
return null;
}
@@ -76,6 +94,7 @@ const resolveDiagnosticTransport = (
export const parseJVMDiagnosticPlan = (
content: string,
translate?: JVMDiagnosticPlanTranslator,
): JVMDiagnosticPlan | null => {
const source = String(content || "").trim();
if (!source) {
@@ -85,13 +104,13 @@ export const parseJVMDiagnosticPlan = (
planFencePattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = planFencePattern.exec(source)) !== null) {
const parsed = tryParsePlan(match[1]);
const parsed = tryParsePlan(match[1], translate);
if (parsed) {
return parsed;
}
}
return tryParsePlan(source);
return tryParsePlan(source, translate);
};
export const matchesJVMDiagnosticPlanTargetTab = (

View File

@@ -1,9 +1,11 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import {
formatJVMDiagnosticChunkText,
formatJVMDiagnosticChunksForDisplay,
formatJVMDiagnosticCommandTypeLabel,
formatJVMDiagnosticEventLabel,
formatJVMDiagnosticPhaseLabel,
formatJVMDiagnosticRiskLabel,
formatJVMDiagnosticSourceLabel,
@@ -17,13 +19,61 @@ describe("jvmDiagnosticPresentation", () => {
it("groups presets by category in a stable order", () => {
const groups = groupJVMDiagnosticPresets();
expect(groups.map((group) => group.label)).toEqual([
"观察类命令",
"跟踪类命令",
"高风险命令",
"Observation commands",
"Trace commands",
"High-risk commands",
]);
expect(groups[0].items.some((item) => item.label === "thread")).toBe(true);
});
it("uses translator values for diagnostic presentation labels and preset descriptions", () => {
const translate = (key: string) =>
({
"jvm_diagnostic.presentation.category.observe": "Observation commands",
"jvm_diagnostic.presentation.category.trace": "Trace commands",
"jvm_diagnostic.presentation.category.mutating": "High-risk commands",
"jvm_diagnostic.presentation.phase.running": "Running",
"jvm_diagnostic.presentation.phase.completed": "Completed",
"jvm_diagnostic.presentation.event.done": "Execution finished",
"jvm_diagnostic.presentation.risk.high": "High risk",
"jvm_diagnostic.presentation.command_type.trace": "Trace",
"jvm_diagnostic.presentation.source.ai_plan": "AI plan",
"jvm_diagnostic.presentation.fallback.unknown": "Unknown",
"jvm_diagnostic.presentation.chunk.empty_event": "Empty event",
"jvm_diagnostic.completion.preset.thread-top.documentation":
"Inspect the busiest threads.",
})[key] || key;
const groups = groupJVMDiagnosticPresets(undefined, translate);
expect(groups.map((group) => group.label)).toEqual([
"Observation commands",
"Trace commands",
"High-risk commands",
]);
expect(groups[0].items[0].description).toBe("Inspect the busiest threads.");
expect(formatJVMDiagnosticPhaseLabel("completed", translate)).toBe("Completed");
expect(formatJVMDiagnosticEventLabel("done", translate)).toBe("Execution finished");
expect(formatJVMDiagnosticRiskLabel("high", translate)).toBe("High risk");
expect(formatJVMDiagnosticCommandTypeLabel("trace", translate)).toBe("Trace");
expect(formatJVMDiagnosticSourceLabel("ai-plan", translate)).toBe("AI plan");
expect(formatJVMDiagnosticPhaseLabel(undefined, translate)).toBe("Unknown");
expect(formatJVMDiagnosticChunkText({ sessionId: "sess-1" }, translate)).toBe(
"Empty event",
);
});
it("keeps diagnostic presentation source free of user-visible Chinese literals", () => {
const source = readFileSync(
new URL("./jvmDiagnosticPresentation.ts", import.meta.url),
"utf8",
);
expect(source).not.toMatch(
/查看最繁忙线程|查看 JVM 运行总览|观察类命令|执行中|低风险|手动输入|未知|空事件/,
);
expect(source).toContain("jvm_diagnostic.presentation.risk.low");
});
it("formats chunk text with localized phase prefix when content exists", () => {
expect(
formatJVMDiagnosticChunkText({
@@ -31,7 +81,7 @@ describe("jvmDiagnosticPresentation", () => {
phase: "running",
content: "thread -n 5",
}),
).toBe("执行中:thread -n 5");
).toBe("Running: thread -n 5");
});
it("redacts sensitive values in diagnostic output chunks", () => {
@@ -233,11 +283,11 @@ describe("jvmDiagnosticPresentation", () => {
});
it("localizes diagnostic status, transport, risk and source labels", () => {
expect(formatJVMDiagnosticPhaseLabel("completed")).toBe("已完成");
expect(formatJVMDiagnosticPhaseLabel("completed")).toBe("Completed");
expect(formatJVMDiagnosticTransportLabel("arthas-tunnel")).toBe("Arthas Tunnel");
expect(formatJVMDiagnosticRiskLabel("high")).toBe("高风险");
expect(formatJVMDiagnosticCommandTypeLabel("trace")).toBe("跟踪类");
expect(formatJVMDiagnosticSourceLabel("ai-plan")).toBe("AI 计划");
expect(formatJVMDiagnosticRiskLabel("high")).toBe("High risk");
expect(formatJVMDiagnosticCommandTypeLabel("trace")).toBe("Trace");
expect(formatJVMDiagnosticSourceLabel("ai-plan")).toBe("AI plan");
});
it("maps risk levels to tag colors", () => {

View File

@@ -8,16 +8,28 @@ export interface JVMDiagnosticCommandPreset {
category: JVMDiagnosticPresetCategory;
command: string;
description: string;
descriptionKey: string;
riskLevel: "low" | "medium" | "high";
}
export type JVMDiagnosticPresentationTranslate = (
key: string,
params?: Record<string, string | number | boolean | null | undefined>,
) => string;
type LocalizedLabel = {
key: string;
fallback: string;
};
export const JVM_DIAGNOSTIC_COMMAND_PRESETS: JVMDiagnosticCommandPreset[] = [
{
key: "thread-top",
label: "thread",
category: "observe",
command: "thread -n 5",
description: "查看最繁忙线程,快速定位阻塞或高 CPU 线程。",
description: "Inspect the busiest threads to find blocking or high-CPU threads quickly.",
descriptionKey: "jvm_diagnostic.completion.preset.thread-top.documentation",
riskLevel: "low",
},
{
@@ -25,7 +37,8 @@ export const JVM_DIAGNOSTIC_COMMAND_PRESETS: JVMDiagnosticCommandPreset[] = [
label: "dashboard",
category: "observe",
command: "dashboard",
description: "查看 JVM 运行总览。",
description: "Inspect the JVM runtime overview.",
descriptionKey: "jvm_diagnostic.completion.preset.dashboard.documentation",
riskLevel: "low",
},
{
@@ -33,7 +46,8 @@ export const JVM_DIAGNOSTIC_COMMAND_PRESETS: JVMDiagnosticCommandPreset[] = [
label: "trace",
category: "trace",
command: "trace com.foo.OrderService submitOrder '#cost > 100'",
description: "跟踪慢方法调用路径。",
description: "Trace slow method call paths.",
descriptionKey: "jvm_diagnostic.completion.preset.trace-slow-method.documentation",
riskLevel: "medium",
},
{
@@ -41,7 +55,8 @@ export const JVM_DIAGNOSTIC_COMMAND_PRESETS: JVMDiagnosticCommandPreset[] = [
label: "watch",
category: "trace",
command: "watch com.foo.OrderService submitOrder '{params,returnObj}' -x 2",
description: "观察入参与返回值。",
description: "Observe parameters and return values.",
descriptionKey: "jvm_diagnostic.completion.preset.watch-return.documentation",
riskLevel: "medium",
},
{
@@ -49,15 +64,25 @@ export const JVM_DIAGNOSTIC_COMMAND_PRESETS: JVMDiagnosticCommandPreset[] = [
label: "ognl",
category: "mutating",
command: "ognl '@java.lang.System@getProperty(\"user.dir\")'",
description: "高风险表达式命令,默认只作示意。",
description: "High-risk expression command, shown as an example only.",
descriptionKey: "jvm_diagnostic.completion.preset.ognl-sample.documentation",
riskLevel: "high",
},
];
const CATEGORY_LABELS: Record<JVMDiagnosticPresetCategory, string> = {
observe: "观察类命令",
trace: "跟踪类命令",
mutating: "高风险命令",
const CATEGORY_LABELS: Record<JVMDiagnosticPresetCategory, LocalizedLabel> = {
observe: {
key: "jvm_diagnostic.presentation.category.observe",
fallback: "Observation commands",
},
trace: {
key: "jvm_diagnostic.presentation.category.trace",
fallback: "Trace commands",
},
mutating: {
key: "jvm_diagnostic.presentation.category.mutating",
fallback: "High-risk commands",
},
};
const RISK_COLORS: Record<"low" | "medium" | "high", string> = {
@@ -66,41 +91,98 @@ const RISK_COLORS: Record<"low" | "medium" | "high", string> = {
high: "red",
};
const PHASE_LABELS: Record<string, string> = {
running: "执行中",
completed: "已完成",
failed: "失败",
canceled: "已取消",
canceling: "取消中",
diagnostic: "诊断事件",
const PHASE_LABELS: Record<string, LocalizedLabel> = {
running: {
key: "jvm_diagnostic.presentation.phase.running",
fallback: "Running",
},
completed: {
key: "jvm_diagnostic.presentation.phase.completed",
fallback: "Completed",
},
failed: {
key: "jvm_diagnostic.presentation.phase.failed",
fallback: "Failed",
},
canceled: {
key: "jvm_diagnostic.presentation.phase.canceled",
fallback: "Canceled",
},
canceling: {
key: "jvm_diagnostic.presentation.phase.canceling",
fallback: "Canceling",
},
diagnostic: {
key: "jvm_diagnostic.presentation.phase.diagnostic",
fallback: "Diagnostic event",
},
};
const EVENT_LABELS: Record<string, string> = {
diagnostic: "诊断输出",
chunk: "输出片段",
done: "执行结束",
const EVENT_LABELS: Record<string, LocalizedLabel> = {
diagnostic: {
key: "jvm_diagnostic.presentation.event.diagnostic",
fallback: "Diagnostic output",
},
chunk: {
key: "jvm_diagnostic.presentation.event.chunk",
fallback: "Output chunk",
},
done: {
key: "jvm_diagnostic.presentation.event.done",
fallback: "Execution finished",
},
};
const TRANSPORT_LABELS: Record<string, string> = {
"agent-bridge": "Agent Bridge",
"arthas-tunnel": "Arthas Tunnel",
const TRANSPORT_LABELS: Record<string, LocalizedLabel> = {
"agent-bridge": {
key: "jvm_diagnostic.presentation.transport.agent_bridge",
fallback: "Agent Bridge",
},
"arthas-tunnel": {
key: "jvm_diagnostic.presentation.transport.arthas_tunnel",
fallback: "Arthas Tunnel",
},
};
const RISK_LABELS: Record<string, string> = {
low: "低风险",
medium: "中风险",
high: "高风险",
const RISK_LABELS: Record<string, LocalizedLabel> = {
low: {
key: "jvm_diagnostic.presentation.risk.low",
fallback: "Low risk",
},
medium: {
key: "jvm_diagnostic.presentation.risk.medium",
fallback: "Medium risk",
},
high: {
key: "jvm_diagnostic.presentation.risk.high",
fallback: "High risk",
},
};
const COMMAND_TYPE_LABELS: Record<string, string> = {
observe: "观察类",
trace: "跟踪类",
mutating: "高风险类",
const COMMAND_TYPE_LABELS: Record<string, LocalizedLabel> = {
observe: {
key: "jvm_diagnostic.presentation.command_type.observe",
fallback: "Observe",
},
trace: {
key: "jvm_diagnostic.presentation.command_type.trace",
fallback: "Trace",
},
mutating: {
key: "jvm_diagnostic.presentation.command_type.mutating",
fallback: "High risk",
},
};
const SOURCE_LABELS: Record<string, string> = {
manual: "手动输入",
"ai-plan": "AI 计划",
const SOURCE_LABELS: Record<string, LocalizedLabel> = {
manual: {
key: "jvm_diagnostic.presentation.source.manual",
fallback: "Manual input",
},
"ai-plan": {
key: "jvm_diagnostic.presentation.source.ai_plan",
fallback: "AI plan",
},
};
const JVM_DIAGNOSTIC_REDACTION_MASK = "********";
@@ -259,7 +341,8 @@ export const redactJVMDiagnosticOutput = (value?: string | null): string =>
export const formatJVMDiagnosticPresetCategory = (
category: JVMDiagnosticPresetCategory,
): string => CATEGORY_LABELS[category];
translate?: JVMDiagnosticPresentationTranslate,
): string => translateLabel(CATEGORY_LABELS[category], translate);
export const resolveJVMDiagnosticRiskColor = (
riskLevel: "low" | "medium" | "high",
@@ -268,40 +351,74 @@ export const resolveJVMDiagnosticRiskColor = (
const normalizeLabelKey = (value?: string | null): string =>
String(value || "").trim().toLowerCase();
const translateWithFallback = (
translate: JVMDiagnosticPresentationTranslate | undefined,
key: string,
fallback: string,
params?: Record<string, string | number | boolean | null | undefined>,
): string => {
if (!translate) {
return fallback;
}
const translated = translate(key, params);
return translated && translated !== key ? translated : fallback;
};
const translateLabel = (
label: LocalizedLabel,
translate?: JVMDiagnosticPresentationTranslate,
): string => translateWithFallback(translate, label.key, label.fallback);
const formatWithFallback = (
value: string | undefined | null,
labels: Record<string, string>,
fallback = "未知",
labels: Record<string, LocalizedLabel>,
translate?: JVMDiagnosticPresentationTranslate,
): string => {
const normalized = normalizeLabelKey(value);
if (!normalized) {
return fallback;
return translateWithFallback(
translate,
"jvm_diagnostic.presentation.fallback.unknown",
"Unknown",
);
}
return labels[normalized] || String(value || "").trim();
const label = labels[normalized];
return label ? translateLabel(label, translate) : String(value || "").trim();
};
export const formatJVMDiagnosticPhaseLabel = (phase?: string | null): string =>
formatWithFallback(phase, PHASE_LABELS);
export const formatJVMDiagnosticPhaseLabel = (
phase?: string | null,
translate?: JVMDiagnosticPresentationTranslate,
): string => formatWithFallback(phase, PHASE_LABELS, translate);
export const formatJVMDiagnosticEventLabel = (event?: string | null): string =>
formatWithFallback(event, EVENT_LABELS);
export const formatJVMDiagnosticEventLabel = (
event?: string | null,
translate?: JVMDiagnosticPresentationTranslate,
): string => formatWithFallback(event, EVENT_LABELS, translate);
export const formatJVMDiagnosticTransportLabel = (
transport?: string | null,
): string => formatWithFallback(transport, TRANSPORT_LABELS);
translate?: JVMDiagnosticPresentationTranslate,
): string => formatWithFallback(transport, TRANSPORT_LABELS, translate);
export const formatJVMDiagnosticRiskLabel = (risk?: string | null): string =>
formatWithFallback(risk, RISK_LABELS);
export const formatJVMDiagnosticRiskLabel = (
risk?: string | null,
translate?: JVMDiagnosticPresentationTranslate,
): string => formatWithFallback(risk, RISK_LABELS, translate);
export const formatJVMDiagnosticCommandTypeLabel = (
type?: string | null,
): string => formatWithFallback(type, COMMAND_TYPE_LABELS);
translate?: JVMDiagnosticPresentationTranslate,
): string => formatWithFallback(type, COMMAND_TYPE_LABELS, translate);
export const formatJVMDiagnosticSourceLabel = (source?: string | null): string =>
formatWithFallback(source, SOURCE_LABELS);
export const formatJVMDiagnosticSourceLabel = (
source?: string | null,
translate?: JVMDiagnosticPresentationTranslate,
): string => formatWithFallback(source, SOURCE_LABELS, translate);
export const groupJVMDiagnosticPresets = (
presets: JVMDiagnosticCommandPreset[] = JVM_DIAGNOSTIC_COMMAND_PRESETS,
translate?: JVMDiagnosticPresentationTranslate,
): Array<{
category: JVMDiagnosticPresetCategory;
label: string;
@@ -309,20 +426,34 @@ export const groupJVMDiagnosticPresets = (
}> =>
(["observe", "trace", "mutating"] as const).map((category) => ({
category,
label: formatJVMDiagnosticPresetCategory(category),
items: presets.filter((item) => item.category === category),
label: formatJVMDiagnosticPresetCategory(category, translate),
items: presets
.filter((item) => item.category === category)
.map((item) => ({
...item,
description: translateWithFallback(
translate,
item.descriptionKey,
item.description,
),
})),
}));
const formatJVMDiagnosticChunkTextWithContent = (
chunk: JVMDiagnosticEventChunk,
content: string,
translate?: JVMDiagnosticPresentationTranslate,
): string => {
const rawPhase = String(chunk.phase || chunk.event || "").trim();
const phase = chunk.phase
? formatJVMDiagnosticPhaseLabel(chunk.phase)
: formatJVMDiagnosticEventLabel(chunk.event);
? formatJVMDiagnosticPhaseLabel(chunk.phase, translate)
: formatJVMDiagnosticEventLabel(chunk.event, translate);
if (!rawPhase && !content) {
return "空事件";
return translateWithFallback(
translate,
"jvm_diagnostic.presentation.chunk.empty_event",
"Empty event",
);
}
if (!rawPhase) {
return content;
@@ -330,25 +461,29 @@ const formatJVMDiagnosticChunkTextWithContent = (
if (!content) {
return phase;
}
return `${phase}${content}`;
return `${phase}: ${content}`;
};
export const formatJVMDiagnosticChunkText = (
chunk: JVMDiagnosticEventChunk,
translate?: JVMDiagnosticPresentationTranslate,
): string =>
formatJVMDiagnosticChunkTextWithContent(
chunk,
redactJVMDiagnosticOutput(chunk.content).trim(),
translate,
);
export const formatJVMDiagnosticChunksForDisplay = (
chunks: JVMDiagnosticEventChunk[],
translate?: JVMDiagnosticPresentationTranslate,
): string[] => {
const state = createJVMDiagnosticRedactionState();
return chunks.map((chunk) =>
formatJVMDiagnosticChunkTextWithContent(
chunk,
redactJVMDiagnosticChunkContent(chunk.content, state).trim(),
translate,
),
);
};

View File

@@ -9,14 +9,27 @@ describe('jvmRuntimePresentation', () => {
});
it('builds overview tab titles with connection name and mode label', () => {
expect(buildJVMTabTitle('Orders JVM', 'overview', 'jmx')).toBe('[Orders JVM] JVM 概览 · JMX');
const translate = (key: string) => `T(${key})`;
expect(buildJVMTabTitle('Orders JVM', 'overview', 'jmx', translate)).toBe('[Orders JVM] T(sidebar.jvm.tab.overview) · JMX');
});
it('builds resource tab titles with the planned label', () => {
expect(buildJVMTabTitle('Orders JVM', 'resource', 'endpoint')).toBe('[Orders JVM] JVM 资源 · Endpoint');
const translate = (key: string) => `T(${key})`;
expect(buildJVMTabTitle('Orders JVM', 'resource', 'endpoint', translate)).toBe('[Orders JVM] T(sidebar.jvm.tab.resource) · Endpoint');
});
it('builds audit tab titles with the planned label', () => {
expect(buildJVMTabTitle('Orders JVM', 'audit', 'jmx')).toBe('[Orders JVM] JVM 审计 · JMX');
const translate = (key: string) => `T(${key})`;
expect(buildJVMTabTitle('Orders JVM', 'audit', 'jmx', translate)).toBe('[Orders JVM] T(sidebar.jvm.tab.audit) · JMX');
});
it('builds diagnostic and monitoring tab titles from i18n keys', () => {
const translate = (key: string) => `T(${key})`;
expect(buildJVMTabTitle('Orders JVM', 'diagnostic', 'agent', translate)).toBe('[Orders JVM] T(sidebar.jvm.tab.diagnostic) · Agent');
expect(buildJVMTabTitle('Orders JVM', 'monitoring', 'jmx', translate)).toBe('[Orders JVM] T(sidebar.jvm.tab.monitoring) · JMX');
});
});

View File

@@ -1,3 +1,5 @@
import { t as translateCatalog } from '../i18n';
export type JVMRuntimeMode = 'jmx' | 'endpoint' | 'agent';
export type JVMTabKind = 'overview' | 'resource' | 'audit' | 'diagnostic' | 'monitoring';
@@ -10,6 +12,8 @@ export type JVMModeMeta = {
export const JVM_RUNTIME_MODES: JVMRuntimeMode[] = ['jmx', 'endpoint', 'agent'];
type JVMRuntimeTranslator = (key: string) => string;
const JVM_MODE_META_MAP: Record<JVMRuntimeMode, JVMModeMeta> = {
jmx: {
mode: 'jmx',
@@ -31,12 +35,12 @@ const JVM_MODE_META_MAP: Record<JVMRuntimeMode, JVMModeMeta> = {
},
};
const JVM_TAB_KIND_LABELS: Record<JVMTabKind, string> = {
overview: 'JVM 概览',
resource: 'JVM 资源',
audit: 'JVM 审计',
diagnostic: 'JVM 诊断',
monitoring: 'JVM 监控',
const JVM_TAB_KIND_LABEL_KEYS: Record<JVMTabKind, string> = {
overview: 'sidebar.jvm.tab.overview',
resource: 'sidebar.jvm.tab.resource',
audit: 'sidebar.jvm.tab.audit',
diagnostic: 'sidebar.jvm.tab.diagnostic',
monitoring: 'sidebar.jvm.tab.monitoring',
};
const normalizeMode = (mode: string): string => String(mode || '').trim().toLowerCase();
@@ -66,9 +70,11 @@ export const buildJVMTabTitle = (
connectionName: string,
tabKind: JVMTabKind,
mode: string,
translate: JVMRuntimeTranslator = translateCatalog,
): string => {
const trimmedConnectionName = String(connectionName || '').trim();
const tabLabel = JVM_TAB_KIND_LABELS[tabKind] || 'JVM';
const tabLabelKey = JVM_TAB_KIND_LABEL_KEYS[tabKind];
const tabLabel = tabLabelKey ? translate(tabLabelKey) : 'JVM';
const modeLabel = resolveJVMModeMeta(mode).label;
const prefix = trimmedConnectionName ? `[${trimmedConnectionName}] ` : '';

View File

@@ -16,12 +16,12 @@ describe("jvmSidebarActions", () => {
).toEqual([
{
key: "conn-1-jvm-monitoring-jmx",
title: "持续监控 · JMX",
title: "Continuous monitoring · JMX",
providerMode: "jmx",
},
{
key: "conn-1-jvm-monitoring-endpoint",
title: "持续监控 · Endpoint",
title: "Continuous monitoring · Endpoint",
providerMode: "endpoint",
},
]);
@@ -36,7 +36,7 @@ describe("jvmSidebarActions", () => {
).toEqual([
{
key: "conn-1-jvm-monitoring-jmx",
title: "持续监控 · JMX",
title: "Continuous monitoring · JMX",
providerMode: "jmx",
},
]);
@@ -50,7 +50,7 @@ describe("jvmSidebarActions", () => {
}),
).toEqual({
key: "conn-1-jvm-diagnostic",
title: "诊断增强 · Arthas Tunnel",
title: "Diagnostic enhancement · Arthas Tunnel",
transport: "arthas-tunnel",
});
@@ -61,4 +61,29 @@ describe("jvmSidebarActions", () => {
}),
).toBeNull();
});
it("localizes JVM sidebar action titles while preserving runtime labels", () => {
const translate = (key: string) => ({
"sidebar.jvm.action.monitoring": "持續監控",
"sidebar.jvm.action.diagnostic": "診斷增強",
}[key] ?? key);
expect(
buildJVMMonitoringActionDescriptors("conn-1", [{ mode: "endpoint" }], translate),
).toEqual([
{
key: "conn-1-jvm-monitoring-endpoint",
title: "持續監控 · Endpoint",
providerMode: "endpoint",
},
]);
expect(
buildJVMDiagnosticActionDescriptor("conn-1", { enabled: true }, translate),
).toEqual({
key: "conn-1-jvm-diagnostic",
title: "診斷增強 · Agent Bridge",
transport: "agent-bridge",
});
});
});

View File

@@ -1,4 +1,5 @@
import type { JVMCapability } from "../types";
import { t as defaultTranslate } from "../i18n";
import {
JVM_RUNTIME_MODES,
resolveJVMModeMeta,
@@ -17,6 +18,8 @@ export type JVMDiagnosticActionDescriptor = {
transport: "agent-bridge" | "arthas-tunnel";
};
type JVMActionTranslator = (key: string) => string;
const normalizeMonitoringMode = (value: unknown): JVMRuntimeMode | null => {
const mode = String(value || "").trim().toLowerCase();
return JVM_RUNTIME_MODES.includes(mode as JVMRuntimeMode)
@@ -24,9 +27,22 @@ const normalizeMonitoringMode = (value: unknown): JVMRuntimeMode | null => {
: null;
};
const MONITORING_ACTION_KEY = "sidebar.jvm.action.monitoring";
const DIAGNOSTIC_ACTION_KEY = "sidebar.jvm.action.diagnostic";
const translateLabel = (
translate: JVMActionTranslator | undefined,
key: string,
): string => {
const fallback = defaultTranslate(key, undefined, "en-US");
const translated = translate ? translate(key) : fallback;
return translated && translated !== key ? translated : fallback;
};
export const buildJVMMonitoringActionDescriptors = (
connectionId: string,
capabilities: Array<Pick<JVMCapability, "mode"> & Partial<Pick<JVMCapability, "canBrowse">>>,
translate?: JVMActionTranslator,
): JVMMonitoringActionDescriptor[] => {
const id = String(connectionId || "").trim();
if (!id) {
@@ -48,7 +64,7 @@ export const buildJVMMonitoringActionDescriptors = (
descriptors.push({
key: `${id}-jvm-monitoring-${providerMode}`,
title: `持续监控 · ${resolveJVMModeMeta(providerMode).label}`,
title: `${translateLabel(translate, MONITORING_ACTION_KEY)} · ${resolveJVMModeMeta(providerMode).label}`,
providerMode,
});
});
@@ -59,6 +75,7 @@ export const buildJVMMonitoringActionDescriptors = (
export const buildJVMDiagnosticActionDescriptor = (
connectionId: string,
diagnostic: { enabled?: boolean; transport?: unknown } | undefined,
translate?: JVMActionTranslator,
): JVMDiagnosticActionDescriptor | null => {
const id = String(connectionId || "").trim();
if (!id || diagnostic?.enabled !== true) {
@@ -71,7 +88,7 @@ export const buildJVMDiagnosticActionDescriptor = (
: "agent-bridge";
return {
key: `${id}-jvm-diagnostic`,
title: `诊断增强 · ${transport === "arthas-tunnel" ? "Arthas Tunnel" : "Agent Bridge"}`,
title: `${translateLabel(translate, DIAGNOSTIC_ACTION_KEY)} · ${transport === "arthas-tunnel" ? "Arthas Tunnel" : "Agent Bridge"}`,
transport,
};
};

View File

@@ -1,11 +1,14 @@
import {
type BusinessArgumentHintTemplate,
type MCPHintTranslator,
type MCPBusinessArgumentHintCategory,
hasDockerImageArg,
hasPackageLikeArg,
localizeBusinessArgumentHintTemplate,
normalizeFlagName,
resolveBusinessArgumentHintTemplate,
sanitizeFlagForDisplay,
translateMCPHintCopy,
toTrimmedString,
} from './mcpArgumentHints';
@@ -68,21 +71,31 @@ 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',
label: 'Unrecognized argument',
detail: `GoNavi cannot infer the business meaning of --${flag} from the argument name, but it will pass it to the MCP process in the current order.`,
valueHint: 'Check the MCP README to confirm whether this argument needs a value; if it does, put the value as the next argument tag or use --name=value.',
sensitive: false,
labelKey: 'ai_settings.mcp_server.argument_hints.generic.label',
detailKey: 'ai_settings.mcp_server.argument_hints.generic.detail',
valueHintKey: 'ai_settings.mcp_server.argument_hints.generic.value_hint',
params: { flag },
});
const sanitizeArgumentValueForDisplay = (value: string, sensitive = false): string => {
const sanitizeArgumentValueForDisplay = (
value: string,
sensitive = false,
translate?: MCPHintTranslator,
): string => {
const text = toTrimmedString(value);
if (!text) return '';
if (sensitive) return '<已隐藏>';
if (sensitive) {
return translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.hidden_value', '<hidden>');
}
if (/^(.{0,24})=(.*)$/u.test(text) && /(token|api[-_]?key|secret|password|credential)/iu.test(text.split('=')[0])) {
return `${text.split('=')[0]}=<已隐藏>`;
return `${text.split('=')[0]}=${translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.hidden_value', '<hidden>')}`;
}
if (/(sk-[a-z0-9_-]{8,}|ghp_[a-z0-9_]{8,}|xox[baprs]-[a-z0-9-]{8,})/iu.test(text)) {
return '<疑似密钥,已隐藏>';
return translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.possible_secret_hidden', '<possible secret hidden>');
}
return text;
};
@@ -101,126 +114,142 @@ const buildArgumentDetail = (
sensitive: template.sensitive,
});
const runtimeTemplate = (
key: string,
template: Omit<BusinessArgumentHintTemplate, 'labelKey' | 'detailKey' | 'valueHintKey'>,
translate?: MCPHintTranslator,
): BusinessArgumentHintTemplate => localizeBusinessArgumentHintTemplate({
...template,
labelKey: `ai_settings.mcp_server.argument_hints.detail.${key}.label`,
detailKey: `ai_settings.mcp_server.argument_hints.detail.${key}.detail`,
valueHintKey: `ai_settings.mcp_server.argument_hints.detail.${key}.value_hint`,
}, translate);
const runtimeArgumentTemplate = (
commandName: string,
args: string[],
arg: string,
index: number,
translate?: MCPHintTranslator,
): BusinessArgumentHintTemplate | null => {
const text = toTrimmedString(arg);
const lower = text.toLowerCase();
if (lower === '--stdio' || lower === 'stdio') {
return {
return runtimeTemplate('stdio', {
category: 'mode',
label: 'stdio 通信模式',
detail: ' MCP Server 通过标准输入输出和 GoNavi 保持通信。',
valueHint: '这是开关参数,一般不需要额外值。',
label: 'stdio communication mode',
detail: 'Let the MCP Server communicate with GoNavi through standard input and output.',
valueHint: 'This is a switch argument and usually does not need an extra value.',
sensitive: false,
};
}, translate);
}
if (lower === '-y' && ['npx', 'npm', 'pnpm', 'yarn'].includes(commandName)) {
return {
return runtimeTemplate('skip_install_confirm', {
category: 'runtime',
label: '跳过安装确认',
detail: '避免 npx 首次启动包时等待交互确认,适合后台工具发现。',
valueHint: '这是开关参数,不需要额外值。',
label: 'Skip install confirmation',
detail: 'Avoid waiting for interactive confirmation when npx starts a package for the first time; useful for background tool discovery.',
valueHint: 'This is a switch argument and does not need an extra value.',
sensitive: false,
};
}, translate);
}
if (lower === '-m' && ['python', 'python3', 'py'].includes(commandName)) {
return {
return runtimeTemplate('python_module_flag', {
category: 'runtime',
label: 'Python 模块启动',
detail: '表示后一个参数是 Python 模块名,而不是脚本文件路径。',
valueHint: '后面补模块名,例如 your_mcp_server',
label: 'Python module launch',
detail: 'Indicates that the next argument is a Python module name, not a script path.',
valueHint: 'Add the module name next, for example your_mcp_server.',
sensitive: false,
};
}, translate);
}
if (commandName === 'docker') {
if (lower === 'run') {
return {
return runtimeTemplate('docker_run', {
category: 'runtime',
label: 'Docker 运行子命令',
detail: '表示启动一个容器来运行 MCP Server',
valueHint: '通常放在 docker 后面的第一个参数。',
label: 'Docker run subcommand',
detail: 'Starts a container to run the MCP Server.',
valueHint: 'Usually the first argument after docker.',
sensitive: false,
};
}, translate);
}
if (lower === '-i' || lower === '--interactive') {
return {
return runtimeTemplate('docker_interactive', {
category: 'runtime',
label: '保持标准输入',
detail: 'MCP stdio 需要容器 stdin 持续打开,否则工具发现可能启动后立刻断开。',
valueHint: '这是 Docker MCP 的关键参数。',
label: 'Keep standard input',
detail: 'MCP stdio needs container stdin to stay open; otherwise tool discovery may disconnect right after startup.',
valueHint: 'This is a key Docker MCP argument.',
sensitive: false,
};
}, translate);
}
if (lower === '--rm') {
return {
return runtimeTemplate('docker_cleanup', {
category: 'runtime',
label: '退出后清理容器',
detail: '测试和日常使用后自动删除临时容器,避免残留。',
valueHint: '这是开关参数,不需要额外值。',
label: 'Clean up container after exit',
detail: 'Automatically remove temporary containers after testing and daily use to avoid leftovers.',
valueHint: 'This is a switch argument and does not need an extra value.',
sensitive: false,
};
}, translate);
}
if (!text.startsWith('-') && hasDockerImageArg(args.slice(0, index + 1))) {
return {
return runtimeTemplate('docker_image_or_arg', {
category: 'runtime',
label: 'Docker 镜像或容器参数',
detail: '这是 docker run 中的镜像名或传给容器内 MCP 服务的位置参数。',
valueHint: '镜像名应来自 MCP README镜像后的参数会传给容器入口程序。',
label: 'Docker image or container argument',
detail: 'This is the image name in docker run or a positional argument passed to the MCP service inside the container.',
valueHint: 'The image name should come from the MCP README; arguments after the image are passed to the container entrypoint.',
sensitive: false,
};
}, translate);
}
}
if (!text.startsWith('-')) {
if (['npx', 'npm', 'pnpm', 'yarn'].includes(commandName) && hasPackageLikeArg([text])) {
return {
return runtimeTemplate('npm_package_or_arg', {
category: 'runtime',
label: 'MCP 包名或位置参数',
detail: '通常是 README 里的 npm 包名,也可能是包自己的业务参数。',
valueHint: '包名一般放在 -y 后、--stdio 前;业务参数以 README 为准。',
label: 'MCP package or positional argument',
detail: 'Usually the npm package name from the README, but it may also be a package-specific business argument.',
valueHint: 'The package name usually goes after -y and before --stdio; business arguments follow the README.',
sensitive: false,
};
}, translate);
}
if (commandName === 'uvx' || commandName === 'uv') {
return {
return runtimeTemplate('uvx_package_or_arg', {
category: 'runtime',
label: 'Python MCP 包名或位置参数',
detail: 'uvx 后面通常跟 MCP 包名;后续位置参数会传给该 MCP 服务。',
valueHint: '第一个位置参数应是 README 里的包名。',
label: 'Python MCP package or positional argument',
detail: 'uvx is usually followed by the MCP package name; later positional arguments are passed to that MCP service.',
valueHint: 'The first positional argument should be the package name from the README.',
sensitive: false,
};
}, translate);
}
if (['node', 'bun', 'deno'].includes(commandName)) {
return {
return runtimeTemplate('script_or_arg', {
category: /\.(c?m?[jt]s)$/iu.test(text) || /[\\/]/u.test(text) ? 'path' : 'runtime',
label: '脚本或位置参数',
detail: '通常是本地 MCP Server 的入口脚本;脚本后的值会作为业务参数传入。',
valueHint: '入口脚本建议使用本机可访问的相对或绝对路径。',
label: 'Script or positional argument',
detail: 'Usually the entry script for a local MCP Server; values after the script are passed as business arguments.',
valueHint: 'Use a relative or absolute path accessible on this machine for the entry script.',
sensitive: false,
};
}, translate);
}
if (['python', 'python3', 'py'].includes(commandName)) {
return {
return runtimeTemplate(args[index - 1] === '-m' ? 'python_module_name' : 'python_script_or_arg', {
category: args[index - 1] === '-m' ? 'runtime' : 'path',
label: args[index - 1] === '-m' ? 'Python 模块名' : 'Python 脚本或位置参数',
label: args[index - 1] === '-m' ? 'Python module name' : 'Python script or positional argument',
detail: args[index - 1] === '-m'
? '这是 -m 后面的模块名,不要带 .py 后缀。'
: '通常是本地 Python MCP 脚本路径,或传给脚本的位置参数。',
valueHint: '以 README 的启动示例为准。',
? 'This is the module name after -m; do not include a .py suffix.'
: 'Usually a local Python MCP script path, or a positional argument passed to the script.',
valueHint: 'Follow the startup example in the README.',
sensitive: false,
};
}, translate);
}
}
return null;
};
export const buildMCPArgumentDetailHints = (commandName: string, args: string[]): MCPArgumentDetailHint[] => {
export const buildMCPArgumentDetailHints = (
commandName: string,
args: string[],
translate?: MCPHintTranslator,
): MCPArgumentDetailHint[] => {
const result: MCPArgumentDetailHint[] = [];
for (let index = 0; index < args.length; index += 1) {
const text = toTrimmedString(args[index]);
@@ -229,34 +258,50 @@ export const buildMCPArgumentDetailHints = (commandName: string, args: string[])
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);
const template = resolveBusinessArgumentHintTemplate(previousFlag, true, translate) || localizeBusinessArgumentHintTemplate(fallbackArgumentHint(previousFlag), translate);
const previousArgument = sanitizeFlagForDisplay(args[index - 1]);
result.push(buildArgumentDetail(
`value-${index}-${previousFlag}`,
sanitizeArgumentValueForDisplay(text, template.sensitive),
sanitizeArgumentValueForDisplay(text, template.sensitive, translate),
{
...template,
label: `${template.label}的值`,
label: translateMCPHintCopy(
translate,
'ai_settings.mcp_server.argument_hints.detail.value_label',
'{{label}} value',
{ label: template.label },
),
detail: template.sensitive
? `这是前一个 ${sanitizeFlagForDisplay(args[index - 1])} 的敏感值,提示中已脱敏。`
: `这是前一个 ${sanitizeFlagForDisplay(args[index - 1])} 参数的值。`,
? translateMCPHintCopy(
translate,
'ai_settings.mcp_server.argument_hints.detail.sensitive_value_detail',
'This is the sensitive value for the previous {{argument}} argument; it is masked in the hint.',
{ argument: previousArgument },
)
: translateMCPHintCopy(
translate,
'ai_settings.mcp_server.argument_hints.detail.value_detail',
'This is the value for the previous {{argument}} argument.',
{ argument: previousArgument },
),
},
));
continue;
}
const runtimeTemplate = runtimeArgumentTemplate(commandName, args, text, index);
if (runtimeTemplate) {
const runtimeHintTemplate = runtimeArgumentTemplate(commandName, args, text, index, translate);
if (runtimeHintTemplate) {
result.push(buildArgumentDetail(
`runtime-${index}-${text}`,
sanitizeArgumentValueForDisplay(text, runtimeTemplate.sensitive),
runtimeTemplate,
sanitizeArgumentValueForDisplay(text, runtimeHintTemplate.sensitive, translate),
runtimeHintTemplate,
));
continue;
}
const flag = normalizeFlagName(text);
if (flag) {
const template = resolveBusinessArgumentHintTemplate(flag, true) || fallbackArgumentHint(flag);
const template = resolveBusinessArgumentHintTemplate(flag, true, translate) || localizeBusinessArgumentHintTemplate(fallbackArgumentHint(flag), translate);
result.push(buildArgumentDetail(
`flag-${index}-${flag}`,
sanitizeFlagForDisplay(text),
@@ -267,14 +312,14 @@ export const buildMCPArgumentDetailHints = (commandName: string, args: string[])
result.push(buildArgumentDetail(
`positional-${index}`,
sanitizeArgumentValueForDisplay(text),
{
sanitizeArgumentValueForDisplay(text, false, translate),
runtimeTemplate('positional', {
category: 'generic',
label: '位置参数',
detail: '这是没有参数名的位置参数GoNavi 会按当前顺序原样传入 MCP 进程。',
valueHint: '请对照 README 判断它是包名、路径、镜像名还是业务参数。',
label: 'Positional argument',
detail: 'This argument has no flag name; GoNavi passes it to the MCP process unchanged in the current order.',
valueHint: 'Check the README to decide whether it is a package name, path, image name, or business argument.',
sensitive: false,
},
}, translate),
));
}
return result;

View File

@@ -8,9 +8,9 @@ describe('mcpArgumentHints', () => {
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');
expect(profile?.orderHint).toContain('-y -> package -> --stdio');
expect(profile?.nextActions).toContain('Add MCP package name, example: @modelcontextprotocol/server-filesystem');
expect(profile?.nextActions).toContain('Add stdio argument, example: --stdio');
});
it('recognizes a complete node script launch', () => {
@@ -25,8 +25,8 @@ describe('mcpArgumentHints', () => {
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');
expect(profile?.orderHint).toContain('-m -> module name -> --stdio');
expect(profile?.nextActions).toContain('Add Module name, example: your_mcp_server');
});
it('guides docker users to keep stdin and provide an image', () => {
@@ -34,8 +34,8 @@ describe('mcpArgumentHints', () => {
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');
expect(profile?.nextActions).toContain('Add Keep standard input, example: -i');
expect(profile?.nextActions).toContain('Add Image name, example: mcp/server-fetch:latest');
});
it('detects full command lines pasted into the command field', () => {
@@ -43,17 +43,17 @@ describe('mcpArgumentHints', () => {
expect(profile?.normalizedCommand).toBe('docker');
expect(profile?.inlineArgs).toEqual(['run', '--rm', 'mcp/server-fetch:latest']);
expect(profile?.commandFieldWarning).toContain('启动命令字段里还包含 3 个参数');
expect(profile?.commandFieldWarning).toContain('The startup command field still contains 3 arguments');
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');
expect(profile?.nextActions).toContain('Add Keep standard input, example: -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 会原样按标签顺序传入');
expect(profile?.title).toContain('Local executable');
expect(profile?.summary).toContain('GoNavi passes arguments in tag order unchanged');
});
it('explains common business arguments beyond startup order', () => {
@@ -71,17 +71,17 @@ describe('mcpArgumentHints', () => {
expect(profile?.businessHints).toEqual(expect.arrayContaining([
expect.objectContaining({
key: 'directory',
label: '授权目录',
label: 'Allowed directory',
category: 'path',
}),
expect.objectContaining({
key: 'transport',
label: '传输模式',
label: 'Transport mode',
category: 'mode',
}),
expect.objectContaining({
key: 'port',
label: '端口',
label: 'Port',
category: 'network',
}),
]));
@@ -99,25 +99,25 @@ describe('mcpArgumentHints', () => {
expect(hints).toEqual(expect.arrayContaining([
expect.objectContaining({
argument: '--tenant',
label: '未识别参数',
label: 'Unrecognized argument',
category: 'generic',
}),
expect.objectContaining({
argument: 'prod',
label: '未识别参数的值',
label: 'Unrecognized argument value',
}),
expect.objectContaining({
argument: '--workspace',
label: '工作区目录',
label: 'Workspace directory',
category: 'path',
}),
expect.objectContaining({
argument: 'D:\\Work',
label: '工作区目录的值',
label: 'Workspace directory value',
}),
expect.objectContaining({
argument: 'extra-target',
label: '位置参数',
label: 'Positional argument',
}),
]));
});
@@ -155,8 +155,8 @@ describe('mcpArgumentHints', () => {
sensitive: true,
}),
expect.objectContaining({
argument: '<已隐藏>',
label: 'Token的值',
argument: '<hidden>',
label: 'Token value',
sensitive: true,
}),
]));

View File

@@ -34,6 +34,22 @@ export interface MCPArgumentHintProfile {
nextActions: string[];
}
export type MCPHintTranslateParams = Record<string, string | number | boolean | null | undefined>;
export type MCPHintTranslator = (key: string, params?: MCPHintTranslateParams) => string;
export const translateMCPHintCopy = (
translate: MCPHintTranslator | undefined,
key: string,
fallback: string,
params?: MCPHintTranslateParams,
): string => {
const translated = translate?.(key, params);
if (translated && translated !== key) {
return translated;
}
return fallback.replace(/\{\{(\w+)\}\}/g, (_, name) => String(params?.[name] ?? ''));
};
export const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const parseCommandField = (command: string): { normalizedCommand: string; commandName: string; inlineArgs: string[] } => {
@@ -123,6 +139,7 @@ export const hasDockerImageArg = (args: string[]): boolean => {
};
const buildStep = (
translate: MCPHintTranslator | undefined,
key: string,
label: string,
example: string,
@@ -131,203 +148,233 @@ const buildStep = (
satisfied: boolean,
): MCPArgumentHintStep => ({
key,
label,
label: translateMCPHintCopy(translate, `ai_settings.mcp_server.argument_hints.step.${key}.label`, label),
example,
detail,
detail: translateMCPHintCopy(translate, `ai_settings.mcp_server.argument_hints.step.${key}.detail`, detail),
required,
satisfied,
});
const buildNextActions = (steps: MCPArgumentHintStep[]): string[] =>
const buildNextActions = (steps: MCPArgumentHintStep[], translate?: MCPHintTranslator): string[] =>
steps
.filter((step) => step.required && !step.satisfied)
.map((step) => `补充 ${step.label},示例:${step.example}`);
.map((step) => translateMCPHintCopy(
translate,
'ai_settings.mcp_server.argument_hints.next_action.add_step',
'Add {{label}}, example: {{example}}',
{ label: step.label, example: step.example },
));
export type BusinessArgumentHintTemplate = Omit<MCPBusinessArgumentHint, 'key' | 'argument'>;
export type BusinessArgumentHintTemplate = Omit<MCPBusinessArgumentHint, 'key' | 'argument'> & {
labelKey?: string;
detailKey?: string;
valueHintKey?: string;
params?: MCPHintTranslateParams;
};
const withBusinessKeys = (
key: string,
template: Omit<BusinessArgumentHintTemplate, 'labelKey' | 'detailKey' | 'valueHintKey'>,
): BusinessArgumentHintTemplate => ({
...template,
labelKey: `ai_settings.mcp_server.argument_hints.business.${key}.label`,
detailKey: `ai_settings.mcp_server.argument_hints.business.${key}.detail`,
valueHintKey: `ai_settings.mcp_server.argument_hints.business.${key}.value_hint`,
});
export const localizeBusinessArgumentHintTemplate = (
template: BusinessArgumentHintTemplate,
translate?: MCPHintTranslator,
): BusinessArgumentHintTemplate => ({
...template,
label: template.labelKey ? translateMCPHintCopy(translate, template.labelKey, template.label, template.params) : template.label,
detail: template.detailKey ? translateMCPHintCopy(translate, template.detailKey, template.detail, template.params) : template.detail,
valueHint: template.valueHintKey ? translateMCPHintCopy(translate, template.valueHintKey, template.valueHint, template.params) : template.valueHint,
});
const BUSINESS_ARGUMENT_HINTS: Record<string, BusinessArgumentHintTemplate> = {
'api-key': {
'api-key': withBusinessKeys('api_key', {
category: 'secret',
label: 'API Key',
detail: '用于把外部 API 密钥传给 MCP 服务。除非 README 明确要求命令参数,否则更建议放到环境变量里。',
valueHint: '填真实 key不要截图或粘贴到聊天里。',
detail: 'Pass an external API key to the MCP service. Prefer environment variables unless the README explicitly requires a command argument.',
valueHint: 'Enter the real key; do not screenshot it or paste it into chat.',
sensitive: true,
},
token: {
}),
token: withBusinessKeys('token', {
category: 'secret',
label: 'Token',
detail: '用于鉴权外部平台或远程 MCP 服务。命令行参数可能被进程列表或日志看到。',
valueHint: '优先改用环境变量,例如 GITHUB_TOKENAPI_TOKEN',
detail: 'Authenticate an external platform or remote MCP service. Command-line arguments may be visible in process lists or logs.',
valueHint: 'Prefer an environment variable such as GITHUB_TOKEN or API_TOKEN.',
sensitive: true,
},
'access-token': {
}),
'access-token': withBusinessKeys('access_token', {
category: 'secret',
label: 'Access Token',
detail: '用于访问第三方 API 或私有资源。',
valueHint: '按最小权限创建 token并优先放环境变量。',
detail: 'Access a third-party API or private resource.',
valueHint: 'Create a least-privilege token and prefer putting it in environment variables.',
sensitive: true,
},
password: {
}),
password: withBusinessKeys('password', {
category: 'secret',
label: '密码',
detail: '密码类参数会进入启动参数列表,风险高于环境变量。',
valueHint: '确认 MCP README 没有环境变量替代方案后再使用。',
label: 'Password',
detail: 'Password arguments enter the launch argument list and are riskier than environment variables.',
valueHint: 'Use this only after confirming the MCP README has no environment-variable alternative.',
sensitive: true,
},
secret: {
}),
secret: withBusinessKeys('secret', {
category: 'secret',
label: '密钥',
detail: '密钥类参数用于鉴权或签名。',
valueHint: '优先使用环境变量或配置文件,避免明文出现在启动参数里。',
label: 'Secret',
detail: 'Secret arguments are used for authentication or signing.',
valueHint: 'Prefer environment variables or config files to keep plaintext out of launch arguments.',
sensitive: true,
},
config: {
}),
config: withBusinessKeys('config', {
category: 'path',
label: '配置文件',
detail: '指向 MCP 服务自己的配置文件。',
valueHint: '填写本机 MCP 进程能访问的绝对路径。',
label: 'Config file',
detail: 'Points to the MCP service config file.',
valueHint: 'Enter an absolute path accessible to the local MCP process.',
sensitive: false,
},
'config-file': {
}),
'config-file': withBusinessKeys('config_file', {
category: 'path',
label: '配置文件',
detail: '指向 MCP 服务自己的配置文件。',
valueHint: 'Windows 建议填写带盘符的绝对路径。',
label: 'Config file',
detail: 'Points to the MCP service config file.',
valueHint: 'On Windows, prefer an absolute path with a drive letter.',
sensitive: false,
},
c: {
}),
c: withBusinessKeys('short_config', {
category: 'path',
label: '配置文件',
detail: '短参数通常表示 config README 为准。',
valueHint: '填写配置文件路径,或按 README 确认 -c 的含义。',
label: 'Config file',
detail: 'The short option usually means config; confirm against the README.',
valueHint: 'Enter the config file path, or confirm what -c means in the README.',
sensitive: false,
},
directory: {
}),
directory: withBusinessKeys('directory', {
category: 'path',
label: '授权目录',
detail: '限制文件系统类 MCP 可访问的目录范围。',
valueHint: '填写要授权给 MCP 的工作目录,不要直接授权整个磁盘。',
label: 'Allowed directory',
detail: 'Limits which directories a filesystem MCP can access.',
valueHint: 'Enter the directory to grant to MCP; do not grant an entire disk by default.',
sensitive: false,
},
dir: {
}),
dir: withBusinessKeys('dir', {
category: 'path',
label: '目录',
detail: '通常表示文件或项目根目录。',
valueHint: '填写本机绝对路径,确认该 MCP 进程有读取权限。',
label: 'Directory',
detail: 'Usually indicates a file or project root directory.',
valueHint: 'Enter a local absolute path and confirm the MCP process has read access.',
sensitive: false,
},
root: {
}),
root: withBusinessKeys('root', {
category: 'path',
label: '根目录',
detail: '通常表示 MCP 服务允许访问或扫描的根目录。',
valueHint: '选择最小必要目录,避免范围过大。',
label: 'Root directory',
detail: 'Usually indicates the root directory the MCP service may access or scan.',
valueHint: 'Choose the smallest necessary directory to avoid excessive scope.',
sensitive: false,
},
workspace: {
}),
workspace: withBusinessKeys('workspace', {
category: 'path',
label: '工作区目录',
detail: '通常表示项目或文件系统服务的工作区。',
valueHint: '填写项目目录或业务数据目录。',
label: 'Workspace directory',
detail: 'Usually indicates the workspace for a project or filesystem service.',
valueHint: 'Enter the project directory or business data directory.',
sensitive: false,
},
path: {
}),
path: withBusinessKeys('path', {
category: 'path',
label: '路径',
detail: '通常表示文件、目录或可执行程序路径。',
valueHint: '填写本机 MCP 进程可访问的路径。',
label: 'Path',
detail: 'Usually indicates a file, directory, or executable path.',
valueHint: 'Enter a path accessible to the local MCP process.',
sensitive: false,
},
url: {
}),
url: withBusinessKeys('url', {
category: 'endpoint',
label: '服务 URL',
detail: 'MCP 服务要访问的 HTTP/HTTPS 地址。',
valueHint: '填写完整 URL例如 https://api.example.com',
label: 'Service URL',
detail: 'HTTP/HTTPS address the MCP service needs to access.',
valueHint: 'Enter a full URL such as https://api.example.com.',
sensitive: false,
},
endpoint: {
}),
endpoint: withBusinessKeys('endpoint', {
category: 'endpoint',
label: 'Endpoint',
detail: '远程服务或 API 的访问入口。',
valueHint: '按 README 填写 endpoint不要混入 token。',
detail: 'Access entry for a remote service or API.',
valueHint: 'Enter the endpoint from the README; do not mix tokens into it.',
sensitive: false,
},
'base-url': {
}),
'base-url': withBusinessKeys('base_url', {
category: 'endpoint',
label: 'Base URL',
detail: '第三方 API 或自建服务的基础地址。',
valueHint: '填写协议、域名和可选端口,不要附带密钥。',
detail: 'Base address for a third-party API or self-hosted service.',
valueHint: 'Enter protocol, domain, and optional port without appending secrets.',
sensitive: false,
},
host: {
}),
host: withBusinessKeys('host', {
category: 'network',
label: '主机地址',
detail: '目标服务主机或本地监听地址。',
valueHint: '本机服务常用 127.0.0.1;远程服务填写域名或 IP',
label: 'Host address',
detail: 'Target service host or local listen address.',
valueHint: 'Local services often use 127.0.0.1; remote services use a domain or IP.',
sensitive: false,
},
port: {
}),
port: withBusinessKeys('port', {
category: 'network',
label: '端口',
detail: '目标服务端口或 MCP 服务监听端口。',
valueHint: '填写 1-65535 的端口号。',
label: 'Port',
detail: 'Target service port or MCP service listen port.',
valueHint: 'Enter a port number from 1 to 65535.',
sensitive: false,
},
transport: {
}),
transport: withBusinessKeys('transport', {
category: 'mode',
label: '传输模式',
detail: '控制 MCP 服务使用 stdiosse http 等通信方式。',
valueHint: 'GoNavi 当前本机 MCP 配置使用 stdio除非 README 特别要求,否则填 stdio。',
label: 'Transport mode',
detail: 'Controls whether the MCP service uses stdio, sse, http, or another transport.',
valueHint: 'GoNavi local MCP config currently uses stdio; use stdio unless the README says otherwise.',
sensitive: false,
},
mode: {
}),
mode: withBusinessKeys('mode', {
category: 'mode',
label: '运行模式',
detail: '控制 MCP 服务的业务模式或兼容模式。',
valueHint: '按 README 的枚举值填写。',
label: 'Run mode',
detail: 'Controls the MCP service business mode or compatibility mode.',
valueHint: 'Enter one of the enum values documented in the README.',
sensitive: false,
},
profile: {
}),
profile: withBusinessKeys('profile', {
category: 'mode',
label: '配置档',
detail: '选择 MCP 服务使用哪套配置或账号档案。',
valueHint: '填写 README 或本机配置中定义的 profile 名称。',
label: 'Profile',
detail: 'Selects which config or account profile the MCP service should use.',
valueHint: 'Enter the profile name defined in the README or local config.',
sensitive: false,
},
'read-only': {
}),
'read-only': withBusinessKeys('read_only', {
category: 'mode',
label: '只读模式',
detail: '限制 MCP 服务只读访问,降低误写风险。',
valueHint: '通常是开关参数,不需要额外值。',
label: 'Read-only mode',
detail: 'Limits the MCP service to read-only access and lowers write-risk.',
valueHint: 'Usually a switch argument and does not need an extra value.',
sensitive: false,
},
readonly: {
}),
readonly: withBusinessKeys('readonly', {
category: 'mode',
label: '只读模式',
detail: '限制 MCP 服务只读访问,降低误写风险。',
valueHint: '通常是开关参数,不需要额外值。',
label: 'Read-only mode',
detail: 'Limits the MCP service to read-only access and lowers write-risk.',
valueHint: 'Usually a switch argument and does not need an extra value.',
sensitive: false,
},
headless: {
}),
headless: withBusinessKeys('headless', {
category: 'runtime',
label: '无头模式',
detail: '浏览器类 MCP 是否使用无界面浏览器。',
valueHint: '需要真实窗口调试时关闭;自动化运行通常开启。',
label: 'Headless mode',
detail: 'Controls whether browser MCP services use a browser without UI.',
valueHint: 'Disable it when debugging with a real window; automation usually enables it.',
sensitive: false,
},
'executable-path': {
}),
'executable-path': withBusinessKeys('executable_path', {
category: 'path',
label: '浏览器或程序路径',
detail: '指定 MCP 服务要启动的浏览器或外部程序。',
valueHint: '填写本机绝对路径。',
label: 'Browser or executable path',
detail: 'Specifies the browser or external program the MCP service should launch.',
valueHint: 'Enter a local absolute path.',
sensitive: false,
},
repo: {
}),
repo: withBusinessKeys('repo', {
category: 'path',
label: '仓库路径',
detail: '限制 Git/GitHub 相关 MCP 操作的本地仓库。',
valueHint: '填写目标仓库目录。',
label: 'Repository path',
detail: 'Limits Git/GitHub-related MCP operations to a local repository.',
valueHint: 'Enter the target repository directory.',
sensitive: false,
},
}),
};
export const normalizeFlagName = (arg: string): string => {
@@ -351,50 +398,65 @@ const inferBusinessArgumentHint = (flag: string): BusinessArgumentHintTemplate |
return BUSINESS_ARGUMENT_HINTS.token;
}
if (/(config|file|path|dir|root|workspace|repo|repository)/iu.test(flag)) {
return {
return withBusinessKeys('inferred_path', {
category: 'path',
label: '路径 / 配置',
detail: '参数名看起来像路径、目录或配置文件。',
valueHint: '填写 MCP 进程能访问的本机路径,并尽量限制到最小范围。',
label: 'Path / config',
detail: 'The argument name looks like a path, directory, or config file.',
valueHint: 'Enter a local path the MCP process can access, and keep the scope as small as possible.',
sensitive: false,
};
});
}
if (/(url|uri|endpoint|base-url|host|addr|address)/iu.test(flag)) {
return {
return withBusinessKeys('inferred_endpoint', {
category: 'endpoint',
label: '地址 / Endpoint',
detail: '参数名看起来像远程服务地址或监听地址。',
valueHint: '填写完整地址或 host密钥不要拼进 URL',
label: 'Address / endpoint',
detail: 'The argument name looks like a remote service address or listen address.',
valueHint: 'Enter the full address or host, and do not put secrets into the 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 {
return withBusinessKeys('inferred_mode', {
category: 'mode',
label: '模式参数',
detail: '参数名看起来像运行模式、传输模式或开关。',
valueHint: '按 README 的枚举值或开关语义填写。',
label: 'Mode argument',
detail: 'The argument name looks like a run mode, transport mode, or switch.',
valueHint: 'Enter the README enum value or switch semantics.',
sensitive: false,
};
});
}
return null;
};
const buildGenericArgumentHint = (flag: string): BusinessArgumentHintTemplate => ({
category: 'generic',
label: '未识别参数',
detail: `GoNavi 不能从参数名 --${flag} 准确判断业务含义,但会按当前顺序原样传给 MCP 进程。`,
valueHint: '请对照 MCP README 确认这个参数是否需要值;需要值时把值作为下一个参数标签,或使用 --name=value。',
label: 'Unrecognized argument',
detail: translateMCPHintCopy(
undefined,
'ai_settings.mcp_server.argument_hints.generic.detail',
'GoNavi cannot infer the business meaning of --{{flag}} from the argument name, but it will pass it to the MCP process in the current order.',
{ flag },
),
valueHint: 'Check the MCP README to confirm whether this argument needs a value; if it does, put the value as the next argument tag or use --name=value.',
sensitive: false,
labelKey: 'ai_settings.mcp_server.argument_hints.generic.label',
detailKey: 'ai_settings.mcp_server.argument_hints.generic.detail',
valueHintKey: 'ai_settings.mcp_server.argument_hints.generic.value_hint',
params: { flag },
});
export const resolveBusinessArgumentHintTemplate = (flag: string, fallbackGeneric = false): BusinessArgumentHintTemplate | null =>
BUSINESS_ARGUMENT_HINTS[flag] || inferBusinessArgumentHint(flag) || (fallbackGeneric && flag ? buildGenericArgumentHint(flag) : null);
export const resolveBusinessArgumentHintTemplate = (
flag: string,
fallbackGeneric = false,
translate?: MCPHintTranslator,
): BusinessArgumentHintTemplate | null => {
const template = BUSINESS_ARGUMENT_HINTS[flag] || inferBusinessArgumentHint(flag) || (fallbackGeneric && flag ? buildGenericArgumentHint(flag) : null);
return template ? localizeBusinessArgumentHintTemplate(template, translate) : null;
};
const buildBusinessArgumentHints = (args: string[]): MCPBusinessArgumentHint[] => {
const buildBusinessArgumentHints = (args: string[], translate?: MCPHintTranslator): MCPBusinessArgumentHint[] => {
const result: MCPBusinessArgumentHint[] = [];
const seen = new Set<string>();
for (const arg of args) {
@@ -402,7 +464,7 @@ const buildBusinessArgumentHints = (args: string[]): MCPBusinessArgumentHint[] =
if (!flag || flag === 'stdio') {
continue;
}
const template = resolveBusinessArgumentHintTemplate(flag);
const template = resolveBusinessArgumentHintTemplate(flag, false, translate);
if (!template) {
continue;
}
@@ -423,6 +485,7 @@ const buildBusinessArgumentHints = (args: string[]): MCPBusinessArgumentHint[] =
export const buildMCPArgumentHintProfile = (
command: string,
args?: string[],
translate?: MCPHintTranslator,
): MCPArgumentHintProfile | null => {
const { normalizedCommand, commandName, inlineArgs } = parseCommandField(command);
if (!commandName) {
@@ -430,126 +493,131 @@ export const buildMCPArgumentHintProfile = (
}
const normalizedArgs = [...inlineArgs, ...normalizeArgs(args)];
const commandFieldWarning = inlineArgs.length > 0
? `检测到启动命令字段里还包含 ${inlineArgs.length} 个参数:${inlineArgs.join(' / ')}。建议 command 只保留 ${normalizedCommand},其余移到命令参数。`
? translateMCPHintCopy(
translate,
'ai_settings.mcp_server.argument_hints.command_field_warning',
'The startup command field still contains {{count}} arguments: {{args}}. Keep only {{command}} in command and move the rest to command arguments.',
{ count: inlineArgs.length, args: 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),
buildStep(translate, 'yes', 'Skip install confirmation', '-y', 'Avoid waiting for interactive confirmation when npx starts a package for the first time. Adjust pnpm/yarn according to the README.', commandName === 'npx', hasArg(normalizedArgs, '-y')),
buildStep(translate, 'package', 'MCP package name', '@modelcontextprotocol/server-filesystem', 'The npm package name or local package entry from the README.', true, hasPackageLikeArg(normalizedArgs)),
buildStep(translate, 'stdio', 'stdio argument', '--stdio', 'Let the service communicate with GoNavi through standard input and output.', true, hasStdioArg(normalizedArgs)),
buildStep(translate, 'scope', 'Allowed directory or business argument', 'C:\\Users\\me\\workspace', 'Filesystem, browser, database proxy, and similar services may also need a directory, port, or mode argument.', false, normalizedArgs.length > 3),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'npx / npm 参数顺序建议',
summary: 'npm 生态 MCP 通常要把安装确认、包名和 --stdio 拆成独立参数标签。',
orderHint: '推荐顺序:-y -> 包名 -> --stdio -> 服务自己的业务参数',
title: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.npm.title', 'npx / npm argument order'),
summary: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.npm.summary', 'npm MCP servers usually need install confirmation, package name, and --stdio split into separate argument tags.'),
orderHint: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.npm.order', 'Recommended order: -y -> package -> --stdio -> service business arguments'),
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
businessHints: buildBusinessArgumentHints(normalizedArgs, translate),
nextActions: buildNextActions(steps, translate),
};
}
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),
buildStep(translate, 'script', 'Script path', 'server.js', 'The js/mjs/ts entry file or package startup script for a local MCP server.', true, hasScriptLikeArg(normalizedArgs) || hasPackageLikeArg(normalizedArgs)),
buildStep(translate, 'stdio', 'stdio argument', '--stdio', 'If the README requires stdio mode, enter --stdio or stdio as a separate argument.', false, hasStdioArg(normalizedArgs)),
buildStep(translate, 'business', 'Business argument', '--port 8811', 'Add only when the README explicitly requires it, such as workspace path, port, or mode.', false, normalizedArgs.length > 2),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'Node 脚本参数顺序建议',
summary: 'Node 类启动器的命令只填 node/bun/deno脚本路径和 --stdio 放到参数里。',
orderHint: '推荐顺序:脚本路径 -> --stdio -> 服务自己的业务参数',
title: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.node.title', 'Node script argument order'),
summary: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.node.summary', 'For Node-style launchers, command should only be node/bun/deno; put script path and --stdio into args.'),
orderHint: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.node.order', 'Recommended order: script path -> --stdio -> service business arguments'),
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
businessHints: buildBusinessArgumentHints(normalizedArgs, translate),
nextActions: buildNextActions(steps, translate),
};
}
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)),
buildStep(translate, 'module-flag', 'Module flag or script', '-m', 'Use -m for module launch; for script launch, enter server.py directly. Choose one.', true, hasArg(normalizedArgs, '-m') || hasScriptLikeArg(normalizedArgs)),
buildStep(translate, 'module-name', 'Module name', 'your_mcp_server', 'When using -m, enter the module name here without a .py suffix.', true, hasPythonModuleArg(normalizedArgs) || hasScriptLikeArg(normalizedArgs)),
buildStep(translate, 'stdio', 'stdio argument', '--stdio', 'If the service supports stdio, add --stdio according to the README.', false, hasStdioArg(normalizedArgs)),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'Python 参数顺序建议',
summary: 'Python MCP 常见形式是 python -m 模块名,-m 和模块名都要作为独立参数。',
orderHint: '推荐顺序:-m -> 模块名 -> --stdio',
title: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.python.title', 'Python argument order'),
summary: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.python.summary', 'Python MCP servers often use python -m module_name; -m and the module name must be separate arguments.'),
orderHint: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.python.order', 'Recommended order: -m -> module name -> --stdio'),
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
businessHints: buildBusinessArgumentHints(normalizedArgs, translate),
nextActions: buildNextActions(steps, translate),
};
}
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),
buildStep(translate, 'package', 'Python MCP package name', 'mcp-server-fetch', 'uvx is usually followed directly by the published MCP package name.', true, hasPackageLikeArg(normalizedArgs)),
buildStep(translate, 'stdio', 'stdio argument', '--stdio', 'If the README requires stdio, add --stdio as a separate argument.', false, hasStdioArg(normalizedArgs)),
buildStep(translate, 'business', 'Business argument', '--config ./config.json', 'The service config file, mode, or address argument.', false, normalizedArgs.length > 2),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'uvx 参数顺序建议',
summary: 'uvx 类 MCP 通常把包名作为第一个参数,再按 README 补 stdio 或配置参数。',
orderHint: '推荐顺序:包名 -> --stdio -> 服务自己的业务参数',
title: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.uvx.title', 'uvx argument order'),
summary: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.uvx.summary', 'uvx MCP servers usually put the package name first, then add stdio or config arguments from the README.'),
orderHint: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.uvx.order', 'Recommended order: package -> --stdio -> service business arguments'),
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
businessHints: buildBusinessArgumentHints(normalizedArgs, translate),
nextActions: buildNextActions(steps, translate),
};
}
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='))),
buildStep(translate, 'run', 'Run subcommand', 'run', 'Docker MCP usually starts a container with docker run.', true, hasDockerRunArg(normalizedArgs)),
buildStep(translate, 'interactive', 'Keep standard input', '-i', 'MCP needs a continuous stdio connection, so the Docker container must keep stdin open.', true, hasDockerInteractiveArg(normalizedArgs)),
buildStep(translate, 'cleanup', 'Clean up container after exit', '--rm', 'Automatically remove the temporary container after testing and daily use to avoid leftovers.', false, hasArg(normalizedArgs, '--rm')),
buildStep(translate, 'image', 'Image name', 'mcp/server-fetch:latest', 'The Docker image name from the README, placed after docker run options.', true, hasDockerImageArg(normalizedArgs)),
buildStep(translate, 'container-env', 'Container environment variable', '-e API_KEY=...', 'Tokens needed inside the container usually need -e/--env so they are passed into the container.', false, normalizedArgs.some((arg) => arg === '-e' || arg === '--env' || arg.startsWith('-e='))),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: 'Docker MCP 参数顺序建议',
summary: 'Docker 场景 command 只填 dockerrun、-i、--rm、镜像名和容器参数都放到 args 里。',
orderHint: '推荐顺序run -> --rm -> -i -> -e KEY=VALUE -> 镜像名 -> 服务自己的业务参数',
title: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.docker.title', 'Docker MCP argument order'),
summary: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.docker.summary', 'For Docker, command should only be docker; put run, -i, --rm, image name, and container arguments into args.'),
orderHint: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.docker.order', 'Recommended order: run -> --rm -> -i -> -e KEY=VALUE -> image name -> service business arguments'),
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
businessHints: buildBusinessArgumentHints(normalizedArgs, translate),
nextActions: buildNextActions(steps, translate),
};
}
const steps = [
buildStep('stdio', 'stdio 模式参数', 'stdio --stdio', '多数本机 MCP 二进制需要显式 stdio 参数;以 README 为准。', false, hasStdioArg(normalizedArgs)),
buildStep('business', '业务参数', '--config ./config.json', '二进制自己的配置文件、工作目录、端口或模式参数。', false, normalizedArgs.length > 0),
buildStep(translate, 'stdio', 'stdio mode argument', 'stdio or --stdio', 'Most local MCP binaries need an explicit stdio argument; follow the README.', false, hasStdioArg(normalizedArgs)),
buildStep(translate, 'business', 'Business argument', '--config ./config.json', 'The binary config file, working directory, port, or mode argument.', false, normalizedArgs.length > 0),
];
return {
commandName,
normalizedCommand,
inlineArgs,
commandFieldWarning,
title: '本机可执行文件参数建议',
summary: '自研或已编译 MCP Server 的参数以 README 为准;GoNavi 会原样按标签顺序传入。',
orderHint: '常见顺序stdio/--stdio -> 配置文件或业务参数',
title: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.executable.title', 'Local executable argument guidance'),
summary: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.executable.summary', 'For custom or compiled MCP servers, follow the README; GoNavi passes arguments in tag order unchanged.'),
orderHint: translateMCPHintCopy(translate, 'ai_settings.mcp_server.argument_hints.profile.executable.order', 'Common order: stdio/--stdio -> config file or business argument'),
steps,
businessHints: buildBusinessArgumentHints(normalizedArgs),
nextActions: buildNextActions(steps),
businessHints: buildBusinessArgumentHints(normalizedArgs, translate),
nextActions: buildNextActions(steps, translate),
};
};

View File

@@ -89,6 +89,56 @@ describe('mcpClientInstallStatus helpers', () => {
expect(pickPreferredMCPClient(statuses)).toBe('codex');
});
it('prefers a client whose English status message reports a configuration error', () => {
const statuses: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'claude',
message: 'No Claude Code user-level GoNavi MCP configuration was detected',
},
{
client: 'codex',
displayName: 'Codex',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'codex',
message: 'Failed to locate Codex configuration: access denied',
},
];
expect(pickPreferredMCPClient(statuses)).toBe('codex');
});
it('treats an English empty-path status as higher priority than a plain missing status', () => {
const statuses: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
displayName: 'Claude Code',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'claude',
message: 'No Claude Code user-level GoNavi MCP configuration was detected',
},
{
client: 'codex',
displayName: 'Codex',
installed: false,
matchesCurrent: false,
clientDetected: false,
clientCommand: 'codex',
message: 'Current GoNavi executable path is empty',
},
];
expect(pickPreferredMCPClient(statuses)).toBe('codex');
});
it('prefers a client that already matches current GoNavi over another client with a stale config', () => {
const statuses: AIMCPClientInstallStatus[] = [
{
@@ -131,15 +181,15 @@ describe('mcpClientInstallStatus helpers', () => {
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('GoNavi MCP remote access guide - OpenClaw');
expect(guide).toContain('The cloud Agent does not need to store database passwords.');
expect(guide).toContain('Remote access uses schema-only mode by default and does not register execute_sql');
expect(guide).toContain('it cannot use the Windows local stdio command directly');
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');
expect(guide).toContain('"Authorization": "Bearer <random-token>"');
expect(guide).toContain('GoNavi.exe mcp-server remote-config --client openclaw --url https://<your-domain-or-tunnel>/mcp --token <random-token> --schema-only');
expect(guide).toContain('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <random-token> --schema-only');
});
it('builds remote quick-start snippets for cloud agents without database secrets', () => {
@@ -150,14 +200,14 @@ describe('mcpClientInstallStatus helpers', () => {
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).toContain('"url": "https://<your-domain-or-tunnel>/mcp"');
expect(quickStart.configJson).toContain('"Authorization": "Bearer <random-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.configCommand).toBe('GoNavi.exe mcp-server remote-config --client hermans --url https://<your-domain-or-tunnel>/mcp --token <random-token> --schema-only');
expect(quickStart.launchCommand).toBe('GoNavi.exe mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <random-token> --schema-only');
expect(quickStart.standaloneCommand).toBe('gonavi-mcp-server http --addr 127.0.0.1:8765 --path /mcp --token <random-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('--schema-only does not register execute_sql by default');
expect(quickStart.securityNotes.join('\n')).toContain('allowMutating=true');
});
});

View File

@@ -1,12 +1,70 @@
import type { AIMCPClientInstallStatus } from '../types';
import { t as catalogTranslate } from '../i18n/catalog';
import { SUPPORTED_LANGUAGES } from '../i18n/resolveLanguage';
import type { I18nParams } from '../i18n/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_PUBLIC_URL = 'https://<your-domain-or-tunnel>/mcp';
const DEFAULT_REMOTE_MCP_LOCAL_ADDR = '127.0.0.1:8765';
const DEFAULT_REMOTE_MCP_PATH = '/mcp';
type MCPClientInstallTranslator = (key: string, params?: I18nParams) => string;
const defaultTranslate: MCPClientInstallTranslator = (key, params) => catalogTranslate('en-US', key, params);
const MCP_CLIENT_STATUS_ERROR_TEMPLATE_KEYS = [
'ai.service.mcp_client.claude_code.config_path_failed',
'ai.service.mcp_client.codex.config_path_failed',
'ai.service.mcp_client.executable_path_failed',
'ai.service.mcp_client.executable_path_empty',
'ai.service.mcp_client.claude_code.config_format_invalid',
'ai.service.mcp_client.codex.config_format_invalid',
'ai.service.mcp_client.claude_code.config_read_failed',
'ai.service.mcp_client.claude_code.config_parse_failed',
'ai.service.mcp_client.claude_code.config_serialize_failed',
'ai.service.mcp_client.claude_code.config_dir_create_failed',
'ai.service.mcp_client.claude_code.config_write_failed',
'ai.service.mcp_client.codex.config_read_failed',
'ai.service.mcp_client.codex.config_dir_create_failed',
'ai.service.mcp_client.codex.config_write_failed',
'ai.service.mcp_client.claude_code.status.path_check_failed',
'ai.service.mcp_client.codex.status.path_check_failed',
] as const;
const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const buildCatalogTemplatePattern = (template: string): RegExp | null => {
const normalized = String(template || '').trim();
if (!normalized) {
return null;
}
const source = normalized
.split(/(\{\{[^}]+\}\})/u)
.filter(Boolean)
.map((segment) => (segment.startsWith('{{') && segment.endsWith('}}') ? '(.+?)' : escapeRegExp(segment)))
.join('');
return source ? new RegExp(`^${source}$`, 'u') : null;
};
const MCP_CLIENT_STATUS_ERROR_PATTERNS: RegExp[] = Array.from(new Set(
SUPPORTED_LANGUAGES.flatMap((language) => (
MCP_CLIENT_STATUS_ERROR_TEMPLATE_KEYS.map((key) => catalogTranslate(language, key))
)),
))
.map((template) => buildCatalogTemplatePattern(template))
.filter((pattern): pattern is RegExp => Boolean(pattern));
const translateMCPClientCopy = (
translate: MCPClientInstallTranslator,
key: string,
fallback: string,
params?: I18nParams,
): string => {
const translated = translate(key, params);
return translated && translated !== key ? translated : fallback;
};
export interface RemoteMCPClientQuickStart {
displayName: string;
@@ -27,49 +85,57 @@ export interface RemoteMCPParameterGuide {
avoid: string;
}
export const REMOTE_MCP_PARAMETER_GUIDES: RemoteMCPParameterGuide[] = [
const REMOTE_MCP_PARAMETER_GUIDE_DEFS: Array<Pick<RemoteMCPParameterGuide, 'key' | 'required' | 'example'>> = [
{
key: 'publicUrl',
title: '公网/隧道 URL',
required: true,
fill: '填云端 Agent 能访问到的 Streamable HTTP MCP 地址,通常以 /mcp 结尾。',
example: 'https://agent-gateway.example.com/mcp',
avoid: '不要填 Windows 本机的 127.0.0.1;云端 Linux 访问不到这个地址。',
},
{
key: 'bearerToken',
title: 'Bearer Token',
required: true,
fill: '填一段随机长 tokenWindows 启动命令和云端 Agent 配置必须一致。',
example: 'Authorization: Bearer gnv_xxx',
avoid: '不要使用空 token、短 token也不要把数据库密码当 token 填进去。',
},
{
key: 'localAddr',
title: '本机监听地址',
required: true,
fill: 'Windows GoNavi HTTP MCP 默认监听 127.0.0.1:8765再交给隧道或反向代理转发。',
example: DEFAULT_REMOTE_MCP_LOCAL_ADDR,
avoid: '没有网关隔离时不要直接绑定 0.0.0.0 暴露到公网。',
},
{
key: 'path',
title: 'MCP 路径',
required: true,
fill: '本机启动命令、隧道 URL 和云端 Agent 配置里的路径要保持一致。',
example: DEFAULT_REMOTE_MCP_PATH,
avoid: '不要一边用 /mcp另一边配置 /api/mcp路径不一致会 404。',
},
{
key: 'serverId',
title: '服务 ID',
required: false,
fill: '给云端 Agent 识别这条 MCP 服务的名称,默认 gonavi 即可。',
example: 'gonavi',
avoid: '不要频繁改名,否则 Agent 里已有的工具引用可能失效。',
},
];
const REMOTE_MCP_PARAMETER_KEY_MAP: Record<string, string> = {
publicUrl: 'public_url',
bearerToken: 'bearer_token',
localAddr: 'local_addr',
path: 'path',
serverId: 'server_id',
};
export const buildRemoteMCPParameterGuides = (
translate: MCPClientInstallTranslator = defaultTranslate,
): RemoteMCPParameterGuide[] =>
REMOTE_MCP_PARAMETER_GUIDE_DEFS.map((item) => {
const key = REMOTE_MCP_PARAMETER_KEY_MAP[item.key] || item.key;
return {
...item,
title: translate(`ai_settings.mcp_server.remote_quick_start.parameter.${key}.title`),
fill: translate(`ai_settings.mcp_server.remote_quick_start.parameter.${key}.fill`),
avoid: translate(`ai_settings.mcp_server.remote_quick_start.parameter.${key}.avoid`),
};
});
export const REMOTE_MCP_PARAMETER_GUIDES: RemoteMCPParameterGuide[] = buildRemoteMCPParameterGuides();
export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
{
client: 'claude-code',
@@ -79,7 +145,7 @@ export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
matchesCurrent: false,
clientDetected: false,
clientCommand: 'claude',
message: '未检测到 Claude Code 用户级 GoNavi MCP 配置',
message: 'No Claude Code user-level GoNavi MCP configuration was detected',
},
{
client: 'codex',
@@ -89,7 +155,7 @@ export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
matchesCurrent: false,
clientDetected: false,
clientCommand: 'codex',
message: '未检测到 Codex 用户级 GoNavi MCP 配置',
message: 'No Codex user-level GoNavi MCP configuration was detected',
},
{
client: 'openclaw',
@@ -99,7 +165,7 @@ export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
matchesCurrent: false,
clientDetected: false,
clientCommand: 'openclaw',
message: 'OpenClaw 通常部署在云端 Linux请通过远程 MCP 桥接接入 Windows GoNavi不要复制数据库密码。',
message: 'OpenClaw usually runs on cloud Linux; use a remote MCP bridge to reach Windows GoNavi and do not copy database passwords.',
},
{
client: 'hermans',
@@ -109,7 +175,7 @@ export const EMPTY_MCP_CLIENT_STATUSES: AIMCPClientInstallStatus[] = [
matchesCurrent: false,
clientDetected: false,
clientCommand: 'hermans',
message: 'Hermans 这类远程 Agent 请通过远程 MCP 桥接接入 Windows GoNavi不要复制数据库密码。',
message: 'Remote Agents such as Hermans should use a remote MCP bridge to reach Windows GoNavi and should not copy database passwords.',
},
];
@@ -137,7 +203,7 @@ export const supportsAutoMCPClientInstall = (status?: Pick<AIMCPClientInstallSta
};
const hasStatusError = (status: AIMCPClientInstallStatus): boolean =>
/|||/u.test(String(status.message || ''));
MCP_CLIENT_STATUS_ERROR_PATTERNS.some((pattern) => pattern.test(String(status.message || '').trim()));
const getMCPClientPriority = (status: AIMCPClientInstallStatus): number => {
if (status.matchesCurrent) {
@@ -224,56 +290,142 @@ export const formatMCPLaunchCommand = (
export const buildRemoteMCPClientGuide = (
status?: Partial<Pick<AIMCPClientInstallStatus, 'client' | 'displayName' | 'message'>> | null,
translate: MCPClientInstallTranslator = defaultTranslate,
): string => {
const quickStart = buildRemoteMCPClientQuickStart(status);
const quickStart = buildRemoteMCPClientQuickStart(status, translate);
const standaloneWithoutToken = quickStart.standaloneCommand.replace(` --token <random-token>`, '');
return [
`GoNavi MCP 远程接入说明 - ${quickStart.displayName}`,
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.title',
'GoNavi MCP remote access guide - {{displayName}}',
{ displayName: 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 读取库表结构。',
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.goal_heading',
'Goal:',
),
`- ${translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.goal.credentials_stay_local',
'Database connections, accounts, and passwords stay in Windows GoNavi. The cloud Agent does not need to store database passwords.',
)}`,
`- ${translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.goal.tools_only',
'The cloud Agent only reads get_connections/get_databases/get_tables/get_columns/get_table_ddl results through MCP tools.',
)}`,
`- ${translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.goal.schema_only',
'Remote access uses schema-only mode by default and does not register execute_sql, suitable for giving OpenClaw/Hermans schema-structure access only.',
)}`,
'',
'当前边界:',
'- GoNavi 内置 MCP 本机入口是 stdio适合 Claude Code / Codex 这类和 GoNavi 在同一台机器上的客户端。',
'- 如果 OpenClaw/Hermans 部署在云端 Linux不能直接使用 Windows 本地 stdio 命令;可在 Windows 上启动 GoNavi Streamable HTTP 模式,再通过隧道或反向代理给云端 Agent 调用。',
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.boundary_heading',
'Current boundary:',
),
`- ${translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.boundary.local_stdio',
'The built-in local GoNavi MCP entry is stdio, suitable for clients such as Claude Code / Codex running on the same machine as GoNavi.',
)}`,
`- ${translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.boundary.remote_cloud',
'If OpenClaw/Hermans runs on cloud Linux, it cannot use the Windows local stdio command directly; start GoNavi Streamable HTTP mode on Windows, then let the cloud Agent call it through a tunnel or reverse proxy.',
)}`,
'',
'建议接入方式:',
'1. Windows 本机保持 GoNavi 可访问,由 GoNavi 读取保存连接和系统凭据。',
`2. 在 Windows 或可信内网侧运行:${quickStart.launchCommand}`,
`3. 在 ${quickStart.displayName} 中添加远程 MCP Servertransport 选择 Streamable HTTPURL 填隧道/反向代理后的 /mcp 地址,并设置 Authorization: Bearer <随机token>。`,
'4. 先调用 get_connections 获取 connectionId再调用表结构工具不要把数据库 host/user/password 写进云端 Agent 配置。',
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.access_heading',
'Recommended access method:',
),
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.step.keep_windows_accessible',
'1. Keep GoNavi reachable on Windows, and let GoNavi read saved connections and system credentials.',
),
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.step.run_command',
'2. Run this on Windows or the trusted intranet side: {{launchCommand}}.',
{ launchCommand: quickStart.launchCommand },
),
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.step.configure_remote_server',
'3. Add a remote MCP Server in {{displayName}}, choose Streamable HTTP transport, set the URL to the tunneled/reverse-proxied /mcp address, and set Authorization: Bearer <random-token>.',
{ displayName: quickStart.displayName },
),
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.step.inspect_schema',
'4. Call get_connections first to obtain connectionId, then call schema tools; do not write database host/user/password into the cloud Agent config.',
),
'',
'可复制配置片段(适用于支持 mcpServers JSON 的 Agent',
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.config_heading',
'Copyable config snippet (for Agents that support mcpServers JSON):',
),
...quickStart.configJson.split('\n'),
'',
'无 GUI / CLI 生成配置命令:',
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.config_command_heading',
'No GUI / CLI config generation command:',
),
quickStart.configCommand,
'',
'CLI / 服务启动命令:',
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.launch_command_heading',
'CLI / service launch command:',
),
quickStart.launchCommand,
`或设置环境变量GONAVI_MCP_HTTP_TOKEN=<随机token> 后运行 ${quickStart.standaloneCommand.replace(' --token <随机token>', '')}`,
'如果明确需要远程执行 SQL可去掉 --schema-only此时 execute_sql 仍受 GoNavi AI 安全控制约束,写操作必须显式传 allowMutating=true。',
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.env_fallback',
'Or set environment variable GONAVI_MCP_HTTP_TOKEN=<random-token>, then run {{standaloneCommand}}',
{ standaloneCommand: standaloneWithoutToken },
),
translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.execute_sql_note',
'If remote SQL execution is explicitly required, remove --schema-only; execute_sql remains constrained by GoNavi AI safety controls, and writes must explicitly pass allowMutating=true.',
),
'',
status?.message ? `当前提示:${status.message}` : '',
status?.message
? translateMCPClientCopy(
translate,
'ai_settings.mcp_server.remote_quick_start.guide.current_hint',
'Current hint: {{message}}',
{ message: status.message },
)
: '',
].filter((line, index, lines) => line || index < lines.length - 1).join('\n');
};
export const buildRemoteMCPClientQuickStart = (
status?: Partial<Pick<AIMCPClientInstallStatus, 'client' | 'displayName'>> | null,
translate: MCPClientInstallTranslator = defaultTranslate,
): RemoteMCPClientQuickStart => {
const displayName = String(status?.displayName || '远程 Agent').trim();
const displayName = String(status?.displayName || translate('ai_settings.mcp_server.remote_quick_start.default_agent_name')).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 launchCommand = `GoNavi.exe mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <random-token> --schema-only`;
const standaloneCommand = `gonavi-mcp-server http --addr ${DEFAULT_REMOTE_MCP_LOCAL_ADDR} --path ${DEFAULT_REMOTE_MCP_PATH} --token <random-token> --schema-only`;
const configCommand = `GoNavi.exe mcp-server remote-config --client ${client} --url ${DEFAULT_REMOTE_MCP_PUBLIC_URL} --token <random-token> --schema-only`;
const configJson = JSON.stringify({
mcpServers: {
gonavi: {
type: 'streamable-http',
url: DEFAULT_REMOTE_MCP_PUBLIC_URL,
headers: {
Authorization: 'Bearer <随机token>',
Authorization: 'Bearer <random-token>',
},
},
},
@@ -286,15 +438,15 @@ export const buildRemoteMCPClientQuickStart = (
launchCommand,
standaloneCommand,
verificationSteps: [
'Windows 本机先访问 http://127.0.0.1:8765/healthz确认 GoNavi MCP HTTP 服务已启动。',
`${displayName} 里配置 Streamable HTTP MCPURL 指向隧道或反向代理后的 /mcp 地址。`,
'先调用 get_connections 获取 connectionId再读取 get_databases / get_tables / get_columns。',
translate('ai_settings.mcp_server.remote_quick_start.verification.healthz'),
translate('ai_settings.mcp_server.remote_quick_start.verification.configure_agent', { displayName }),
translate('ai_settings.mcp_server.remote_quick_start.verification.inspect_schema'),
],
securityNotes: [
'数据库账号和密码仍保存在 Windows GoNavi本段配置不要写数据库密码。',
'默认 --schema-only 不注册 execute_sql远程 Agent 只能走库表结构类工具。',
'HTTP MCP 必须使用随机 Bearer Token并放在 HTTPS、私有网络或受控隧道后面。',
'如去掉 --schema-only 开放 execute_sql仍受 GoNavi AI 安全控制约束,写操作仍必须显式传 allowMutating=true。',
translate('ai_settings.mcp_server.remote_quick_start.security.credentials_stay_local'),
translate('ai_settings.mcp_server.remote_quick_start.security.schema_only'),
translate('ai_settings.mcp_server.remote_quick_start.security.token_required'),
translate('ai_settings.mcp_server.remote_quick_start.security.execute_sql'),
],
};
};

View File

@@ -84,7 +84,8 @@ describe('mcpCommandDraft helpers', () => {
it('reports unclosed quotes instead of producing a broken parse', () => {
expect(splitShellLikeCommand('uvx "broken command')).toEqual({
tokens: ['uvx'],
error: '命令中存在未闭合的引号,请检查后重试。',
error: 'The command contains an unclosed quote. Check it and try again.',
errorKey: 'ai_settings.mcp_server.command_parse.error.unclosed_quote',
});
});
});

View File

@@ -8,6 +8,7 @@ export interface ParseMCPCommandDraftResult {
ok: boolean;
draft?: ParsedMCPCommandDraft;
error?: string;
errorKey?: string;
}
const ENV_ASSIGNMENT_RE = /^[A-Za-z_][A-Za-z0-9_]*=.*/u;
@@ -19,7 +20,7 @@ const pushToken = (tokens: string[], current: string) => {
}
};
export const splitShellLikeCommand = (input: string): { tokens: string[]; error?: string } => {
export const splitShellLikeCommand = (input: string): { tokens: string[]; error?: string; errorKey?: string } => {
const text = String(input || '').trim();
if (!text) {
return { tokens: [] };
@@ -88,7 +89,8 @@ export const splitShellLikeCommand = (input: string): { tokens: string[]; error?
if (quoteMode) {
return {
tokens,
error: '命令中存在未闭合的引号,请检查后重试。',
error: 'The command contains an unclosed quote. Check it and try again.',
errorKey: 'ai_settings.mcp_server.command_parse.error.unclosed_quote',
};
}
@@ -152,12 +154,16 @@ const consumeLeadingEnvAssignments = (tokens: string[], env: Record<string, stri
};
export const parseMCPCommandDraft = (input: string): ParseMCPCommandDraftResult => {
const { tokens, error } = splitShellLikeCommand(input);
const { tokens, error, errorKey } = splitShellLikeCommand(input);
if (error) {
return { ok: false, error };
return { ok: false, error, errorKey };
}
if (tokens.length === 0) {
return { ok: false, error: '请先粘贴完整命令。' };
return {
ok: false,
error: 'Paste the full command first.',
errorKey: 'ai_settings.mcp_server.command_parse.error.empty',
};
}
const env: Record<string, string> = {};
@@ -167,7 +173,8 @@ export const parseMCPCommandDraft = (input: string): ParseMCPCommandDraftResult
if (!command) {
return {
ok: false,
error: '没有解析出启动命令,请至少提供可执行程序名。',
error: 'No startup command was parsed. Provide at least an executable name.',
errorKey: 'ai_settings.mcp_server.command_parse.error.missing_command',
};
}

View File

@@ -2,6 +2,30 @@ import { describe, expect, it } from 'vitest';
import { buildMCPEnvHintProfile } from './mcpEnvHints';
const translatedCopy: Record<string, string> = {
'ai_settings.mcp_server.env_hints.known.github_token.detail': 'T:github-token-detail',
'ai_settings.mcp_server.env_hints.known.github_token.value_hint': 'T:github-token-value',
'ai_settings.mcp_server.env_hints.known.https_proxy.label': 'T:https-proxy-label',
'ai_settings.mcp_server.env_hints.known.https_proxy.detail': 'T:https-proxy-detail',
'ai_settings.mcp_server.env_hints.known.https_proxy.value_hint': 'T:https-proxy-value',
'ai_settings.mcp_server.env_hints.inferred.secret.label': 'T:secret-label',
'ai_settings.mcp_server.env_hints.inferred.secret.detail': 'T:secret-detail',
'ai_settings.mcp_server.env_hints.inferred.secret.value_hint': 'T:secret-value',
'ai_settings.mcp_server.env_hints.warning.empty': 'T:empty {{count}}',
'ai_settings.mcp_server.env_hints.warning.placeholder': 'T:placeholder {{count}}',
'ai_settings.mcp_server.env_hints.warning.docker_env_not_forwarded': 'T:docker-boundary',
'ai_settings.mcp_server.env_hints.next_action.empty': 'T:fill {{keys}}',
'ai_settings.mcp_server.env_hints.next_action.placeholder': 'T:replace {{keys}}',
'ai_settings.mcp_server.env_hints.next_action.docker_env': 'T:docker-env',
'ai_settings.mcp_server.env_hints.next_action.secrets_local': 'T:secrets-local',
'ai_settings.mcp_server.env_hints.next_action.keys_recognized': 'T:keys-recognized',
};
const translate = (
key: string,
params?: Record<string, string | number | boolean | null | undefined>,
) => (translatedCopy[key] || key).replace(/\{\{(\w+)\}\}/g, (_match, name) => String(params?.[name] ?? ''));
describe('mcpEnvHints', () => {
it('explains common secret and proxy env vars without exposing values', () => {
const profile = buildMCPEnvHintProfile('uvx', ['mcp-server-github', '--stdio'], {
@@ -18,7 +42,7 @@ describe('mcpEnvHints', () => {
known: true,
});
expect(profile?.items.find((item) => item.key === 'HTTPS_PROXY')).toMatchObject({
label: 'HTTPS 代理',
label: 'HTTPS proxy',
category: 'proxy',
sensitive: false,
known: true,
@@ -27,14 +51,59 @@ describe('mcpEnvHints', () => {
expect(JSON.stringify(profile)).not.toContain('127.0.0.1:7890');
});
it('localizes known env hint copy while preserving raw env keys and values', () => {
const profile = buildMCPEnvHintProfile('uvx', ['mcp-server-github', '--stdio'], {
GITHUB_TOKEN: 'ghp_real_secret_value',
HTTPS_PROXY: 'http://127.0.0.1:7890',
}, translate);
expect(profile?.items.find((item) => item.key === 'GITHUB_TOKEN')).toMatchObject({
label: 'GitHub Token',
detail: 'T:github-token-detail',
valueHint: 'T:github-token-value',
});
expect(profile?.items.find((item) => item.key === 'HTTPS_PROXY')).toMatchObject({
label: 'T:https-proxy-label',
detail: 'T:https-proxy-detail',
valueHint: 'T:https-proxy-value',
});
expect(JSON.stringify(profile)).toContain('GITHUB_TOKEN');
expect(JSON.stringify(profile)).not.toContain('ghp_real_secret_value');
expect(JSON.stringify(profile)).not.toContain('127.0.0.1:7890');
});
it('localizes inferred env hints and warning actions without translating raw keys', () => {
const profile = buildMCPEnvHintProfile('docker', ['run', '--rm', '-i', 'mcp/server-fetch:latest'], {
API_KEY: '',
CUSTOM_TOKEN: '...',
}, translate);
expect(profile?.items.find((item) => item.key === 'API_KEY')).toMatchObject({
label: 'T:secret-label',
detail: 'T:secret-detail',
valueHint: 'T:secret-value',
});
expect(profile?.warnings).toEqual(expect.arrayContaining([
'T:empty 1',
'T:placeholder 1',
'T:docker-boundary',
]));
expect(profile?.nextActions).toEqual(expect.arrayContaining([
'T:fill API_KEY',
'T:replace CUSTOM_TOKEN',
'T:docker-env',
'T:secrets-local',
]));
});
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?.warnings).toContain('1 environment variable values are empty and must be filled or removed before testing.');
expect(profile?.warnings).toContain('1 environment variables still look like example placeholder values.');
expect(profile?.nextActions.join('\n')).toContain('GITHUB_TOKEN');
expect(profile?.nextActions.join('\n')).toContain('OPENAI_API_KEY');
});
@@ -44,7 +113,7 @@ describe('mcpEnvHints', () => {
API_KEY: 'secret',
});
expect(profile?.warnings).toContain('command=docker 时,这里的环境变量只传给 docker CLI不会自动进入容器。');
expect(profile?.warnings).toContain('When command=docker, these environment variables are passed only to the docker CLI and do not automatically enter the container.');
expect(profile?.nextActions.join('\n')).toContain('-e KEY=VALUE');
});
@@ -55,9 +124,9 @@ describe('mcpEnvHints', () => {
expect(profile?.items[0]).toMatchObject({
key: 'DOCKER_HOST',
label: 'Docker Daemon 地址',
label: 'Docker Daemon address',
category: 'runtime',
});
expect(profile?.warnings.join('\n')).not.toContain('不会自动进入容器');
expect(profile?.warnings.join('\n')).not.toContain('do not automatically enter the container');
});
});

View File

@@ -1,4 +1,5 @@
import { splitShellLikeCommand } from './mcpCommandDraft';
import { translateMCPHintCopy, type MCPHintTranslator } from './mcpArgumentHints';
export type MCPEnvHintCategory = 'secret' | 'endpoint' | 'proxy' | 'path' | 'runtime' | 'generic';
@@ -29,115 +30,140 @@ interface KnownEnvHint {
detail: string;
valueHint: string;
sensitive?: boolean;
labelKey?: string;
detailKey?: string;
valueHintKey?: string;
}
export type MCPEnvHintTranslator = MCPHintTranslator;
const withEnvHintKeys = (
key: string,
hint: Omit<KnownEnvHint, 'labelKey' | 'detailKey' | 'valueHintKey'>,
): KnownEnvHint => ({
...hint,
labelKey: `ai_settings.mcp_server.env_hints.${key}.label`,
detailKey: `ai_settings.mcp_server.env_hints.${key}.detail`,
valueHintKey: `ai_settings.mcp_server.env_hints.${key}.value_hint`,
});
const localizeEnvHint = (
hint: KnownEnvHint,
translate?: MCPEnvHintTranslator,
): KnownEnvHint => ({
...hint,
label: hint.labelKey ? translateMCPHintCopy(translate, hint.labelKey, hint.label) : hint.label,
detail: hint.detailKey ? translateMCPHintCopy(translate, hint.detailKey, hint.detail) : hint.detail,
valueHint: hint.valueHintKey ? translateMCPHintCopy(translate, hint.valueHintKey, hint.valueHint) : hint.valueHint,
});
const KNOWN_ENV_HINTS: Record<string, KnownEnvHint> = {
GITHUB_TOKEN: {
GITHUB_TOKEN: withEnvHintKeys('known.github_token', {
category: 'secret',
label: 'GitHub Token',
detail: '通常给 GitHub MCP 读取仓库、Issue、PR 或 Actions 使用。',
valueHint: ' GitHub Personal Access Token,按 MCP README 要求授予最小权限。',
detail: 'Usually used by GitHub MCP services to read repositories, issues, pull requests, or Actions.',
valueHint: 'Enter a GitHub Personal Access Token with the minimum permissions required by the MCP README.',
sensitive: true,
},
GITLAB_TOKEN: {
}),
GITLAB_TOKEN: withEnvHintKeys('known.gitlab_token', {
category: 'secret',
label: 'GitLab Token',
detail: '通常给 GitLab MCP 访问项目、Merge Request 或 CI 使用。',
valueHint: ' GitLab Access Token,并限制到需要访问的项目范围。',
detail: 'Usually used by GitLab MCP services to access projects, merge requests, or CI.',
valueHint: 'Enter a GitLab Access Token and restrict it to the required project scope.',
sensitive: true,
},
OPENAI_API_KEY: {
}),
OPENAI_API_KEY: withEnvHintKeys('known.openai_api_key', {
category: 'secret',
label: 'OpenAI API Key',
detail: '给依赖 OpenAI API 的 MCP 服务调用模型或 embedding 接口。',
valueHint: '填真实 API Key不要写到 commandargs 或聊天消息里。',
detail: 'Used by MCP services that depend on OpenAI APIs for model or embedding calls.',
valueHint: 'Enter the real API Key; do not put it in command, args, or chat messages.',
sensitive: true,
},
ANTHROPIC_API_KEY: {
}),
ANTHROPIC_API_KEY: withEnvHintKeys('known.anthropic_api_key', {
category: 'secret',
label: 'Anthropic API Key',
detail: '给依赖 Anthropic Claude API 的 MCP 服务使用。',
valueHint: '填真实 API Key确认服务确实需要该变量后再配置。',
detail: 'Used by MCP services that depend on the Anthropic Claude API.',
valueHint: 'Enter the real API Key only after confirming the service requires this variable.',
sensitive: true,
},
GEMINI_API_KEY: {
}),
GEMINI_API_KEY: withEnvHintKeys('known.gemini_api_key', {
category: 'secret',
label: 'Gemini API Key',
detail: '给依赖 Google Gemini API 的 MCP 服务使用。',
valueHint: '填真实 API Key也有服务会要求 GOOGLE_API_KEY',
detail: 'Used by MCP services that depend on the Google Gemini API.',
valueHint: 'Enter the real API Key; some services may require GOOGLE_API_KEY instead.',
sensitive: true,
},
GOOGLE_API_KEY: {
}),
GOOGLE_API_KEY: withEnvHintKeys('known.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',
detail: 'Used by Google, Gemini, Maps, or Search MCP services.',
valueHint: 'Enter the real API Key and confirm whether the README requires GOOGLE_API_KEY or GEMINI_API_KEY.',
sensitive: true,
},
SLACK_BOT_TOKEN: {
}),
SLACK_BOT_TOKEN: withEnvHintKeys('known.slack_bot_token', {
category: 'secret',
label: 'Slack Bot Token',
detail: ' Slack MCP 读取频道、消息或发送通知使用。',
valueHint: '填 xoxb- 开头的 Bot Token并控制 workspace 权限。',
detail: 'Used by Slack MCP services to read channels, messages, or send notifications.',
valueHint: 'Enter the Bot Token starting with xoxb- and restrict workspace permissions.',
sensitive: true,
},
NOTION_API_KEY: {
}),
NOTION_API_KEY: withEnvHintKeys('known.notion_api_key', {
category: 'secret',
label: 'Notion API Key',
detail: ' Notion MCP 访问页面、数据库或 workspace 内容使用。',
valueHint: ' Notion integration secret,并只授权需要的页面。',
detail: 'Used by Notion MCP services to access pages, databases, or workspace content.',
valueHint: 'Enter the Notion integration secret and authorize only the required pages.',
sensitive: true,
},
DATABASE_URL: {
}),
DATABASE_URL: withEnvHintKeys('known.database_url', {
category: 'endpoint',
label: '数据库连接串',
detail: '给 MCP 服务自己连接数据库使用;这会把数据库连接信息交给该 MCP 进程。',
valueHint: '只在确实要让该 MCP 直连数据库时填写,优先考虑使用 GoNavi MCP 避免密码外泄。',
label: 'Database connection string',
detail: 'Lets the MCP service connect to a database itself; this gives database connection information to that MCP process.',
valueHint: 'Fill this only when the MCP must connect to the database directly; prefer GoNavi MCP to avoid password exposure.',
sensitive: true,
},
HTTP_PROXY: {
}),
HTTP_PROXY: withEnvHintKeys('known.http_proxy', {
category: 'proxy',
label: 'HTTP 代理',
detail: '让 MCP 进程访问 HTTP 资源时走指定代理。',
valueHint: ' http://host:port;如果代理带账号密码,按敏感变量处理。',
},
HTTPS_PROXY: {
label: 'HTTP proxy',
detail: 'Routes HTTP resource access from the MCP process through the specified proxy.',
valueHint: 'Enter http://host:port; treat it as sensitive if the proxy includes a username or password.',
}),
HTTPS_PROXY: withEnvHintKeys('known.https_proxy', {
category: 'proxy',
label: 'HTTPS 代理',
detail: '让 MCP 进程访问 HTTPS 资源时走指定代理。',
valueHint: ' http://host:port https://host:port',
},
NO_PROXY: {
label: 'HTTPS proxy',
detail: 'Routes HTTPS resource access from the MCP process through the specified proxy.',
valueHint: 'Enter http://host:port or https://host:port.',
}),
NO_PROXY: withEnvHintKeys('known.no_proxy', {
category: 'proxy',
label: '代理绕过列表',
detail: '指定哪些域名或地址不走代理。',
valueHint: '逗号分隔,例如 localhost,127.0.0.1,.corp.local',
},
DOCKER_HOST: {
label: 'Proxy bypass list',
detail: 'Specifies which domains or addresses should bypass the proxy.',
valueHint: 'Use comma-separated entries, for example localhost,127.0.0.1,.corp.local.',
}),
DOCKER_HOST: withEnvHintKeys('known.docker_host', {
category: 'runtime',
label: 'Docker Daemon 地址',
detail: ' docker CLI 指定连接哪个 Docker Engine',
valueHint: 'Windows 常见为 npipe:////./pipe/docker_engine;远端 Docker 请确认安全边界。',
},
GONAVI_MCP_HTTP_TOKEN: {
label: 'Docker Daemon address',
detail: 'Tells the docker CLI which Docker Engine to connect to.',
valueHint: 'Common on Windows: npipe:////./pipe/docker_engine; confirm security boundaries for remote Docker.',
}),
GONAVI_MCP_HTTP_TOKEN: withEnvHintKeys('known.gonavi_mcp_http_token', {
category: 'secret',
label: 'GoNavi MCP HTTP Token',
detail: '给远程 MCP HTTP 服务开启 Bearer Token 鉴权时使用。',
valueHint: '填高熵随机 token不要复用数据库密码或模型 API Key',
detail: 'Used when a remote MCP HTTP service enables Bearer Token authentication.',
valueHint: 'Enter a high-entropy random token; do not reuse database passwords or model API Keys.',
sensitive: true,
},
NODE_ENV: {
}),
NODE_ENV: withEnvHintKeys('known.node_env', {
category: 'runtime',
label: 'Node 运行环境',
detail: '影响部分 Node MCP 服务的日志、调试或生产模式。',
valueHint: '通常填 productiondevelopment README 指定值。',
},
LOG_LEVEL: {
label: 'Node runtime environment',
detail: 'Affects logging, debugging, or production mode for some Node MCP services.',
valueHint: 'Usually production, development, or a value specified by the README.',
}),
LOG_LEVEL: withEnvHintKeys('known.log_level', {
category: 'runtime',
label: '日志级别',
detail: '控制 MCP 服务输出多少日志。',
valueHint: '常见值为 debuginfowarn、error排障时可临时调高。',
},
label: 'Log level',
detail: 'Controls how much log output the MCP service emits.',
valueHint: 'Common values are debug, info, warn, and error; temporarily raise it for troubleshooting.',
}),
};
const SECRET_KEY_RE = /(TOKEN|API[_-]?KEY|SECRET|PASSWORD|PASS|PRIVATE[_-]?KEY|ACCESS[_-]?KEY|DATABASE_URL|DSN)/iu;
@@ -162,52 +188,52 @@ const normalizeCommandName = (command: string): string => {
const inferEnvHint = (key: string): KnownEnvHint => {
if (SECRET_KEY_RE.test(key)) {
return {
return withEnvHintKeys('inferred.secret', {
category: 'secret',
label: '密钥 / Token',
detail: '变量名看起来像密钥、Token、密码或连接串。',
valueHint: '填真实值,但只保存在本机 MCP 配置里;不要放到 commandargs 或聊天内容。',
label: 'Secret / Token',
detail: 'The variable name looks like a secret, token, password, or connection string.',
valueHint: 'Enter the real value, but keep it only in local MCP configuration; do not put it in command, args, or chat content.',
sensitive: true,
};
});
}
if (PROXY_KEY_RE.test(key)) {
return {
return withEnvHintKeys('inferred.proxy', {
category: 'proxy',
label: '代理配置',
detail: '变量名看起来像网络代理设置。',
valueHint: '按 README 或企业代理格式填写,例如 http://127.0.0.1:7890',
};
label: 'Proxy configuration',
detail: 'The variable name looks like a network proxy setting.',
valueHint: 'Follow the README or enterprise proxy format, for example http://127.0.0.1:7890.',
});
}
if (ENDPOINT_KEY_RE.test(key)) {
return {
return withEnvHintKeys('inferred.endpoint', {
category: 'endpoint',
label: '服务地址',
detail: '变量名看起来像服务地址、接口地址或主机配置。',
valueHint: '填写 MCP Server 要访问的 URLhost endpoint',
};
label: 'Service endpoint',
detail: 'The variable name looks like a service URL, API endpoint, or host configuration.',
valueHint: 'Enter the URL, host, or endpoint the MCP Server needs to access.',
});
}
if (PATH_KEY_RE.test(key)) {
return {
return withEnvHintKeys('inferred.path', {
category: 'path',
label: '路径 / 配置文件',
detail: '变量名看起来像本地路径、目录或配置文件位置。',
valueHint: '填写本机 MCP 进程能访问的绝对路径Windows 路径建议保留盘符。',
};
label: 'Path / config file',
detail: 'The variable name looks like a local path, directory, or config file location.',
valueHint: 'Enter an absolute path accessible to the local MCP process; keep the drive letter for Windows paths.',
});
}
if (RUNTIME_KEY_RE.test(key)) {
return {
return withEnvHintKeys('inferred.runtime', {
category: 'runtime',
label: '运行时开关',
detail: '变量名看起来像运行环境、日志或调试开关。',
valueHint: '按 README 指定的枚举值填写。',
};
label: 'Runtime switch',
detail: 'The variable name looks like a runtime environment, logging, or debug switch.',
valueHint: 'Use the enum value specified by the README.',
});
}
return {
return withEnvHintKeys('inferred.generic', {
category: 'generic',
label: '自定义配置',
detail: '未命中内置变量库,按 MCP README 对应字段说明填写。',
valueHint: '确认变量名大小写和 README 完全一致。',
};
label: 'Custom configuration',
detail: 'No built-in variable hint matched; follow the matching field description in the MCP README.',
valueHint: 'Confirm the variable name casing exactly matches the README.',
});
};
const isPlaceholderValue = (value: string): boolean => {
@@ -218,10 +244,10 @@ const isPlaceholderValue = (value: string): boolean => {
return PLACEHOLDER_VALUE_RE.test(text) || text.includes('...');
};
const buildEnvHintItem = ([key, value]: [string, string]): MCPEnvHintItem => {
const buildEnvHintItem = ([key, value]: [string, string], translate?: MCPEnvHintTranslator): MCPEnvHintItem => {
const normalizedKey = normalizeEnvKey(key);
const knownHint = KNOWN_ENV_HINTS[normalizedKey];
const hint = knownHint || inferEnvHint(normalizedKey);
const hint = localizeEnvHint(knownHint || inferEnvHint(normalizedKey), translate);
return {
key: normalizedKey,
category: hint.category,
@@ -239,10 +265,11 @@ export const buildMCPEnvHintProfile = (
command: string,
args: string[] | undefined,
env: Record<string, string> | undefined,
translate?: MCPEnvHintTranslator,
): MCPEnvHintProfile | null => {
const items = Object.entries(env || {})
.sort(([left], [right]) => normalizeEnvKey(left).localeCompare(normalizeEnvKey(right)))
.map(buildEnvHintItem);
.map((entry) => buildEnvHintItem(entry, translate));
if (items.length === 0) {
return null;
@@ -258,22 +285,60 @@ export const buildMCPEnvHintProfile = (
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('、')} 的值,或删除不需要的变量。`);
const keys = emptyItems.map((item) => item.key).slice(0, 3).join(', ');
warnings.push(translateMCPHintCopy(
translate,
'ai_settings.mcp_server.env_hints.warning.empty',
'{{count}} environment variable values are empty and must be filled or removed before testing.',
{ count: emptyItems.length },
));
nextActions.push(translateMCPHintCopy(
translate,
'ai_settings.mcp_server.env_hints.next_action.empty',
'Fill values for {{keys}}, or remove variables you do not need.',
{ keys },
));
}
if (placeholderItems.length > 0) {
warnings.push(`${placeholderItems.length} 个环境变量看起来仍是示例占位值。`);
nextActions.push(`${placeholderItems.map((item) => item.key).slice(0, 3).join('、')} 替换成真实值后再测试工具发现。`);
const keys = placeholderItems.map((item) => item.key).slice(0, 3).join(', ');
warnings.push(translateMCPHintCopy(
translate,
'ai_settings.mcp_server.env_hints.warning.placeholder',
'{{count}} environment variables still look like example placeholder values.',
{ count: placeholderItems.length },
));
nextActions.push(translateMCPHintCopy(
translate,
'ai_settings.mcp_server.env_hints.next_action.placeholder',
'Replace {{keys}} with real values before testing tool discovery.',
{ keys },
));
}
if (dockerCommand && items.length > 0 && !dockerEnvForwarded) {
warnings.push('command=docker 时,这里的环境变量只传给 docker CLI不会自动进入容器。');
nextActions.push('如果容器内 MCP 需要这些变量,请在 args 里按 README 增加 -e KEY=VALUE 或 --env KEY=VALUE。');
warnings.push(translateMCPHintCopy(
translate,
'ai_settings.mcp_server.env_hints.warning.docker_env_not_forwarded',
'When command=docker, these environment variables are passed only to the docker CLI and do not automatically enter the container.',
));
nextActions.push(translateMCPHintCopy(
translate,
'ai_settings.mcp_server.env_hints.next_action.docker_env',
'If the MCP inside the container needs these variables, add -e KEY=VALUE or --env KEY=VALUE to args according to the README.',
));
}
if (secretLikeCount > 0) {
nextActions.push('密钥类变量只保存在本机配置不要把真实值发到聊天、Issue 或截图里。');
nextActions.push(translateMCPHintCopy(
translate,
'ai_settings.mcp_server.env_hints.next_action.secrets_local',
'Secret-like variables are stored only in local configuration; do not send real values to chat, issues, or screenshots.',
));
}
if (nextActions.length === 0) {
nextActions.push('环境变量 key 已可识别;测试失败时优先核对 README 要求的变量名大小写。');
nextActions.push(translateMCPHintCopy(
translate,
'ai_settings.mcp_server.env_hints.next_action.keys_recognized',
'Environment variable keys are recognizable; if testing fails, first check the variable name casing required by the README.',
));
}
return {

View File

@@ -1,8 +1,11 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { parseMCPCommandDraft } from './mcpCommandDraft';
import { buildMCPQuickAddServerSeed, buildMCPServerDraftSeed } from './mcpServerDraftSeed';
const source = readFileSync(new URL('./mcpServerDraftSeed.ts', import.meta.url), 'utf8');
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');
@@ -51,4 +54,15 @@ describe('mcpServerDraftSeed', () => {
env: { GITHUB_TOKEN: '***' },
});
});
it('localizes the fallback service name when no raw name candidate is available', () => {
const seed = buildMCPServerDraftSeed(
{ command: '', args: [] },
(key) => key === 'ai_settings.mcp_server.draft.default_name' ? 'T:MCP default service' : key,
);
expect(seed.name).toBe('T:MCP default service');
expect(source).not.toContain("'MCP 服务'");
expect(source).not.toContain('"MCP 服务"');
});
});

View File

@@ -10,9 +10,24 @@ export interface MCPServerDraftSeedInput {
timeoutSeconds?: number;
}
export type MCPServerDraftSeedTranslator = (
key: string,
params?: Record<string, string | number | boolean | null | undefined>,
) => string;
const stripCommandSuffix = (value: string): string =>
value.replace(/\.(exe|cmd|bat|ps1|c?m?[jt]s|py)$/iu, '');
const translateDraftSeedCopy = (
translate: MCPServerDraftSeedTranslator | undefined,
key: string,
fallback: string,
): string => {
if (!translate) return fallback;
const translated = translate(key);
return translated && translated !== key ? translated : fallback;
};
const toDisplayNamePart = (value: string): string => {
const text = String(value || '').trim();
if (!text) return '';
@@ -86,10 +101,14 @@ export const buildMCPServerDraftSeed = ({
env = {},
name,
timeoutSeconds,
}: MCPServerDraftSeedInput): Partial<AIMCPServerConfig> => {
}: MCPServerDraftSeedInput, translate?: MCPServerDraftSeedTranslator): 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 服务';
const namePart = toDisplayNamePart(name || pickDraftNameCandidate(command, normalizedArgs)) || translateDraftSeedCopy(
translate,
'ai_settings.mcp_server.draft.default_name',
'MCP service',
);
return {
name: namePart,
@@ -104,8 +123,9 @@ export const buildMCPServerDraftSeed = ({
export const buildMCPQuickAddServerSeed = (
draft: ParsedMCPCommandDraft,
translate?: MCPServerDraftSeedTranslator,
): Partial<AIMCPServerConfig> => buildMCPServerDraftSeed({
command: draft.command,
args: draft.args,
env: draft.env,
});
}, translate);

View File

@@ -1,32 +1,87 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { catalogs } from '../i18n/catalog';
import {
MCP_AUTHORING_NOTES,
MCP_FIELD_GUIDES,
MCP_SERVER_FILL_STEPS,
MCP_TROUBLESHOOTING_GUIDES,
} from './mcpServerGuidance';
const source = readFileSync(new URL('./mcpServerGuidance.ts', import.meta.url), 'utf8');
const supportedLanguages = ['zh-CN', 'zh-TW', 'en-US', 'ja-JP', 'de-DE', 'ru-RU'] as const;
const getPlaceholders = (value: string) =>
Array.from(value.matchAll(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g), (match) => match[1]).sort();
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 || ''])
it('keeps MCP guide copy behind i18n keys instead of hard-coded Chinese source text', () => {
expect(source).not.toMatch(/[\p{Script=Han}]/u);
expect(MCP_SERVER_FILL_STEPS.every((item) => item.titleKey.startsWith('ai_settings.mcp_server.guide.step.'))).toBe(true);
expect(MCP_FIELD_GUIDES.every((item) => item.titleKey.startsWith('ai_settings.mcp_server.guide.field.'))).toBe(true);
expect(MCP_TROUBLESHOOTING_GUIDES.every((item) => item.symptomKey.startsWith('ai_settings.mcp_server.guide.troubleshooting.'))).toBe(true);
expect(MCP_AUTHORING_NOTES.every((key) => key.startsWith('ai_settings.mcp_server.guide.note.'))).toBe(true);
});
it('keeps raw examples in MCP guidance metadata', () => {
const allExamples = MCP_TROUBLESHOOTING_GUIDES
.map((item) => 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');
expect(allExamples).toContain('command=npx');
expect(allExamples).toContain('KEY=VALUE');
expect(allExamples).toContain('stdio');
});
it('warns users to keep secrets in local env config instead of chat content', () => {
const notes = MCP_AUTHORING_NOTES.join('\n');
it('retains stable guide identities for rendering and snapshots', () => {
expect(MCP_SERVER_FILL_STEPS.map((item) => item.key)).toEqual([
'template',
'name',
'command',
'args',
'env-timeout',
]);
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 &&');
expect(MCP_FIELD_GUIDES.map((item) => item.key)).toEqual([
'name',
'enabled',
'transport',
'command',
'args',
'env',
'timeout',
]);
expect(MCP_TROUBLESHOOTING_GUIDES.map((item) => item.key)).toEqual([
'command-not-found',
'timeout-or-no-tools',
'auth-failed',
'stdio-only',
]);
});
it('keeps MCP guide and section catalog keys available in all supported languages', () => {
const baseCatalog = catalogs['en-US'] as Record<string, string>;
const requiredKeys = Object.keys(baseCatalog)
.filter((key) => key.startsWith('ai_settings.mcp_server.guide.') || key.startsWith('ai_settings.mcp_server.section.'))
.sort();
expect(requiredKeys.length).toBeGreaterThan(0);
for (const language of supportedLanguages) {
const catalog = catalogs[language] as Record<string, string>;
for (const key of requiredKeys) {
expect(catalog[key], `${language}:${key}`).toBeTruthy();
expect(getPlaceholders(catalog[key]), `${language}:${key}`).toEqual(getPlaceholders(baseCatalog[key]));
}
}
expect(getPlaceholders(baseCatalog['ai_settings.mcp_server.guide.full_command.placeholder'])).toEqual(['example']);
expect(getPlaceholders(baseCatalog['ai_settings.mcp_server.guide.full_command.parsed_summary'])).toEqual([
'argsCount',
'command',
'envCount',
]);
});
});

View File

@@ -2,27 +2,30 @@ export type MCPFieldState = 'required' | 'optional' | 'fixed';
export interface MCPFieldGuide {
key: string;
title: string;
summary: string;
detail: string;
fill: string;
avoid: string;
titleKey: string;
summaryKey: string;
detailKey: string;
fillKey: string;
avoidKey: string;
fieldState: MCPFieldState;
example?: string;
exampleKey?: string;
}
export interface MCPFillStep {
key: string;
step: string;
title: string;
detail: string;
titleKey: string;
detailKey: string;
}
export interface MCPTroubleshootingGuide {
key: string;
symptom: string;
likelyCause: string;
fix: string;
symptomKey: string;
likelyCauseKey: string;
fixKey: string;
example?: string;
exampleKey?: string;
}
export const MCP_COMMAND_EXAMPLES = [
@@ -36,124 +39,124 @@ export const MCP_COMMAND_EXAMPLES = [
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: '只有在服务确实需要额外配置时再补,不需要可以留空。' },
{ key: 'template', step: '1', titleKey: 'ai_settings.mcp_server.guide.step.template.title', detailKey: 'ai_settings.mcp_server.guide.step.template.detail' },
{ key: 'name', step: '2', titleKey: 'ai_settings.mcp_server.guide.step.name.title', detailKey: 'ai_settings.mcp_server.guide.step.name.detail' },
{ key: 'command', step: '3', titleKey: 'ai_settings.mcp_server.guide.step.command.title', detailKey: 'ai_settings.mcp_server.guide.step.command.detail' },
{ key: 'args', step: '4', titleKey: 'ai_settings.mcp_server.guide.step.args.title', detailKey: 'ai_settings.mcp_server.guide.step.args.detail' },
{ key: 'env-timeout', step: '5', titleKey: 'ai_settings.mcp_server.guide.step.env_timeout.title', detailKey: 'ai_settings.mcp_server.guide.step.env_timeout.detail' },
];
export const MCP_FIELD_GUIDES: MCPFieldGuide[] = [
{
key: 'name',
title: '服务名称',
summary: '保存后显示给你和 AI 看的名字。',
detail: '按用途命名,建议写成 Browser、GitHub、Filesystem 这类一眼能认出的名字。',
fill: '这个 MCP 的用途名,例如 GitHub 或 Filesystem。',
avoid: '不要写 server、test、mcp1 这类看不出用途的名字。',
titleKey: 'ai_settings.mcp_server.guide.field.name.title',
summaryKey: 'ai_settings.mcp_server.guide.field.name.summary',
detailKey: 'ai_settings.mcp_server.guide.field.name.detail',
fillKey: 'ai_settings.mcp_server.guide.field.name.fill',
avoidKey: 'ai_settings.mcp_server.guide.field.name.avoid',
fieldState: 'required',
example: 'Filesystem / Browser / GitHub',
},
{
key: 'enabled',
title: '启用状态',
summary: '控制这条配置现在要不要参与工具发现和调用。',
detail: '禁用只是不使用,不会删除下面填好的配置。',
fill: '临时不用选已禁用;确认要给 AI 用时选已启用。',
avoid: '不要用删除代替临时停用,避免重新配置 command、args、env。',
titleKey: 'ai_settings.mcp_server.guide.field.enabled.title',
summaryKey: 'ai_settings.mcp_server.guide.field.enabled.summary',
detailKey: 'ai_settings.mcp_server.guide.field.enabled.detail',
fillKey: 'ai_settings.mcp_server.guide.field.enabled.fill',
avoidKey: 'ai_settings.mcp_server.guide.field.enabled.avoid',
fieldState: 'optional',
example: '已启用 / 已禁用',
exampleKey: 'ai_settings.mcp_server.guide.field.enabled.example',
},
{
key: 'transport',
title: '传输方式',
summary: 'GoNavi 用什么方式和这个 MCP Server 通信。',
detail: '当前固定为 stdio表示本机直接启动进程并通过标准输入输出交互。',
fill: '保持 stdio。',
avoid: '不要填写 HTTP、SSE、URL 或端口;当前新增入口不是远程 MCP URL 配置。',
titleKey: 'ai_settings.mcp_server.guide.field.transport.title',
summaryKey: 'ai_settings.mcp_server.guide.field.transport.summary',
detailKey: 'ai_settings.mcp_server.guide.field.transport.detail',
fillKey: 'ai_settings.mcp_server.guide.field.transport.fill',
avoidKey: 'ai_settings.mcp_server.guide.field.transport.avoid',
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。',
titleKey: 'ai_settings.mcp_server.guide.field.command.title',
summaryKey: 'ai_settings.mcp_server.guide.field.command.summary',
detailKey: 'ai_settings.mcp_server.guide.field.command.detail',
fillKey: 'ai_settings.mcp_server.guide.field.command.fill',
avoidKey: 'ai_settings.mcp_server.guide.field.command.avoid',
fieldState: 'required',
example: 'npx / node / uvx / python / docker',
},
{
key: 'args',
title: '命令参数',
summary: '把脚本名、模块名、开关参数拆开逐项填写。',
detail: '例如 npx -y pkg --stdio要拆成 -y、pkg 和 --stdiodocker run --rm -i image 要拆成 run、--rm、-i 和 image。',
fill: '逐项填 -y、包名、脚本名、-m、--stdio、run、--rm、-i、镜像名等参数。',
avoid: '不要再填 npx/node/uvx/python/docker也不要把多个参数粘成一个长字符串。',
titleKey: 'ai_settings.mcp_server.guide.field.args.title',
summaryKey: 'ai_settings.mcp_server.guide.field.args.summary',
detailKey: 'ai_settings.mcp_server.guide.field.args.detail',
fillKey: 'ai_settings.mcp_server.guide.field.args.fill',
avoidKey: 'ai_settings.mcp_server.guide.field.args.avoid',
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。',
titleKey: 'ai_settings.mcp_server.guide.field.env.title',
summaryKey: 'ai_settings.mcp_server.guide.field.env.summary',
detailKey: 'ai_settings.mcp_server.guide.field.env.detail',
fillKey: 'ai_settings.mcp_server.guide.field.env.fill',
avoidKey: 'ai_settings.mcp_server.guide.field.env.avoid',
fieldState: 'optional',
example: 'OPENAI_API_KEY=... / GITHUB_TOKEN=...',
},
{
key: 'timeout',
title: '超时(秒)',
summary: '单次工具发现或调用最多等待多久。',
detail: '本机常规工具一般 20 秒就够,启动慢或远端链路再适当调大。',
fill: '常规填 20启动慢时填 45 或 60。',
avoid: '不要随意填过小3 秒以下很容易让工具发现误判失败。',
titleKey: 'ai_settings.mcp_server.guide.field.timeout.title',
summaryKey: 'ai_settings.mcp_server.guide.field.timeout.summary',
detailKey: 'ai_settings.mcp_server.guide.field.timeout.detail',
fillKey: 'ai_settings.mcp_server.guide.field.timeout.fill',
avoidKey: 'ai_settings.mcp_server.guide.field.timeout.avoid',
fieldState: 'optional',
example: '20 / 45 / 60',
},
];
export const MCP_AUTHORING_NOTES = [
'启动命令只填程序本身,不要把脚本名、模块名和 --stdio 混进去。',
'README 给 npx 示例时command 填 npxargs 逐项填 -y、包名和 --stdio不要把整行 npx 命令放进 command。',
'README 给 Docker 示例时command 填 dockerargs 逐项填 run、--rm、-i、镜像名和容器参数容器内 token 通常用 -e KEY=VALUE 传给容器。',
'如果 README 里只给了一整行命令,优先粘到完整命令框自动拆分;支持 KEY=VALUE、env KEY=VALUE、PowerShell $env:KEY=VALUE; 和 Windows set KEY=VALUE && 这几类前缀环境变量写法。',
'环境变量每行一条 KEY=VALUE不要写 export也不要和启动命令混成一行保存。',
'密钥类环境变量会保存到本机配置,并只在启动 MCP 进程时作为进程环境传入;不要把密钥写进聊天内容。',
'测试工具发现只会临时启动一次做探测,不会自动保存配置。',
'ai_settings.mcp_server.guide.note.command_only',
'ai_settings.mcp_server.guide.note.npx',
'ai_settings.mcp_server.guide.note.docker',
'ai_settings.mcp_server.guide.note.full_command',
'ai_settings.mcp_server.guide.note.env_lines',
'ai_settings.mcp_server.guide.note.secrets',
'ai_settings.mcp_server.guide.note.test_discovery',
];
export const MCP_TROUBLESHOOTING_GUIDES: MCPTroubleshootingGuide[] = [
{
key: 'command-not-found',
symptom: '测试提示找不到命令',
likelyCause: '启动命令填了整串命令、命令没加入 PATH或 Windows 路径里有空格但没有用真实 exe 路径。',
fix: '启动命令只填可执行程序本身;脚本名和 --stdio 放到命令参数里。命令不在 PATH 时,直接填绝对路径。',
symptomKey: 'ai_settings.mcp_server.guide.troubleshooting.command_not_found.symptom',
likelyCauseKey: 'ai_settings.mcp_server.guide.troubleshooting.command_not_found.cause',
fixKey: 'ai_settings.mcp_server.guide.troubleshooting.command_not_found.fix',
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',
symptomKey: 'ai_settings.mcp_server.guide.troubleshooting.timeout_or_no_tools.symptom',
likelyCauseKey: 'ai_settings.mcp_server.guide.troubleshooting.timeout_or_no_tools.cause',
fixKey: 'ai_settings.mcp_server.guide.troubleshooting.timeout_or_no_tools.fix',
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=...',
symptomKey: 'ai_settings.mcp_server.guide.troubleshooting.auth_failed.symptom',
likelyCauseKey: 'ai_settings.mcp_server.guide.troubleshooting.auth_failed.cause',
fixKey: 'ai_settings.mcp_server.guide.troubleshooting.auth_failed.fix',
example: 'GITHUB_TOKEN=... / KEY=VALUE',
},
{
key: 'stdio-only',
symptom: 'README 只给了 URL 或 SSE 配置',
likelyCause: '这类配置通常不是本机 stdio 进程,当前 GoNavi 新增 MCP 服务暂不直接支持。',
fix: '优先找该服务的 stdio 启动方式;如果只有 HTTP/SSE请先用官方网关或本机包装器转成 stdio。',
example: '当前只支持 stdio',
symptomKey: 'ai_settings.mcp_server.guide.troubleshooting.stdio_only.symptom',
likelyCauseKey: 'ai_settings.mcp_server.guide.troubleshooting.stdio_only.cause',
fixKey: 'ai_settings.mcp_server.guide.troubleshooting.stdio_only.fix',
example: 'stdio',
},
];

View File

@@ -2,6 +2,10 @@ import type { AIMCPServerConfig } from '../types';
export interface MCPServerDraftTemplate {
key: string;
titleKey: string;
descriptionKey: string;
detailKey: string;
seedNameKey: string;
title: string;
description: string;
detail: string;
@@ -11,11 +15,15 @@ export interface MCPServerDraftTemplate {
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`,把包名和路径参数改成实际值。',
titleKey: 'ai_settings.mcp_server.template.npx.title',
descriptionKey: 'ai_settings.mcp_server.template.npx.description',
detailKey: 'ai_settings.mcp_server.template.npx.detail',
seedNameKey: 'ai_settings.mcp_server.template.npx.seed_name',
title: 'npx package',
description: 'For npm MCP packages whose README uses `npx -y xxx --stdio`.',
detail: 'The example uses `npx -y @modelcontextprotocol/server-filesystem --stdio`; replace the package name and path arguments with the real values.',
seed: {
name: 'npx ',
name: 'npx package',
command: 'npx',
args: ['-y', '@modelcontextprotocol/server-filesystem', '--stdio'],
env: {},
@@ -24,11 +32,15 @@ export const MCP_SERVER_DRAFT_TEMPLATES: MCPServerDraftTemplate[] = [
},
{
key: 'uvx',
title: 'uvx 工具',
description: '适合 Python/uv 生态里已经发布好的 MCP 包。',
detail: '示例会填成 `uvx some-mcp-server`,保存前把包名改成你自己的。',
titleKey: 'ai_settings.mcp_server.template.uvx.title',
descriptionKey: 'ai_settings.mcp_server.template.uvx.description',
detailKey: 'ai_settings.mcp_server.template.uvx.detail',
seedNameKey: 'ai_settings.mcp_server.template.uvx.seed_name',
title: 'uvx tool',
description: 'For published MCP packages in the Python/uv ecosystem.',
detail: 'The example uses `uvx some-mcp-server`; replace the package name before saving.',
seed: {
name: 'uvx 工具',
name: 'uvx tool',
command: 'uvx',
args: ['some-mcp-server'],
env: {},
@@ -37,11 +49,15 @@ export const MCP_SERVER_DRAFT_TEMPLATES: MCPServerDraftTemplate[] = [
},
{
key: 'node',
title: 'Node 脚本',
description: '适合本地 js/ts 脚本或 npm 安装后的 node 启动器。',
detail: '示例会填成 `node server.js --stdio`,脚本名和参数可以继续改。',
titleKey: 'ai_settings.mcp_server.template.node.title',
descriptionKey: 'ai_settings.mcp_server.template.node.description',
detailKey: 'ai_settings.mcp_server.template.node.detail',
seedNameKey: 'ai_settings.mcp_server.template.node.seed_name',
title: 'Node script',
description: 'For local js/ts scripts or node launchers installed from npm.',
detail: 'The example uses `node server.js --stdio`; you can adjust the script name and arguments.',
seed: {
name: 'Node 脚本',
name: 'Node script',
command: 'node',
args: ['server.js', '--stdio'],
env: {},
@@ -50,11 +66,15 @@ export const MCP_SERVER_DRAFT_TEMPLATES: MCPServerDraftTemplate[] = [
},
{
key: 'python',
title: 'Python 模块',
description: '适合 `python -m xxx` 这种按模块启动的服务。',
detail: '示例会填成 `python -m your_mcp_server`,模块名改成实际值即可。',
titleKey: 'ai_settings.mcp_server.template.python.title',
descriptionKey: 'ai_settings.mcp_server.template.python.description',
detailKey: 'ai_settings.mcp_server.template.python.detail',
seedNameKey: 'ai_settings.mcp_server.template.python.seed_name',
title: 'Python module',
description: 'For services launched as modules, such as `python -m xxx`.',
detail: 'The example uses `python -m your_mcp_server`; replace the module name with the real one.',
seed: {
name: 'Python 模块',
name: 'Python module',
command: 'python',
args: ['-m', 'your_mcp_server'],
env: {},
@@ -63,9 +83,13 @@ export const MCP_SERVER_DRAFT_TEMPLATES: MCPServerDraftTemplate[] = [
},
{
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 放到参数里。',
titleKey: 'ai_settings.mcp_server.template.docker.title',
descriptionKey: 'ai_settings.mcp_server.template.docker.description',
detailKey: 'ai_settings.mcp_server.template.docker.detail',
seedNameKey: 'ai_settings.mcp_server.template.docker.seed_name',
title: 'Docker image',
description: 'For containerized MCP services whose README uses `docker run -i --rm image`. Docker must be installed locally.',
detail: 'The example uses `docker run --rm -i mcp/server-fetch:latest`; container tokens are usually passed with -e KEY=VALUE in arguments.',
seed: {
name: 'Docker MCP',
command: 'docker',
@@ -76,11 +100,15 @@ export const MCP_SERVER_DRAFT_TEMPLATES: MCPServerDraftTemplate[] = [
},
{
key: 'exe',
title: '本机 EXE',
description: '适合已经编译好的本机二进制或公司内部工具。',
detail: '示例会填成 `your-mcp-server.exe stdio`,把 exe 路径换成真实值。',
titleKey: 'ai_settings.mcp_server.template.exe.title',
descriptionKey: 'ai_settings.mcp_server.template.exe.description',
detailKey: 'ai_settings.mcp_server.template.exe.detail',
seedNameKey: 'ai_settings.mcp_server.template.exe.seed_name',
title: 'Local EXE',
description: 'For compiled local binaries or internal company tools.',
detail: 'The example uses `your-mcp-server.exe stdio`; replace the exe path with the real value.',
seed: {
name: '本机 EXE',
name: 'Local EXE',
command: 'your-mcp-server.exe',
args: ['stdio'],
env: {},

View File

@@ -1,7 +1,24 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { validateMCPServerDraft } from './mcpServerValidation';
const source = readFileSync(new URL('./mcpServerValidation.ts', import.meta.url), 'utf8');
const ISSUE_KEYS = [
'name_missing',
'transport_unsupported',
'command_missing',
'command_whole_line',
'args_missing_for_launcher',
'docker_run_missing',
'docker_interactive_missing',
'docker_image_missing',
'args_contain_env_or_shell_glue',
'timeout_out_of_range',
'env_invalid_lines',
];
describe('mcpServerValidation', () => {
it('blocks testing and saving when required MCP launch fields are invalid', () => {
const validation = validateMCPServerDraft({
@@ -16,6 +33,7 @@ describe('mcpServerValidation', () => {
expect(validation.canSave).toBe(false);
expect(validation.errorCount).toBe(1);
expect(validation.issues.map((issue) => issue.key)).toContain('command-missing');
expect(validation.issues.find((issue) => issue.key === 'command-missing')?.title).toBe('Startup command is missing');
});
it('warns when users paste a whole command into the command field', () => {
@@ -93,4 +111,39 @@ describe('mcpServerValidation', () => {
expect(validation.issues.map((issue) => issue.key)).not.toContain('docker-interactive-missing');
expect(validation.issues.map((issue) => issue.key)).not.toContain('docker-image-missing');
});
it('localizes validation issue title and detail with a supplied translator while preserving raw env lines', () => {
const seen: Array<{ key: string; params?: Record<string, unknown> }> = [];
const validation = validateMCPServerDraft({
name: '',
transport: 'stdio',
command: 'uvx',
args: ['mcp-server-github', '--stdio'],
timeoutSeconds: 45,
}, { invalidLines: ['export GITHUB_TOKEN=abc'] }, (key, params) => {
seen.push({ key, params });
if (key.endsWith('.title')) return `标题:${key}`;
if (key.endsWith('.detail')) return `详情:${params?.count}:${params?.lines}`;
return key;
});
const nameIssue = validation.issues.find((issue) => issue.key === 'name-missing');
const envIssue = validation.issues.find((issue) => issue.key === 'env-invalid-lines');
expect(nameIssue?.title).toBe('标题:ai_settings.mcp_server.validation.issue.name_missing.title');
expect(nameIssue?.detail).toBe('详情:undefined:undefined');
expect(envIssue?.title).toBe('标题:ai_settings.mcp_server.validation.issue.env_invalid_lines.title');
expect(envIssue?.detail).toBe('详情:1:export GITHUB_TOKEN=abc');
expect(seen.map((entry) => entry.key)).toContain('ai_settings.mcp_server.validation.issue.env_invalid_lines.detail');
});
it('keeps MCP validation issue copy out of production Chinese literals', () => {
for (const key of ISSUE_KEYS) {
expect(source).toContain(`ai_settings.mcp_server.validation.issue.${key}.title`);
expect(source).toContain(`ai_settings.mcp_server.validation.issue.${key}.detail`);
}
expect(source).not.toContain('服务名称为空');
expect(source).not.toContain('启动命令未填写');
expect(source).not.toContain('环境变量存在无效行');
});
});

View File

@@ -11,6 +11,11 @@ export interface MCPServerDraftIssue {
detail: string;
}
export type MCPServerValidationTranslator = (
key: string,
params?: Record<string, string | number | boolean | null | undefined>,
) => string;
export interface MCPServerDraftValidation {
issues: MCPServerDraftIssue[];
errorCount: number;
@@ -48,6 +53,113 @@ const toTrimmedString = (value: unknown): string => String(value ?? '').trim();
const countIssues = (issues: MCPServerDraftIssue[], severity: MCPServerDraftIssueSeverity): number =>
issues.filter((issue) => issue.severity === severity).length;
const ISSUE_COPY = {
nameMissing: {
titleKey: 'ai_settings.mcp_server.validation.issue.name_missing.title',
detailKey: 'ai_settings.mcp_server.validation.issue.name_missing.detail',
fallbackTitle: 'Service name is empty',
fallbackDetail: 'Use a purpose name such as Browser, GitHub, or Filesystem; otherwise it can only be identified by command after saving.',
},
transportUnsupported: {
titleKey: 'ai_settings.mcp_server.validation.issue.transport_unsupported.title',
detailKey: 'ai_settings.mcp_server.validation.issue.transport_unsupported.detail',
fallbackTitle: 'Transport is not supported',
fallbackDetail: 'GoNavi can only add stdio MCP services here. Keep the transport set to stdio.',
},
commandMissing: {
titleKey: 'ai_settings.mcp_server.validation.issue.command_missing.title',
detailKey: 'ai_settings.mcp_server.validation.issue.command_missing.detail',
fallbackTitle: 'Startup command is missing',
fallbackDetail: 'Enter at least node, uvx, python, or a local executable path. Put the script name and --stdio in command arguments.',
},
commandWholeLine: {
titleKey: 'ai_settings.mcp_server.validation.issue.command_whole_line.title',
detailKey: 'ai_settings.mcp_server.validation.issue.command_whole_line.detail',
fallbackTitle: 'Startup command may contain the whole command line',
fallbackDetail: 'Put only the executable itself in startup command. Move the script name, module name, --stdio, and environment variables into arguments or environment variables.',
},
argsMissingForLauncher: {
titleKey: 'ai_settings.mcp_server.validation.issue.args_missing_for_launcher.title',
detailKey: 'ai_settings.mcp_server.validation.issue.args_missing_for_launcher.detail',
fallbackTitle: 'Command arguments may be missing the script or module name',
fallbackDetail: 'Launchers such as node, python, uvx, and npx usually also need server.js, -m your_server, or a package name as an argument.',
},
dockerRunMissing: {
titleKey: 'ai_settings.mcp_server.validation.issue.docker_run_missing.title',
detailKey: 'ai_settings.mcp_server.validation.issue.docker_run_missing.detail',
fallbackTitle: 'Docker arguments are missing run',
fallbackDetail: 'Docker MCP usually uses command=docker, with run, --rm, -i, the image name, and service arguments entered separately in args.',
},
dockerInteractiveMissing: {
titleKey: 'ai_settings.mcp_server.validation.issue.docker_interactive_missing.title',
detailKey: 'ai_settings.mcp_server.validation.issue.docker_interactive_missing.detail',
fallbackTitle: 'Docker arguments are missing -i',
fallbackDetail: 'MCP needs to keep reading standard input. Add -i or --interactive for docker run, otherwise tool discovery may disconnect immediately.',
},
dockerImageMissing: {
titleKey: 'ai_settings.mcp_server.validation.issue.docker_image_missing.title',
detailKey: 'ai_settings.mcp_server.validation.issue.docker_image_missing.detail',
fallbackTitle: 'Docker arguments may be missing the image name',
fallbackDetail: 'Enter the image name from the README after docker run options, for example mcp/server-fetch:latest.',
},
argsContainEnvOrShellGlue: {
titleKey: 'ai_settings.mcp_server.validation.issue.args_contain_env_or_shell_glue.title',
detailKey: 'ai_settings.mcp_server.validation.issue.args_contain_env_or_shell_glue.detail',
fallbackTitle: 'Command arguments may include environment variables or shell glue',
fallbackDetail: 'KEY=VALUE, $env:KEY=VALUE, set, env, and && belong in full-command auto split or in the environment variables field.',
},
timeoutOutOfRange: {
titleKey: 'ai_settings.mcp_server.validation.issue.timeout_out_of_range.title',
detailKey: 'ai_settings.mcp_server.validation.issue.timeout_out_of_range.detail',
fallbackTitle: 'Timeout is outside the recommended range',
fallbackDetail: 'GoNavi will clamp it between 3 and 120 seconds. Regular local services usually use 20 seconds; slow-starting services can use 45 or 60 seconds.',
},
envInvalidLines: {
titleKey: 'ai_settings.mcp_server.validation.issue.env_invalid_lines.title',
detailKey: 'ai_settings.mcp_server.validation.issue.env_invalid_lines.detail',
fallbackTitle: 'Environment variables contain invalid lines',
fallbackDetail: ({ count, lines }: { count: number; lines: string }) =>
`Each line must be KEY=VALUE. ${count} line(s) will not be saved: ${lines}`,
},
} as const;
const buildIssueCopy = (
copy: {
titleKey: string;
detailKey: string;
fallbackTitle: string;
fallbackDetail: string | ((params: { count: number; lines: string }) => string);
},
translate?: MCPServerValidationTranslator,
params?: Record<string, string | number | boolean | null | undefined>,
): Pick<MCPServerDraftIssue, 'title' | 'detail'> => {
const title = translate ? translate(copy.titleKey, params) : copy.fallbackTitle;
const detail = translate
? translate(copy.detailKey, params)
: typeof copy.fallbackDetail === 'function'
? copy.fallbackDetail({
count: Number(params?.count || 0),
lines: String(params?.lines || ''),
})
: copy.fallbackDetail;
return { title, detail };
};
const pushIssue = (
issues: MCPServerDraftIssue[],
key: string,
severity: MCPServerDraftIssueSeverity,
copy: Parameters<typeof buildIssueCopy>[0],
translate?: MCPServerValidationTranslator,
params?: Record<string, string | number | boolean | null | undefined>,
) => {
issues.push({
key,
severity,
...buildIssueCopy(copy, translate, params),
});
};
const firstShellToken = (value: string): string => {
const { tokens } = splitShellLikeCommand(value);
return toTrimmedString(tokens[0]).toLowerCase();
@@ -124,6 +236,7 @@ const hasDockerImageArg = (args: string[]): boolean => {
export const validateMCPServerDraft = (
server: Pick<AIMCPServerConfig, 'name' | 'transport' | 'command' | 'args' | 'timeoutSeconds'>,
parsedEnvDraft?: Pick<ParsedMCPEnvDraft, 'invalidLines'>,
translate?: MCPServerValidationTranslator,
): MCPServerDraftValidation => {
const issues: MCPServerDraftIssue[] = [];
const command = toTrimmedString(server.command);
@@ -131,100 +244,48 @@ export const validateMCPServerDraft = (
const timeoutSeconds = Number(server.timeoutSeconds);
if (!toTrimmedString(server.name)) {
issues.push({
key: 'name-missing',
severity: 'warning',
title: '服务名称为空',
detail: '建议写成 Browser、GitHub、Filesystem 这类用途名;否则保存后只能靠命令名识别。',
});
pushIssue(issues, 'name-missing', 'warning', ISSUE_COPY.nameMissing, translate);
}
if (server.transport !== 'stdio') {
issues.push({
key: 'transport-unsupported',
severity: 'error',
title: '传输方式不支持',
detail: '当前 GoNavi 新增 MCP 服务只支持 stdio请保持传输方式为 stdio。',
});
pushIssue(issues, 'transport-unsupported', 'error', ISSUE_COPY.transportUnsupported, translate);
}
if (!command) {
issues.push({
key: 'command-missing',
severity: 'error',
title: '启动命令未填写',
detail: '至少填写 node、uvx、python 或本机 exe 路径;脚本名和 --stdio 放到命令参数里。',
});
pushIssue(issues, 'command-missing', 'error', ISSUE_COPY.commandMissing, translate);
} else if (commandLooksLikeWholeLine(command)) {
issues.push({
key: 'command-whole-line',
severity: 'warning',
title: '启动命令可能填成了整行命令',
detail: '启动命令只填可执行程序本身;把脚本名、模块名、--stdio 和环境变量拆到命令参数或环境变量里。',
});
pushIssue(issues, 'command-whole-line', 'warning', ISSUE_COPY.commandWholeLine, translate);
}
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 或包名作为参数。',
});
pushIssue(issues, 'args-missing-for-launcher', 'warning', ISSUE_COPY.argsMissingForLauncher, translate);
}
if (command && isDockerCommand(command)) {
if (!hasDockerRunArg(args)) {
issues.push({
key: 'docker-run-missing',
severity: 'warning',
title: 'Docker 参数缺少 run',
detail: 'Docker MCP 通常需要 command=dockerargs 里单独填写 run、--rm、-i、镜像名和服务参数。',
});
pushIssue(issues, 'docker-run-missing', 'warning', ISSUE_COPY.dockerRunMissing, translate);
}
if (!hasDockerInteractiveArg(args)) {
issues.push({
key: 'docker-interactive-missing',
severity: 'warning',
title: 'Docker 参数缺少 -i',
detail: 'MCP 需要持续读取标准输入docker run 场景请加 -i 或 --interactive否则工具发现可能立即断开。',
});
pushIssue(issues, 'docker-interactive-missing', 'warning', ISSUE_COPY.dockerInteractiveMissing, translate);
}
if (!hasDockerImageArg(args)) {
issues.push({
key: 'docker-image-missing',
severity: 'warning',
title: 'Docker 参数可能缺少镜像名',
detail: '请在 docker run 选项之后填写 README 提供的镜像名,例如 mcp/server-fetch:latest。',
});
pushIssue(issues, 'docker-image-missing', 'warning', ISSUE_COPY.dockerImageMissing, translate);
}
}
if (argsContainEnvOrShellGlue(args)) {
issues.push({
key: 'args-contain-env-or-shell-glue',
severity: 'warning',
title: '命令参数里疑似混入环境变量或 Shell 连接符',
detail: 'KEY=VALUE、$env:KEY=VALUE、set、env、&& 这类内容应放到完整命令自动拆分或环境变量输入框里。',
});
pushIssue(issues, 'args-contain-env-or-shell-glue', 'warning', ISSUE_COPY.argsContainEnvOrShellGlue, translate);
}
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 秒。',
});
pushIssue(issues, 'timeout-out-of-range', 'warning', ISSUE_COPY.timeoutOutOfRange, translate);
}
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(' / ')}`,
pushIssue(issues, 'env-invalid-lines', 'error', ISSUE_COPY.envInvalidLines, translate, {
count: invalidEnvLines.length,
lines: invalidEnvLines.slice(0, 2).join(' / '),
});
}

View File

@@ -1,4 +1,10 @@
import { t as catalogTranslate } from '../i18n/catalog';
export type OceanBaseProtocol = 'mysql' | 'oracle';
type OceanBaseProtocolTranslator = (
key: string,
params?: Record<string, string | number | boolean | null | undefined>,
) => string;
export const OCEANBASE_PROTOCOL_PARAM_KEYS = [
'protocol',
@@ -16,6 +22,20 @@ type OceanBaseProtocolResolution = {
};
const normalizeToken = (value: unknown): string => String(value ?? '').trim().toLowerCase();
const UNSUPPORTED_PROTOCOL_KEY = 'connection.oceanbase.error.unsupported_protocol';
const translateWithFallback = (
translate: OceanBaseProtocolTranslator | undefined,
key: string,
fallback: string,
params?: Record<string, string | number | boolean | null | undefined>,
): string => {
if (!translate) {
return fallback;
}
const translated = translate(key, params);
return translated && translated !== key ? translated : fallback;
};
export const normalizeOceanBaseProtocol = (value: unknown): OceanBaseProtocol | undefined => {
const normalized = normalizeToken(value);
@@ -36,10 +56,17 @@ export const isUnsupportedOceanBaseProtocolValue = (value: unknown): boolean =>
return normalized !== '' && !normalizeOceanBaseProtocol(normalized);
};
export const describeUnsupportedOceanBaseProtocol = (value: unknown): string => {
export const describeUnsupportedOceanBaseProtocol = (
value: unknown,
translate?: OceanBaseProtocolTranslator,
): string => {
const raw = String(value ?? '').trim();
const label = raw ? ` "${raw}"` : '';
return `OceanBase 当前仅支持 MySQL/Oracle 租户协议,不支持${label};请改为 MySQL 或 Oracle。`;
return translateWithFallback(
translate,
UNSUPPORTED_PROTOCOL_KEY,
catalogTranslate('en-US', UNSUPPORTED_PROTOCOL_KEY, { value: raw }),
{ value: raw },
);
};
export const resolveOceanBaseProtocolFromQueryText = (raw: unknown): OceanBaseProtocolResolution => {

View File

@@ -76,6 +76,7 @@ describe('resolveEditRowLocator', () => {
})).toMatchObject({
strategy: 'none',
readOnly: true,
reason: 'No primary key or usable unique index was found, so changes cannot be submitted safely.',
});
});
@@ -87,7 +88,49 @@ describe('resolveEditRowLocator', () => {
})).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '结果集中缺少主键列 ID无法安全提交修改。',
reason: 'The result set is missing primary key column ID, so changes cannot be submitted safely.',
});
});
it('localizes read-only reasons while preserving raw locator names', () => {
const translate = (key: string, params?: Record<string, string | number | boolean | null | undefined>) => ({
'data_viewer.read_only.reason.primary_key_column_missing': `結果集中缺少主鍵欄位 ${params?.columns},無法安全提交修改。`,
'data_viewer.read_only.reason.no_safe_locator': '未偵測到主鍵或可用唯一索引,無法安全提交修改。',
'data_viewer.read_only.reason.oracle_rowid_missing': '未偵測到主鍵或可用唯一索引,且結果集中缺少 Oracle ROWID無法安全提交修改。',
'data_viewer.read_only.reason.duckdb_rowid_missing': '未偵測到主鍵、可用唯一索引或 DuckDB rowid無法安全提交修改。',
}[key] ?? key);
expect(resolveEditRowLocator({
dbType: 'mysql',
resultColumns: ['NAME'],
primaryKeys: ['TENANT_ID', 'ID'],
translate,
})).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '結果集中缺少主鍵欄位 TENANT_ID, ID無法安全提交修改。',
});
expect(resolveEditRowLocator({
dbType: 'oracle',
resultColumns: ['NAME'],
allowOracleRowID: true,
translate,
})).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '未偵測到主鍵或可用唯一索引,且結果集中缺少 Oracle ROWID無法安全提交修改。',
});
expect(resolveEditRowLocator({
dbType: 'duckdb',
resultColumns: ['name'],
allowDuckDBRowID: true,
translate,
})).toMatchObject({
strategy: 'none',
readOnly: true,
reason: '未偵測到主鍵、可用唯一索引或 DuckDB rowid無法安全提交修改。',
});
});

View File

@@ -1,6 +1,7 @@
import type { IndexDefinition } from '../types';
import { resolveUniqueKeyGroupsFromIndexes } from '../components/dataGridCopyInsert';
import { isOracleLikeDialect } from './sqlDialect';
import { t as translateCatalog, type I18nParams } from '../i18n';
export const ORACLE_ROWID_LOCATOR_COLUMN = '__gonavi_oracle_rowid__';
export const DUCKDB_ROWID_LOCATOR_COLUMN = '__gonavi_duckdb_rowid__';
@@ -24,6 +25,7 @@ export type ResolveEditRowLocatorParams = {
indexes?: IndexDefinition[];
allowOracleRowID?: boolean;
allowDuckDBRowID?: boolean;
translate?: RowLocatorTranslator;
};
export type ResolveRowLocatorValuesResult =
@@ -35,6 +37,8 @@ export type RowLocatorMessages = {
emptyLocatorValue?: (column: string) => string;
};
type RowLocatorTranslator = (key: string, params?: I18nParams) => string;
const normalizeColumnName = (value: string): string => String(value || '').trim();
const hasColumn = (columns: string[], target: string): boolean => {
@@ -55,6 +59,23 @@ const buildReadOnlyLocator = (reason: string): EditRowLocator => ({
reason,
});
const ROW_LOCATOR_REASON_KEYS = {
noSafeLocator: 'data_viewer.read_only.reason.no_safe_locator',
oracleRowIDMissing: 'data_viewer.read_only.reason.oracle_rowid_missing',
duckDBRowIDMissing: 'data_viewer.read_only.reason.duckdb_rowid_missing',
primaryKeyColumnMissing: 'data_viewer.read_only.reason.primary_key_column_missing',
} as const;
const translateReason = (
translate: RowLocatorTranslator | undefined,
key: string,
params?: I18nParams,
): string => {
const fallback = translateCatalog(key, params, 'en-US');
const translated = translate ? translate(key, params) : fallback;
return translated && translated !== key ? translated : fallback;
};
export const resolveEditRowLocator = ({
dbType,
resultColumns,
@@ -62,6 +83,7 @@ export const resolveEditRowLocator = ({
indexes,
allowOracleRowID = false,
allowDuckDBRowID = false,
translate,
}: ResolveEditRowLocatorParams): EditRowLocator => {
const columns = (resultColumns || []).map(normalizeColumnName).filter(Boolean);
const primaryKeyColumns = (primaryKeys || []).map(normalizeColumnName).filter(Boolean);
@@ -76,7 +98,11 @@ export const resolveEditRowLocator = ({
readOnly: false,
};
}
return buildReadOnlyLocator(`结果集中缺少主键列 ${missing.join(', ')},无法安全提交修改。`);
return buildReadOnlyLocator(translateReason(
translate,
ROW_LOCATOR_REASON_KEYS.primaryKeyColumnMissing,
{ columns: missing.join(', ') },
));
}
const uniqueKeyGroups = resolveUniqueKeyGroupsFromIndexes(indexes);
@@ -113,14 +139,14 @@ export const resolveEditRowLocator = ({
}
if (allowOracleRowID && isOracleLikeDialect(dbType)) {
return buildReadOnlyLocator('未检测到主键或可用唯一索引,且结果中缺少 Oracle ROWID无法安全提交修改。');
return buildReadOnlyLocator(translateReason(translate, ROW_LOCATOR_REASON_KEYS.oracleRowIDMissing));
}
if (allowDuckDBRowID && String(dbType || '').trim().toLowerCase() === 'duckdb') {
return buildReadOnlyLocator('未检测到主键、可用唯一索引或 DuckDB rowid无法安全提交修改。');
return buildReadOnlyLocator(translateReason(translate, ROW_LOCATOR_REASON_KEYS.duckDBRowIDMissing));
}
return buildReadOnlyLocator('未检测到主键或可用唯一索引,无法安全提交修改。');
return buildReadOnlyLocator(translateReason(translate, ROW_LOCATOR_REASON_KEYS.noSafeLocator));
};
export const resolveRowLocatorValues = (

View File

@@ -7,6 +7,7 @@ import { LEGACY_PERSIST_KEY } from './legacyConnectionStorage';
import {
bootstrapSavedQueries,
readLegacySavedQueriesFromPayload,
saveSavedQueryToBackend,
stripLegacySavedQueries,
} from './savedQueryPersistence';
@@ -158,8 +159,22 @@ describe('saved query persistence', () => {
]);
});
it('localizes missing context errors when saving a query', async () => {
setCurrentLanguage('en-US');
await expect(saveSavedQueryToBackend(undefined, {
id: 'missing-context',
name: 'Missing context',
sql: '',
connectionId: '',
dbName: '',
createdAt: 100,
})).rejects.toThrow('Saved query is missing SQL, connection, or database context');
});
it('does not hardcode Chinese generated saved query names', () => {
const source = readFileSync(new URL('./savedQueryPersistence.ts', import.meta.url), 'utf8');
expect(source).not.toContain('`查询-${index + 1}`');
expect(source).not.toContain('保存查询缺少 SQL、连接或数据库上下文');
});
});

View File

@@ -231,7 +231,7 @@ export const saveSavedQueryToBackend = async (
): Promise<SavedQuery> => {
const sanitized = sanitizeSavedQuery(query, 0);
if (!sanitized) {
throw new Error('保存查询缺少 SQL、连接或数据库上下文');
throw new Error(translate('saved_query.error.missing_context'));
}
if (typeof backend?.SaveQuery !== 'function') {
return sanitized;

View File

@@ -125,6 +125,8 @@ describe('shortcut localization', () => {
try {
expect(SHORTCUT_ACTION_META.runQuery.label).toBe('Run SQL');
expect(SHORTCUT_ACTION_META.saveQuery.description).toBe('Save the current query tab; unnamed queries open the save dialog');
expect(SHORTCUT_ACTION_META.toggleQueryResultsPanel.label).toBe('Toggle Results Panel');
expect(SHORTCUT_ACTION_META.toggleQueryResultsPanel.description).toBe('Show or hide the results area below the query editor');
expect(SHORTCUT_ACTION_META.sendAIChatMessage.description).toContain('Shift+Enter');
expect(describeConflictContext('global')).toBe('Browser');

View File

@@ -152,8 +152,8 @@ const SHORTCUT_ACTION_META_DEFINITIONS: Record<ShortcutAction, ShortcutActionMet
allowInEditable: true,
},
toggleQueryResultsPanel: {
label: '切换结果区',
description: '在查询编辑器中显示或隐藏下方结果区域',
labelKey: 'app.shortcuts.action.toggleQueryResultsPanel.label',
descriptionKey: 'app.shortcuts.action.toggleQueryResultsPanel.description',
scope: 'queryEditor',
allowInEditable: true,
},

View File

@@ -1,37 +1,114 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { formatSqlExecutionError } from './sqlErrorSemantics';
const source = readFileSync(new URL('./sqlErrorSemantics.ts', import.meta.url), 'utf8');
describe('formatSqlExecutionError', () => {
it('adds Chinese semantic explanation for SQL syntax errors and keeps raw text', () => {
it('adds 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"');
expect(formatted).toContain('Semantic meaning: SQL syntax error');
expect(formatted).toContain('Suggestion:');
expect(formatted).toContain('Raw error: 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');
expect(formatted).toContain('Semantic meaning: Table or object does not exist');
expect(formatted).toContain('Raw error: 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 条语句执行失败:',
prefix: 'Statement 2 failed:',
});
expect(formatted.startsWith('第 2 条语句执行失败:\n中文语义唯一约束或主键冲突')).toBe(true);
expect(formatted).toContain('原始错误:Duplicate entry "1" for key "PRIMARY"');
expect(formatted.startsWith('Statement 2 failed:\nSemantic meaning: Unique constraint or primary key conflict')).toBe(true);
expect(formatted).toContain('Raw error: 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');
expect(formatted).toContain('Semantic meaning: Database execution error');
expect(formatted).toContain('Raw error: driver returned unexpected status 123');
});
it('recognizes localized connection-timeout wrappers as timeout semantics', () => {
const translate = (key: string, params?: Record<string, unknown>) => {
if (key === 'query_editor.sql_error.wrapper.semantic_line') {
return `SEM:${params?.label}|${params?.explanation}`;
}
if (key === 'query_editor.sql_error.wrapper.suggestion_line') {
return `SUG:${params?.suggestion}`;
}
if (key === 'query_editor.sql_error.wrapper.raw_line') {
return `RAW:${params?.error}`;
}
if (key === 'query_editor.sql_error.rule.timeout_or_canceled.label') {
return 'TIMEOUT_LABEL';
}
if (key === 'query_editor.sql_error.rule.timeout_or_canceled.explanation') {
return 'TIMEOUT_EXPLANATION';
}
if (key === 'query_editor.sql_error.rule.timeout_or_canceled.suggestion') {
return 'TIMEOUT_SUGGESTION';
}
if (key === 'query_editor.sql_error.rule.generic.label') {
return 'GENERIC_LABEL';
}
if (key === 'query_editor.sql_error.rule.generic.explanation') {
return 'GENERIC_EXPLANATION';
}
if (key === 'query_editor.sql_error.rule.generic.suggestion') {
return 'GENERIC_SUGGESTION';
}
return key;
};
const localizedTimeoutMessages = [
'\u8cc7\u6599\u5eab\u9023\u7dda\u903e\u6642\uff1amysql 127.0.0.1:3306/main\uff1a\u7db2\u8def\u903e\u6642',
'\u30c7\u30fc\u30bf\u30d9\u30fc\u30b9\u63a5\u7d9a\u304c\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8\u3057\u307e\u3057\u305f: mysql 127.0.0.1:3306/main: \u30bf\u30a4\u30e0\u30a2\u30a6\u30c8',
'Zeit\u00fcberschreitung bei der Datenbankverbindung: mysql 127.0.0.1:3306/main: netzwerk-timeout',
'\u0422\u0430\u0439\u043c-\u0430\u0443\u0442 \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0435\u043d\u0438\u044f \u043a \u0431\u0430\u0437\u0435 \u0434\u0430\u043d\u043d\u044b\u0445: mysql 127.0.0.1:3306/main: \u0442\u0430\u0439\u043c-\u0430\u0443\u0442 \u0441\u0435\u0442\u0438',
] as const;
for (const raw of localizedTimeoutMessages) {
const formatted = formatSqlExecutionError(raw, { translate });
expect(formatted).toContain('SEM:TIMEOUT_LABEL|TIMEOUT_EXPLANATION');
expect(formatted).toContain('SUG:TIMEOUT_SUGGESTION');
expect(formatted).toContain(`RAW:${raw}`);
expect(formatted).not.toContain('SEM:GENERIC_LABEL|GENERIC_EXPLANATION');
}
});
it('localizes semantic wrapper copy with a supplied translator without translating raw database errors', () => {
const seen: Array<{ key: string; params?: Record<string, unknown> }> = [];
const formatted = formatSqlExecutionError('ERROR: relation "orders" does not exist', {
translate: (key, params) => {
seen.push({ key, params });
if (key === 'query_editor.sql_error.wrapper.semantic_line') {
return `语义:${params?.label}|${params?.explanation}`;
}
if (key === 'query_editor.sql_error.wrapper.suggestion_line') {
return `建议:${params?.suggestion}`;
}
if (key === 'query_editor.sql_error.wrapper.raw_line') {
return `RAW:${params?.error}`;
}
return `T:${key}`;
},
});
expect(formatted).toContain('语义:T:query_editor.sql_error.rule.object_missing.label|T:query_editor.sql_error.rule.object_missing.explanation');
expect(formatted).toContain('建议:T:query_editor.sql_error.rule.object_missing.suggestion');
expect(formatted).toContain('RAW:ERROR: relation "orders" does not exist');
expect(seen.map((entry) => entry.key)).toContain('query_editor.sql_error.rule.object_missing.label');
expect(seen.map((entry) => entry.key)).toContain('query_editor.sql_error.wrapper.raw_line');
});
it('does not format an already formatted message again', () => {
@@ -43,4 +120,14 @@ describe('formatSqlExecutionError', () => {
expect(formatSqlExecutionError(raw)).toBe(raw);
});
it('keeps SQL execution semantic copy out of production Chinese literals', () => {
expect(source).toContain('query_editor.sql_error.rule.');
expect(source).toContain("key: 'syntax'");
expect(source).toContain('query_editor.sql_error.wrapper.semantic_line');
expect(source).not.toContain('SQL 语法错误');
expect(source).not.toContain('处理建议');
expect(source).not.toContain('原始错误');
expect(source).not.toContain('数据库执行错误');
});
});

View File

@@ -1,19 +1,35 @@
export type SqlExecutionErrorFormatOptions = {
prefix?: string;
translate?: SqlExecutionErrorTranslator;
};
export type SqlExecutionErrorTranslator = (
key: string,
params?: Record<string, string | number | boolean | null | undefined>,
) => string;
type SqlErrorSemanticRule = {
label: string;
explanation: string;
suggestion: string;
key: string;
fallbackLabel: string;
fallbackExplanation: string;
fallbackSuggestion: string;
patterns: RegExp[];
};
const LOCALIZED_TIMEOUT_KEYWORDS = [
'\u8d85\u65f6',
'\u903e\u6642',
'\u30bf\u30a4\u30e0\u30a2\u30a6\u30c8',
'zeit\u00fcberschreitung',
'\u0442\u0430\u0439\u043c-\u0430\u0443\u0442',
] as const;
const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
{
label: 'SQL 语法错误',
explanation: '通常是关键字、逗号、括号、引号、语句顺序或当前数据库方言不匹配。',
suggestion: '检查报错位置附近的 SQL 片段,并确认当前连接的数据源类型与 SQL 方言一致。',
key: 'syntax',
fallbackLabel: 'SQL syntax error',
fallbackExplanation: 'Usually caused by keywords, commas, parentheses, quotes, statement order, or SQL dialect mismatch.',
fallbackSuggestion: 'Check the SQL fragment near the reported position and confirm the current data source type matches the SQL dialect.',
patterns: [
/syntax error/i,
/sql syntax/i,
@@ -25,9 +41,10 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
],
},
{
label: '表或对象不存在',
explanation: 'SQL 引用了当前库或 schema 中找不到的表、视图、序列或其他数据库对象。',
suggestion: '确认对象名称、大小写、schema/database 前缀,以及当前查询所选数据库是否正确。',
key: 'object_missing',
fallbackLabel: 'Table or object does not exist',
fallbackExplanation: 'The SQL references a table, view, sequence, or other database object that cannot be found in the current database or schema.',
fallbackSuggestion: 'Check the object name, casing, schema/database prefix, and whether the selected database for this query is correct.',
patterns: [
/relation\s+["'`].+["'`]\s+does not exist/i,
/table\s+.+doesn'?t exist/i,
@@ -38,9 +55,10 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
],
},
{
label: '字段不存在',
explanation: 'SQL 引用了结果集中不存在、拼写不一致或当前表没有的字段。',
suggestion: '检查字段名、别名、大小写、引用表别名,以及字段是否属于当前 FROM/JOIN 的对象。',
key: 'column_missing',
fallbackLabel: 'Column does not exist',
fallbackExplanation: 'The SQL references a column that is not in the result set, is spelled differently, or does not exist on the current table.',
fallbackSuggestion: 'Check column names, aliases, casing, table aliases, and whether the column belongs to the current FROM/JOIN object.',
patterns: [
/column\s+["'`].+["'`]\s+does not exist/i,
/unknown column/i,
@@ -50,9 +68,10 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
],
},
{
label: '唯一约束或主键冲突',
explanation: '插入或更新的数据与唯一索引、主键或唯一约束中的已有数据重复。',
suggestion: '检查重复键值,必要时改为 UPDATE、UPSERT或调整唯一键字段值。',
key: 'unique_conflict',
fallbackLabel: 'Unique constraint or primary key conflict',
fallbackExplanation: 'The inserted or updated data duplicates an existing value in a unique index, primary key, or unique constraint.',
fallbackSuggestion: 'Check the duplicate key value and use UPDATE or UPSERT if appropriate, or adjust the unique-key field value.',
patterns: [
/duplicate key/i,
/duplicate entry/i,
@@ -62,9 +81,10 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
],
},
{
label: '权限不足',
explanation: '当前数据库账号没有执行该 SQL 或访问相关对象的权限。',
suggestion: '确认账号权限、schema 授权、只读连接限制,以及是否需要由管理员授权。',
key: 'permission_denied',
fallbackLabel: 'Insufficient permissions',
fallbackExplanation: 'The current database account does not have permission to execute this SQL or access the related objects.',
fallbackSuggestion: 'Check account privileges, schema grants, read-only connection limits, and whether an administrator needs to grant access.',
patterns: [
/permission denied/i,
/access denied/i,
@@ -74,9 +94,10 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
],
},
{
label: '数据类型或格式不匹配',
explanation: '写入、比较或转换的数据格式不符合目标字段或表达式要求。',
suggestion: '检查日期、数字、布尔值、枚举值、隐式转换和字段类型,必要时显式 CAST。',
key: 'type_mismatch',
fallbackLabel: 'Data type or format mismatch',
fallbackExplanation: 'The value being written, compared, or converted does not match the target column or expression format.',
fallbackSuggestion: 'Check dates, numbers, booleans, enum values, implicit casts, and column types; use an explicit CAST if needed.',
patterns: [
/invalid input syntax/i,
/incorrect\s+.+\s+value/i,
@@ -88,9 +109,10 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
],
},
{
label: '约束校验失败',
explanation: '数据不满足外键、非空、检查约束或引用完整性规则。',
suggestion: '检查关联父表记录、必填字段、CHECK 条件,以及写入顺序是否正确。',
key: 'constraint_failed',
fallbackLabel: 'Constraint validation failed',
fallbackExplanation: 'The data violates a foreign key, non-null, check constraint, or referential integrity rule.',
fallbackSuggestion: 'Check related parent records, required fields, CHECK conditions, and whether the write order is correct.',
patterns: [
/foreign key constraint/i,
/violates foreign key constraint/i,
@@ -101,9 +123,10 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
],
},
{
label: '查询超时或被取消',
explanation: 'SQL 执行时间超过超时限制,或执行过程被手动取消。',
suggestion: '检查 SQL 执行计划、过滤条件和索引,必要时缩小查询范围或调整超时时间。',
key: 'timeout_or_canceled',
fallbackLabel: 'Query timed out or was canceled',
fallbackExplanation: 'The SQL ran longer than the timeout limit, or execution was manually canceled.',
fallbackSuggestion: 'Check the SQL execution plan, filters, and indexes; narrow the query range or adjust the timeout if needed.',
patterns: [
/context deadline exceeded/i,
/statement canceled/i,
@@ -115,9 +138,10 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
],
},
{
label: '数据库连接或认证失败',
explanation: '客户端无法连接数据库,或认证信息、网络、实例状态存在问题。',
suggestion: '检查主机、端口、账号密码、网络连通性、代理/SSH 隧道和数据库服务状态。',
key: 'connection_or_auth',
fallbackLabel: 'Database connection or authentication failed',
fallbackExplanation: 'The client could not connect to the database, or credentials, network, or instance state may be wrong.',
fallbackSuggestion: 'Check host, port, username, password, network reachability, proxy/SSH tunnel, and database service status.',
patterns: [
/password authentication failed/i,
/connection refused/i,
@@ -130,6 +154,17 @@ const SQL_ERROR_RULES: SqlErrorSemanticRule[] = [
},
];
const GENERIC_SQL_ERROR_RULE: SqlErrorSemanticRule = {
key: 'generic',
fallbackLabel: 'Database execution error',
fallbackExplanation: 'The database returned an execution failure, and no more specific error type was matched.',
fallbackSuggestion: 'Continue troubleshooting with the raw error, SQL fragment, and current database dialect.',
patterns: [],
};
const LEGACY_SEMANTIC_PREFIX = '\u4e2d\u6587\u8bed\u4e49\uff1a';
const LEGACY_RAW_PREFIX = '\u539f\u59cb\u9519\u8bef\uff1a';
const normalizeErrorText = (raw: unknown): string => {
if (raw instanceof Error) {
return raw.message || String(raw);
@@ -147,31 +182,93 @@ const normalizeErrorText = (raw: unknown): string => {
}
};
const includesLocalizedKeyword = (
message: string,
keywords: readonly string[],
): boolean => {
const lower = String(message || '').toLowerCase();
return keywords.some((keyword) => lower.includes(keyword.toLowerCase()));
};
export const hasLocalizedSqlTimeoutKeyword = (message: string): boolean =>
includesLocalizedKeyword(message, LOCALIZED_TIMEOUT_KEYWORDS);
const findSqlErrorSemantic = (message: string): SqlErrorSemanticRule | null => {
const text = String(message || '');
return SQL_ERROR_RULES.find((rule) => rule.patterns.some((pattern) => pattern.test(text))) || null;
const matchedRule = SQL_ERROR_RULES.find((rule) => rule.patterns.some((pattern) => pattern.test(text)));
if (matchedRule) {
return matchedRule;
}
if (hasLocalizedSqlTimeoutKeyword(text)) {
return SQL_ERROR_RULES.find((rule) => rule.key === 'timeout_or_canceled') || null;
}
return null;
};
const translateSqlErrorCopy = (
translate: SqlExecutionErrorTranslator | undefined,
key: string,
fallback: string,
params?: Record<string, string | number | boolean | null | undefined>,
): string => {
if (!translate) {
return fallback;
}
const translated = translate(key, params);
return translated && translated !== key ? translated : fallback;
};
const localizeRule = (
rule: SqlErrorSemanticRule,
translate?: SqlExecutionErrorTranslator,
) => {
const baseKey = `query_editor.sql_error.rule.${rule.key}`;
return {
label: translateSqlErrorCopy(translate, `${baseKey}.label`, rule.fallbackLabel),
explanation: translateSqlErrorCopy(translate, `${baseKey}.explanation`, rule.fallbackExplanation),
suggestion: translateSqlErrorCopy(translate, `${baseKey}.suggestion`, rule.fallbackSuggestion),
};
};
export const formatSqlExecutionError = (
raw: unknown,
options: SqlExecutionErrorFormatOptions = {},
): string => {
const rawMessage = normalizeErrorText(raw).trim() || '未知错误';
if (/中文语义:/.test(rawMessage) && /原始错误:/.test(rawMessage)) {
const translate = options.translate;
const rawMessage = normalizeErrorText(raw).trim() || translateSqlErrorCopy(
translate,
'query_editor.sql_error.unknown',
'Unknown error',
);
if (rawMessage.includes(LEGACY_SEMANTIC_PREFIX) && rawMessage.includes(LEGACY_RAW_PREFIX)) {
return rawMessage;
}
const semantic = findSqlErrorSemantic(rawMessage) || {
label: '数据库执行错误',
explanation: '数据库返回了执行失败信息,当前未匹配到更具体的错误类型。',
suggestion: '结合原始错误、SQL 片段和当前数据库方言继续排查。',
};
const semantic = localizeRule(findSqlErrorSemantic(rawMessage) || GENERIC_SQL_ERROR_RULE, translate);
const prefix = String(options.prefix || '').trim();
return [
prefix,
`中文语义:${semantic.label}${semantic.explanation}`,
`处理建议:${semantic.suggestion}`,
`原始错误:${rawMessage}`,
translateSqlErrorCopy(
translate,
'query_editor.sql_error.wrapper.semantic_line',
`Semantic meaning: ${semantic.label}. ${semantic.explanation}`,
{
label: semantic.label,
explanation: semantic.explanation,
},
),
translateSqlErrorCopy(
translate,
'query_editor.sql_error.wrapper.suggestion_line',
`Suggestion: ${semantic.suggestion}`,
{ suggestion: semantic.suggestion },
),
translateSqlErrorCopy(
translate,
'query_editor.sql_error.wrapper.raw_line',
`Raw error: ${rawMessage}`,
{ error: rawMessage },
),
].filter(Boolean).join('\n');
};

View File

@@ -1,3 +1,4 @@
import { readFileSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import {
@@ -51,9 +52,34 @@ describe('sqlFileTabDirty', () => {
})).toBe(false);
});
it('falls back to Traditional Chinese missing-file messages when ReadSQLFile returns message-only failures', () => {
expect(isSQLFileMissingReadResult({
success: false,
message: '\u7121\u6cd5\u8b80\u53d6\u6a94\u6848\u8cc7\u8a0a: \u7cfb\u7d71\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6a94\u6848',
})).toBe(true);
expect(isSQLFileMissingReadResult({
success: false,
message: '\u7121\u6cd5\u8b80\u53d6\u6a94\u6848\u8cc7\u8a0a: permission denied',
})).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);
});
it('keeps raw missing-file Han literals out of production fallback patterns', () => {
const source = readFileSync(new URL('./sqlFileTabDirty.ts', import.meta.url), 'utf8');
[
'\u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6',
'\u6587\u4ef6\u4e0d\u5b58\u5728',
'\u7cfb\u7d71\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6a94\u6848',
'\u6a94\u6848\u4e0d\u5b58\u5728',
].forEach((text) => {
expect(source).not.toContain(text);
});
});
});

View File

@@ -37,8 +37,10 @@ const SQL_FILE_MISSING_MESSAGE_PATTERNS = [
'system cannot find the file specified',
'does not exist',
'not exist',
'系统找不到指定的文件',
'文件不存在',
'\u7cfb\u7edf\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6587\u4ef6',
'\u6587\u4ef6\u4e0d\u5b58\u5728',
'\u7cfb\u7d71\u627e\u4e0d\u5230\u6307\u5b9a\u7684\u6a94\u6848',
'\u6a94\u6848\u4e0d\u5b58\u5728',
];
export const isSQLFileMissingErrorMessage = (message: unknown): boolean => {

View File

@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { DEFAULT_SQL_SNIPPETS, BUILTIN_SNIPPET_MAP } from './sqlSnippetDefaults';
import { readFileSync } from 'node:fs';
import { DEFAULT_SQL_SNIPPETS, BUILTIN_SNIPPET_MAP, createDefaultSqlSnippets } from './sqlSnippetDefaults';
import type { SqlSnippet } from '../types';
describe('sqlSnippetDefaults', () => {
@@ -70,4 +71,27 @@ describe('sqlSnippetDefaults', () => {
expect(mapped).not.toBe(s);
}
});
it('localizes built-in snippet metadata while keeping SQL bodies raw', () => {
const snippets = createDefaultSqlSnippets((key) => `T(${key})`);
const first = snippets.find((s) => s.id === 'builtin-sel');
expect(first?.name).toBe('T(sql_snippets.builtin.sel.name)');
expect(first?.description).toBe('T(sql_snippets.builtin.sel.description)');
expect(first?.body).toBe('SELECT ${1:column_list} FROM ${2:table_name}$0;');
});
it('keeps built-in snippet Chinese metadata out of source defaults', () => {
const source = readFileSync(new URL('./sqlSnippetDefaults.ts', import.meta.url), 'utf8');
[
'SELECT 基本查询',
'基本 SELECT 查询模板',
'子查询',
'按日期条件过滤的 SELECT 查询',
'INSERT 模板,自动填入当前时间戳',
].forEach((literal) => {
expect(source).not.toContain(literal);
});
});
});

View File

@@ -1,152 +1,154 @@
import type { SqlSnippet } from "../types";
import { t } from "../i18n";
const builtinSnippets: Omit<SqlSnippet, "createdAt">[] = [
type SqlSnippetTranslator = (key: string) => string;
type BuiltinSnippetDefinition = {
id: string;
prefix: string;
nameKey: string;
descriptionKey: string;
body: string;
};
const builtinSnippetDefinitions: BuiltinSnippetDefinition[] = [
{
id: "builtin-sel",
prefix: "sel",
name: "SELECT 基本查询",
description: "基本 SELECT 查询模板",
nameKey: "sql_snippets.builtin.sel.name",
descriptionKey: "sql_snippets.builtin.sel.description",
body: "SELECT ${1:column_list} FROM ${2:table_name}$0;",
isBuiltin: true,
},
{
id: "builtin-selw",
prefix: "selw",
name: "SELECT WHERE",
description: "带 WHERE 条件的 SELECT 查询",
nameKey: "sql_snippets.builtin.selw.name",
descriptionKey: "sql_snippets.builtin.selw.description",
body: "SELECT ${1:columns} FROM ${2:table_name} WHERE ${3:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-selj",
prefix: "selj",
name: "SELECT JOIN",
description: "带 INNER JOIN 的 SELECT 查询",
nameKey: "sql_snippets.builtin.selj.name",
descriptionKey: "sql_snippets.builtin.selj.description",
body: "SELECT ${1:columns}\nFROM ${2:t1}\nINNER JOIN ${3:t2} ON ${4:t1.id} = ${5:t2.id}\nWHERE ${6:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-ins",
prefix: "ins",
name: "INSERT",
description: "INSERT 插入数据模板",
nameKey: "sql_snippets.builtin.ins.name",
descriptionKey: "sql_snippets.builtin.ins.description",
body: "INSERT INTO ${1:table_name} (${2:columns})\nVALUES (${3:values})$0;",
isBuiltin: true,
},
{
id: "builtin-upd",
prefix: "upd",
name: "UPDATE",
description: "UPDATE 更新数据模板",
nameKey: "sql_snippets.builtin.upd.name",
descriptionKey: "sql_snippets.builtin.upd.description",
body: "UPDATE ${1:table_name}\nSET ${2:column} = ${3:value}\nWHERE ${4:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-del",
prefix: "del",
name: "DELETE",
description: "DELETE 删除数据模板",
nameKey: "sql_snippets.builtin.del.name",
descriptionKey: "sql_snippets.builtin.del.description",
body: "DELETE FROM ${1:table_name}\nWHERE ${2:condition}$0;",
isBuiltin: true,
},
{
id: "builtin-ct",
prefix: "ct",
name: "CREATE TABLE",
description: "CREATE TABLE 建表模板",
nameKey: "sql_snippets.builtin.ct.name",
descriptionKey: "sql_snippets.builtin.ct.description",
body: "CREATE TABLE ${1:table_name} (\n ${2:id} INT PRIMARY KEY AUTO_INCREMENT,\n ${3:col} ${4:VARCHAR(255)} NOT NULL\n)$0;",
isBuiltin: true,
},
{
id: "builtin-alt",
prefix: "alt",
name: "ALTER TABLE",
description: "ALTER TABLE 添加列模板",
nameKey: "sql_snippets.builtin.alt.name",
descriptionKey: "sql_snippets.builtin.alt.description",
body: "ALTER TABLE ${1:table_name}\nADD COLUMN ${2:col} ${3:VARCHAR(255)}$0;",
isBuiltin: true,
},
{
id: "builtin-dro",
prefix: "dro",
name: "DROP TABLE",
description: "DROP TABLE 删表模板",
nameKey: "sql_snippets.builtin.dro.name",
descriptionKey: "sql_snippets.builtin.dro.description",
body: "DROP TABLE IF EXISTS ${1:table_name}$0;",
isBuiltin: true,
},
{
id: "builtin-grp",
prefix: "grp",
name: "GROUP BY",
description: "带 GROUP BY 的聚合查询模板",
nameKey: "sql_snippets.builtin.grp.name",
descriptionKey: "sql_snippets.builtin.grp.description",
body: "SELECT ${1:col}, COUNT(*)\nFROM ${2:table_name}\nGROUP BY ${1:col}$0;",
isBuiltin: true,
},
{
id: "builtin-ljo",
prefix: "ljo",
name: "LEFT JOIN",
description: "LEFT JOIN 左连接模板",
nameKey: "sql_snippets.builtin.ljo.name",
descriptionKey: "sql_snippets.builtin.ljo.description",
body: "LEFT JOIN ${1:t} ON ${2:left.col} = ${3:right.col}$0",
isBuiltin: true,
},
{
id: "builtin-sub",
prefix: "sub",
name: "子查询",
description: "IN 子查询模板",
nameKey: "sql_snippets.builtin.sub.name",
descriptionKey: "sql_snippets.builtin.sub.description",
body: "SELECT ${1:cols}\nFROM ${2:t1}\nWHERE ${3:col} IN (\n SELECT ${4:col} FROM ${5:t2} WHERE ${6:cond}\n)$0;",
isBuiltin: true,
},
{
id: "builtin-lim",
prefix: "lim",
name: "LIMIT 查询",
description: "带 LIMIT 的分页查询模板",
nameKey: "sql_snippets.builtin.lim.name",
descriptionKey: "sql_snippets.builtin.lim.description",
body: "SELECT ${1:cols} FROM ${2:table_name} LIMIT ${3:10}$0;",
isBuiltin: true,
},
{
id: "builtin-ord",
prefix: "ord",
name: "ORDER BY",
description: "带排序的查询模板",
nameKey: "sql_snippets.builtin.ord.name",
descriptionKey: "sql_snippets.builtin.ord.description",
body: "SELECT ${1:cols} FROM ${2:table_name} ORDER BY ${3:col} ${4|ASC,DESC|}$0;",
isBuiltin: true,
},
{
id: "builtin-seld",
prefix: "seld",
name: "SELECT 按日期查询",
description: "按日期条件过滤的 SELECT 查询,自动填入当天日期",
nameKey: "sql_snippets.builtin.seld.name",
descriptionKey: "sql_snippets.builtin.seld.description",
body: "SELECT ${1:cols} FROM ${2:table_name}\nWHERE ${3:date_col} >= '${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE}'$0;",
isBuiltin: true,
},
{
id: "builtin-ctt",
prefix: "ctt",
name: "CREATE TABLE含时间列",
description: "建表模板,含 created_at / updated_at 时间列",
nameKey: "sql_snippets.builtin.ctt.name",
descriptionKey: "sql_snippets.builtin.ctt.description",
body: "CREATE TABLE ${1:table_name} (\n ${2:id} INT PRIMARY KEY AUTO_INCREMENT,\n ${3:col} ${4:VARCHAR(255)},\n created_at DATETIME DEFAULT CURRENT_TIMESTAMP,\n updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP\n)$0;",
isBuiltin: true,
},
{
id: "builtin-inst",
prefix: "inst",
name: "INSERT含时间戳",
description: "INSERT 模板,自动填入当前时间戳",
nameKey: "sql_snippets.builtin.inst.name",
descriptionKey: "sql_snippets.builtin.inst.description",
body: "INSERT INTO ${1:table_name} (${2:columns}, created_at)\nVALUES (${3:values}, '${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} ${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND}')$0;",
isBuiltin: true,
},
];
const now = Date.now();
export const createDefaultSqlSnippets = (
translate: SqlSnippetTranslator = t,
): SqlSnippet[] => {
const now = Date.now();
return builtinSnippetDefinitions.map((snippet, index) => ({
id: snippet.id,
prefix: snippet.prefix,
name: translate(snippet.nameKey),
description: translate(snippet.descriptionKey),
body: snippet.body,
isBuiltin: true,
createdAt: now + index,
}));
};
export const DEFAULT_SQL_SNIPPETS: SqlSnippet[] = builtinSnippets.map(
(s, i) => ({
...s,
createdAt: now + i,
})
);
export const DEFAULT_SQL_SNIPPETS: SqlSnippet[] = createDefaultSqlSnippets();
export const BUILTIN_SNIPPET_MAP: Record<string, SqlSnippet> = {};
for (const s of DEFAULT_SQL_SNIPPETS) {

View File

@@ -4,16 +4,20 @@ import { getConnectionWorkbenchState } from './startupReadiness';
describe('startup readiness helpers', () => {
it('blocks sidebar interactions before local store hydration completes', () => {
expect(getConnectionWorkbenchState(false, false)).toEqual({
const translate = (key: string) => `T(${key})`;
expect(getConnectionWorkbenchState(false, false, translate)).toEqual({
ready: false,
message: '正在加载本地配置...',
message: 'T(app.startup_readiness.loading_local_config)',
});
});
it('keeps sidebar blocked until secure config bootstrap finishes', () => {
expect(getConnectionWorkbenchState(true, false)).toEqual({
const translate = (key: string) => `T(${key})`;
expect(getConnectionWorkbenchState(true, false, translate)).toEqual({
ready: false,
message: '正在加载安全配置...',
message: 'T(app.startup_readiness.loading_security_config)',
});
});

View File

@@ -1,22 +1,27 @@
import { t as translateCatalog } from '../i18n';
export interface ConnectionWorkbenchState {
ready: boolean;
message: string;
}
type StartupReadinessTranslator = (key: string) => string;
export function getConnectionWorkbenchState(
isStoreHydrated: boolean,
hasAppliedInitialGlobalProxy: boolean
hasAppliedInitialGlobalProxy: boolean,
translate: StartupReadinessTranslator = translateCatalog,
): ConnectionWorkbenchState {
if (!isStoreHydrated) {
return {
ready: false,
message: '正在加载本地配置...',
message: translate('app.startup_readiness.loading_local_config'),
};
}
if (!hasAppliedInitialGlobalProxy) {
return {
ready: false,
message: '正在加载安全配置...',
message: translate('app.startup_readiness.loading_security_config'),
};
}
return {

View File

@@ -12,6 +12,13 @@ import {
stripSchemaFromTabObjectLabel,
} from './tabDisplay';
const keyEchoTranslate = (key: string, params?: Record<string, unknown>): string => {
if (key === 'sidebar.tab.new_query') return 'T(New query)';
if (key === 'sidebar.tab.redis_command') return `T(Command ${params?.database})`;
if (key === 'sidebar.tab.redis_monitor') return `T(Monitor ${params?.database})`;
return key;
};
const redisConnection: SavedConnection = {
id: 'redis-1',
name: '订单缓存',
@@ -58,8 +65,28 @@ describe('tabDisplay', () => {
redisDB: 1,
};
expect(buildTabDisplayTitle(commandTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] 命令 - db1');
expect(buildTabDisplayTitle(monitorTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] 监控 - db1');
expect(buildTabDisplayTitle(commandTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] Command - db1');
expect(buildTabDisplayTitle(monitorTab, redisConnection)).toBe('[订单缓存 | 10.10.0.12 +2] Monitor - db1');
});
it('localizes redis command and monitor fallback titles while keeping db labels raw', () => {
const commandTab: TabData = {
id: 'cmd-1',
title: '命令 - db1',
type: 'redis-command',
connectionId: 'redis-1',
redisDB: 1,
};
const monitorTab: TabData = {
id: 'monitor-1',
title: '监控: 订单缓存',
type: 'redis-monitor',
connectionId: 'redis-1',
redisDB: 1,
};
expect(buildTabDisplayTitle(commandTab, redisConnection, undefined, keyEchoTranslate)).toBe('[订单缓存 | 10.10.0.12 +2] T(Command db1)');
expect(buildTabDisplayTitle(monitorTab, redisConnection, undefined, keyEchoTranslate)).toBe('[订单缓存 | 10.10.0.12 +2] T(Monitor db1)');
});
it('keeps table tabs on the existing prefix strategy', () => {
@@ -222,7 +249,24 @@ describe('tabDisplay', () => {
const model = buildTabDisplayModel(queryTab, connection);
expect(model.primaryText).toBe('[开发240] SQL 新建查询');
expect(model.primaryText).toBe('[开发240] SQL New query');
expect(model.fullTitle).not.toContain('fs_org_auth_application');
expect(model.fullTitle).not.toContain('select *');
});
it('localizes query tab fallback object labels without translating raw SQL', () => {
const queryTab: TabData = {
id: 'query-1',
title: 'select * from fs_org_auth_application where application_id is not null;',
type: 'query',
connectionId: 'mysql-1',
dbName: 'front_end_sys',
query: 'select * from fs_org_auth_application where application_id is not null;',
};
const model = buildTabDisplayModel(queryTab, undefined, undefined, keyEchoTranslate);
expect(model.primaryText).toBe('SQL T(New query)');
expect(model.fullTitle).not.toContain('fs_org_auth_application');
expect(model.fullTitle).not.toContain('select *');
});

View File

@@ -1,4 +1,6 @@
import type { ConnectionConfig, SavedConnection, TabData } from '../types';
import { t as catalogTranslate } from '../i18n/catalog';
import type { I18nParams } from '../i18n/types';
export const TAB_DISPLAY_ELEMENT_KEYS = ['connection', 'kind', 'object', 'database', 'schema', 'host'] as const;
@@ -18,6 +20,10 @@ export interface TabDisplaySettings {
double?: TabDisplayLayoutSnapshot;
}
export type TabDisplayTranslate = (key: string, params?: I18nParams) => string;
const defaultTranslate: TabDisplayTranslate = (key, params) => catalogTranslate('en-US', key, params);
export const TAB_DISPLAY_SECONDARY_DEFAULT_KEYS: TabDisplayElementKey[] = ['connection', 'database', 'schema', 'host'];
export const TAB_DISPLAY_ELEMENT_META: Record<TabDisplayElementKey, { labelKey: string; descriptionKey: string }> = {
@@ -259,10 +265,10 @@ const isRedisTab = (tab: TabData): boolean => {
return tab.type === 'redis-keys' || tab.type === 'redis-command' || tab.type === 'redis-monitor';
};
const buildRedisBaseTitle = (tab: TabData): string => {
const buildRedisBaseTitle = (tab: TabData, translate: TabDisplayTranslate = defaultTranslate): string => {
const dbLabel = `db${tab.redisDB ?? 0}`;
if (tab.type === 'redis-command') return `命令 - ${dbLabel}`;
if (tab.type === 'redis-monitor') return `监控 - ${dbLabel}`;
if (tab.type === 'redis-command') return translate('sidebar.tab.redis_command', { database: dbLabel });
if (tab.type === 'redis-monitor') return translate('sidebar.tab.redis_monitor', { database: dbLabel });
return dbLabel;
};
@@ -399,7 +405,6 @@ const stripSchemaFromTableOverviewTitle = (title: string): string => {
return rawTitle.replace(/\s+\([^()]+\)\s*$/, '').trim() || rawTitle;
};
const QUERY_TAB_FALLBACK_TITLE = '新建查询';
const QUERY_TAB_TITLE_MAX_LENGTH = 28;
const getFileNameFromPath = (value: string): string => (
@@ -413,23 +418,23 @@ const isLikelyRawSqlTitle = (value: string): boolean => {
return /^(select|with|insert|update|delete|merge|create|alter|drop|truncate|explain|show|desc|describe)\b/i.test(text);
};
const compactQueryTabTitle = (tab: TabData): string => {
const compactQueryTabTitle = (tab: TabData, translate: TabDisplayTranslate = defaultTranslate): string => {
const filePath = String(tab.filePath || '').trim();
if (filePath) {
return getFileNameFromPath(filePath);
}
const rawTitle = String(tab.title || '').trim();
const title = rawTitle && !isLikelyRawSqlTitle(rawTitle) ? rawTitle : QUERY_TAB_FALLBACK_TITLE;
const title = rawTitle && !isLikelyRawSqlTitle(rawTitle) ? rawTitle : translate('sidebar.tab.new_query');
if (title.length <= QUERY_TAB_TITLE_MAX_LENGTH) {
return title;
}
return `${title.slice(0, QUERY_TAB_TITLE_MAX_LENGTH - 3)}...`;
};
const buildCompactObjectTabTitle = (tab: TabData): string => {
const buildCompactObjectTabTitle = (tab: TabData, translate: TabDisplayTranslate = defaultTranslate): string => {
if (tab.type === 'query') {
return compactQueryTabTitle(tab);
return compactQueryTabTitle(tab, translate);
}
if (tab.type === 'table') {
return stripSchemaFromTabObjectLabel(tab.tableName || tab.title) || tab.title;
@@ -469,8 +474,8 @@ export const getTabDisplayKindLabel = (tab: TabData): string => {
return 'TAB';
};
const getTabRawObjectLabel = (tab: TabData): string => {
if (tab.type === 'query') return compactQueryTabTitle(tab);
const getTabRawObjectLabel = (tab: TabData, translate: TabDisplayTranslate = defaultTranslate): string => {
if (tab.type === 'query') return compactQueryTabTitle(tab, translate);
if (tab.tableName) return tab.tableName;
if (tab.viewName) return tab.viewName;
if (tab.eventName) return tab.eventName;
@@ -491,8 +496,9 @@ const getTabDisplayElementValue = (
key: TabDisplayElementKey,
tab: TabData,
connection?: SavedConnection,
translate: TabDisplayTranslate = defaultTranslate,
): string => {
const rawObjectLabel = getTabRawObjectLabel(tab);
const rawObjectLabel = getTabRawObjectLabel(tab, translate);
switch (key) {
case 'connection':
return getTabConnectionLabel(connection);
@@ -502,7 +508,7 @@ const getTabDisplayElementValue = (
return buildCompactObjectTabTitle({
...tab,
title: tab.type === 'table' || tab.type === 'query' ? rawObjectLabel : tab.title,
});
}, translate);
case 'database':
return String(tab.dbName || '').trim();
case 'schema':
@@ -540,9 +546,10 @@ const buildTabDisplayParts = (
keys: TabDisplayElementKey[],
tab: TabData,
connection?: SavedConnection,
translate: TabDisplayTranslate = defaultTranslate,
): TabDisplayPart[] => keys
.map((key) => {
const value = getTabDisplayElementValue(key, tab, connection);
const value = getTabDisplayElementValue(key, tab, connection, translate);
return {
key,
value,
@@ -555,11 +562,12 @@ export const buildTabDisplayModel = (
tab: TabData,
connection?: SavedConnection,
settings?: Partial<TabDisplaySettings> | null,
translate: TabDisplayTranslate = defaultTranslate,
): TabDisplayModel => {
const sanitized = sanitizeTabDisplaySettings(settings);
const primaryParts = buildTabDisplayParts(sanitized.primaryElements, tab, connection);
const secondaryParts = buildTabDisplayParts(sanitized.secondaryElements, tab, connection);
const primaryText = primaryParts.map((part) => part.text).join(' ').trim() || buildCompactObjectTabTitle(tab);
const primaryParts = buildTabDisplayParts(sanitized.primaryElements, tab, connection, translate);
const secondaryParts = buildTabDisplayParts(sanitized.secondaryElements, tab, connection, translate);
const primaryText = primaryParts.map((part) => part.text).join(' ').trim() || buildCompactObjectTabTitle(tab, translate);
const secondaryText = secondaryParts.map((part) => part.text).join('·').trim();
const fullTitle = [primaryText, secondaryText].filter(Boolean).join(' · ');
return {
@@ -576,9 +584,10 @@ export const buildTabDisplayTitle = (
tab: TabData,
connection?: SavedConnection,
settings?: Partial<TabDisplaySettings> | null,
translate: TabDisplayTranslate = defaultTranslate,
): string => {
if (settings) {
return buildTabDisplayModel(tab, connection, settings).fullTitle;
return buildTabDisplayModel(tab, connection, settings, translate).fullTitle;
}
const connectionName = String(connection?.name || '').trim();
@@ -586,10 +595,10 @@ export const buildTabDisplayTitle = (
if (isRedisTab(tab)) {
const hostSummary = resolveConnectionHostSummary(connection?.config);
const identity = [connectionName, hostSummary].filter(Boolean).join(' | ');
return identity ? `[${identity}] ${buildRedisBaseTitle(tab)}` : buildRedisBaseTitle(tab);
return identity ? `[${identity}] ${buildRedisBaseTitle(tab, translate)}` : buildRedisBaseTitle(tab, translate);
}
const baseTitle = buildCompactObjectTabTitle(tab);
const baseTitle = buildCompactObjectTabTitle(tab, translate);
if (tab.type !== 'table' && tab.type !== 'design' && tab.type !== 'table-overview') {
return baseTitle;
}

View File

@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest';
import { readFileSync } from 'node:fs';
import { buildEditableTriggerSql } from './triggerEditSql';
@@ -10,10 +11,51 @@ describe('triggerEditSql', () => {
{ dropSql: 'DROP TRIGGER IF EXISTS `bit_check`' },
);
expect(sql).toContain('-- 修改触发器: bit_check');
expect(sql).toContain('表设计修改会先删除原触发器,再创建新触发器');
expect(sql).toContain('-- Edit trigger: bit_check');
expect(sql).toContain('The table design change will drop the original trigger before creating a new one');
expect(sql).toContain('DROP TRIGGER IF EXISTS `bit_check`;');
expect(sql).toContain('CREATE TRIGGER `bit_check`');
expect(sql.trim().endsWith(';')).toBe(true);
});
it('localizes editable trigger SQL comments while keeping SQL and names raw', () => {
const translate = (key: string, params?: Record<string, unknown>): string => {
const values: Record<string, string> = {
'trigger_viewer.edit_sql.header': 'Edit trigger: {{name}}',
'trigger_viewer.edit_sql.replace_hint': 'The original trigger will be dropped before recreating it.',
'trigger_viewer.edit_sql.compatibility_hint': 'Review compatibility with the current database before running.',
'trigger_viewer.edit_sql.empty_definition': 'The trigger definition is empty. Complete the CREATE TRIGGER statement before running.',
'trigger_viewer.edit_sql.fragment_definition': 'Only a trigger definition fragment was returned. Complete the CREATE TRIGGER statement before running.',
};
return (values[key] || key).replace(/\{\{(\w+)\}\}/g, (_, name) => String(params?.[name] ?? ''));
};
const sql = buildEditableTriggerSql(
'bit_check',
'BEGIN\n SET NEW.flag = 1;\nEND',
{ dropSql: 'DROP TRIGGER IF EXISTS `bit_check`', translate },
);
expect(sql).toContain('-- Edit trigger: bit_check');
expect(sql).toContain('-- The original trigger will be dropped before recreating it.');
expect(sql).toContain('-- Only a trigger definition fragment was returned. Complete the CREATE TRIGGER statement before running.');
expect(sql).toContain('DROP TRIGGER IF EXISTS `bit_check`;');
expect(sql).toContain('bit_check');
expect(sql).toContain('CREATE TRIGGER');
expect(sql).not.toContain('修改触发器');
expect(sql).not.toContain('请补全 CREATE TRIGGER 语句');
});
it('keeps editable trigger SQL comment copy in catalogs instead of source literals', () => {
const source = readFileSync(new URL('./triggerEditSql.ts', import.meta.url), 'utf8');
expect(source).toContain('trigger_viewer.edit_sql.header');
expect(source).toContain('trigger_viewer.edit_sql.replace_hint');
expect(source).toContain('trigger_viewer.edit_sql.compatibility_hint');
expect(source).toContain('trigger_viewer.edit_sql.empty_definition');
expect(source).toContain('trigger_viewer.edit_sql.fragment_definition');
expect(source).not.toContain('修改触发器');
expect(source).not.toContain('请确认语法兼容当前数据库后执行');
expect(source).not.toContain('请补全 CREATE TRIGGER 语句后执行');
});
});

View File

@@ -1,3 +1,21 @@
import { t as translateCatalog, type I18nParams } from '../i18n';
type TriggerEditSqlTranslator = (key: string, params?: I18nParams) => string;
type TriggerEditSqlOptions = {
dropSql?: string;
translate?: TriggerEditSqlTranslator;
};
const translateTriggerEditCopy = (
translate: TriggerEditSqlTranslator | undefined,
key: string,
params?: I18nParams,
): string => {
const resolved = (translate || translateCatalog)(key, params);
return resolved && resolved !== key ? resolved : key;
};
export const ensureSqlStatementTerminator = (sql: string): string => {
const normalized = String(sql || '').trim();
if (!normalized) return '';
@@ -6,23 +24,27 @@ export const ensureSqlStatementTerminator = (sql: string): string => {
const buildTriggerEditHeader = (
triggerName: string,
options?: { dropSql?: string },
options?: TriggerEditSqlOptions,
): string => {
const normalizedName = String(triggerName || '').trim();
const hint = String(options?.dropSql || '').trim()
? '表设计修改会先删除原触发器,再创建新触发器,请确认后执行'
: '请确认语法兼容当前数据库后执行';
return `-- 修改触发器: ${normalizedName}\n-- ${hint}\n`;
? translateTriggerEditCopy(options?.translate, 'trigger_viewer.edit_sql.replace_hint')
: translateTriggerEditCopy(options?.translate, 'trigger_viewer.edit_sql.compatibility_hint');
const title = translateTriggerEditCopy(options?.translate, 'trigger_viewer.edit_sql.header', {
name: normalizedName,
});
return `-- ${title}\n-- ${hint}\n`;
};
const normalizeEditableTriggerDefinition = (
triggerName: string,
triggerDefinition: string,
translate?: TriggerEditSqlTranslator,
): string => {
const normalizedName = String(triggerName || '').trim();
const normalizedDefinition = String(triggerDefinition || '').trim();
if (!normalizedDefinition) {
return '-- 当前触发器定义为空,请补全 CREATE TRIGGER 语句后执行';
return `-- ${translateTriggerEditCopy(translate, 'trigger_viewer.edit_sql.empty_definition')}`;
}
if (/^\s*create\s+(?:or\s+replace\s+)?trigger\b/i.test(normalizedDefinition)) {
return ensureSqlStatementTerminator(normalizedDefinition);
@@ -35,17 +57,17 @@ const normalizeEditableTriggerDefinition = (
if (/^\s*(?:before|after|instead\s+of)\b/i.test(normalizedDefinition)) {
return ensureSqlStatementTerminator(`CREATE OR REPLACE TRIGGER ${normalizedName}\n${normalizedDefinition}`);
}
return `-- 当前数据源仅返回触发器定义片段,请补全 CREATE TRIGGER 语句后执行\n${ensureSqlStatementTerminator(normalizedDefinition)}`;
return `-- ${translateTriggerEditCopy(translate, 'trigger_viewer.edit_sql.fragment_definition')}\n${ensureSqlStatementTerminator(normalizedDefinition)}`;
};
export const buildEditableTriggerSql = (
triggerName: string,
triggerDefinition: string,
options?: { dropSql?: string },
options?: TriggerEditSqlOptions,
): string => {
const header = buildTriggerEditHeader(triggerName, options);
const dropSql = String(options?.dropSql || '').trim();
const createSql = normalizeEditableTriggerDefinition(triggerName, triggerDefinition);
const createSql = normalizeEditableTriggerDefinition(triggerName, triggerDefinition, options?.translate);
if (!dropSql) {
return `${header}${createSql}`;
}