From 14592e1b4d3e3f5496ccb51be23e0b2494ba03ae Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Wed, 29 Jul 2026 21:16:44 -0700 Subject: [PATCH] feat(agent): add PublishMiniApp tool for one-step market submission MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users could ask the agent to build a MiniApp but not to publish it — the market submission flow lived only behind the desktop form. Add a PublishMiniApp agent tool that derives listing metadata from the app manifest (with slug suggestion, local→market category mapping, and release-number resolution from submission history), walks the full draft → package → screenshots → submit flow, and hands the user a GitHub authorization link (polled in the background) when signed out. The submission orchestration moves from the Tauri command into services-integrations::miniapp_market::submit so the desktop UI and the agent tool share one implementation. Upload progress is mirrored onto the existing miniapp-market-upload-progress event so an open submissions view shows agent-driven uploads. The miniapp-dev skill documents the publish flow; the stale InitMiniApp exposure row is corrected. --- MiniApp/Skills/miniapp-dev/SKILL.md | 14 + .../desktop/src/api/miniapp_market_api.rs | 90 +--- src/crates/assembly/core/Cargo.toml | 1 + .../core/builtin_skills/miniapp-dev/SKILL.md | 14 + .../agentic/agents/definitions/modes/claw.rs | 1 + .../agents/definitions/modes/cowork.rs | 1 + .../assembly/core/src/agentic/agents/mod.rs | 1 + .../src/agentic/tools/agent-tool-exposure.md | 3 +- .../implementations/miniapp_publish_tool.rs | 416 ++++++++++++++++++ .../src/agentic/tools/implementations/mod.rs | 2 + .../tools/product_runtime/materialization.rs | 1 + .../core/src/agentic/tools/registry.rs | 2 + .../execution/tool-provider-groups/src/lib.rs | 2 + .../src/miniapp_market/mod.rs | 5 + .../src/miniapp_market/submit.rs | 348 +++++++++++++++ .../flow_chat/tool-cards/toolCardMetadata.ts | 10 + 16 files changed, 833 insertions(+), 78 deletions(-) create mode 100644 src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs create mode 100644 src/crates/services/services-integrations/src/miniapp_market/submit.rs diff --git a/MiniApp/Skills/miniapp-dev/SKILL.md b/MiniApp/Skills/miniapp-dev/SKILL.md index 2850d4cbfc..3a55839df5 100644 --- a/MiniApp/Skills/miniapp-dev/SKILL.md +++ b/MiniApp/Skills/miniapp-dev/SKILL.md @@ -422,3 +422,17 @@ BuiltinApp { - 重构计划: `.cursor/plans/miniapp_v2_full_refactor_*.plan.md` - 架构说明见 plan 内「MiniApp V2 一步到位重构计划」 + +## 发布到市场 + +用户明确要求把 MiniApp 发布/上架到市场时,用 `PublishMiniApp` 工具: + +- 传 `app_id` 和 1–5 张截图路径(PNG/JPEG/WebP,单张 ≤ 5 MiB)。 + 没有截图时先向用户要,或请用户在「市场 → 我的投稿」用「截取当前画面」生成。 +- 名称、描述、图标、分类、标签自动取自 `meta.json`;slug 和版本号自动推导。 + 发布前确认 `meta.json` 的 `description` 非空、权限是最小集 + (市场会拒绝 `node.enabled=true`、宽泛 fs scope 等)。 +- 未登录时工具会返回 GitHub 授权链接:把链接给用户,等用户完成授权后 + 用相同参数再调用一次即可继续。 +- 提交后进入人工审核;用户可在「市场 → 我的投稿」查看状态。 +- 这是对外动作:只在用户明确要求发布时调用,不要主动发布。 diff --git a/src/apps/desktop/src/api/miniapp_market_api.rs b/src/apps/desktop/src/api/miniapp_market_api.rs index e85c101141..3ca47dd606 100644 --- a/src/apps/desktop/src/api/miniapp_market_api.rs +++ b/src/apps/desktop/src/api/miniapp_market_api.rs @@ -14,7 +14,7 @@ use bitfun_product_domains::miniapp::market::{ MarketSubmission, MarketSubmissionDraftRequest, }; use bitfun_services_integrations::miniapp_market::{ - build_market_package, validate_market_package, DesktopAuthPollRequest, DesktopAuthPollResponse, + submit_installed_app, validate_market_package, DesktopAuthPollRequest, DesktopAuthPollResponse, DesktopAuthStart, FavoriteAggregate, MarketBrowseRequest, MarketClient, MarketMe, RatingAggregate, ValidatedMarketPackage, }; @@ -517,63 +517,26 @@ pub async fn miniapp_market_submit_installed( state: State<'_, AppState>, request: MarketSubmitInstalledRequest, ) -> Result { - if request.screenshot_paths.is_empty() || request.screenshot_paths.len() > 5 { - return Err("Choose between 1 and 5 screenshots.".to_string()); - } let installed = state .miniapp_manager .get(&request.app_id) .await .map_err(|error| error.to_string())?; - // The reviewed listing metadata is part of the immutable submission - // snapshot. Build the package from the selected app's source and - // permissions, but use the values the author entered in the submission - // form so the package and review record cannot disagree. - let mut package_app = installed.clone(); - package_app.name = request.draft.name.clone(); - package_app.description = request.draft.description.clone(); - package_app.icon = request.draft.icon.clone(); - package_app.category = request.draft.category.clone(); - package_app.tags = request.draft.tags.clone(); - let package = build_market_package(&package_app).map_err(|error| error.to_string())?; - emit_upload_progress(&app, None, "validating", 1, 1); - let mut client = MarketClient::from_environment() .await .map_err(market_error)?; - let submission = client - .create_submission(&request.draft) - .await - .map_err(market_error)?; - let submission_id = submission.submission_id.clone(); - emit_upload_progress(&app, Some(&submission_id), "package", 0, 1); - client - .upload_submission_package(&submission_id, package) - .await - .map_err(market_error)?; - emit_upload_progress(&app, Some(&submission_id), "package", 1, 1); - - let screenshot_total = request.screenshot_paths.len() as u32; - for (position, path) in request.screenshot_paths.iter().enumerate() { - let path = PathBuf::from(path); - let (media_type, bytes) = read_screenshot(&path).await?; - client - .upload_submission_screenshot(&submission_id, position as u32, media_type, bytes) - .await - .map_err(market_error)?; - emit_upload_progress( - &app, - Some(&submission_id), - "screenshots", - position as u32 + 1, - screenshot_total, - ); - } - let submission = client - .submit_submission(&submission_id) - .await - .map_err(market_error)?; - emit_upload_progress(&app, Some(&submission_id), "submitted", 1, 1); + let mut progress = |submission_id: Option<&str>, phase: &'static str, completed, total| { + emit_upload_progress(&app, submission_id, phase, completed, total); + }; + let submission = submit_installed_app( + &mut client, + &installed, + &request.draft, + &request.screenshot_paths, + &mut progress, + ) + .await + .map_err(market_error)?; remove_owned_capture_files(&app, &request.screenshot_paths).await; Ok(submission) } @@ -697,33 +660,6 @@ fn validate_minimum_bitfun_version(minimum: &str) -> Result<(), String> { Ok(()) } -async fn read_screenshot(path: &Path) -> Result<(&'static str, Vec), String> { - let metadata = tokio::fs::metadata(path) - .await - .map_err(|error| format!("Could not read screenshot metadata: {error}"))?; - if !metadata.is_file() - || metadata.len() > bitfun_product_domains::miniapp::market::MARKET_MAX_SCREENSHOT_BYTES - { - return Err("Each screenshot must be a file no larger than 5 MiB.".to_string()); - } - let media_type = match path - .extension() - .and_then(|extension| extension.to_str()) - .unwrap_or_default() - .to_ascii_lowercase() - .as_str() - { - "png" => "image/png", - "jpg" | "jpeg" => "image/jpeg", - "webp" => "image/webp", - _ => return Err("Screenshots must be PNG, JPEG, or WebP.".to_string()), - }; - let bytes = tokio::fs::read(path) - .await - .map_err(|error| format!("Could not read screenshot: {error}"))?; - Ok((media_type, bytes)) -} - fn emit_upload_progress( app: &AppHandle, submission_id: Option<&str>, diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 68a4c9a46f..f2b1272dd3 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -208,6 +208,7 @@ product-domains = [ "plugin-source", "bitfun-services-integrations/function-agents", "bitfun-services-integrations/miniapp-runtime", + "bitfun-services-integrations/miniapp-market", "bitfun-product-domains/product-full", ] canvas-runtime = [ diff --git a/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md b/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md index 1cc6dc30b5..d8ae6ceae5 100644 --- a/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/miniapp-dev/SKILL.md @@ -249,3 +249,17 @@ await app.fs.readFile(...) - i18n 至少覆盖 `zh-CN` / `en-US` - light/dark 没有明显样式问题 - 没有遗留 “TODO / 占位 / Lorem ipsum” + +## 发布到市场 + +用户明确要求把 MiniApp 发布/上架到市场时,用 `PublishMiniApp` 工具: + +- 传 `app_id` 和 1–5 张截图路径(PNG/JPEG/WebP,单张 ≤ 5 MiB)。 + 没有截图时先向用户要,或请用户在「市场 → 我的投稿」用「截取当前画面」生成。 +- 名称、描述、图标、分类、标签自动取自 `meta.json`;slug 和版本号自动推导。 + 发布前确认 `meta.json` 的 `description` 非空、权限是最小集 + (市场会拒绝 `node.enabled=true`、宽泛 fs scope 等)。 +- 未登录时工具会返回 GitHub 授权链接:把链接给用户,等用户完成授权后 + 用相同参数再调用一次即可继续。 +- 提交后进入人工审核;用户可在「市场 → 我的投稿」查看状态。 +- 这是对外动作:只在用户明确要求发布时调用,不要主动发布。 diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs index 5ea6cf9cd5..58d8732bb4 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/claw.rs @@ -43,6 +43,7 @@ impl ClawMode { // agent/tool instead of being surfaced as a ControlHub domain. "ControlHub".to_string(), "InitMiniApp".to_string(), + "PublishMiniApp".to_string(), "PageDeploy".to_string(), "PagePublish".to_string(), ], diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs index 059e60b052..660ba8b703 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/cowork.rs @@ -54,6 +54,7 @@ impl CoworkMode { "WebFetch".to_string(), "ControlHub".to_string(), "InitMiniApp".to_string(), + "PublishMiniApp".to_string(), ], } } diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index bbe5149089..d984559e23 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -130,6 +130,7 @@ pub fn shared_coding_mode_tools() -> Vec { "ReviewPlatform".to_string(), "ControlHub".to_string(), "InitMiniApp".to_string(), + "PublishMiniApp".to_string(), "PageDeploy".to_string(), "PagePublish".to_string(), ]; diff --git a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md index aff43f305a..0f7872a082 100644 --- a/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md +++ b/src/crates/assembly/core/src/agentic/tools/agent-tool-exposure.md @@ -43,7 +43,8 @@ Notes: | `GetMCPPrompt` | Deferred | None | - | | `GenerativeUI` | Deferred | None | - | | `Git` | Deferred | `ReviewFixer`, `ReviewWorker`, `ReviewJudge` | Direct | -| `InitMiniApp` | Deferred | None | - | +| `InitMiniApp` | Direct | None | - | +| `PublishMiniApp` | Direct | None | - | | `ControlHub` | Deferred | `ComputerUse` | Direct | | `ComputerUse` | Deferred | `ComputerUse` | Direct | | `Playbook` | Deferred | None | - | diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs new file mode 100644 index 0000000000..0d703dffdd --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/miniapp_publish_tool.rs @@ -0,0 +1,416 @@ +//! PublishMiniApp tool — submit an installed MiniApp to the MiniApp market +//! for human review, deriving the listing metadata from the app manifest. + +use crate::agentic::tools::framework::{PermissionIntent, Tool, ToolResult, ToolUseContext}; +use crate::infrastructure::events::{emit_global_event, BackendEvent}; +use crate::miniapp::try_get_global_miniapp_manager; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use bitfun_product_domains::miniapp::market::{ + MarketLicense, MarketSubmissionDraftRequest, MARKET_CATEGORIES, MARKET_MAX_SCREENSHOTS, +}; +use bitfun_services_integrations::miniapp_market::{ + map_local_category_to_market, resolve_release_target, submit_installed_app, + suggest_market_slug, DesktopAuthPollRequest, MarketClient, ReleaseTarget, +}; +use serde_json::{json, Value}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const DEFAULT_MIN_BITFUN_VERSION: &str = "0.1.0"; +const DEFAULT_LICENSE: &str = "MIT"; + +pub struct PublishMiniAppTool; + +impl PublishMiniAppTool { + pub fn new() -> Self { + Self + } +} + +impl Default for PublishMiniAppTool { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl Tool for PublishMiniAppTool { + fn name(&self) -> &str { + "PublishMiniApp" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Submit an installed MiniApp to the BitFun MiniApp market for human review. + +Listing metadata (name, description, icon, category, tags) is derived from the app's manifest; the marketplace slug and release number are derived automatically from the user's submission history. Provide 1-5 screenshot file paths (PNG/JPEG/WebP, each <= 5 MiB) — ask the user for screenshots, or have them use 市场 → 我的投稿 → 截取当前画面 to capture one. + +If the user is not signed in to the market, the tool returns a GitHub authorization link. Show the link to the user, wait for them to authorize in the browser, then call this tool again with the same arguments. + +Publishing is an outward-facing action: only call this when the user explicitly asks to publish/submit the app to the market."# + .to_string()) + } + + fn short_description(&self) -> String { + "Submit an installed MiniApp to the market for review.".to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["app_id", "screenshot_paths"], + "properties": { + "app_id": { + "type": "string", + "description": "Installed MiniApp id (returned by InitMiniApp, or the directory name under the miniapps data root)" + }, + "screenshot_paths": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "maxItems": 5, + "description": "1-5 absolute paths to PNG/JPEG/WebP screenshots of the running app, each <= 5 MiB" + }, + "changelog": { + "type": "string", + "description": "What changed in this release. Defaults to a generic note." + }, + "slug": { + "type": "string", + "description": "Marketplace slug override (3-63 lowercase letters, digits, hyphens). Immutable after first publish. Defaults to a slug derived from the app name." + }, + "description": { + "type": "string", + "description": "Public listing description override (1-500 chars). Defaults to the manifest description." + }, + "category": { + "type": "string", + "description": "Market category override: developer, productivity, data, creative, education, utilities, entertainment, other. Defaults to a mapping of the manifest category." + } + } + }) + } + + fn is_readonly(&self) -> bool { + false + } + + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let app_id = input + .get("app_id") + .and_then(Value::as_str) + .unwrap_or("unknown") + .trim(); + Ok(vec![PermissionIntent::new( + "custom_tool", + vec![format!("miniapp:PublishMiniApp:{app_id}")], + )]) + } + + async fn call_impl( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let manager = try_get_global_miniapp_manager() + .ok_or_else(|| BitFunError::tool("MiniAppManager not initialized".to_string()))?; + + let app_id = input + .get("app_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| BitFunError::validation("Missing required field: app_id"))?; + let screenshot_paths: Vec = input + .get("screenshot_paths") + .and_then(Value::as_array) + .map(|items| { + items + .iter() + .filter_map(Value::as_str) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + if screenshot_paths.is_empty() || screenshot_paths.len() > MARKET_MAX_SCREENSHOTS { + return Err(BitFunError::validation( + "screenshot_paths must contain 1-5 image paths (PNG/JPEG/WebP). Ask the user for screenshots of the running app, or have them capture one via 市场 → 我的投稿 → 截取当前画面.", + )); + } + for path in &screenshot_paths { + if tokio::fs::metadata(path).await.is_err() { + return Err(BitFunError::validation(format!( + "Screenshot not found: {path}" + ))); + } + } + + let app = manager + .get(app_id) + .await + .map_err(|e| BitFunError::tool(format!("Installed MiniApp not found: {e}")))?; + + let mut client = MarketClient::from_environment() + .await + .map_err(|e| BitFunError::tool(format!("MiniApp market unavailable: {e}")))?; + + // Not signed in: hand the GitHub authorization link to the user and + // keep polling in the background so their browser approval lands in + // the shared credential vault before the next tool call. + let me = client + .me() + .await + .map_err(|e| BitFunError::tool(format!("MiniApp market sign-in check failed: {e}")))?; + let Some(me) = me else { + let start = client + .start_desktop_auth() + .await + .map_err(|e| BitFunError::tool(format!("Could not start GitHub sign-in: {e}")))?; + let authorization_url = start.authorization_url.clone(); + let poll_request = DesktopAuthPollRequest { + transaction_id: start.transaction_id.clone(), + transaction_secret: start.transaction_secret.clone(), + }; + let interval = Duration::from_secs(start.poll_interval_seconds.max(1) as u64); + let expires_at = start.expires_at; + tokio::spawn(async move { + loop { + if unix_now() >= expires_at { + break; + } + tokio::time::sleep(interval).await; + match client.poll_desktop_auth(&poll_request).await { + Ok(response) if response.status == "authorized" => break, + Ok(response) if response.status == "expired" => break, + Ok(_) => continue, + Err(_) => break, + } + } + }); + let message = format!( + "The user is not signed in to the MiniApp market. Show this GitHub authorization link to the user and ask them to open it in a browser: {authorization_url}\nBitFun keeps polling in the background; after the user finishes authorizing, call PublishMiniApp again with the same arguments to continue publishing." + ); + return Ok(vec![ToolResult::Result { + data: json!({ + "status": "sign_in_required", + "authorization_url": authorization_url, + "expires_at": expires_at, + }), + result_for_assistant: Some(message), + image_attachments: None, + }]); + }; + + // Derive the public listing metadata from the manifest, with explicit + // overrides taking precedence. + let description = input + .get("description") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| app.description.trim().to_string()); + if description.is_empty() { + return Err(BitFunError::validation( + "The app has no description. Update meta.json's description (or pass the description parameter) before publishing.", + )); + } + let category = match input + .get("category") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + { + Some(value) if MARKET_CATEGORIES.contains(&value) => value.to_string(), + Some(value) => { + return Err(BitFunError::validation(format!( + "Unknown market category '{value}'. Use one of: {}.", + MARKET_CATEGORIES.join(", ") + ))) + } + None => map_local_category_to_market(&app.category), + }; + let slug = input + .get("slug") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| suggest_market_slug(&app.name, &app.id)); + + let submissions = client + .list_submissions() + .await + .map_err(|e| BitFunError::tool(format!("Could not load submission history: {e}")))?; + let (listing_id, release_number) = match resolve_release_target(&submissions, &slug) { + ReleaseTarget::NewListing => (None, 1), + ReleaseTarget::ExistingListing { + listing_id, + next_release, + } => (Some(listing_id), next_release), + ReleaseTarget::PendingReview { + submission_id, + release_number, + } => { + let message = format!( + "'{slug}' v{release_number} is already under review (submission {submission_id}). Wait for the review to finish, or withdraw it in 市场 → 我的投稿 before publishing a new release." + ); + return Ok(vec![ToolResult::Result { + data: json!({ + "status": "pending_review", + "slug": slug, + "submission_id": submission_id, + "release_number": release_number, + }), + result_for_assistant: Some(message), + image_attachments: None, + }]); + } + }; + + let changelog = input + .get("changelog") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| { + if release_number > 1 { + "General updates and improvements.".to_string() + } else { + "Initial release.".to_string() + } + }); + + let mut tags: Vec = app + .tags + .iter() + .map(|tag| tag.trim().to_string()) + .filter(|tag| !tag.is_empty() && tag.chars().count() <= 32) + .collect(); + tags.truncate(10); + + let draft = MarketSubmissionDraftRequest { + listing_id, + slug: slug.clone(), + release_number, + name: app.name.trim().to_string(), + description, + icon: app.icon.clone(), + category, + tags, + min_bitfun_version: DEFAULT_MIN_BITFUN_VERSION.to_string(), + changelog, + license: MarketLicense { + spdx_expression: Some(DEFAULT_LICENSE.to_string()), + custom_url: None, + }, + repository_url: None, + }; + + // Mirror upload progress onto the same event the submissions view + // already listens to, so an open UI shows the agent-driven upload. + let (progress_tx, mut progress_rx) = + tokio::sync::mpsc::unbounded_channel::<(Option, &'static str, u32, u32)>(); + let forwarder = tokio::spawn(async move { + while let Some((submission_id, phase, completed, total)) = progress_rx.recv().await { + let _ = emit_global_event(BackendEvent::Custom { + event_name: "miniapp-market-upload-progress".to_string(), + payload: json!({ + "submissionId": submission_id, + "phase": phase, + "completed": completed, + "total": total, + }), + }) + .await; + } + }); + let mut progress = |submission_id: Option<&str>, phase: &'static str, completed, total| { + let _ = progress_tx.send((submission_id.map(str::to_string), phase, completed, total)); + }; + let result = + submit_installed_app(&mut client, &app, &draft, &screenshot_paths, &mut progress).await; + drop(progress_tx); + let _ = forwarder.await; + let submission = result.map_err(|error| { + let hint = match error.code.as_str() { + "slug_taken" => " Pass a different `slug` parameter and retry.", + "authentication_required" => " Call PublishMiniApp again to start GitHub sign-in.", + "invalid_release_number" => { + " Retry once; the release number is derived from the latest submission history." + } + _ => "", + }; + BitFunError::tool(format!( + "Publishing failed ({}): {}{hint}", + error.code, error + )) + })?; + + let message = format!( + "MiniApp '{}' submitted for review as '{}' v{} (signed in as {}). The user can track review status in 市场 → 我的投稿; published versions stay downloadable while the review runs.", + submission.name, submission.slug, submission.release_number, me.user.login + ); + Ok(vec![ToolResult::Result { + data: json!({ + "status": "submitted", + "submission_id": submission.submission_id, + "slug": submission.slug, + "release_number": submission.release_number, + "name": submission.name, + "category": submission.category, + }), + result_for_assistant: Some(message), + image_attachments: None, + }]) + } +} + +fn unix_now() -> i64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|value| value.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::PublishMiniAppTool; + use crate::agentic::tools::framework::{Tool, ToolExposure, ToolUseContext}; + use serde_json::json; + + #[test] + fn publish_miniapp_stays_expanded_for_assistant_use() { + let tool = PublishMiniAppTool::new(); + assert_eq!(tool.default_exposure(), ToolExposure::Direct); + } + + #[test] + fn publish_miniapp_emits_stable_permission_identity() { + let tool = PublishMiniAppTool::new(); + let context = ToolUseContext::for_tool_listing(None, None); + let intents = tool + .permission_intents(&json!({ "app_id": "abc-123" }), &context) + .expect("permission intent"); + + assert_eq!(intents.len(), 1); + assert_eq!(intents[0].action, "custom_tool"); + assert_eq!( + intents[0].resources, + ["miniapp:PublishMiniApp:abc-123".to_string()] + ); + } + + #[test] + fn publish_miniapp_schema_requires_app_and_screenshots() { + let tool = PublishMiniAppTool::new(); + let schema = tool.input_schema(); + assert_eq!(schema["required"], json!(["app_id", "screenshot_paths"])); + assert_eq!(schema["additionalProperties"], json!(false)); + } +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs index 9976283260..76792d01a5 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -29,6 +29,7 @@ pub mod list_models_tool; pub mod ls_tool; pub mod mcp_tools; pub mod miniapp_init_tool; +pub mod miniapp_publish_tool; pub mod page_deploy_tool; pub mod page_publish_tool; pub mod playbook_tool; @@ -77,6 +78,7 @@ pub use mcp_tools::{ GetMCPPromptTool, ListMCPPromptsTool, ListMCPResourcesTool, ReadMCPResourceTool, }; pub use miniapp_init_tool::InitMiniAppTool; +pub use miniapp_publish_tool::PublishMiniAppTool; pub use page_deploy_tool::PageDeployTool; pub use page_publish_tool::PagePublishTool; pub use playbook_tool::PlaybookTool; diff --git a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs index 271f9a7eb4..d887144912 100644 --- a/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs +++ b/src/crates/assembly/core/src/agentic/tools/product_runtime/materialization.rs @@ -67,6 +67,7 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "Worktree" => Some(Arc::new(WorktreeTool::new())), "ReviewPlatform" => Some(Arc::new(ReviewPlatformTool::new())), "InitMiniApp" => Some(Arc::new(InitMiniAppTool::new())), + "PublishMiniApp" => Some(Arc::new(PublishMiniAppTool::new())), "PageDeploy" => Some(Arc::new(PageDeployTool::new())), "PagePublish" => Some(Arc::new(PagePublishTool::new())), "ControlHub" => Some(Arc::new(ControlHubTool::new())), diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index b0857e5ceb..6828d0e159 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -544,6 +544,7 @@ mod tests { "Worktree", "ReviewPlatform", "InitMiniApp", + "PublishMiniApp", "PageDeploy", "PagePublish", "ControlHub", @@ -699,6 +700,7 @@ mod tests { assert!(registry.is_tool_deferred("Worktree")); assert!(registry.is_tool_deferred("ReviewPlatform")); assert!(!registry.is_tool_deferred("InitMiniApp")); + assert!(!registry.is_tool_deferred("PublishMiniApp")); } #[test] diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 9464ae505f..9a0bd6cb07 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -181,6 +181,7 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "Worktree", "ReviewPlatform", "InitMiniApp", + "PublishMiniApp", "PageDeploy", "PagePublish", "ControlHub", @@ -398,6 +399,7 @@ mod tests { "Worktree", "ReviewPlatform", "InitMiniApp", + "PublishMiniApp", "PageDeploy", "PagePublish", "ControlHub", diff --git a/src/crates/services/services-integrations/src/miniapp_market/mod.rs b/src/crates/services/services-integrations/src/miniapp_market/mod.rs index 55ec827771..8ec280dc96 100644 --- a/src/crates/services/services-integrations/src/miniapp_market/mod.rs +++ b/src/crates/services/services-integrations/src/miniapp_market/mod.rs @@ -3,6 +3,7 @@ mod client; mod credentials; mod package; +mod submit; pub use client::{ DesktopAuthPollRequest, DesktopAuthPollResponse, DesktopAuthStart, FavoriteAggregate, @@ -16,3 +17,7 @@ pub use credentials::{ pub use package::{ build_market_package, validate_market_package, MarketPackageError, ValidatedMarketPackage, }; +pub use submit::{ + map_local_category_to_market, read_screenshot_file, resolve_release_target, + submit_installed_app, suggest_market_slug, ReleaseTarget, SubmitProgress, +}; diff --git a/src/crates/services/services-integrations/src/miniapp_market/submit.rs b/src/crates/services/services-integrations/src/miniapp_market/submit.rs new file mode 100644 index 0000000000..6f24c57ba6 --- /dev/null +++ b/src/crates/services/services-integrations/src/miniapp_market/submit.rs @@ -0,0 +1,348 @@ +//! Shared submission orchestration: package an installed MiniApp and walk it +//! through the market's draft → upload → submit flow. Used by the desktop +//! Tauri command and the PublishMiniApp agent tool so the two paths cannot +//! drift. + +use super::client::{MarketClient, MarketClientError}; +use super::package::build_market_package; +use bitfun_product_domains::miniapp::market::{ + MarketSubmission, MarketSubmissionDraftRequest, MarketSubmissionStatus, MARKET_CATEGORIES, + MARKET_MAX_SCREENSHOTS, MARKET_MAX_SCREENSHOT_BYTES, +}; +use bitfun_product_domains::miniapp::types::MiniApp; +use std::path::Path; + +/// Upload progress callback: (submission_id, phase, completed, total). +/// Phases mirror the desktop UI contract: validating, package, screenshots, +/// submitted. +pub type SubmitProgress<'a> = &'a mut (dyn FnMut(Option<&str>, &'static str, u32, u32) + Send); + +/// Where an installed app's next submission should land on the market. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReleaseTarget { + /// No listing with this slug is owned by the caller: first release. + NewListing, + /// The caller already owns the listing: publish `next_release` to it. + ExistingListing { + listing_id: String, + next_release: u32, + }, + /// A submission for this slug is still under review; publishing another + /// release now would just be rejected or create reviewer noise. + PendingReview { + submission_id: String, + release_number: u32, + }, +} + +/// Derive the release target for `slug` from the caller's own submission +/// history. Release numbers on the server are `MAX(releases)+1`, and releases +/// are only created on approval, so approved submissions are the ground truth. +pub fn resolve_release_target(submissions: &[MarketSubmission], slug: &str) -> ReleaseTarget { + if let Some(pending) = submissions + .iter() + .find(|s| s.slug == slug && s.status == MarketSubmissionStatus::Submitted) + { + return ReleaseTarget::PendingReview { + submission_id: pending.submission_id.clone(), + release_number: pending.release_number, + }; + } + let listing_id = submissions + .iter() + .filter(|s| s.slug == slug) + .find_map(|s| s.listing_id.clone()); + let latest_approved = submissions + .iter() + .filter(|s| s.slug == slug && s.status == MarketSubmissionStatus::Approved) + .map(|s| s.release_number) + .max(); + match (listing_id, latest_approved) { + (Some(listing_id), Some(latest)) => ReleaseTarget::ExistingListing { + listing_id, + next_release: latest + 1, + }, + (Some(listing_id), None) => ReleaseTarget::ExistingListing { + listing_id, + next_release: 1, + }, + _ => ReleaseTarget::NewListing, + } +} + +/// Suggest a marketplace slug from an app name, matching the submissions UI: +/// lowercase ASCII letters, digits and hyphens, 3–63 chars. +pub fn suggest_market_slug(name: &str, fallback_seed: &str) -> String { + let mut value = String::new(); + let mut last_hyphen = true; + for ch in name.chars().flat_map(|c| c.to_lowercase()) { + if ch.is_ascii_alphanumeric() { + value.push(ch); + last_hyphen = false; + } else if !last_hyphen { + value.push('-'); + last_hyphen = true; + } + if value.len() >= 63 { + break; + } + } + let value = value.trim_matches('-').to_string(); + if value.len() >= 3 { + return value; + } + let seed: String = fallback_seed + .chars() + .filter(|c| c.is_ascii_alphanumeric()) + .take(8) + .collect::() + .to_lowercase(); + format!("miniapp-{seed}") +} + +/// Map a locally installed app's category (utility, media, dev, productivity, +/// game, …) onto the market's fixed category set. +pub fn map_local_category_to_market(category: &str) -> String { + let normalized = category.trim().to_lowercase(); + let mapped = match normalized.as_str() { + "utility" | "utilities" | "tool" | "tools" => "utilities", + "dev" | "developer" | "development" => "developer", + "media" | "creative" | "design" => "creative", + "productivity" => "productivity", + "game" | "games" | "entertainment" => "entertainment", + "data" => "data", + "education" => "education", + other => other, + }; + if MARKET_CATEGORIES.contains(&mapped) { + mapped.to_string() + } else { + "other".to_string() + } +} + +/// Read one screenshot from disk, enforcing the market's type and size limits. +pub async fn read_screenshot_file( + path: &Path, +) -> Result<(&'static str, Vec), MarketClientError> { + let metadata = tokio::fs::metadata(path).await.map_err(|error| { + screenshot_error(format!( + "Could not read screenshot metadata for {}: {error}", + path.display() + )) + })?; + if !metadata.is_file() || metadata.len() > MARKET_MAX_SCREENSHOT_BYTES { + return Err(screenshot_error(format!( + "Each screenshot must be a file no larger than 5 MiB: {}", + path.display() + ))); + } + let media_type = match path + .extension() + .and_then(|extension| extension.to_str()) + .unwrap_or_default() + .to_ascii_lowercase() + .as_str() + { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + _ => { + return Err(screenshot_error(format!( + "Screenshots must be PNG, JPEG, or WebP: {}", + path.display() + ))) + } + }; + let bytes = tokio::fs::read(path).await.map_err(|error| { + screenshot_error(format!( + "Could not read screenshot {}: {error}", + path.display() + )) + })?; + Ok((media_type, bytes)) +} + +/// Package `app` with the draft's public metadata and drive the full market +/// submission flow. The reviewed listing metadata is part of the immutable +/// submission snapshot, so the package is built from the app's source and +/// permissions but carries the draft's name/description/icon/category/tags — +/// the package and the review record cannot disagree. +pub async fn submit_installed_app( + client: &mut MarketClient, + app: &MiniApp, + draft: &MarketSubmissionDraftRequest, + screenshot_paths: &[String], + progress: SubmitProgress<'_>, +) -> Result { + if screenshot_paths.is_empty() || screenshot_paths.len() > MARKET_MAX_SCREENSHOTS { + return Err(screenshot_error( + "Choose between 1 and 5 screenshots.".to_string(), + )); + } + let mut package_app = app.clone(); + package_app.name = draft.name.clone(); + package_app.description = draft.description.clone(); + package_app.icon = draft.icon.clone(); + package_app.category = draft.category.clone(); + package_app.tags = draft.tags.clone(); + let package = build_market_package(&package_app).map_err(|error| MarketClientError { + code: "invalid_package".to_string(), + message: error.to_string(), + request_id: None, + })?; + progress(None, "validating", 1, 1); + + let submission = client.create_submission(draft).await?; + let submission_id = submission.submission_id.clone(); + progress(Some(&submission_id), "package", 0, 1); + client + .upload_submission_package(&submission_id, package) + .await?; + progress(Some(&submission_id), "package", 1, 1); + + let screenshot_total = screenshot_paths.len() as u32; + for (position, path) in screenshot_paths.iter().enumerate() { + let (media_type, bytes) = read_screenshot_file(Path::new(path)).await?; + client + .upload_submission_screenshot(&submission_id, position as u32, media_type, bytes) + .await?; + progress( + Some(&submission_id), + "screenshots", + position as u32 + 1, + screenshot_total, + ); + } + let submission = client.submit_submission(&submission_id).await?; + progress(Some(&submission_id), "submitted", 1, 1); + Ok(submission) +} + +fn screenshot_error(message: String) -> MarketClientError { + MarketClientError { + code: "invalid_screenshot".to_string(), + message, + request_id: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::miniapp::market::MarketLicense; + use bitfun_product_domains::miniapp::types::MiniAppPermissions; + + fn submission( + slug: &str, + release: u32, + status: MarketSubmissionStatus, + listing_id: Option<&str>, + ) -> MarketSubmission { + MarketSubmission { + submission_id: format!("sub-{slug}-{release}"), + listing_id: listing_id.map(str::to_string), + slug: slug.to_string(), + release_number: release, + name: "App".to_string(), + description: "Desc".to_string(), + icon: "📦".to_string(), + category: "utilities".to_string(), + tags: Vec::new(), + min_bitfun_version: "0.1.0".to_string(), + changelog: "Initial".to_string(), + license: MarketLicense { + spdx_expression: Some("MIT".to_string()), + custom_url: None, + }, + repository_url: None, + permissions: MiniAppPermissions::default(), + status, + package_sha256: None, + package_size: None, + screenshot_urls: Vec::new(), + rejection_reason: None, + created_at: 0, + updated_at: 0, + } + } + + #[test] + fn resolve_release_target_first_release() { + assert_eq!( + resolve_release_target(&[], "fresh-app"), + ReleaseTarget::NewListing + ); + } + + #[test] + fn resolve_release_target_bumps_after_approval() { + let history = vec![ + submission("my-app", 1, MarketSubmissionStatus::Approved, Some("l1")), + submission("my-app", 2, MarketSubmissionStatus::Approved, Some("l1")), + submission("other", 5, MarketSubmissionStatus::Approved, Some("l2")), + ]; + assert_eq!( + resolve_release_target(&history, "my-app"), + ReleaseTarget::ExistingListing { + listing_id: "l1".to_string(), + next_release: 3, + } + ); + } + + #[test] + fn resolve_release_target_flags_pending_review() { + let history = vec![ + submission("my-app", 1, MarketSubmissionStatus::Approved, Some("l1")), + submission("my-app", 2, MarketSubmissionStatus::Submitted, Some("l1")), + ]; + assert_eq!( + resolve_release_target(&history, "my-app"), + ReleaseTarget::PendingReview { + submission_id: "sub-my-app-2".to_string(), + release_number: 2, + } + ); + } + + #[test] + fn resolve_release_target_ignores_withdrawn_and_rejected() { + let history = vec![ + submission("my-app", 1, MarketSubmissionStatus::Withdrawn, None), + submission("my-app", 1, MarketSubmissionStatus::Rejected, None), + ]; + assert_eq!( + resolve_release_target(&history, "my-app"), + ReleaseTarget::NewListing + ); + } + + #[test] + fn suggest_market_slug_normalizes_names() { + assert_eq!( + suggest_market_slug("Regex Playground!", "unused"), + "regex-playground" + ); + assert_eq!(suggest_market_slug("My App", "unused"), "my-app"); + } + + #[test] + fn suggest_market_slug_falls_back_for_short_or_cjk_names() { + assert_eq!( + suggest_market_slug("正则游乐场", "1a2b3c4d-5e6f"), + "miniapp-1a2b3c4d" + ); + } + + #[test] + fn map_local_category_covers_local_and_market_values() { + assert_eq!(map_local_category_to_market("utility"), "utilities"); + assert_eq!(map_local_category_to_market("dev"), "developer"); + assert_eq!(map_local_category_to_market("game"), "entertainment"); + assert_eq!(map_local_category_to_market("media"), "creative"); + assert_eq!(map_local_category_to_market("productivity"), "productivity"); + assert_eq!(map_local_category_to_market("developer"), "developer"); + assert_eq!(map_local_category_to_market("nonsense"), "other"); + } +} diff --git a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts index 9dd1ec5344..70fdce1ce7 100644 --- a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts +++ b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts @@ -321,6 +321,16 @@ export const TOOL_CARD_CONFIGS: Record = { displayMode: 'standard', primaryColor: UI_EXCEPTION_ACCENTS.miniApp }, + 'PublishMiniApp': { + toolName: 'PublishMiniApp', + displayName: 'Publish Mini App', + icon: 'APP', + requiresConfirmation: false, + resultDisplayType: 'detailed', + description: 'Submit a Mini App to the market for review', + displayMode: 'standard', + primaryColor: UI_EXCEPTION_ACCENTS.miniApp + }, 'PageDeploy': { toolName: 'PageDeploy', displayName: 'Deploy Page',