feat(grokbuild): add first-class Grok Build support (#5453)

* feat(grokbuild): add backend integration

* feat(grokbuild): add provider and management UI

* test(grokbuild): cover configuration and integrations

* fix(grokbuild): address backend review feedback

* fix(grokbuild): complete UI review feedback

* feat(grokbuild): add CLI lifecycle management

* fix(grokbuild): align provider icon fallback

* test(grokbuild): cover provider icon persistence
This commit is contained in:
Thefool
2026-07-17 15:50:50 +08:00
committed by GitHub
parent f6e37ed994
commit 1c0ee0c58a
112 changed files with 4530 additions and 246 deletions
+8 -5
View File
@@ -13,7 +13,7 @@ impl Database {
pub fn get_all_mcp_servers(&self) -> Result<IndexMap<String, McpServer>, AppError> {
let conn = lock_conn!(self.conn);
let mut stmt = conn.prepare(
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
"SELECT id, name, server_config, description, homepage, docs, tags, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
FROM mcp_servers
ORDER BY name ASC, id ASC"
).map_err(|e| AppError::Database(e.to_string()))?;
@@ -30,8 +30,9 @@ impl Database {
let enabled_claude: bool = row.get(7)?;
let enabled_codex: bool = row.get(8)?;
let enabled_gemini: bool = row.get(9)?;
let enabled_opencode: bool = row.get(10)?;
let enabled_hermes: bool = row.get(11)?;
let enabled_grokbuild: bool = row.get(10)?;
let enabled_opencode: bool = row.get(11)?;
let enabled_hermes: bool = row.get(12)?;
let server = serde_json::from_str(&server_config_str).unwrap_or_default();
let tags = serde_json::from_str(&tags_str).unwrap_or_default();
@@ -46,6 +47,7 @@ impl Database {
claude: enabled_claude,
codex: enabled_codex,
gemini: enabled_gemini,
grokbuild: enabled_grokbuild,
opencode: enabled_opencode,
hermes: enabled_hermes,
},
@@ -72,8 +74,8 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO mcp_servers (
id, name, server_config, description, homepage, docs, tags,
enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
params![
server.id,
server.name,
@@ -88,6 +90,7 @@ impl Database {
server.apps.claude,
server.apps.codex,
server.apps.gemini,
server.apps.grokbuild,
server.apps.opencode,
server.apps.hermes,
],
+13
View File
@@ -328,6 +328,7 @@ impl Database {
"claude" => (6, 90, 180, 8, 3, 90, 0.7, 15),
"codex" => (3, 60, 120, 4, 2, 60, 0.6, 10),
"gemini" => (5, 60, 120, 4, 2, 60, 0.6, 10),
"grokbuild" => (3, 60, 120, 4, 2, 60, 0.6, 10),
_ => (3, 60, 120, 4, 2, 60, 0.6, 10), // 默认值
};
@@ -398,6 +399,18 @@ impl Database {
)
.map_err(|e| AppError::Database(e.to_string()))?;
// grokbuild: Responses protocol, same timeout defaults as Codex.
conn.execute(
"INSERT OR IGNORE INTO proxy_config (
app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests
) VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
+21 -18
View File
@@ -22,8 +22,8 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
enabled_hermes, installed_at, content_hash, updated_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
FROM skills ORDER BY name ASC",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -43,12 +43,13 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
},
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
})
})
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -67,8 +68,8 @@ impl Database {
let mut stmt = conn
.prepare(
"SELECT id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode,
enabled_hermes, installed_at, content_hash, updated_at
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild,
enabled_opencode, enabled_hermes, installed_at, content_hash, updated_at
FROM skills WHERE id = ?1",
)
.map_err(|e| AppError::Database(e.to_string()))?;
@@ -87,12 +88,13 @@ impl Database {
claude: row.get(8)?,
codex: row.get(9)?,
gemini: row.get(10)?,
opencode: row.get(11)?,
hermes: row.get(12)?,
grokbuild: row.get(11)?,
opencode: row.get(12)?,
hermes: row.get(13)?,
},
installed_at: row.get(13)?,
content_hash: row.get(14)?,
updated_at: row.get::<_, i64>(15).unwrap_or(0),
installed_at: row.get(14)?,
content_hash: row.get(15)?,
updated_at: row.get::<_, i64>(16).unwrap_or(0),
})
});
@@ -109,9 +111,9 @@ impl Database {
conn.execute(
"INSERT OR REPLACE INTO skills
(id, name, description, directory, repo_owner, repo_name, repo_branch,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_opencode, enabled_hermes,
readme_url, enabled_claude, enabled_codex, enabled_gemini, enabled_grokbuild, enabled_opencode, enabled_hermes,
installed_at, content_hash, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16)",
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17)",
params![
skill.id,
skill.name,
@@ -124,6 +126,7 @@ impl Database {
skill.apps.claude,
skill.apps.codex,
skill.apps.gemini,
skill.apps.grokbuild,
skill.apps.opencode,
skill.apps.hermes,
skill.installed_at,
@@ -157,8 +160,8 @@ impl Database {
let conn = lock_conn!(self.conn);
let affected = conn
.execute(
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_opencode = ?4, enabled_hermes = ?5 WHERE id = ?6",
params![apps.claude, apps.codex, apps.gemini, apps.opencode, apps.hermes, id],
"UPDATE skills SET enabled_claude = ?1, enabled_codex = ?2, enabled_gemini = ?3, enabled_grokbuild = ?4, enabled_opencode = ?5, enabled_hermes = ?6 WHERE id = ?7",
params![apps.claude, apps.codex, apps.gemini, apps.grokbuild, apps.opencode, apps.hermes, id],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(affected > 0)
+1 -1
View File
@@ -52,7 +52,7 @@ use std::sync::Mutex;
/// 当前 Schema 版本号
/// 每次修改表结构时递增,并在 schema.rs 中添加相应的迁移逻辑
pub(crate) const SCHEMA_VERSION: i32 = 13;
pub(crate) const SCHEMA_VERSION: i32 = 15;
/// 安全地序列化 JSON,避免 unwrap panic
pub(crate) fn to_json_string<T: Serialize>(value: &T) -> Result<String, AppError> {
+238 -4
View File
@@ -65,7 +65,8 @@ impl Database {
id TEXT PRIMARY KEY, name TEXT NOT NULL, server_config TEXT NOT NULL,
description TEXT, homepage TEXT, docs TEXT, tags TEXT NOT NULL DEFAULT '[]',
enabled_claude BOOLEAN NOT NULL DEFAULT 0, enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0, enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes BOOLEAN NOT NULL DEFAULT 0
)",
[],
@@ -93,6 +94,7 @@ impl Database {
enabled_claude BOOLEAN NOT NULL DEFAULT 0,
enabled_codex BOOLEAN NOT NULL DEFAULT 0,
enabled_gemini BOOLEAN NOT NULL DEFAULT 0,
enabled_grokbuild BOOLEAN NOT NULL DEFAULT 0,
enabled_opencode BOOLEAN NOT NULL DEFAULT 0,
enabled_hermes BOOLEAN NOT NULL DEFAULT 0,
installed_at INTEGER NOT NULL DEFAULT 0,
@@ -122,7 +124,7 @@ impl Database {
// 8. Proxy Config 表(三行结构,app_type 主键)
conn.execute("CREATE TABLE IF NOT EXISTS proxy_config (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
@@ -169,6 +171,15 @@ impl Database {
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests)
VALUES ('grokbuild', 3, 60, 120, 600, 4, 2, 60, 0.6, 10)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
}
// 9. Provider Health 表
@@ -485,6 +496,16 @@ impl Database {
Self::migrate_v12_to_v13(conn)?;
Self::set_user_version(conn, 13)?;
}
13 => {
log::info!("迁移数据库从 v13 到 v14(添加 Grok Build 代理配置)");
Self::migrate_v13_to_v14(conn)?;
Self::set_user_version(conn, 14)?;
}
14 => {
log::info!("迁移数据库从 v14 到 v15Skills/MCP 添加 Grok Build 支持)");
Self::migrate_v14_to_v15(conn)?;
Self::set_user_version(conn, 15)?;
}
_ => {
return Err(AppError::Database(format!(
"未知的数据库版本 {version},无法迁移到 {SCHEMA_VERSION}"
@@ -798,12 +819,25 @@ impl Database {
old_cb.3,
old_cb.4,
),
(
"grokbuild",
false,
false,
3,
old_config.4,
old_config.5,
old_cb.0,
old_cb.1,
old_cb.2,
old_cb.3,
old_cb.4,
),
];
// 创建新表
conn.execute("DROP TABLE IF EXISTS proxy_config_new", [])?;
conn.execute("CREATE TABLE proxy_config_new (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini')),
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0, listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721, enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0, auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
@@ -814,6 +848,7 @@ impl Database {
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
live_takeover_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')), updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)", [])?;
@@ -1354,6 +1389,127 @@ impl Database {
Ok(())
}
/// v13 -> v14: allow Grok Build to own an independent proxy configuration row.
fn migrate_v13_to_v14(conn: &Connection) -> Result<(), AppError> {
if !Self::table_exists(conn, "proxy_config")? {
return Ok(());
}
conn.execute("DROP TABLE IF EXISTS proxy_config_v14", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"CREATE TABLE proxy_config_v14 (
app_type TEXT PRIMARY KEY CHECK (app_type IN ('claude','codex','gemini','grokbuild')),
proxy_enabled INTEGER NOT NULL DEFAULT 0,
listen_address TEXT NOT NULL DEFAULT '127.0.0.1',
listen_port INTEGER NOT NULL DEFAULT 15721,
enable_logging INTEGER NOT NULL DEFAULT 1,
enabled INTEGER NOT NULL DEFAULT 0,
auto_failover_enabled INTEGER NOT NULL DEFAULT 0,
max_retries INTEGER NOT NULL DEFAULT 3,
streaming_first_byte_timeout INTEGER NOT NULL DEFAULT 60,
streaming_idle_timeout INTEGER NOT NULL DEFAULT 120,
non_streaming_timeout INTEGER NOT NULL DEFAULT 600,
circuit_failure_threshold INTEGER NOT NULL DEFAULT 4,
circuit_success_threshold INTEGER NOT NULL DEFAULT 2,
circuit_timeout_seconds INTEGER NOT NULL DEFAULT 60,
circuit_error_rate_threshold REAL NOT NULL DEFAULT 0.6,
circuit_min_requests INTEGER NOT NULL DEFAULT 10,
default_cost_multiplier TEXT NOT NULL DEFAULT '1',
pricing_model_source TEXT NOT NULL DEFAULT 'response',
live_takeover_active INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
)",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
let copied_columns = [
("app_type", "'claude'"),
("proxy_enabled", "0"),
("listen_address", "'127.0.0.1'"),
("listen_port", "15721"),
("enable_logging", "1"),
("enabled", "0"),
("auto_failover_enabled", "0"),
("max_retries", "3"),
("streaming_first_byte_timeout", "60"),
("streaming_idle_timeout", "120"),
("non_streaming_timeout", "600"),
("circuit_failure_threshold", "4"),
("circuit_success_threshold", "2"),
("circuit_timeout_seconds", "60"),
("circuit_error_rate_threshold", "0.6"),
("circuit_min_requests", "10"),
("default_cost_multiplier", "'1'"),
("pricing_model_source", "'response'"),
("live_takeover_active", "0"),
("created_at", "datetime('now')"),
("updated_at", "datetime('now')"),
]
.into_iter()
.map(|(column, fallback)| {
Self::has_column(conn, "proxy_config", column).map(|exists| {
if exists {
format!("\"{column}\"")
} else {
fallback.into()
}
})
})
.collect::<Result<Vec<_>, AppError>>()?
.join(", ");
let copy_sql = format!(
"INSERT INTO proxy_config_v14 (
app_type, proxy_enabled, listen_address, listen_port, enable_logging,
enabled, auto_failover_enabled, max_retries,
streaming_first_byte_timeout, streaming_idle_timeout, non_streaming_timeout,
circuit_failure_threshold, circuit_success_threshold, circuit_timeout_seconds,
circuit_error_rate_threshold, circuit_min_requests,
default_cost_multiplier, pricing_model_source, live_takeover_active,
created_at, updated_at
)
SELECT {copied_columns} FROM proxy_config"
);
conn.execute(&copy_sql, [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("DROP TABLE proxy_config", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute("ALTER TABLE proxy_config_v14 RENAME TO proxy_config", [])
.map_err(|e| AppError::Database(e.to_string()))?;
conn.execute(
"INSERT OR IGNORE INTO proxy_config (app_type) VALUES ('grokbuild')",
[],
)
.map_err(|e| AppError::Database(e.to_string()))?;
Ok(())
}
/// v14 -> v15: persist Grok Build enablement for unified Skills and MCP.
fn migrate_v14_to_v15(conn: &Connection) -> Result<(), AppError> {
if Self::table_exists(conn, "mcp_servers")? {
Self::add_column_if_missing(
conn,
"mcp_servers",
"enabled_grokbuild",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
}
if Self::table_exists(conn, "skills")? {
Self::add_column_if_missing(
conn,
"skills",
"enabled_grokbuild",
"BOOLEAN NOT NULL DEFAULT 0",
)?;
}
Ok(())
}
/// 插入默认模型定价数据
/// 格式: (model_id, display_name, input, output, cache_read, cache_creation)
/// 注意: model_id 使用短横线格式(如 claude-haiku-4-5),与 API 返回的模型名称标准化后一致
@@ -2766,7 +2922,7 @@ mod tests {
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, 13);
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
assert!(Database::has_column(
&conn,
"proxy_request_logs",
@@ -2787,4 +2943,82 @@ mod tests {
Ok(())
}
#[test]
fn migrate_v13_to_v14_adds_grokbuild_proxy_row_and_preserves_values() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
Database::create_tables_on_conn(&conn)?;
conn.execute("DELETE FROM proxy_config WHERE app_type = 'grokbuild'", [])?;
conn.execute(
"UPDATE proxy_config SET enabled = 1, max_retries = 9 WHERE app_type = 'codex'",
[],
)?;
Database::set_user_version(&conn, 13)?;
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
let grok_rows: i64 = conn.query_row(
"SELECT COUNT(*) FROM proxy_config WHERE app_type = 'grokbuild'",
[],
|row| row.get(0),
)?;
assert_eq!(grok_rows, 1);
let codex_values: (i64, i64) = conn.query_row(
"SELECT enabled, max_retries FROM proxy_config WHERE app_type = 'codex'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(codex_values, (1, 9));
Ok(())
}
#[test]
fn migrate_v14_to_v15_adds_grokbuild_skill_and_mcp_flags() -> Result<(), AppError> {
let conn = Connection::open_in_memory()?;
conn.execute_batch(
"CREATE TABLE mcp_servers (
id TEXT PRIMARY KEY,
enabled_codex BOOLEAN NOT NULL DEFAULT 0
);
CREATE TABLE skills (
id TEXT PRIMARY KEY,
enabled_codex BOOLEAN NOT NULL DEFAULT 0
);",
)?;
conn.execute(
"INSERT INTO mcp_servers (id, enabled_codex) VALUES ('mcp-1', 1)",
[],
)?;
conn.execute(
"INSERT INTO skills (id, enabled_codex) VALUES ('skill-1', 1)",
[],
)?;
Database::set_user_version(&conn, 14)?;
Database::apply_schema_migrations_on_conn(&conn)?;
assert_eq!(Database::get_user_version(&conn)?, SCHEMA_VERSION);
assert!(Database::has_column(
&conn,
"mcp_servers",
"enabled_grokbuild"
)?);
assert!(Database::has_column(&conn, "skills", "enabled_grokbuild")?);
let mcp_values: (i64, i64) = conn.query_row(
"SELECT enabled_codex, enabled_grokbuild FROM mcp_servers WHERE id = 'mcp-1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
let skill_values: (i64, i64) = conn.query_row(
"SELECT enabled_codex, enabled_grokbuild FROM skills WHERE id = 'skill-1'",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)?;
assert_eq!(mcp_values, (1, 0));
assert_eq!(skill_values, (1, 0));
Ok(())
}
}
+2 -2
View File
@@ -517,7 +517,7 @@ fn schema_create_tables_repairs_legacy_proxy_config_singleton_to_per_app() {
let count: i32 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count rows");
assert_eq!(count, 3, "per-app proxy_config should have 3 rows");
assert_eq!(count, 4, "per-app proxy_config should have 4 rows");
// 新结构下应能按 app_type 查询
let _: i32 = conn
@@ -657,7 +657,7 @@ fn migration_from_v3_8_schema_v1_to_current_schema_v3() {
let proxy_rows: i64 = conn
.query_row("SELECT COUNT(*) FROM proxy_config", [], |r| r.get(0))
.expect("count proxy_config rows");
assert_eq!(proxy_rows, 3);
assert_eq!(proxy_rows, 4);
// model_pricing 应具备默认数据(迁移时会 seed)
let pricing_rows: i64 = conn