tool card UI redesign: semantic icons, inline diff persistence, tool detail page, MCP-friendly titles
Nightly Build / build (push) Successful in 6m38s
Nightly Build / build (push) Successful in 6m38s
This commit is contained in:
@@ -11,6 +11,13 @@ pub struct LlmToolCall {
|
||||
/// payload, e.g. MCP `structuredContent`). Drives frontend rendering.
|
||||
pub result_type: String,
|
||||
pub status: String,
|
||||
/// For a file-write tool: the file content **before**/**after** the write,
|
||||
/// captured at execution time so the diff renders inline in the chat card and
|
||||
/// survives a page reload (it was previously only on the transient `PendingWrite`
|
||||
/// event). `None` for non-write tools, an unreadable path, or content over the
|
||||
/// size cap (no diff shown then). Only populated by `for_message` (history).
|
||||
pub preview_old: Option<String>,
|
||||
pub preview_new: Option<String>,
|
||||
}
|
||||
|
||||
/// Inserts a tool call in `running` state and returns its id.
|
||||
@@ -55,6 +62,25 @@ pub async fn complete(pool: &SqlitePool, id: i64, result: &str, result_type: &st
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persists a file-write tool's before/after snapshot (the diff preview) on its row.
|
||||
/// Both `None` is a valid no-op state (non-write tool, unreadable path, or content
|
||||
/// over the size cap). Separate from [`complete`] so the status/result write and the
|
||||
/// preview write stay independent, and so it can run for both the live and resume paths.
|
||||
pub async fn set_preview(
|
||||
pool: &SqlitePool,
|
||||
id: i64,
|
||||
old: Option<&str>,
|
||||
new: Option<&str>,
|
||||
) -> anyhow::Result<()> {
|
||||
sqlx::query("UPDATE chat_llm_tools SET preview_old = ?, preview_new = ? WHERE id = ?")
|
||||
.bind(old)
|
||||
.bind(new)
|
||||
.bind(id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn fail(pool: &SqlitePool, id: i64, error: &str) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE chat_llm_tools SET result = ?, status = 'failed' WHERE id = ?",
|
||||
@@ -117,13 +143,15 @@ pub async fn pending_for_stack(
|
||||
Ok(rows.into_iter().map(row_to_tool).collect())
|
||||
}
|
||||
|
||||
/// All tool calls for a single assistant message, ordered chronologically.
|
||||
/// All tool calls for a single assistant message, ordered chronologically. Unlike
|
||||
/// [`pending_for_stack`], this also reads the diff-preview columns, so the history
|
||||
/// projection can re-render a write's diff after a page reload.
|
||||
pub async fn for_message(
|
||||
pool: &SqlitePool,
|
||||
message_id: i64,
|
||||
) -> anyhow::Result<Vec<LlmToolCall>> {
|
||||
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String)>(
|
||||
"SELECT id, message_id, name, arguments, result, result_type, status
|
||||
let rows = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String, Option<String>, Option<String>)>(
|
||||
"SELECT id, message_id, name, arguments, result, result_type, status, preview_old, preview_new
|
||||
FROM chat_llm_tools
|
||||
WHERE message_id = ?
|
||||
ORDER BY id ASC",
|
||||
@@ -132,7 +160,28 @@ pub async fn for_message(
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
|
||||
Ok(rows.into_iter().map(row_to_tool).collect())
|
||||
Ok(rows.into_iter()
|
||||
.map(|(id, message_id, name, arguments, result, result_type, status, preview_old, preview_new)| {
|
||||
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old, preview_new }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// A single tool call by id, with its diff-preview columns. Backs the tool-detail
|
||||
/// page (`GET /api/tools/{id}`). Returns `None` when the id is unknown in this pool.
|
||||
pub async fn get(pool: &SqlitePool, id: i64) -> anyhow::Result<Option<LlmToolCall>> {
|
||||
let row = sqlx::query_as::<_, (i64, i64, String, Option<String>, Option<String>, String, String, Option<String>, Option<String>)>(
|
||||
"SELECT id, message_id, name, arguments, result, result_type, status, preview_old, preview_new
|
||||
FROM chat_llm_tools
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(pool)
|
||||
.await?;
|
||||
|
||||
Ok(row.map(|(id, message_id, name, arguments, result, result_type, status, preview_old, preview_new)| {
|
||||
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old, preview_new }
|
||||
}))
|
||||
}
|
||||
|
||||
fn row_to_tool(
|
||||
@@ -140,5 +189,6 @@ fn row_to_tool(
|
||||
i64, i64, String, Option<String>, Option<String>, String, String,
|
||||
),
|
||||
) -> LlmToolCall {
|
||||
LlmToolCall { id, message_id, name, arguments, result, result_type, status }
|
||||
// The resume path (`pending_for_stack`) never needs the diff preview.
|
||||
LlmToolCall { id, message_id, name, arguments, result, result_type, status, preview_old: None, preview_new: None }
|
||||
}
|
||||
|
||||
@@ -54,6 +54,11 @@ pub struct McpCatalogRow {
|
||||
pub icon_large_path: Option<String>,
|
||||
pub friendly_name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
/// Manifest-declared friendly tool names: a JSON array of `{name, display_name}`
|
||||
/// snapshotting the connector's `tools[]` block. Drives the UI card title for an
|
||||
/// `mcp__<server>__<tool>` call (override > live MCP `title` > prettified name).
|
||||
/// NULL when the manifest declares none.
|
||||
pub tool_meta_json: Option<String>,
|
||||
/// Marketplace build number — the **comparison key** for updates (a feed entry
|
||||
/// with a higher `version` than this installed one is "update available").
|
||||
/// Monotonic per connector; `version_string`/`version_release_date` are display
|
||||
@@ -105,9 +110,26 @@ const SELECT: &str =
|
||||
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json, \
|
||||
deliver_json, role_filter, verify_command, \
|
||||
verify_script_path, icon_small_path, icon_large_path, friendly_name, \
|
||||
description, version, version_string, version_release_date, created_at \
|
||||
description, tool_meta_json, version, version_string, version_release_date, created_at \
|
||||
FROM mcp_catalog";
|
||||
|
||||
/// Parses a catalog row's `tool_meta_json` (a `[{name, display_name}]` array) into
|
||||
/// the `tool name → display title` override map the runtime feeds into
|
||||
/// [`McpServerSpec::tool_titles`]. Empty on NULL or malformed JSON — the runtime then
|
||||
/// falls back to the server's live MCP `title` and finally a prettified raw name.
|
||||
pub fn parse_tool_titles(tool_meta_json: Option<&str>) -> HashMap<String, String> {
|
||||
#[derive(serde::Deserialize)]
|
||||
struct ToolMeta { name: String, display_name: Option<String> }
|
||||
tool_meta_json
|
||||
.and_then(|s| serde_json::from_str::<Vec<ToolMeta>>(s).ok())
|
||||
.map(|metas| {
|
||||
metas.into_iter()
|
||||
.filter_map(|m| m.display_name.map(|dn| (m.name, dn)))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ── Reads ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub async fn list(pool: &SqlitePool) -> Result<Vec<McpCatalogRow>> {
|
||||
@@ -167,6 +189,8 @@ pub struct UpsertCatalog<'a> {
|
||||
pub icon_large_path: Option<&'a str>,
|
||||
pub friendly_name: Option<&'a str>,
|
||||
pub description: Option<&'a str>,
|
||||
/// JSON array of `{name, display_name}` snapshotting the manifest's `tools[]`.
|
||||
pub tool_meta_json: Option<String>,
|
||||
/// Versioning (from the feed). All three `None` for the admin's manual form,
|
||||
/// which COALESCEs them away rather than blanking an installed entry's version.
|
||||
pub version: Option<i64>,
|
||||
@@ -181,8 +205,8 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
script_path, config_schema_json, auth_kind, oauth_provider, oauth_scopes_json,
|
||||
deliver_json, role_filter, verify_command,
|
||||
verify_script_path, icon_small_path, icon_large_path, friendly_name, description,
|
||||
version, version_string, version_release_date)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24)
|
||||
tool_meta_json, version, version_string, version_release_date)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16, ?17, ?18, ?19, ?20, ?21, ?22, ?23, ?24, ?25)
|
||||
ON CONFLICT(name) DO UPDATE SET
|
||||
scope = excluded.scope,
|
||||
source = excluded.source,
|
||||
@@ -208,6 +232,9 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
icon_large_path = COALESCE(excluded.icon_large_path, mcp_catalog.icon_large_path),
|
||||
friendly_name = excluded.friendly_name,
|
||||
description = excluded.description,
|
||||
-- Manifest tool titles are installer-owned like icons: COALESCE so the
|
||||
-- admin's manual catalog form (which never sends them) can't blank them.
|
||||
tool_meta_json = COALESCE(excluded.tool_meta_json, mcp_catalog.tool_meta_json),
|
||||
-- Version fields come from the feed on (re)install; the admin's manual
|
||||
-- form passes NULL, so COALESCE keeps the installed version rather than
|
||||
-- wiping it (same rationale as icons above).
|
||||
@@ -237,6 +264,7 @@ pub async fn upsert(pool: &SqlitePool, e: UpsertCatalog<'_>) -> Result<i64> {
|
||||
.bind(e.icon_large_path)
|
||||
.bind(e.friendly_name)
|
||||
.bind(e.description)
|
||||
.bind(e.tool_meta_json)
|
||||
.bind(e.version)
|
||||
.bind(e.version_string)
|
||||
.bind(e.version_release_date)
|
||||
|
||||
@@ -557,6 +557,7 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
icon_large_path TEXT,
|
||||
friendly_name TEXT,
|
||||
description TEXT,
|
||||
tool_meta_json TEXT, -- [{name,display_name}] friendly tool names from the manifest
|
||||
version INTEGER, -- marketplace build number: the update-comparison key
|
||||
version_string TEXT, -- semver, display only
|
||||
version_release_date TEXT, -- ISO date, display only
|
||||
@@ -569,6 +570,8 @@ async fn create_registry_tables(pool: &SqlitePool) -> Result<()> {
|
||||
ensure_column(pool, "mcp_catalog", "oauth_provider", "TEXT").await?;
|
||||
ensure_column(pool, "mcp_catalog", "oauth_scopes_json", "TEXT").await?;
|
||||
ensure_column(pool, "mcp_catalog", "deliver_json", "TEXT").await?;
|
||||
// Manifest-declared friendly tool names (UI card titles) — additive.
|
||||
ensure_column(pool, "mcp_catalog", "tool_meta_json", "TEXT").await?;
|
||||
// Versioning columns are additive: the installed `version` integer is compared
|
||||
// against the feed's to surface "update available" in the marketplace UI.
|
||||
ensure_column(pool, "mcp_catalog", "version", "INTEGER").await?;
|
||||
@@ -745,11 +748,16 @@ pub async fn create_owner_tables(pool: &SqlitePool) -> Result<()> {
|
||||
result TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'running' CHECK(status IN ('running', 'pending', 'done', 'failed', 'cancelled', 'rejected')),
|
||||
result_type TEXT NOT NULL DEFAULT 'string' CHECK(result_type IN ('string', 'json')),
|
||||
preview_old TEXT, -- file-write diff: content before the write
|
||||
preview_new TEXT, -- file-write diff: content after the write
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
)",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
// Diff-preview columns are additive — reach an already-created table in place.
|
||||
ensure_column(pool, "chat_llm_tools", "preview_old", "TEXT").await?;
|
||||
ensure_column(pool, "chat_llm_tools", "preview_new", "TEXT").await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_stack_session ON chat_sessions_stack(session_id)",
|
||||
|
||||
Reference in New Issue
Block a user