diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index 8d0fabb96b..b53cc421df 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -240,7 +240,7 @@ Assembly 是唯一组装根,只选择下层能力和实现,不能反向依 命名工作流的无 I/O 决策归 `agent-workflows`;具体 I/O 归 Services;`product-capabilities` 只选择工作流能力、Agent ID 和原子工具组。依赖方向固定为 `Assembly → agent-workflows → agent-runtime / contracts`,`agent-runtime` 禁止反向依赖 `agent-workflows`。当前 `agent-workflows` 的 DeepResearch 报告后处理不需要 Runtime 类型,因此直接依赖基础库;这不改变上述长期方向。 -原子逻辑模块应满足三个条件:一个明确 owner、一个可独立测试的输入输出契约、一个变化原因。`core.basic`、`core.agent`、`core.session`、`core.git`、`core.web`、`core.mcp`、`core.computer-use`、`core.review`、`core.miniapp`、`core.canvas` 分别表达实际工具职责;不得重新合成 `core.integration` 这类同时包含网络、Git、MCP、产品工具和平台能力的大组。 +原子逻辑模块应满足三个条件:一个明确 owner、一个可独立测试的输入输出契约、一个变化原因。`core.basic`、`core.agent`、`core.session`、`core.git`、`core.web`、`core.mcp`、`core.computer-use`、`core.review`、`core.miniapp`、`core.creation`、`core.canvas` 分别表达实际工具职责;其中 `core.creation` 只承载创造模式的产品创作入口,不吸收 MiniApp runtime/market owner。不得重新合成 `core.integration` 这类同时包含网络、Git、MCP、产品工具和平台能力的大组。 当前生产循环仍由 `assembly/core` 的 `ConversationCoordinator → Scheduler → ExecutionEngine → RoundExecutor → ToolPipeline` 持有;`agent-runtime` 已拥有可移植决策与 Rust preview facade,但尚未独立拥有完整循环。`assembly/core` 因此仍是过渡期兼容组装与实际 owner,不能被描述成只有 wiring。后续迁移必须逐条切换真实调用方、保留行为等价测试并删除旧写入方;移动 DTO、增加 feature 或 re-export 均不算 owner 迁移。当前仍位于 Runtime/Core 的 DeepReview 兼容逻辑只允许迁出和修复,不继续承接新的产品行为。 @@ -832,10 +832,10 @@ flowchart LR | 当前入口 | 已有能力 | 明确边界 | |---|---|---| -| Desktop | 使用 `product-full`;Settings 从现有来源目录和 integration policy 生成简短应用概览,具体审批与冲突仍进入 Tool、Agent、MCP 或 Hook owner | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | -| CLI / TUI | 使用 `DeliveryProfile::Cli` 和显式 Core owner closure;Runtime 只注册 Code Agent 清单及 `basic / agent / session / git / web / mcp / computer-use` 七个原子工具组,外部来源、插件和 Remote/SSH 仍由各自 owner 按入口需要装配 | 不注册 DeepReview、DeepResearch、MiniApp 或 Canvas Agent / Tool;非交互不等待权限输入,远程能力未接入时不回退本机 | +| Desktop | 使用 `product-full`,包含 Creative Agent 与 `core.creation`;Settings 从现有来源目录和 integration policy 生成简短应用概览,具体审批与冲突仍进入 Tool、Agent、MCP 或 Hook owner | 可执行能力在事实所在 Host 运行;Safe Mode 只阻止新调用,不改来源、不取消正在运行的调用 | +| CLI / TUI | 使用 `DeliveryProfile::Cli` 和显式 Core owner closure;Runtime 只注册 Code Agent 清单及 `basic / agent / session / git / web / mcp / computer-use` 七个原子工具组,外部来源、插件和 Remote/SSH 仍由各自 owner 按入口需要装配 | 不注册 DeepReview、DeepResearch、MiniApp、Creation 或 Canvas Agent / Tool;非交互不等待权限输入,远程能力未接入时不回退本机 | | ACP | 使用 `DeliveryProfile::Acp`、Runtime Parts、Code Agent 清单与同一组七个原子工具组,不选择 CLI 的 plugin runtime 和 Remote Connect owner | load 成功后才发布活动状态;完整历史和兼容配置仍由 Core/ACP 管理;未选择的产品工作流不得借 Cargo feature union 偶然注册 | -| SDK Host(preview) | 使用 `DeliveryProfile::Sdk`、Runtime Parts、Code Agent 清单和七个原子工具组;TLS provider 由 Host 进程入口安装 | 不注册 DeepReview、DeepResearch、MiniApp 或 Canvas;当前协议也不暴露远程 workspace/SSH,未来远程 SDK 必须复用 Server/Remote 的认证和执行域 | +| SDK Host(preview) | 使用 `DeliveryProfile::Sdk`、Runtime Parts、Code Agent 清单和七个原子工具组;TLS provider 由 Host 进程入口安装 | 不注册 DeepReview、DeepResearch、MiniApp、Creation 或 Canvas;当前协议也不暴露远程 workspace/SSH,未来远程 SDK 必须复用 Server/Remote 的认证和执行域 | | Peer / Server | Peer Host 执行真实工作区操作;通用 HTTP Server 未绑定可信 workspace owner 时明确返回不支持 | 控制端不替远端发现或执行;loopback 单用户边界不扩展到远程/多用户;SSH Remote 未接入时返回不支持 | | Web / Mobile Web | 依赖现有后端入口 | 不持有插件执行单元,也不能据空 profile 宣称独立能力 | | HarmonyOS 手机 Remote | phone-only ArkTS 远程入口 | 不等于 HarmonyOS PC 本地 Runtime、CLI/TUI 或 GUI | diff --git a/docs/architecture/rust-build-dependency-boundaries.md b/docs/architecture/rust-build-dependency-boundaries.md index ee29a0d96e..44760f6a07 100644 --- a/docs/architecture/rust-build-dependency-boundaries.md +++ b/docs/architecture/rust-build-dependency-boundaries.md @@ -71,7 +71,8 @@ runtime dependency;真实产品入口必须同时显式选择 owner 与 modifi 把完整 adapter、service 或 tool runtime 拉回窄闭包。 Function Agent 的 Git/AI 适配由 `function-agents` 选择,MiniApp 的 domain/runtime/market -闭包由 `tools-miniapp` 选择;不得再通过一个通用 `product-domains` Core feature 把两者、 +闭包由 `tools-miniapp` 选择,创造模式专属的前端工作台由独立的 `tools-creation` 选择; +产品装配可以为 Creative 同时选择两个 owner,但不得再通过一个通用 `product-domains` Core feature 把它们、 Plugin Source 和完整 domain feature 集合一起带回 Agent Runtime。产品装配计划若声明了当前 二进制未编译的工具组,必须在 registry materialization 前明确失败,不能静默删掉该组。 diff --git a/docs/interactive-capabilities/README.md b/docs/interactive-capabilities/README.md index 047e012501..836ac8687c 100644 --- a/docs/interactive-capabilities/README.md +++ b/docs/interactive-capabilities/README.md @@ -1,9 +1,9 @@ # BitFun 功能与设置目录 / BitFun Features & Settings -BitFun Playbook 当前包含 **22 个功能**和 **16 个设置页**,共 **38 个**用户可理解的条目、**315 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、BitFun 全局搜索和 `BitFunControl` Agent 工具。 +BitFun Playbook 当前包含 **22 个功能**和 **16 个设置页**,共 **38 个**用户可理解的条目、**316 项**有源码证据的子能力。每个条目有独立 Markdown,并直接服务于说明书网站、BitFun 全局搜索和 `BitFunControl` Agent 工具。 -BitFun Playbook currently contains **22 features**, **16 settings pages**, and **315** source-backed sub-capabilities across **38** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `BitFunControl` agent tool. +BitFun Playbook currently contains **22 features**, **16 settings pages**, and **316** source-backed sub-capabilities across **38** user-facing entries. Every entry has its own Markdown page and directly powers the website, in-app global search, and the `BitFunControl` agent tool. ## 唯一事实源 / Single source of truth @@ -27,20 +27,20 @@ BitFun Playbook currently contains **22 features**, **16 settings pages**, and * - Generated runtime catalog: `src/crates/contracts/product-domains/src/generated/product-control-catalog.json` - Generated per-item interaction audit: `docs/interactive-capabilities/technical/product-control-open-audit.json` -说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **651** 个 Tauri 命令,以及 **365** 个产品交互源码文件中的 **4421** 个交互候选,只用于实现覆盖审计。 +说明书、网站、搜索和 Agent 只看“功能 + 设置 + 子能力”。每项子能力都必须引用已注册 Tauri Command 或可解析的源码标记;这些证据不会进入公开目录。当前 **653** 个 Tauri 命令,以及 **366** 个产品交互源码文件中的 **4395** 个交互候选,只用于实现覆盖审计。 -Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **651** Tauri commands and **4421** interaction candidates across **365** product UI source files remain implementation-audit evidence only. +Docs, website, search, and agents see only features, settings, and documented sub-capabilities. Every sub-capability must reference a registered Tauri command or a resolvable source marker; evidence is stripped from public projections. The **653** Tauri commands and **4395** interaction candidates across **366** product UI source files remain implementation-audit evidence only. ## 控制边界 / Control boundary -- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **49**、委托 **61**、需交互 **205**、不支持 **0**。 +- 每个子能力都明确标记为直接控制、委托给专用 Agent 工具、需交互打开或不支持;“打开页面”不会再被统计成“Agent 已控制”。当前覆盖:直接 **49**、委托 **62**、需交互 **205**、不支持 **0**。 - 稳定行为声明为带 JSON 输入契约的 `operations` 或 `options`,并绑定原生产品控制 Provider;Agent 不接触原始 Tauri Command。 -- `BitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 315 项子能力都不会写入 system prompt。 +- `BitFunControl list` 和 `search` 都返回带 `nextCursor` 的精简分页结果;目录可持续增长,不靠固定总量上限。完整目录和 316 项子能力都不会写入 system prompt。 - 目录发现与契约读取不依赖 React 或可见窗口。普通配置型 option 统一由 Product Assembly 的共享 ConfigService 执行器读、写并回读,因此 Desktop、CLI 与 Headless 表面走同一份实现;只有宿主原生 operation/provider option 和界面导航按表面注册适配器,缺失时必须明确返回不可用,禁止静默回退本机。只读 Agent 只能发现和读取目录。 -- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **49 direct**, **61 delegated**, **205 interactive**, and **0 unsupported**. +- Every documented item is classified as direct control, delegated Agent control, interactive opening, or unsupported; opening a page is never counted as direct control. Current coverage is **49 direct**, **62 delegated**, **205 interactive**, and **0 unsupported**. - Stable behavior becomes a typed `operation` or `option` with a JSON input contract and a native product-control provider. Agents never receive raw Tauri commands. -- `BitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 315 documented items enters the system prompt. +- `BitFunControl list` and `search` return compact pages with a `nextCursor`; the catalog can grow without a fixed total-size ceiling. Neither the full catalog nor its 316 documented items enters the system prompt. - Discovery and contract lookup do not depend on React or a visible window. Ordinary config-backed options are read, written, and read back by one Product Assembly ConfigService executor shared by Desktop, CLI, and headless surfaces. Only host-native operations/provider options and presentation routes install surface adapters; missing adapters return explicit unavailability without local fallback. Read-only agents may only discover and inspect entries. ## 防腐化门禁 / Anti-drift gates diff --git a/docs/interactive-capabilities/capabilities.json b/docs/interactive-capabilities/capabilities.json index 9dd56180a0..a2e30e237a 100644 --- a/docs/interactive-capabilities/capabilities.json +++ b/docs/interactive-capabilities/capabilities.json @@ -4,7 +4,7 @@ "title": "BitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "45868ac0809aca71acdf49b9ed64e7e139f15cd8a1504d1330fdaf29ccf9ca61", + "digest": "b915ffcb329182de3e033d8e48186a1dbd5c683d975484567d9dfe9b9c595c13", "ownerDigest": "a6a8c5337fb877e641b66f5774740d5213caed4800fe9b006eb53632f7eb1c2d", "searchAcceptance": [ { @@ -138,10 +138,10 @@ "features": 22, "settings": 16, "userFacing": 38, - "documentedItems": 315, + "documentedItems": 316, "controlCoverage": { "direct": 49, - "delegated": 61, + "delegated": 62, "interactive": 205, "unsupported": 0 } @@ -192,6 +192,7 @@ "create-session", "session-lifecycle", "model-mode-permissions", + "creative-frontend-authoring", "turn-control", "permission-mailbox", "history-search", @@ -383,6 +384,54 @@ "sceneId": "session" } }, + { + "id": "feature.ai-assistant:delegate:creative-frontend-authoring", + "capabilityId": "feature.ai-assistant", + "itemIds": [ + "creative-frontend-authoring" + ], + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": true + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "delegateTools": [ + "FrontendWorkbench", + "Read", + "Write", + "Edit" + ], + "presentationTarget": { + "kind": "scene", + "sceneId": "session" + } + }, { "id": "feature.ai-assistant:open:turn-control", "capabilityId": "feature.ai-assistant", @@ -18181,7 +18230,9 @@ "新建会话", "继续任务", "权限", - "目标" + "目标", + "创造模式", + "前端定制" ], "keywordsEn": [ "chat", @@ -18190,17 +18241,21 @@ "new session", "continue task", "permission", - "goal" + "goal", + "Creative mode", + "frontend customization" ], "highlightsZh": [ "创建、归档、恢复和分叉会话", "暂停、继续或引导正在运行的任务", - "查看工具调用、权限请求和用量" + "查看工具调用、权限请求和用量", + "用创造模式安全定制正在运行的 BitFun 前端" ], "highlightsEn": [ "Create, archive, restore, and fork sessions", "Pause, continue, or steer running work", - "Inspect tool calls, permission requests, and usage" + "Inspect tool calls, permission requests, and usage", + "Safely customize the running BitFun frontend in Creative mode" ], "items": [ { @@ -18237,6 +18292,28 @@ "reasonEn": "“Choose the model, work mode, memory mode, and permission mode per session” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "creative-frontend-authoring", + "titleZh": "让创造模式安全修改正在运行的 BitFun 前端", + "titleEn": "Let Creative mode safely customize the running BitFun frontend", + "control": { + "kind": "delegate", + "tools": [ + "FrontendWorkbench", + "Read", + "Write", + "Edit" + ], + "workflowZh": [ + "仅在本地桌面的创造模式中调用 FrontendWorkbench prepare,随后只编辑返回的草稿目录", + "用原草稿 ID 调用 apply 后,告知用户必须在 15 秒内通过不可变确认窗口保留修改;再次查询 status,只有不再 pending 才算完成" + ], + "workflowEn": [ + "Call FrontendWorkbench prepare only from Creative mode on the local desktop, then edit only the returned draft directory", + "Apply with the original draft ID, tell the user to keep the change through the immutable confirmation window within 15 seconds, and treat it as complete only after status is no longer pending" + ] + } + }, { "id": "turn-control", "titleZh": "开始、引导、打断、取消或恢复一次 Agent 运行", @@ -18449,6 +18526,8 @@ "继续任务", "权限", "目标", + "创造模式", + "前端定制", "chat", "conversation", "session", @@ -18456,18 +18535,24 @@ "continue task", "permission", "goal", + "Creative mode", + "frontend customization", "创建、归档、恢复和分叉会话", "暂停、继续或引导正在运行的任务", "查看工具调用、权限请求和用量", + "用创造模式安全定制正在运行的 BitFun 前端", "Create, archive, restore, and fork sessions", "Pause, continue, or steer running work", "Inspect tool calls, permission requests, and usage", + "Safely customize the running BitFun frontend in Creative mode", "打开并新建一个 AI 会话", "Open and create a new AI session", "重命名、归档、恢复、删除、分叉与导出会话", "Rename, archive, restore, delete, fork, and export sessions", "为会话选择模型、工作模式、记忆模式与权限模式", "Choose the model, work mode, memory mode, and permission mode per session", + "让创造模式安全修改正在运行的 BitFun 前端", + "Let Creative mode safely customize the running BitFun frontend", "开始、引导、打断、取消或恢复一次 Agent 运行", "Start, steer, interrupt, cancel, or recover an agent turn", "查看并逐个或批量回答等待中的权限请求", @@ -18494,9 +18579,16 @@ "打开 AI 助手,我想继续刚才的任务", "Start a new session so we can debug this build error", "Open the AI assistant; I want to continue my previous task", + "FrontendWorkbench", + "Read", + "Write", + "Edit", + "仅在本地桌面的创造模式中调用 FrontendWorkbench prepare,随后只编辑返回的草稿目录", + "用原草稿 ID 调用 apply 后,告知用户必须在 15 秒内通过不可变确认窗口保留修改;再次查询 status,只有不再 pending 才算完成", + "Call FrontendWorkbench prepare only from Creative mode on the local desktop, then edit only the returned draft directory", + "Apply with the original draft ID, tell the user to keep the change through the immutable confirmation window within 15 seconds, and treat it as complete only after status is no longer pending", "SessionControl", "SessionHistory", - "Read", "Grep", "先用 SessionControl 的 list 找到目标会话,再用 SessionHistory 导出带索引的会话记录", "先只读取导出文件的索引区,再用 Read 或 Grep 定位并读取需要的轮次,避免把完整历史塞进上下文", diff --git a/docs/interactive-capabilities/capabilities/feature.ai-assistant.md b/docs/interactive-capabilities/capabilities/feature.ai-assistant.md index e565dfda33..66aabae3ff 100644 --- a/docs/interactive-capabilities/capabilities/feature.ai-assistant.md +++ b/docs/interactive-capabilities/capabilities/feature.ai-assistant.md @@ -23,6 +23,8 @@ Work with AI in project context, create sessions, continue tasks, answer permiss - Rename, archive, restore, delete, fork, and export sessions - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 为会话选择模型、工作模式、记忆模式与权限模式 - Choose the model, work mode, memory mode, and permission mode per session +- **由专用 Agent 工具控制 / Delegated Agent tool** · `FrontendWorkbench` / `Read` / `Write` / `Edit` · 让创造模式安全修改正在运行的 BitFun 前端 + - Let Creative mode safely customize the running BitFun frontend - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 开始、引导、打断、取消或恢复一次 Agent 运行 - Start, steer, interrupt, cancel, or recover an agent turn - **Agent 可定位入口,需交互完成 / Agent opens; interaction required** · 查看并逐个或批量回答等待中的权限请求 diff --git a/docs/interactive-capabilities/technical/product-control-open-audit.json b/docs/interactive-capabilities/technical/product-control-open-audit.json index c0d7c59d07..1df2d63322 100644 --- a/docs/interactive-capabilities/technical/product-control-open-audit.json +++ b/docs/interactive-capabilities/technical/product-control-open-audit.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "45868ac0809aca71acdf49b9ed64e7e139f15cd8a1504d1330fdaf29ccf9ca61", + "catalogDigest": "b915ffcb329182de3e033d8e48186a1dbd5c683d975484567d9dfe9b9c595c13", "count": 205, "reasonCounts": { "externalAuth": 4, diff --git a/docs/interactive-capabilities/technical/tauri-command-map.json b/docs/interactive-capabilities/technical/tauri-command-map.json index 2766b411b7..55299214ee 100644 --- a/docs/interactive-capabilities/technical/tauri-command-map.json +++ b/docs/interactive-capabilities/technical/tauri-command-map.json @@ -1,11 +1,11 @@ { "schemaVersion": 2, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "45868ac0809aca71acdf49b9ed64e7e139f15cd8a1504d1330fdaf29ccf9ca61", - "commandCount": 651, + "catalogDigest": "b915ffcb329182de3e033d8e48186a1dbd5c683d975484567d9dfe9b9c595c13", + "commandCount": 653, "coverage": { - "commandCount": 651, - "documentedCommandCount": 613, + "commandCount": 653, + "documentedCommandCount": 615, "implementationCommandCount": 38, "implementationDigest": "965d441b20c0092bc25c1dd48db3e31f72a977479fe9d1ff7c788d8e17d7395b" }, @@ -1472,6 +1472,22 @@ "signature": "fn computer_use_request_permissions() -> Result<(), String>", "remoteWorkspacePolicy": "LocalOnly" }, + { + "id": "confirm_frontend_update", + "moduleId": "frontend_workbench", + "capabilityId": "feature.ai-assistant", + "capabilityIds": [ + "feature.ai-assistant" + ], + "documentedItemIds": [ + "feature.ai-assistant:creative-frontend-authoring" + ], + "visibility": "documented", + "rustPath": "frontend_workbench::confirm_frontend_update", + "sourceFile": "src/apps/desktop/src/frontend_workbench.rs", + "signature": "fn confirm_frontend_update( state: tauri::State<'_, Arc>, webview: tauri::WebviewWindow, request: FrontendUpdateDecisionRequest, ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "control_background_command", "moduleId": "agentic", @@ -8021,6 +8037,22 @@ "signature": "fn review_platform_update_auth_token( _state: State<'_, AppState>, request: ReviewPlatformUpdateAuthTokenRequest, ) -> Result<(), String>", "remoteWorkspacePolicy": "WorkspaceAgnostic" }, + { + "id": "rollback_frontend_update", + "moduleId": "frontend_workbench", + "capabilityId": "feature.ai-assistant", + "capabilityIds": [ + "feature.ai-assistant" + ], + "documentedItemIds": [ + "feature.ai-assistant:creative-frontend-authoring" + ], + "visibility": "documented", + "rustPath": "frontend_workbench::rollback_frontend_update", + "sourceFile": "src/apps/desktop/src/frontend_workbench.rs", + "signature": "fn rollback_frontend_update( state: tauri::State<'_, Arc>, webview: tauri::WebviewWindow, request: FrontendUpdateDecisionRequest, ) -> Result", + "remoteWorkspacePolicy": "LocalOnly" + }, { "id": "rollback_miniapp", "moduleId": "miniapp", diff --git a/docs/interactive-capabilities/technical/ui-interaction-inventory.json b/docs/interactive-capabilities/technical/ui-interaction-inventory.json index d6625972ab..b6c60988fd 100644 --- a/docs/interactive-capabilities/technical/ui-interaction-inventory.json +++ b/docs/interactive-capabilities/technical/ui-interaction-inventory.json @@ -1,13 +1,13 @@ { "schemaVersion": 1, "generatedFrom": "src/shared/interactive-capabilities/catalog.json", - "catalogDigest": "45868ac0809aca71acdf49b9ed64e7e139f15cd8a1504d1330fdaf29ccf9ca61", + "catalogDigest": "b915ffcb329182de3e033d8e48186a1dbd5c683d975484567d9dfe9b9c595c13", "roots": [ "src/web-ui/src" ], - "digest": "981019f2d18ab8ca85a2247122aab358ec6618c69ca8a693b480f28e480555eb", - "fileCount": 365, - "interactionCount": 4421, + "digest": "97d7b40c2075a564ba77eedb7393f3d8b2663d77f20b0a2719d3ff9eb11310b9", + "fileCount": 366, + "interactionCount": 4395, "files": [ { "sourceFile": "src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx", @@ -126,13 +126,13 @@ }, { "sourceFile": "src/web-ui/src/app/components/RemoteConnectDialog/AccountPanel.tsx", - "interactionCount": 36, - "digest": "87f4fb6403cda53eb678def002714fd769ab919150da0afcf5b0893d5ba77408" + "interactionCount": 28, + "digest": "a656559f1bf2833d78eb55d85918ed9d641334618e90835b61a5336bac643910" }, { "sourceFile": "src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDialog.tsx", - "interactionCount": 78, - "digest": "09abb0dbf16dacac21d4b739599ee4d57866292d34371df902d3c1767dd771b3" + "interactionCount": 46, + "digest": "8747879670a8d485fd4b31c5772348e4523516fe59d24c40759f9b1de341f6c9" }, { "sourceFile": "src/web-ui/src/app/components/RemoteConnectDialog/RemoteConnectDisclaimer.tsx", @@ -141,14 +141,24 @@ }, { "sourceFile": "src/web-ui/src/app/components/SceneBar/SceneBar.tsx", - "interactionCount": 12, - "digest": "ebf091544288b0654cd940503aa5f8c2404fdfccb0c265aa3568728d99c3b2e0" + "interactionCount": 10, + "digest": "64e1538aaa632aab087d1409b62b5c946a99dd107ee9031c610db65e2aceadd6" }, { "sourceFile": "src/web-ui/src/app/components/SceneBar/useSceneTabNavigation.ts", "interactionCount": 1, "digest": "493da28b1558566c11075d3148cbf0b9f75c2103c7c45d6b65846593246ea7f5" }, + { + "sourceFile": "src/web-ui/src/app/components/SceneTopBar/SceneChrome.tsx", + "interactionCount": 5, + "digest": "a62f29ca25ddbcefe04fad595f846b481c7a6c6ffebbb813277699fc4d459d67" + }, + { + "sourceFile": "src/web-ui/src/app/components/SceneTopBar/SceneTopBar.tsx", + "interactionCount": 2, + "digest": "ed7201691ffc51cf9c13baa941212e60befd549c24c00a8baebe1697b1839e3e" + }, { "sourceFile": "src/web-ui/src/app/components/TitleBar/NotificationButton.tsx", "interactionCount": 7, @@ -266,8 +276,8 @@ }, { "sourceFile": "src/web-ui/src/app/global-search/GlobalSearchRoot.tsx", - "interactionCount": 19, - "digest": "2aeaca8d728a5fa487c2843b356604d6928c8b28f5fce98392449befd4020471" + "interactionCount": 26, + "digest": "5e99cbff91db02f087edc00d046284f538bec8af3ad1d02c9581f00d2de12b80" }, { "sourceFile": "src/web-ui/src/app/global-search/interactiveCapabilityCatalog.ts", @@ -422,7 +432,7 @@ { "sourceFile": "src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx", "interactionCount": 16, - "digest": "a5ad6f4e5ab83494d0dcec51b2580cfb9152e8ea1b62865ea83da6491b6478e6" + "digest": "efcfbd180088f3dbf8d16878bafa313ce204b971564867cec76fc419505c6c37" }, { "sourceFile": "src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.tsx", @@ -644,11 +654,6 @@ "interactionCount": 6, "digest": "ea060e14ea967a8f3d7614d37a30e40745f47f2def1318e842dd858ae653e0be" }, - { - "sourceFile": "src/web-ui/src/component-library/components/Modal/Modal.tsx", - "interactionCount": 3, - "digest": "b8051f3ce05c2bc03a14d7c5661ee67c5467e161116d223f9633885ea6d89977" - }, { "sourceFile": "src/web-ui/src/component-library/components/NumberInput/NumberInput.tsx", "interactionCount": 16, @@ -691,8 +696,8 @@ }, { "sourceFile": "src/web-ui/src/component-library/components/registry.tsx", - "interactionCount": 67, - "digest": "f0c04827acbc395c56442a6a9f56976199ff7e74944c4acf92e5e687c8f5bb71" + "interactionCount": 66, + "digest": "82f8a85b6e40ee054b07d430f158d3f1ded1b770d07f188ce5e9caa1931c4ff5" }, { "sourceFile": "src/web-ui/src/component-library/preview/MarkdownPreview.tsx", @@ -781,8 +786,8 @@ }, { "sourceFile": "src/web-ui/src/flow_chat/components/ChatInput.tsx", - "interactionCount": 72, - "digest": "e651a27bb5f56afdfd959d347d30ffb5d77e11a53f483bd477c1bfc881c961d7" + "interactionCount": 63, + "digest": "8899ee85648843c3003c6e56129a5e74e7a2f3c1b375396d2c4d2bc308abcee1" }, { "sourceFile": "src/web-ui/src/flow_chat/components/ChatInputApprovalBand.tsx", @@ -836,8 +841,8 @@ }, { "sourceFile": "src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx", - "interactionCount": 6, - "digest": "46f5e5d7ea81bfc842800d0828091753e4e50a2958cd498e6491bb05e4f70f62" + "interactionCount": 21, + "digest": "02e327e71efacffd9bc329df134b9de2e46d601752ad09e3b28901ed7ad1e2c0" }, { "sourceFile": "src/web-ui/src/flow_chat/components/ImageAnalysisCard.tsx", @@ -921,8 +926,8 @@ }, { "sourceFile": "src/web-ui/src/flow_chat/components/modern/FlowChatHeader.tsx", - "interactionCount": 49, - "digest": "1a2907522c75ea3b8faeb75ca1a0db41d2578473cc02e25353b1e92e7f4db6bf" + "interactionCount": 46, + "digest": "66c15f43f218808294928f84b519406e7098a07f679af433e12f6698ff877fd6" }, { "sourceFile": "src/web-ui/src/flow_chat/components/modern/FlowChatTurnRail.tsx", @@ -1371,8 +1376,8 @@ }, { "sourceFile": "src/web-ui/src/infrastructure/config/components/MemorySettingsPage.tsx", - "interactionCount": 26, - "digest": "b2e66d55d31b5ccf6714a150f1570126bc8257305d509cee0e113b6ddad66188" + "interactionCount": 29, + "digest": "01dab833ff1b42aa8c3139d56b80157872d465df343f9695ef289f4aaeaa50e2" }, { "sourceFile": "src/web-ui/src/infrastructure/config/components/ModelSelectionRadio.tsx", @@ -1382,7 +1387,7 @@ { "sourceFile": "src/web-ui/src/infrastructure/config/components/ModelSettingsPage.tsx", "interactionCount": 150, - "digest": "3ea0b2ee92bfbd4b6ad40f3fed55fa73b5756a9fd55d1b05b209a0638d4e15e6" + "digest": "6157193e1e92b1356d44c249bd050310726cd16ef56623c5de112630ffdd9551" }, { "sourceFile": "src/web-ui/src/infrastructure/config/components/QuickActionsConfig.tsx", @@ -1427,7 +1432,7 @@ { "sourceFile": "src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.tsx", "interactionCount": 12, - "digest": "8dbdabc85f9522b362facf6cf76a4423e255dc32cf714247ce6e0343014b1914" + "digest": "64de6d71828e1281d830869669e97b760267704bc1506d8894eb1d108702b52c" }, { "sourceFile": "src/web-ui/src/infrastructure/config/components/VoiceInputConfig.tsx", diff --git a/scripts/check-core-boundaries.test.mjs b/scripts/check-core-boundaries.test.mjs index 5fe22b357a..6045bdc4bd 100644 --- a/scripts/check-core-boundaries.test.mjs +++ b/scripts/check-core-boundaries.test.mjs @@ -1259,6 +1259,7 @@ test('Core product-full explicitly assembles service and tool capability owners' 'tools-mcp', 'tools-browser-web', 'tools-computer-use', + 'tools-creation', 'tools-image-analysis', 'tools-miniapp', 'tools-canvas', diff --git a/scripts/core-boundaries/cargo-dependency-boundaries.mjs b/scripts/core-boundaries/cargo-dependency-boundaries.mjs index 20c6339bf2..f6cbae069e 100644 --- a/scripts/core-boundaries/cargo-dependency-boundaries.mjs +++ b/scripts/core-boundaries/cargo-dependency-boundaries.mjs @@ -1446,6 +1446,7 @@ export function findProductEntrypointCoreFeatureViolations( 'tools-browser-web', 'tools-canvas', 'tools-computer-use', + 'tools-creation', 'tools-git', 'tools-image-analysis', 'tools-mcp', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 0204a8df9c..f4720269c2 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -212,6 +212,7 @@ export const optionalDependencyFeatureOwnerRules = [ 'tools-browser-web', 'tools-canvas', 'tools-computer-use', + 'tools-creation', 'tools-git', 'tools-image-analysis', 'tools-mcp', @@ -678,6 +679,7 @@ export const coreProductFullFeatureAssemblyRule = { 'tools-browser-web', 'tools-canvas', 'tools-computer-use', + 'tools-creation', 'tools-git', 'tools-image-analysis', 'tools-mcp', @@ -1290,6 +1292,15 @@ export const coreClosedFeatureProfileRules = [ exact: true, reason: 'tools-miniapp must compose only MiniApp publication and runtime tool dependencies', }, + { + manifestPath: 'src/crates/assembly/core/Cargo.toml', + featureName: 'tools-creation', + requiredFeatureRefs: [ + 'bitfun-tool-packs/creation', + ], + exact: true, + reason: 'tools-creation must own only Creative product-authoring tools', + }, { manifestPath: 'src/crates/assembly/core/Cargo.toml', featureName: 'tools-canvas', @@ -1657,6 +1668,7 @@ export const ownerCrateFeatureAssemblyRules = [ 'computer-use', 'image-analysis', 'miniapp', + 'creation', 'canvas', 'agent-control', ], diff --git a/scripts/desktop-tauri-build.mjs b/scripts/desktop-tauri-build.mjs index d2ab567a26..b6df17e251 100644 --- a/scripts/desktop-tauri-build.mjs +++ b/scripts/desktop-tauri-build.mjs @@ -234,6 +234,7 @@ export function prepareTauriConfig( // packaging injects it here; frontend:build-all (beforeBuildCommand) // compiles the profile before Tauri copies resources. injectDshProfileResource(config); + injectExternalFrontendResource(config); const release = releaseChannel ?? resolveReleaseChannel(process.env.BITFUN_RELEASE_CHANNEL); @@ -296,6 +297,8 @@ export function prepareTauriConfig( const DSH_PROFILE_RESOURCE_SOURCE = '../../../packages/dsh-acp/dist-profile'; const DSH_PROFILE_RESOURCE_TARGET = 'resources/dsh-profile'; +const EXTERNAL_FRONTEND_RESOURCE_SOURCE = '../../../dist'; +const EXTERNAL_FRONTEND_RESOURCE_TARGET = 'frontend/dist'; function injectDshProfileResource(config) { const resources = { ...(config.bundle?.resources || {}) }; @@ -306,6 +309,15 @@ function injectDshProfileResource(config) { }; } +function injectExternalFrontendResource(config) { + const resources = { ...(config.bundle?.resources || {}) }; + resources[EXTERNAL_FRONTEND_RESOURCE_SOURCE] = EXTERNAL_FRONTEND_RESOURCE_TARGET; + config.bundle = { + ...(config.bundle || {}), + resources, + }; +} + function injectTargetFlashgrepResource(config, desktopDir, flashgrepBinary) { const resources = { ...(config.bundle?.resources || {}) }; delete resources['../../../resources/flashgrep']; diff --git a/scripts/desktop-tauri-build.test.mjs b/scripts/desktop-tauri-build.test.mjs index 1dd4350ac6..b942903efc 100644 --- a/scripts/desktop-tauri-build.test.mjs +++ b/scripts/desktop-tauri-build.test.mjs @@ -344,6 +344,10 @@ test('official packaging injects the DeepSeek profile resource', () => { config.bundle.resources['../../../packages/dsh-acp/dist-profile'], 'resources/dsh-profile', ); + assert.equal( + config.bundle.resources['../../../dist'], + 'frontend/dist', + ); assert.equal( config.bundle.resources['resources/worker_host.js'], 'resources/worker_host.js', @@ -353,6 +357,16 @@ test('official packaging injects the DeepSeek profile resource', () => { } }); +test('static desktop configs keep the full frontend outside Tauri embedded assets', () => { + for (const name of ['tauri.conf.json', 'tauri.dev.conf.json']) { + const config = JSON.parse( + readFileSync(join(ROOT, 'src', 'apps', 'desktop', name), 'utf8') + ); + assert.equal(config.build.frontendDist, 'bootstrap-ui'); + assert.equal(config.bundle.resources['../../../dist'], undefined); + } +}); + test('Desktop release config bundles models.dev notices and provenance', () => { const config = JSON.parse( readFileSync(join(ROOT, 'src', 'apps', 'desktop', 'tauri.conf.json'), 'utf8') diff --git a/src/apps/cli/src/peer_host/deny.rs b/src/apps/cli/src/peer_host/deny.rs index 91a1da0f1b..8b0fcfb067 100644 --- a/src/apps/cli/src/peer_host/deny.rs +++ b/src/apps/cli/src/peer_host/deny.rs @@ -10,6 +10,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ "show_main_window", "hide_main_window_after_close_request", + "confirm_frontend_update", + "rollback_frontend_update", "quit_app", "minimize_to_tray", "initialize_tray_after_startup", @@ -276,6 +278,12 @@ mod tests { )); } + #[test] + fn frontend_update_decisions_stay_on_the_controller_device() { + assert!(is_local_only_command("confirm_frontend_update")); + assert!(is_local_only_command("rollback_frontend_update")); + } + #[test] fn product_control_presentation_callbacks_stay_on_the_controller_device() { for command in [ diff --git a/src/apps/desktop/bootstrap-ui/frontend-update-confirm.html b/src/apps/desktop/bootstrap-ui/frontend-update-confirm.html new file mode 100644 index 0000000000..a0352c0980 --- /dev/null +++ b/src/apps/desktop/bootstrap-ui/frontend-update-confirm.html @@ -0,0 +1,68 @@ + + + + + + Confirm frontend update + + + +
+

Keep this frontend?

+

Confirm within 15 seconds or BitFun will restore the previous version automatically.

+
+ + +
+ +
+ + + diff --git a/src/apps/desktop/bootstrap-ui/index.html b/src/apps/desktop/bootstrap-ui/index.html new file mode 100644 index 0000000000..362dffc6d1 --- /dev/null +++ b/src/apps/desktop/bootstrap-ui/index.html @@ -0,0 +1,21 @@ + + + + + + BitFun recovery + + + +
+

BitFun frontend recovery

+

The external frontend bundle could not be loaded. Restart BitFun to restore the packaged revision. / 外置前端无法加载,请重启 BitFun 以恢复打包版本。

+
+ + diff --git a/src/apps/desktop/capabilities/default.json b/src/apps/desktop/capabilities/default.json index 2ecc97db95..d559b42e8b 100644 --- a/src/apps/desktop/capabilities/default.json +++ b/src/apps/desktop/capabilities/default.json @@ -2,7 +2,7 @@ "$schema": "../gen/schemas/desktop-schema.json", "identifier": "default", "description": "BitFun default capabilities", - "windows": ["main", "agent-companion-pet", "spotlight"], + "windows": ["main", "agent-companion-pet", "spotlight", "frontend-update-confirm"], "permissions": [ "log:default", "autostart:default", diff --git a/src/apps/desktop/src/api/peer_host_invoke.rs b/src/apps/desktop/src/api/peer_host_invoke.rs index 5a53026cfd..0247b7f8d3 100644 --- a/src/apps/desktop/src/api/peer_host_invoke.rs +++ b/src/apps/desktop/src/api/peer_host_invoke.rs @@ -32,6 +32,8 @@ static LOCAL_ONLY_COMMANDS: &[&str] = &[ // Window / tray / process chrome "show_main_window", "hide_main_window_after_close_request", + "confirm_frontend_update", + "rollback_frontend_update", "quit_app", "minimize_to_tray", "initialize_tray_after_startup", @@ -668,6 +670,12 @@ mod tests { )); } + #[test] + fn frontend_update_decisions_stay_with_the_controller_window() { + assert!(is_local_only_command("confirm_frontend_update")); + assert!(is_local_only_command("rollback_frontend_update")); + } + #[test] fn product_control_presentation_callbacks_stay_with_the_controller_window() { for command in [ diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 0c529d4951..32398ec5a2 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -53,6 +53,8 @@ pub enum RemoteWorkspacePolicy { /// Declared remote-workspace policy for every registered Tauri command. pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = &[ + ("confirm_frontend_update", RemoteWorkspacePolicy::LocalOnly), + ("rollback_frontend_update", RemoteWorkspacePolicy::LocalOnly), ("accept_file", RemoteWorkspacePolicy::LegacyUnaudited), ("accept_operation", RemoteWorkspacePolicy::LegacyUnaudited), ("accept_session", RemoteWorkspacePolicy::LegacyUnaudited), @@ -2061,6 +2063,17 @@ mod tests { ); } + #[test] + fn frontend_update_decisions_are_local_desktop_only() { + for command in ["confirm_frontend_update", "rollback_frontend_update"] { + assert_eq!( + remote_workspace_policy(command), + Some(RemoteWorkspacePolicy::LocalOnly), + "{command} must stay with the immutable controller-side confirmation window" + ); + } + } + #[test] fn external_mcp_import_commands_explicitly_reject_remote_workspaces() { for command in [ diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index 55f4e32270..6d6631a614 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -581,7 +581,7 @@ pub fn create_main_window( } } } else { - WebviewUrl::App("index.html".into()) + crate::frontend_workbench::custom_frontend_url("index.html") }; let main_url_kind = match &main_url { WebviewUrl::External(_) => "external", @@ -623,10 +623,9 @@ pub fn create_main_window( // Keep HTML5 drag-and-drop working inside the webview for desktop UI drag targets. builder = builder.disable_drag_drop_handler(); - // Debug builds may reload the initial page for development. Release builds - // keep top-level reloads blocked. Both modes continue to reject navigation - // to a different main-page URL and allow local sandboxed MiniApp documents. - let navigation_policy = MainWebviewNavigationPolicy::new(cfg!(debug_assertions)); + // Exact top-level reloads are required for provisional Creative frontend + // activation and rollback. Different main-page URLs remain blocked. + let navigation_policy = MainWebviewNavigationPolicy::new(true); builder = builder.on_navigation(move |url| navigation_policy.should_allow(url)); #[cfg(target_os = "macos")] @@ -746,12 +745,7 @@ fn app_url(path: &str) -> WebviewUrl { } } } else { - let app_path = if path.starts_with('?') { - format!("index.html{}", path) - } else { - path.to_string() - }; - WebviewUrl::App(app_path.into()) + crate::frontend_workbench::custom_frontend_url(path) } } diff --git a/src/apps/desktop/src/embedded_relay_host.rs b/src/apps/desktop/src/embedded_relay_host.rs index e663084eee..4f75354279 100644 --- a/src/apps/desktop/src/embedded_relay_host.rs +++ b/src/apps/desktop/src/embedded_relay_host.rs @@ -256,6 +256,30 @@ mod tests { panic!("could not find a free port for the embedded relay: {last_err}"); } + /// Restart on the same port while tolerating a transient claim from another + /// socket in the runner's ephemeral range. A leaked relay listener never + /// becomes bindable and therefore still fails at the deadline. + async fn restart_on_same_port( + host: &DesktopEmbeddedRelayHost, + port: u16, + static_dir: Option, + ) { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match host.start(port, static_dir.clone()).await { + Ok(()) => return, + Err(error) => { + if std::time::Instant::now() >= deadline { + panic!( + "embedded relay could not restart on port {port} after it was stopped: {error}" + ); + } + } + } + tokio::time::sleep(std::time::Duration::from_millis(25)).await; + } + } + #[tokio::test] async fn bind_failure_does_not_create_an_active_runtime() { let occupied = tokio::net::TcpListener::bind("0.0.0.0:0") @@ -333,9 +357,7 @@ mod tests { drop(client); host.stop().await; - host.start(port, Some(static_dir.to_string_lossy().into_owned())) - .await - .expect("embedded relay should restart immediately on the same port"); + restart_on_same_port(&host, port, Some(static_dir.to_string_lossy().into_owned())).await; host.stop().await; assert_port_released(port, "stop must release the listener before returning").await; diff --git a/src/apps/desktop/src/frontend_workbench.rs b/src/apps/desktop/src/frontend_workbench.rs new file mode 100644 index 0000000000..e4fea1dd77 --- /dev/null +++ b/src/apps/desktop/src/frontend_workbench.rs @@ -0,0 +1,939 @@ +//! Writable packaged-frontend revisions with crash-safe provisional activation. + +use std::fs; +use std::path::{Component, Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use bitfun_core::agentic::tools::frontend_workbench_host::{ + set_frontend_workbench_handler, FrontendWorkbenchHostRequest, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; +use tauri::{Manager, Url, WebviewUrl, WebviewWindowBuilder}; +use uuid::Uuid; + +pub const FRONTEND_PROTOCOL_SCHEME: &str = "bitfun-ui"; +pub const FRONTEND_URL: &str = "bitfun-ui://localhost/index.html"; +const CONFIRM_WINDOW_LABEL: &str = "frontend-update-confirm"; +const CONFIRM_TIMEOUT: Duration = Duration::from_secs(15); +const STATE_SCHEMA_VERSION: u32 = 1; +const RECOVERY_HTML: &[u8] = include_bytes!("../bootstrap-ui/index.html"); + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct FrontendWorkbenchState { + schema_version: u32, + bundled_revision: Option, + active_revision: Option, + previous_revision: Option, + pending: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +struct PendingFrontendRevision { + transaction_id: String, + revision_id: String, + previous_revision: String, + expires_at_unix_ms: u64, +} + +impl Default for PendingFrontendRevision { + fn default() -> Self { + Self { + transaction_id: String::new(), + revision_id: String::new(), + previous_revision: String::new(), + expires_at_unix_ms: 0, + } + } +} + +pub struct FrontendWorkbenchManager { + root: PathBuf, + state: Mutex, + app: OnceLock, +} + +impl FrontendWorkbenchManager { + pub fn new(user_data_dir: &Path) -> Self { + Self { + root: user_data_dir.join("frontend-workbench"), + state: Mutex::new(FrontendWorkbenchState::default()), + app: OnceLock::new(), + } + } + + fn revisions_dir(&self) -> PathBuf { + self.root.join("revisions") + } + + fn drafts_dir(&self) -> PathBuf { + self.root.join("drafts") + } + + fn state_path(&self) -> PathBuf { + self.root.join("state.json") + } + + fn revision_dir(&self, revision_id: &str) -> PathBuf { + self.revisions_dir().join(revision_id) + } + + pub fn initialize( + self: &Arc, + app: &tauri::AppHandle, + bundled_frontend: &Path, + ) -> Result<(), String> { + validate_frontend_tree(bundled_frontend)?; + fs::create_dir_all(self.revisions_dir()).map_err(io_error("create revision directory"))?; + fs::create_dir_all(self.drafts_dir()).map_err(io_error("create draft directory"))?; + + let mut state = self.load_state(); + state.schema_version = STATE_SCHEMA_VERSION; + + // A pending candidate can never survive a process exit: startup restores + // the prior revision before any editable content is served. + if let Some(pending) = state.pending.take() { + if self.revision_is_available(&pending.previous_revision) { + state.active_revision = Some(pending.previous_revision.clone()); + state.previous_revision = None; + log::warn!( + "Recovered an unconfirmed frontend update by rolling back: transaction_id={}", + pending.transaction_id + ); + } else { + // Never keep serving an unconfirmed candidate just because its + // prior revision was damaged outside BitFun. The bundled copy + // below becomes the recovery target. + state.active_revision = None; + state.previous_revision = None; + log::warn!( + "Discarded an unconfirmed frontend update whose previous revision is unavailable: transaction_id={}", + pending.transaction_id + ); + } + } + + let bundled_revision = bundled_revision_id(bundled_frontend)?; + let bundled_destination = self.revision_dir(&bundled_revision); + if !bundled_destination.join("index.html").is_file() { + copy_tree_transactional(bundled_frontend, &bundled_destination)?; + } + + let bundled_changed = state.bundled_revision.as_deref() != Some(&bundled_revision); + let active_is_valid = state + .active_revision + .as_deref() + .is_some_and(|revision| self.revision_is_available(revision)); + if bundled_changed { + state.previous_revision = state.active_revision.filter(|_| active_is_valid); + state.active_revision = Some(bundled_revision.clone()); + state.bundled_revision = Some(bundled_revision); + } else if !active_is_valid { + state.active_revision = Some(bundled_revision.clone()); + state.bundled_revision = Some(bundled_revision); + state.previous_revision = None; + } + + self.save_state(&state)?; + *self.state.lock().map_err(lock_error)? = state; + let _ = self.app.set(app.clone()); + self.install_tool_host(); + Ok(()) + } + + fn install_tool_host(self: &Arc) { + let manager = Arc::clone(self); + set_frontend_workbench_handler(Arc::new(move |request| { + let manager = Arc::clone(&manager); + Box::pin(async move { manager.handle_tool_request(request) }) + })); + } + + fn handle_tool_request( + self: &Arc, + request: FrontendWorkbenchHostRequest, + ) -> Result { + ensure_local_confirmation_surface( + crate::api::peer_host_invoke::attached_controllers().len(), + )?; + match request.action.as_str() { + "prepare" => self.prepare(), + "status" => self.status(), + "apply" => self.apply( + request + .draft_id + .as_deref() + .ok_or_else(|| "draft_id is required for apply".to_string())?, + ), + "rollback" => self.rollback_confirmed(), + other => Err(format!("Unsupported FrontendWorkbench action: {other}")), + } + } + + fn prepare(&self) -> Result { + let active_revision = self + .state + .lock() + .map_err(lock_error)? + .active_revision + .clone() + .ok_or_else(|| "No active frontend revision is available".to_string())?; + validate_revision_id(&active_revision)?; + let source = self.revision_dir(&active_revision); + validate_frontend_tree(&source)?; + + let draft_id = Uuid::new_v4().to_string(); + let draft_path = self.drafts_dir().join(&draft_id); + copy_tree_transactional(&source, &draft_path)?; + fs::write( + draft_path.join("CREATION.md"), + format!( + "# BitFun frontend draft\n\nDraft id: `{draft_id}`\nBase revision: `{active_revision}`\n\nEdit only this directory. Preserve `index.html`. Prefer `bitfun-creation.css` and `bitfun-creation.js` for isolated overrides. Apply with `FrontendWorkbench` and this exact draft id; the user must confirm within 15 seconds.\n" + ), + ) + .map_err(io_error("write draft instructions"))?; + + Ok(json!({ + "status": "prepared", + "draftId": draft_id, + "draftPath": draft_path.to_string_lossy(), + "baseRevision": active_revision, + })) + } + + fn apply(self: &Arc, draft_id: &str) -> Result { + validate_uuid(draft_id, "draft_id")?; + let draft_path = self.drafts_dir().join(draft_id); + validate_frontend_tree(&draft_path)?; + + let revision_id = format!("creative-{}", Uuid::new_v4()); + copy_tree_transactional(&draft_path, &self.revision_dir(&revision_id))?; + let transaction_id = Uuid::new_v4().to_string(); + let expires_at_unix_ms = unix_ms().saturating_add(CONFIRM_TIMEOUT.as_millis() as u64); + + { + let mut state = self.state.lock().map_err(lock_error)?; + if state.pending.is_some() { + return Err( + "Another frontend update is awaiting confirmation; confirm or roll it back first" + .to_string(), + ); + } + let previous_revision = state + .active_revision + .clone() + .ok_or_else(|| "No active frontend revision is available".to_string())?; + let mut next = state.clone(); + next.previous_revision = Some(previous_revision.clone()); + next.active_revision = Some(revision_id.clone()); + next.pending = Some(PendingFrontendRevision { + transaction_id: transaction_id.clone(), + revision_id: revision_id.clone(), + previous_revision, + expires_at_unix_ms, + }); + self.save_state(&next)?; + *state = next; + } + + // Arm the host-owned timeout before touching either webview. Even if a + // navigation or confirmation-window operation fails, the candidate can + // never remain active indefinitely. + let manager = Arc::clone(self); + let timer_transaction_id = transaction_id.clone(); + tauri::async_runtime::spawn(async move { + tokio::time::sleep(CONFIRM_TIMEOUT).await; + if let Err(error) = manager.rollback_pending(&timer_transaction_id, "timeout") { + log::warn!( + "Failed to auto-rollback provisional frontend update: transaction_id={}, error={}", + timer_transaction_id, + error + ); + } + }); + + let activation_result = self + .app + .get() + .cloned() + .ok_or_else(|| "Frontend workbench desktop host is not initialized".to_string()) + .and_then(|app| { + navigate_main_to_frontend(&app)?; + show_confirmation_window(&app, &transaction_id) + }); + if let Err(activation_error) = activation_result { + return match self.rollback_pending(&transaction_id, "activation_error") { + Ok(_) => Err(activation_error), + Err(rollback_error) => Err(format!( + "{activation_error}; the immediate rollback also failed: {rollback_error}" + )), + }; + } + + Ok(json!({ + "status": "pending_confirmation", + "revisionId": revision_id, + "transactionId": transaction_id, + "expiresAtUnixMs": expires_at_unix_ms, + "confirmationTimeoutSeconds": CONFIRM_TIMEOUT.as_secs(), + })) + } + + pub fn confirm_pending(&self, transaction_id: &str) -> Result { + let expired = { + let state = self.state.lock().map_err(lock_error)?; + let pending = state + .pending + .as_ref() + .ok_or_else(|| "No frontend update is awaiting confirmation".to_string())?; + if pending.transaction_id != transaction_id { + return Err("The frontend confirmation transaction is stale".to_string()); + } + unix_ms() >= pending.expires_at_unix_ms + }; + if expired { + self.rollback_pending(transaction_id, "expired_confirmation")?; + return Err("The 15-second frontend confirmation window has expired".to_string()); + } + + let revision_id = { + let mut state = self.state.lock().map_err(lock_error)?; + let pending = state + .pending + .as_ref() + .ok_or_else(|| "No frontend update is awaiting confirmation".to_string())?; + if pending.transaction_id != transaction_id { + return Err("The frontend confirmation transaction is stale".to_string()); + } + let revision_id = pending.revision_id.clone(); + let mut next = state.clone(); + next.pending = None; + self.save_state(&next)?; + *state = next; + revision_id + }; + self.close_confirmation_window(); + Ok(json!({"status": "confirmed", "revisionId": revision_id})) + } + + pub fn rollback_pending(&self, transaction_id: &str, reason: &str) -> Result { + let restored_revision = { + let mut state = self.state.lock().map_err(lock_error)?; + let Some(pending) = state.pending.as_ref() else { + return Ok(self.status_value(&state)); + }; + if pending.transaction_id != transaction_id { + return Ok(self.status_value(&state)); + } + let restored_revision = pending.previous_revision.clone(); + if !self.revision_is_available(&restored_revision) { + return Err("The previous frontend revision is unavailable".to_string()); + } + let mut next = state.clone(); + next.active_revision = Some(restored_revision.clone()); + next.previous_revision = None; + next.pending = None; + self.save_state(&next)?; + *state = next; + restored_revision + }; + self.close_confirmation_window(); + if let Some(app) = self.app.get() { + navigate_main_to_frontend(app)?; + } + log::info!( + "Frontend revision rolled back: reason={}, restored_revision={}", + reason, + restored_revision + ); + Ok(json!({"status": "rolled_back", "activeRevision": restored_revision, "reason": reason})) + } + + fn rollback_confirmed(&self) -> Result { + let pending_transaction = self + .state + .lock() + .map_err(lock_error)? + .pending + .as_ref() + .map(|pending| pending.transaction_id.clone()); + if let Some(transaction_id) = pending_transaction { + return self.rollback_pending(&transaction_id, "explicit"); + } + + let restored_revision = { + let mut state = self.state.lock().map_err(lock_error)?; + let target = state.previous_revision.clone().ok_or_else(|| { + "No previous confirmed frontend revision is available".to_string() + })?; + if !self.revision_is_available(&target) { + return Err("The previous frontend revision is unavailable".to_string()); + } + let mut next = state.clone(); + let current = next.active_revision.replace(target.clone()); + next.previous_revision = current; + self.save_state(&next)?; + *state = next; + target + }; + if let Some(app) = self.app.get() { + navigate_main_to_frontend(app)?; + } + Ok(json!({"status": "rolled_back", "activeRevision": restored_revision})) + } + + fn status(&self) -> Result { + let state = self.state.lock().map_err(lock_error)?; + Ok(self.status_value(&state)) + } + + fn status_value(&self, state: &FrontendWorkbenchState) -> Value { + json!({ + "status": if state.pending.is_some() { "pending_confirmation" } else { "ready" }, + "activeRevision": state.active_revision, + "bundledRevision": state.bundled_revision, + "previousRevision": state.previous_revision, + "pending": state.pending, + "confirmationTimeoutSeconds": CONFIRM_TIMEOUT.as_secs(), + }) + } + + pub fn protocol_response( + &self, + request: tauri::http::Request>, + ) -> tauri::http::Response> { + let request_path = request.uri().path(); + match self.read_protocol_asset(request_path) { + Ok((bytes, content_type)) => tauri::http::Response::builder() + .status(tauri::http::StatusCode::OK) + .header(tauri::http::header::CONTENT_TYPE, content_type) + .header(tauri::http::header::CACHE_CONTROL, "no-store, max-age=0") + .header(tauri::http::header::ACCESS_CONTROL_ALLOW_ORIGIN, "*") + .body(bytes) + .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())), + Err(error) if matches!(request_path, "" | "/" | "/index.html") => { + log::error!("Serving immutable frontend recovery page: error={error}"); + tauri::http::Response::builder() + .status(tauri::http::StatusCode::SERVICE_UNAVAILABLE) + .header( + tauri::http::header::CONTENT_TYPE, + "text/html; charset=utf-8", + ) + .header(tauri::http::header::CACHE_CONTROL, "no-store, max-age=0") + .body(RECOVERY_HTML.to_vec()) + .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())) + } + Err(error) => tauri::http::Response::builder() + .status(tauri::http::StatusCode::NOT_FOUND) + .header( + tauri::http::header::CONTENT_TYPE, + "text/plain; charset=utf-8", + ) + .header(tauri::http::header::CACHE_CONTROL, "no-store, max-age=0") + .body(error.into_bytes()) + .unwrap_or_else(|_| tauri::http::Response::new(Vec::new())), + } + } + + fn read_protocol_asset(&self, request_path: &str) -> Result<(Vec, &'static str), String> { + let decoded = urlencoding::decode(request_path) + .map_err(|_| "Invalid frontend asset path encoding".to_string())?; + let relative = decoded.trim_start_matches('/'); + let relative = if relative.is_empty() { + "index.html" + } else { + relative + }; + let relative_path = Path::new(relative); + if relative_path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err("Invalid frontend asset path".to_string()); + } + + let active_revision = self + .state + .lock() + .map_err(lock_error)? + .active_revision + .clone() + .ok_or_else(|| "Frontend workbench is not initialized".to_string())?; + validate_revision_id(&active_revision)?; + let root = self.revision_dir(&active_revision); + let canonical_root = root + .canonicalize() + .map_err(|error| format!("Frontend root is unavailable: {error}"))?; + let mut candidate = root.join(relative_path); + if candidate.is_dir() { + candidate = candidate.join("index.html"); + } + if !candidate.is_file() && relative_path.extension().is_none() { + candidate = root.join("index.html"); + } + let canonical_candidate = candidate + .canonicalize() + .map_err(|_| format!("Frontend asset not found: {relative}"))?; + if !canonical_candidate.starts_with(&canonical_root) || !canonical_candidate.is_file() { + return Err("Frontend asset path escaped the active revision".to_string()); + } + let content_type = content_type_for(&canonical_candidate); + fs::read(&canonical_candidate) + .map(|bytes| (bytes, content_type)) + .map_err(|error| format!("Failed to read frontend asset: {error}")) + } + + fn load_state(&self) -> FrontendWorkbenchState { + let path = self.state_path(); + let Ok(bytes) = fs::read(&path) else { + return FrontendWorkbenchState::default(); + }; + match serde_json::from_slice(&bytes) { + Ok(state) => state, + Err(error) => { + let preserved = self.root.join(format!("state.invalid.{}.json", unix_ms())); + if let Err(copy_error) = fs::copy(&path, &preserved) { + log::warn!( + "Failed to preserve unreadable frontend state: source={}, destination={}, error={}", + path.display(), + preserved.display(), + copy_error + ); + } + log::warn!( + "Ignoring unreadable frontend workbench state after preserving it: path={}, error={}", + path.display(), + error + ); + FrontendWorkbenchState::default() + } + } + } + + fn save_state(&self, state: &FrontendWorkbenchState) -> Result<(), String> { + fs::create_dir_all(&self.root).map_err(io_error("create frontend workbench root"))?; + let bytes = serde_json::to_vec_pretty(state) + .map_err(|error| format!("Failed to serialize frontend workbench state: {error}"))?; + let temporary = self.root.join(format!("state.{}.tmp", Uuid::new_v4())); + fs::write(&temporary, bytes).map_err(io_error("write frontend workbench state"))?; + match fs::rename(&temporary, self.state_path()) { + Ok(()) => Ok(()), + Err(_error) if self.state_path().exists() => { + let backup = self.root.join("state.previous.json"); + let _ = fs::copy(self.state_path(), backup); + fs::remove_file(self.state_path()) + .map_err(io_error("replace frontend workbench state"))?; + fs::rename(&temporary, self.state_path()) + .map_err(io_error("commit frontend workbench state")) + } + Err(error) => Err(format!( + "Failed to commit frontend workbench state: {error}" + )), + } + } + + fn close_confirmation_window(&self) { + if let Some(window) = self + .app + .get() + .and_then(|app| app.get_webview_window(CONFIRM_WINDOW_LABEL)) + { + let _ = window.close(); + } + } + + fn revision_is_available(&self, revision_id: &str) -> bool { + validate_revision_id(revision_id).is_ok() + && self.revision_dir(revision_id).join("index.html").is_file() + } +} + +fn ensure_local_confirmation_surface(attached_peer_controllers: usize) -> Result<(), String> { + if attached_peer_controllers > 0 { + return Err( + "FrontendWorkbench is unavailable while this BitFun host is controlled through Peer Device Mode; run Creative mode on the visible local desktop instead" + .to_string(), + ); + } + Ok(()) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrontendUpdateDecisionRequest { + transaction_id: String, +} + +#[tauri::command] +pub async fn confirm_frontend_update( + state: tauri::State<'_, Arc>, + webview: tauri::WebviewWindow, + request: FrontendUpdateDecisionRequest, +) -> Result { + require_confirmation_window(&webview)?; + state.confirm_pending(&request.transaction_id) +} + +#[tauri::command] +pub async fn rollback_frontend_update( + state: tauri::State<'_, Arc>, + webview: tauri::WebviewWindow, + request: FrontendUpdateDecisionRequest, +) -> Result { + require_confirmation_window(&webview)?; + state.rollback_pending(&request.transaction_id, "user") +} + +fn require_confirmation_window(webview: &tauri::WebviewWindow) -> Result<(), String> { + if webview.label() != CONFIRM_WINDOW_LABEL { + return Err( + "Frontend updates can only be confirmed from the immutable confirmation window" + .to_string(), + ); + } + Ok(()) +} + +pub fn custom_frontend_url(path: &str) -> WebviewUrl { + let suffix = if path.is_empty() { + "index.html".to_string() + } else if path.starts_with('?') { + format!("index.html{path}") + } else { + path.trim_start_matches('/').to_string() + }; + let url = format!("{FRONTEND_PROTOCOL_SCHEME}://localhost/{suffix}") + .parse::() + .expect("static frontend custom-protocol URL must parse"); + WebviewUrl::CustomProtocol(url) +} + +fn show_confirmation_window(app: &tauri::AppHandle, transaction_id: &str) -> Result<(), String> { + if let Some(window) = app.get_webview_window(CONFIRM_WINDOW_LABEL) { + let _ = window.close(); + } + let url = WebviewUrl::App( + format!( + "frontend-update-confirm.html?transactionId={}", + urlencoding::encode(transaction_id) + ) + .into(), + ); + WebviewWindowBuilder::new(app, CONFIRM_WINDOW_LABEL, url) + .title("Confirm BitFun frontend update") + .inner_size(420.0, 260.0) + .resizable(false) + .always_on_top(true) + .center() + .focused(true) + .build() + .map(|_| ()) + .map_err(|error| format!("Failed to open frontend confirmation window: {error}")) +} + +fn navigate_main_to_frontend(app: &tauri::AppHandle) -> Result<(), String> { + let window = app + .get_webview_window("main") + .ok_or_else(|| "Main window is unavailable".to_string())?; + let url = FRONTEND_URL + .parse::() + .map_err(|error| format!("Invalid frontend URL: {error}"))?; + window + .navigate(url) + .map_err(|error| format!("Failed to reload the active frontend revision: {error}")) +} + +fn bundled_revision_id(root: &Path) -> Result { + let mut files = Vec::new(); + visit_tree(root, &mut |path, metadata| { + if metadata.is_file() { + files.push(path.to_path_buf()); + } + Ok(()) + })?; + files.sort(); + + let mut hasher = Sha256::new(); + hasher.update(env!("CARGO_PKG_VERSION").as_bytes()); + hasher.update([0]); + for path in files { + let relative = path + .strip_prefix(root) + .map_err(|_| "Bundled frontend asset escaped its root".to_string())? + .to_string_lossy() + .replace('\\', "/"); + let bytes = fs::read(&path).map_err(io_error("read bundled frontend asset"))?; + hasher.update((relative.len() as u64).to_le_bytes()); + hasher.update(relative.as_bytes()); + hasher.update((bytes.len() as u64).to_le_bytes()); + hasher.update(bytes); + } + let digest = format!("{:x}", hasher.finalize()); + Ok(format!("bundled-{}", &digest[..16])) +} + +fn validate_frontend_tree(root: &Path) -> Result<(), String> { + if !root.is_dir() { + return Err(format!( + "Frontend directory is unavailable: {}", + root.display() + )); + } + if !root.join("index.html").is_file() { + return Err(format!( + "Frontend directory has no index.html: {}", + root.display() + )); + } + visit_tree(root, &mut |path, metadata| { + if metadata.file_type().is_symlink() { + return Err(format!( + "Frontend revisions cannot contain symbolic links: {}", + path.display() + )); + } + Ok(()) + }) +} + +fn copy_tree_transactional(source: &Path, destination: &Path) -> Result<(), String> { + if destination.exists() { + return Err(format!( + "Frontend destination already exists: {}", + destination.display() + )); + } + validate_frontend_tree(source)?; + let parent = destination + .parent() + .ok_or_else(|| "Frontend destination has no parent".to_string())?; + fs::create_dir_all(parent).map_err(io_error("create frontend destination parent"))?; + let staging = parent.join(format!(".copy-{}", Uuid::new_v4())); + fs::create_dir(&staging).map_err(io_error("create frontend copy staging directory"))?; + let result = copy_tree_contents(source, &staging) + .and_then(|_| fs::rename(&staging, destination).map_err(io_error("commit frontend copy"))); + if result.is_err() { + let _ = fs::remove_dir_all(&staging); + } + result +} + +fn copy_tree_contents(source: &Path, destination: &Path) -> Result<(), String> { + for entry in fs::read_dir(source).map_err(io_error("read frontend directory"))? { + let entry = entry.map_err(io_error("read frontend directory entry"))?; + let source_path = entry.path(); + let metadata = fs::symlink_metadata(&source_path) + .map_err(io_error("inspect frontend directory entry"))?; + if metadata.file_type().is_symlink() { + return Err(format!( + "Frontend revisions cannot contain symbolic links: {}", + source_path.display() + )); + } + let destination_path = destination.join(entry.file_name()); + if metadata.is_dir() { + fs::create_dir(&destination_path).map_err(io_error("create frontend subdirectory"))?; + copy_tree_contents(&source_path, &destination_path)?; + } else if metadata.is_file() { + fs::copy(&source_path, &destination_path).map_err(io_error("copy frontend asset"))?; + } + } + Ok(()) +} + +fn visit_tree( + root: &Path, + visitor: &mut impl FnMut(&Path, &fs::Metadata) -> Result<(), String>, +) -> Result<(), String> { + for entry in fs::read_dir(root).map_err(io_error("read frontend tree"))? { + let entry = entry.map_err(io_error("read frontend tree entry"))?; + let path = entry.path(); + let metadata = fs::symlink_metadata(&path).map_err(io_error("inspect frontend tree"))?; + visitor(&path, &metadata)?; + if metadata.is_dir() { + visit_tree(&path, visitor)?; + } + } + Ok(()) +} + +fn validate_uuid(value: &str, field: &str) -> Result<(), String> { + let parsed = Uuid::parse_str(value).map_err(|_| format!("{field} is invalid"))?; + if parsed.to_string() != value.to_ascii_lowercase() { + return Err(format!("{field} is invalid")); + } + Ok(()) +} + +fn validate_revision_id(value: &str) -> Result<(), String> { + let mut components = Path::new(value).components(); + if value.is_empty() + || !matches!(components.next(), Some(Component::Normal(_))) + || components.next().is_some() + { + return Err("Frontend revision id is invalid".to_string()); + } + Ok(()) +} + +fn content_type_for(path: &Path) -> &'static str { + match path.extension().and_then(|extension| extension.to_str()) { + Some("html") => "text/html; charset=utf-8", + Some("css") => "text/css; charset=utf-8", + Some("js" | "mjs") => "text/javascript; charset=utf-8", + Some("json" | "map") => "application/json; charset=utf-8", + Some("svg") => "image/svg+xml", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + Some("gif") => "image/gif", + Some("webp") => "image/webp", + Some("ico") => "image/x-icon", + Some("woff") => "font/woff", + Some("woff2") => "font/woff2", + Some("ttf") => "font/ttf", + Some("wasm") => "application/wasm", + _ => "application/octet-stream", + } +} + +fn unix_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} + +fn io_error(operation: &'static str) -> impl FnOnce(std::io::Error) -> String { + move |error| format!("Failed to {operation}: {error}") +} + +fn lock_error(error: std::sync::PoisonError) -> String { + format!("Frontend workbench state lock is unavailable: {error}") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_frontend(root: &Path, label: &str) { + fs::create_dir_all(root.join("assets")).expect("asset directory"); + fs::write(root.join("index.html"), format!("

{label}

")).expect("index.html"); + fs::write(root.join("assets/app.js"), "export {};").expect("asset"); + } + + #[test] + fn transactional_copy_preserves_a_valid_frontend() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + let destination = temp.path().join("destination"); + write_frontend(&source, "source"); + copy_tree_transactional(&source, &destination).expect("copy"); + assert_eq!( + fs::read_to_string(destination.join("index.html")).expect("copied index"), + "

source

" + ); + } + + #[test] + fn bundled_revision_fingerprint_covers_non_index_assets() { + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + write_frontend(&source, "source"); + let first = bundled_revision_id(&source).expect("first fingerprint"); + + fs::write(source.join("assets/app.js"), "export const changed = true;") + .expect("change asset"); + let second = bundled_revision_id(&source).expect("second fingerprint"); + + assert_ne!(first, second); + } + + #[cfg(unix)] + #[test] + fn validation_rejects_symlinked_assets() { + use std::os::unix::fs::symlink; + let temp = tempfile::tempdir().expect("tempdir"); + let source = temp.path().join("source"); + write_frontend(&source, "source"); + symlink(source.join("index.html"), source.join("linked.html")).expect("symlink"); + assert!(validate_frontend_tree(&source) + .expect_err("symlink should fail") + .contains("symbolic links")); + } + + #[test] + fn revision_ids_cannot_escape_the_revision_root() { + for value in ["", ".", "..", "../outside", "/outside"] { + assert!(validate_revision_id(value).is_err(), "accepted {value}"); + } + assert!(validate_revision_id("bundled-0123456789abcdef").is_ok()); + assert!(validate_revision_id("creative-38e14f63-30ad-4ad7-9e4e-5ad556450ba3").is_ok()); + } + + #[test] + fn peer_control_requires_a_visible_local_confirmation_surface() { + assert!(ensure_local_confirmation_surface(0).is_ok()); + let error = ensure_local_confirmation_surface(1) + .expect_err("peer-controlled frontend updates must fail loudly"); + assert!(error.contains("Peer Device Mode")); + } + + #[test] + fn legacy_state_fields_default_without_data_loss() { + let state: FrontendWorkbenchState = + serde_json::from_str(r#"{"activeRevision":"bundled-old","unknownFutureField":true}"#) + .expect("legacy state"); + assert_eq!(state.active_revision.as_deref(), Some("bundled-old")); + assert!(state.pending.is_none()); + } + + #[test] + fn expired_confirmation_restores_the_previous_revision() { + let temp = tempfile::tempdir().expect("tempdir"); + let manager = FrontendWorkbenchManager::new(temp.path()); + write_frontend(&manager.revision_dir("previous"), "previous"); + write_frontend(&manager.revision_dir("candidate"), "candidate"); + *manager.state.lock().expect("state") = FrontendWorkbenchState { + schema_version: STATE_SCHEMA_VERSION, + bundled_revision: Some("previous".to_string()), + active_revision: Some("candidate".to_string()), + previous_revision: Some("previous".to_string()), + pending: Some(PendingFrontendRevision { + transaction_id: "expired-transaction".to_string(), + revision_id: "candidate".to_string(), + previous_revision: "previous".to_string(), + expires_at_unix_ms: unix_ms().saturating_sub(1), + }), + }; + + let error = manager + .confirm_pending("expired-transaction") + .expect_err("expired confirmation must fail"); + assert!(error.contains("expired")); + let state = manager.state.lock().expect("state"); + assert_eq!(state.active_revision.as_deref(), Some("previous")); + assert!(state.pending.is_none()); + } + + #[test] + fn protocol_uses_immutable_recovery_page_when_no_revision_is_ready() { + let temp = tempfile::tempdir().expect("tempdir"); + let manager = FrontendWorkbenchManager::new(temp.path()); + let request = tauri::http::Request::builder() + .uri("bitfun-ui://localhost/index.html") + .body(Vec::new()) + .expect("request"); + + let response = manager.protocol_response(request); + + assert_eq!( + response.status(), + tauri::http::StatusCode::SERVICE_UNAVAILABLE + ); + assert!(String::from_utf8_lossy(response.body()).contains("BitFun frontend recovery")); + } +} diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 660a2740ad..da8082f1cc 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -27,6 +27,7 @@ mod builtin_browser_host; pub mod computer_use; pub mod crash_diagnostics; mod embedded_relay_host; +pub mod frontend_workbench; pub mod logging; pub mod macos_menubar; pub mod runtime; @@ -49,6 +50,7 @@ use bitfun_core::util::{elapsed_ms, TimingCollector}; use bitfun_events::AgenticEvent; use bitfun_transport::{TauriTransportAdapter, TransportAdapter}; use serde::Deserialize; +use std::path::PathBuf; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, OnceLock, @@ -810,8 +812,16 @@ pub async fn run() { let terminal_state = api::terminal_api::TerminalState::new(); let path_manager = get_path_manager_arc(); + let frontend_workbench = Arc::new(frontend_workbench::FrontendWorkbenchManager::new( + &path_manager.user_data_dir(), + )); let mut builder = tauri::Builder::default(); + let frontend_protocol_manager = Arc::clone(&frontend_workbench); + builder = builder.register_uri_scheme_protocol( + frontend_workbench::FRONTEND_PROTOCOL_SCHEME, + move |_context, request| frontend_protocol_manager.protocol_response(request), + ); #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] { @@ -864,6 +874,7 @@ pub async fn run() { .manage(coordinator) .manage(scheduler) .manage(terminal_state) + .manage(Arc::clone(&frontend_workbench)) .manage(startup_trace.clone()) .on_page_load(|webview, payload| { let label = webview.label(); @@ -917,6 +928,36 @@ pub async fn run() { ); startup_trace.record_logging_ready_and_stop_persistence(); + let bundled_frontend = if cfg!(debug_assertions) { + let development_dist = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../../..") + .join("dist"); + development_dist + .join("index.html") + .is_file() + .then_some(development_dist) + } else { + app.path() + .resolve("frontend/dist", tauri::path::BaseDirectory::Resource) + .ok() + .filter(|path| path.join("index.html").is_file()) + }; + if let Some(bundled_frontend) = bundled_frontend { + if let Err(error) = + frontend_workbench.initialize(app.handle(), &bundled_frontend) + { + log::error!( + "Failed to initialize the external frontend workbench: path={}, error={}", + bundled_frontend.display(), + error + ); + } + } else { + log::warn!( + "External frontend bundle is unavailable; Creative frontend editing will remain disabled" + ); + } + // Ensure the Tauri NSIS registry install-location key points to the // actual install directory, so that auto-updates respect the custom // install path chosen during initial installation. @@ -1353,6 +1394,8 @@ pub async fn run() { }) .invoke_handler(tauri::generate_handler![ appearance::show_main_window, + frontend_workbench::confirm_frontend_update, + frontend_workbench::rollback_frontend_update, hide_main_window_after_close_request, api::agentic_api::create_session, api::agentic_api::update_session_mode, diff --git a/src/apps/desktop/tauri.conf.json b/src/apps/desktop/tauri.conf.json index 8ec3c4515d..0a4c19a43e 100644 --- a/src/apps/desktop/tauri.conf.json +++ b/src/apps/desktop/tauri.conf.json @@ -6,7 +6,7 @@ "beforeDevCommand": "pnpm run dev:web", "devUrl": "http://localhost:1422", "beforeBuildCommand": "pnpm run frontend:build-all", - "frontendDist": "../../../dist" + "frontendDist": "bootstrap-ui" }, "bundle": { "active": true, diff --git a/src/apps/desktop/tauri.dev.conf.json b/src/apps/desktop/tauri.dev.conf.json index c7959fbf1d..a14b754cfe 100644 --- a/src/apps/desktop/tauri.dev.conf.json +++ b/src/apps/desktop/tauri.dev.conf.json @@ -6,7 +6,7 @@ "beforeDevCommand": "pnpm run dev:web", "devUrl": "http://localhost:1422", "beforeBuildCommand": "pnpm run frontend:build-all", - "frontendDist": "../../../dist" + "frontendDist": "bootstrap-ui" }, "bundle": { "active": true, diff --git a/src/crates/assembly/agent-content/prompts/agents/creative_mode_first_entry_reminder.md b/src/crates/assembly/agent-content/prompts/agents/creative_mode_first_entry_reminder.md new file mode 100644 index 0000000000..150d182423 --- /dev/null +++ b/src/crates/assembly/agent-content/prompts/agents/creative_mode_first_entry_reminder.md @@ -0,0 +1,6 @@ +You have entered Creative mode. Product-creation capabilities are intentionally isolated here. + +- For MiniApps, load the `miniapp-dev` skill before editing. Start with `InitMiniApp`, edit only the returned app directory, then run `FinalizeMiniApp`. Call `PublishMiniApp` only when the user explicitly asks to submit or publish. +- For the BitFun client frontend, load the `bitfun-frontend-dev` skill first. Call `FrontendWorkbench` with `prepare`, edit only the returned draft directory, then call `apply` with its draft id. +- An applied frontend revision is provisional for 15 seconds. Never claim it was kept until the user presses the native confirmation button and `FrontendWorkbench status` reports no pending revision. If confirmation does not arrive, BitFun rolls back automatically. +- Frontend customization is local-desktop-only. Never substitute a controller-local path for a remote workspace. diff --git a/src/crates/assembly/agent-content/tests/prompt_catalog_contracts.rs b/src/crates/assembly/agent-content/tests/prompt_catalog_contracts.rs index 7e1431efbd..16acf1814c 100644 --- a/src/crates/assembly/agent-content/tests/prompt_catalog_contracts.rs +++ b/src/crates/assembly/agent-content/tests/prompt_catalog_contracts.rs @@ -33,6 +33,10 @@ const CATALOG_PROMPT_SOURCES: &[(&str, &[u8])] = &[ "cowork_mode", include_bytes!("../prompts/agents/cowork_mode.md"), ), + ( + "creative_mode_first_entry_reminder", + include_bytes!("../prompts/agents/creative_mode_first_entry_reminder.md"), + ), ( "deep_research_agent", include_bytes!("../prompts/agents/deep_research_agent.md"), diff --git a/src/crates/assembly/core/Cargo.toml b/src/crates/assembly/core/Cargo.toml index 8c571d3dc6..e86b8dbe6f 100644 --- a/src/crates/assembly/core/Cargo.toml +++ b/src/crates/assembly/core/Cargo.toml @@ -163,6 +163,7 @@ product-full = [ "tools-computer-use", "tools-image-analysis", "tools-miniapp", + "tools-creation", "tools-canvas", "tools-agent-control", "function-agents", @@ -358,6 +359,9 @@ tools-miniapp = [ "dep:reqwest", "dep:semver", ] +tools-creation = [ + "bitfun-tool-packs/creation", +] tools-canvas = [ "bitfun-tool-packs/canvas", "canvas-runtime", diff --git a/src/crates/assembly/core/builtin_skills/bitfun-frontend-dev/SKILL.md b/src/crates/assembly/core/builtin_skills/bitfun-frontend-dev/SKILL.md new file mode 100644 index 0000000000..02ea150a21 --- /dev/null +++ b/src/crates/assembly/core/builtin_skills/bitfun-frontend-dev/SKILL.md @@ -0,0 +1,28 @@ +--- +name: bitfun-frontend-dev +description: Safely customize the running packaged BitFun desktop frontend through a draft, a provisional hot apply, and a 15-second user-confirmed rollback window. Use only in BitFun Creative mode when the user asks to change BitFun's own client UI. +--- + +# BitFun frontend customization + +Use this workflow only for the running BitFun desktop client's own frontend. It is not for a website in the user's workspace, a MiniApp, or a remote BitFun host. + +## Required workflow + +1. Call `FrontendWorkbench` with `action: "prepare"`. +2. Edit only the returned draft directory. Never edit the packaged resource directory, active revision, state file, or another draft. +3. Preserve a valid `index.html`. Prefer small changes to the stable `bitfun-creation.css` and `bitfun-creation.js` override files when those are sufficient. +4. Call `FrontendWorkbench` with `action: "apply"` and the exact returned `draft_id`. +5. Tell the user that the change is provisional and must be confirmed in the native 15-second confirmation window. +6. Call `FrontendWorkbench` with `action: "status"` before saying the revision was kept. A pending revision means it is not confirmed. + +`apply` replaces the running frontend immediately, but the desktop host owns the safety timer. If the user does not confirm within 15 seconds, if BitFun exits, or if the candidate cannot load, the host restores the prior revision. Never bypass or emulate the confirmation timer in page JavaScript. + +Use `action: "rollback"` when the user explicitly asks to undo the currently active customization. Do not delete revision history manually. + +## Boundaries + +- This capability is local-desktop-only. If the tool reports a remote or unsupported surface, explain that state; never fall back to a controller-local path. +- Do not call `FrontendWorkbench` outside Creative mode. +- Treat third-party code in a draft as untrusted. Do not add remote scripts, hidden telemetry, credential capture, or code that disables recovery controls. +- Keep Tauri invocation access intact. The confirmation window is immutable host UI and must remain independent of the editable frontend. 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 d0f8814ffe..d65cf8a18d 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 @@ -49,9 +49,6 @@ impl ClawMode { // Local desktop/system control is delegated to the ComputerUse // agent/tool instead of being surfaced as a ControlHub domain. "ControlHub".to_string(), - "InitMiniApp".to_string(), - "FinalizeMiniApp".to_string(), - "PublishMiniApp".to_string(), "PublishAppearance".to_string(), "PageDeploy".to_string(), "PagePublish".to_string(), @@ -105,10 +102,12 @@ mod tests { use bitfun_agent_runtime::prompt::UserContextSection; #[test] - fn claw_mode_includes_miniapp_lifecycle_tools_in_defaults() { + fn claw_mode_excludes_creation_only_tools_from_defaults() { let tools = ClawMode::new().default_tools(); - assert!(tools.contains(&"InitMiniApp".to_string())); - assert!(tools.contains(&"FinalizeMiniApp".to_string())); + assert!(!tools.contains(&"InitMiniApp".to_string())); + assert!(!tools.contains(&"FinalizeMiniApp".to_string())); + assert!(!tools.contains(&"PublishMiniApp".to_string())); + assert!(!tools.contains(&"FrontendWorkbench".to_string())); assert!(tools.contains(&"ListModels".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 1fea14be23..9bd635d115 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 @@ -65,9 +65,6 @@ impl CoworkMode { // `wait` sends schedules here rather than pinning a turn open // for the interval. "Cron".to_string(), - "InitMiniApp".to_string(), - "FinalizeMiniApp".to_string(), - "PublishMiniApp".to_string(), "PublishAppearance".to_string(), ], } @@ -139,10 +136,12 @@ mod tests { } #[test] - fn cowork_mode_includes_miniapp_lifecycle_tools_in_defaults() { + fn cowork_mode_excludes_creation_only_tools_from_defaults() { let tools = CoworkMode::new().default_tools(); - assert!(tools.contains(&"InitMiniApp".to_string())); - assert!(tools.contains(&"FinalizeMiniApp".to_string())); + assert!(!tools.contains(&"InitMiniApp".to_string())); + assert!(!tools.contains(&"FinalizeMiniApp".to_string())); + assert!(!tools.contains(&"PublishMiniApp".to_string())); + assert!(!tools.contains(&"FrontendWorkbench".to_string())); assert!(tools.contains(&"ListModels".to_string())); } } diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/creative.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/creative.rs new file mode 100644 index 0000000000..c6c7d3c98e --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/creative.rs @@ -0,0 +1,135 @@ +//! Creative Mode +//! +//! Owns product-creation capabilities that intentionally do not appear in the +//! default tool manifests of general coding, office, or assistant modes. + +use crate::agentic::agents::{ + get_embedded_prompt, shared_coding_mode_tool_exposure_overrides, shared_coding_mode_tools, + shared_coding_mode_user_context_policy, Agent, AgentToolPolicyOverrides, UserContextPolicy, + SHARED_CODING_MODE_PROMPT_TEMPLATE, +}; +use async_trait::async_trait; + +const CREATIVE_MODE_FIRST_ENTRY_REMINDER_TEMPLATE: &str = "creative_mode_first_entry_reminder"; + +pub struct CreativeMode { + default_tools: Vec, + tool_exposure_overrides: AgentToolPolicyOverrides, +} + +impl Default for CreativeMode { + fn default() -> Self { + Self::new() + } +} + +impl CreativeMode { + pub fn new() -> Self { + let mut default_tools = shared_coding_mode_tools(); + default_tools.extend( + [ + "InitMiniApp", + "FinalizeMiniApp", + "PublishMiniApp", + "FrontendWorkbench", + ] + .into_iter() + .map(str::to_string), + ); + Self { + default_tools, + tool_exposure_overrides: shared_coding_mode_tool_exposure_overrides(), + } + } +} + +#[async_trait] +impl Agent for CreativeMode { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + "Creative" + } + + fn name(&self) -> &str { + "Creative" + } + + fn description(&self) -> &str { + "Creation mode for building MiniApps and safely customizing the running BitFun frontend" + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + SHARED_CODING_MODE_PROMPT_TEMPLATE + } + + fn default_tools(&self) -> Vec { + self.default_tools.clone() + } + + fn tool_exposure_overrides(&self) -> &AgentToolPolicyOverrides { + &self.tool_exposure_overrides + } + + fn user_context_policy(&self) -> UserContextPolicy { + shared_coding_mode_user_context_policy() + } + + async fn get_system_reminder( + &self, + previous_agent_type: Option<&str>, + _workspace: Option<&crate::agentic::WorkspaceBinding>, + ) -> crate::util::errors::BitFunResult { + if previous_agent_type == Some(self.id()) { + return Ok(String::new()); + } + get_embedded_prompt(CREATIVE_MODE_FIRST_ENTRY_REMINDER_TEMPLATE) + .map(str::to_string) + .ok_or_else(|| { + crate::util::errors::BitFunError::Agent(format!( + "{} not found in embedded files", + CREATIVE_MODE_FIRST_ENTRY_REMINDER_TEMPLATE + )) + }) + } + + fn is_readonly(&self) -> bool { + false + } +} + +#[cfg(test)] +mod tests { + use super::CreativeMode; + use crate::agentic::agents::Agent; + + #[test] + fn creative_mode_owns_product_creation_tools() { + let tools = CreativeMode::new().default_tools(); + for tool in [ + "InitMiniApp", + "FinalizeMiniApp", + "PublishMiniApp", + "FrontendWorkbench", + ] { + assert!(tools.contains(&tool.to_string()), "missing {tool}"); + } + } + + #[tokio::test] + async fn creative_reminder_is_only_injected_on_entry() { + let mode = CreativeMode::new(); + assert!(mode + .get_system_reminder(None, None) + .await + .expect("reminder") + .contains("FrontendWorkbench")); + assert!(mode + .get_system_reminder(Some("Creative"), None) + .await + .expect("ongoing reminder") + .is_empty()); + } +} diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs index 692620ec6f..0b753ce031 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/modes/mod.rs @@ -1,6 +1,7 @@ mod agentic; mod claw; mod cowork; +mod creative; mod deep_research; mod minimal; mod multitask; @@ -10,6 +11,7 @@ mod ultra; pub use agentic::AgenticMode; pub use claw::ClawMode; pub use cowork::CoworkMode; +pub use creative::CreativeMode; pub use deep_research::DeepResearchMode; pub use minimal::MinimalMode; pub use multitask::MultitaskMode; diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index d1829000f6..cd1c65294f 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -28,8 +28,8 @@ pub use definitions::custom::{CustomMode, CustomSubagent, CustomSubagentKind}; pub(crate) use definitions::external::ExternalProvidedAgent; pub use definitions::hidden::{CodeReviewAgent, DeepReviewAgent, GenerateDocAgent}; pub use definitions::modes::{ - AgenticMode, ClawMode, CoworkMode, DeepResearchMode, MinimalMode, MultitaskMode, PlanMode, - UltraMode, + AgenticMode, ClawMode, CoworkMode, CreativeMode, DeepResearchMode, MinimalMode, MultitaskMode, + PlanMode, UltraMode, }; pub use definitions::review::{ReviewFixerAgent, ReviewJudgeAgent, ReviewWorkerAgent}; pub use definitions::shared::ReadonlySubagent; @@ -157,9 +157,6 @@ pub fn shared_coding_mode_tools() -> Vec { // further out than an hour, to Cron rather than holding the turn open // for the interval. "Cron".to_string(), - "InitMiniApp".to_string(), - "FinalizeMiniApp".to_string(), - "PublishMiniApp".to_string(), "PublishAppearance".to_string(), "PageDeploy".to_string(), "PagePublish".to_string(), diff --git a/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs b/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs index d93f0bc3cf..dca3385bb7 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/catalog.rs @@ -1,10 +1,11 @@ use super::types::AgentCategory; use super::visibility::SubagentVisibilityPolicy; use crate::agentic::agents::{ - Agent, AgenticMode, ClawMode, CodeReviewAgent, ComputerUseMode, CoworkMode, DeepResearchMode, - DeepReviewAgent, ExploreAgent, GeneralPurposeAgent, GenerateDocAgent, MinimalMode, - MultitaskMode, PlanMode, ResearchSpecialistAgent, ReviewFixerAgent, ReviewJudgeAgent, - ReviewWorkerAgent, SwarmPlannerAgent, SwarmReviewerAgent, SwarmWorkerAgent, UltraMode, + Agent, AgenticMode, ClawMode, CodeReviewAgent, ComputerUseMode, CoworkMode, CreativeMode, + DeepResearchMode, DeepReviewAgent, ExploreAgent, GeneralPurposeAgent, GenerateDocAgent, + MinimalMode, MultitaskMode, PlanMode, ResearchSpecialistAgent, ReviewFixerAgent, + ReviewJudgeAgent, ReviewWorkerAgent, SwarmPlannerAgent, SwarmReviewerAgent, SwarmWorkerAgent, + UltraMode, }; use crate::agentic::memories::MemoryPhase2Agent; use bitfun_agent_runtime::agents as runtime_agents; @@ -47,6 +48,7 @@ fn builtin_agent_factory(id: &str) -> fn() -> Arc { "minimal" => || Arc::new(MinimalMode::new()), "agentic" => || Arc::new(AgenticMode::new()), "Cowork" => || Arc::new(CoworkMode::new()), + "Creative" => || Arc::new(CreativeMode::new()), "Multitask" => || Arc::new(MultitaskMode::new()), "Plan" => || Arc::new(PlanMode::new()), "Claw" => || Arc::new(ClawMode::new()), diff --git a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs index 84262171bb..d628aa1362 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -236,6 +236,7 @@ fn top_level_modes_default_to_auto() { "agentic", "Multitask", "Cowork", + "Creative", "Plan", "Claw", "DeepResearch", @@ -342,6 +343,32 @@ fn every_builtin_standard_mode_defaults_to_the_thread_goal_lifecycle() { } } +#[test] +fn creation_tools_default_only_to_creative_mode() { + const CREATION_TOOLS: &[&str] = &[ + "InitMiniApp", + "FinalizeMiniApp", + "PublishMiniApp", + "FrontendWorkbench", + ]; + + for spec in builtin_agent_specs() + .iter() + .filter(|spec| spec.category == AgentCategory::Mode) + { + let mode = (spec.factory)(); + let default_tools = mode.default_tools(); + for tool_name in CREATION_TOOLS { + assert_eq!( + default_tools.iter().any(|tool| tool == tool_name), + mode.id() == "Creative", + "creation tool {tool_name} has unexpected default exposure in {}", + mode.id() + ); + } + } +} + #[test] fn every_builtin_mode_with_control_hub_can_also_schedule_with_cron() { // ControlHub's `wait` documentation tells the agent to schedule anything 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 20b42fa220..879d339f8f 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 @@ -47,6 +47,7 @@ Notes: | `InitMiniApp` | Direct | None | - | | `FinalizeMiniApp` | Direct | None | - | | `PublishMiniApp` | Direct | None | - | +| `FrontendWorkbench` | Direct | None | - | | `PublishAppearance` | Direct | None | - | | `ControlHub` | Deferred | `ComputerUse` | Direct | | `ComputerUse` | Deferred | `ComputerUse` | Direct | diff --git a/src/crates/assembly/core/src/agentic/tools/frontend_workbench_host.rs b/src/crates/assembly/core/src/agentic/tools/frontend_workbench_host.rs new file mode 100644 index 0000000000..dc85edb2a2 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/frontend_workbench_host.rs @@ -0,0 +1,42 @@ +//! Host boundary for the packaged-frontend workbench. +//! +//! Core owns the tool contract; the desktop adapter owns revision storage, +//! webview navigation, confirmation UI, and rollback timing. + +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, OnceLock}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FrontendWorkbenchHostRequest { + pub action: String, + pub draft_id: Option, +} + +pub type FrontendWorkbenchFuture = + Pin> + Send + 'static>>; +pub type FrontendWorkbenchHandler = + Arc FrontendWorkbenchFuture + Send + Sync>; + +static FRONTEND_WORKBENCH_HANDLER: OnceLock = OnceLock::new(); + +pub fn set_frontend_workbench_handler(handler: FrontendWorkbenchHandler) { + let _ = FRONTEND_WORKBENCH_HANDLER.set(handler); +} + +pub fn frontend_workbench_host_available() -> bool { + FRONTEND_WORKBENCH_HANDLER.get().is_some() +} + +pub async fn invoke_frontend_workbench( + request: FrontendWorkbenchHostRequest, +) -> Result { + let Some(handler) = FRONTEND_WORKBENCH_HANDLER.get() else { + return Err("FrontendWorkbench is available only in the BitFun desktop app".to_string()); + }; + handler(request).await +} diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs b/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs new file mode 100644 index 0000000000..de656044d1 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs @@ -0,0 +1,229 @@ +//! Creative-mode tool for safely editing the running packaged frontend. + +use crate::agentic::tools::framework::{PermissionIntent, Tool, ToolResult, ToolUseContext}; +use crate::agentic::tools::frontend_workbench_host::{ + frontend_workbench_host_available, invoke_frontend_workbench, FrontendWorkbenchHostRequest, +}; +use crate::util::errors::{BitFunError, BitFunResult}; +use async_trait::async_trait; +use serde_json::{json, Value}; + +pub struct FrontendWorkbenchTool; + +impl FrontendWorkbenchTool { + pub fn new() -> Self { + Self + } +} + +impl Default for FrontendWorkbenchTool { + fn default() -> Self { + Self::new() + } +} + +fn creative_local_context(context: Option<&ToolUseContext>) -> bool { + context.is_some_and(|context| { + context.agent_type.as_deref() == Some("Creative") && !context.is_remote() + }) +} + +#[async_trait] +impl Tool for FrontendWorkbenchTool { + fn name(&self) -> &str { + "FrontendWorkbench" + } + + async fn description(&self) -> BitFunResult { + Ok(r#"Safely customize the frontend of the running packaged BitFun desktop client. Creative mode only. + +Workflow: call prepare, edit only the returned draft_path using file tools, then call apply with draft_id. Apply hot-loads the candidate and starts an authoritative 15-second confirmation window in immutable native-host UI. If the user does not confirm, BitFun automatically restores the previous revision. Call status before claiming that a revision was kept. rollback explicitly restores the previous confirmed revision. + +Actions: +- prepare: create an editable draft from the current active frontend. +- status: inspect active/pending revision state. +- apply: validate and provisionally activate a prepared draft; requires fresh permission. +- rollback: restore the previous confirmed revision; requires fresh permission. + +This tool is unavailable for remote workspaces and non-desktop surfaces."#.to_string()) + } + + fn short_description(&self) -> String { + "Draft and hot-apply the packaged BitFun frontend with 15-second rollback protection." + .to_string() + } + + fn input_schema(&self) -> Value { + json!({ + "type": "object", + "additionalProperties": false, + "required": ["action"], + "properties": { + "action": { + "type": "string", + "enum": ["prepare", "status", "apply", "rollback"] + }, + "draft_id": { + "type": "string", + "description": "Exact draft id returned by prepare. Required for apply." + } + } + }) + } + + fn is_readonly(&self) -> bool { + false + } + + async fn is_available_in_context(&self, context: Option<&ToolUseContext>) -> bool { + frontend_workbench_host_available() && creative_local_context(context) + } + + fn permission_intents( + &self, + input: &Value, + _context: &ToolUseContext, + ) -> BitFunResult> { + let action = input.get("action").and_then(Value::as_str).unwrap_or(""); + if !matches!(action, "apply" | "rollback") { + return Ok(Vec::new()); + } + + let resource = if action == "apply" { + input + .get("draft_id") + .and_then(Value::as_str) + .map(|id| format!("bitfun-frontend:draft:{id}")) + .unwrap_or_else(|| "bitfun-frontend:draft:".to_string()) + } else { + "bitfun-frontend:previous-confirmed-revision".to_string() + }; + let mut intent = PermissionIntent::new("frontend_workbench", vec![resource]); + intent.save_resources.clear(); + intent + .display_metadata + .insert("requiresFreshApproval".to_string(), Value::Bool(true)); + Ok(vec![intent]) + } + + async fn call_impl( + &self, + input: &Value, + context: &ToolUseContext, + ) -> BitFunResult> { + if context.agent_type.as_deref() != Some("Creative") { + return Err(BitFunError::tool( + "FrontendWorkbench is restricted to Creative mode".to_string(), + )); + } + if context.is_remote() { + return Err(BitFunError::tool( + "FrontendWorkbench cannot modify a remote workspace or remote BitFun host" + .to_string(), + )); + } + + let action = input + .get("action") + .and_then(Value::as_str) + .ok_or_else(|| BitFunError::validation("Missing required field: action"))?; + let draft_id = input + .get("draft_id") + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string); + if action == "apply" && draft_id.is_none() { + return Err(BitFunError::validation( + "draft_id is required when action is apply", + )); + } + + let result = invoke_frontend_workbench(FrontendWorkbenchHostRequest { + action: action.to_string(), + draft_id, + }) + .await + .map_err(BitFunError::tool)?; + let assistant = match action { + "prepare" => "Frontend draft prepared. Edit only draft_path, then apply the exact draft_id.", + "apply" => "Frontend candidate is live provisionally. The user must confirm it within 15 seconds or BitFun will roll it back automatically.", + "rollback" => "Frontend rollback requested.", + _ => "Frontend workbench status returned.", + }; + + Ok(vec![ToolResult::Result { + data: result, + result_for_assistant: Some(assistant.to_string()), + image_attachments: None, + }]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn context(agent_type: &str) -> ToolUseContext { + ToolUseContext { + tool_call_id: None, + agent_type: Some(agent_type.to_string()), + session_id: None, + dialog_turn_id: None, + workspace: None, + loaded_deferred_tool_specs: Vec::new(), + primary_model_facts: Default::default(), + custom_data: HashMap::new(), + computer_use_host: None, + runtime_tool_restrictions: Default::default(), + runtime_handles: Default::default(), + } + } + + #[tokio::test] + async fn execution_rejects_non_creative_agents_even_if_called_directly() { + let error = FrontendWorkbenchTool::new() + .call_impl(&json!({"action": "status"}), &context("agentic")) + .await + .expect_err("non-Creative call must fail"); + assert!(error.to_string().contains("Creative mode")); + } + + #[tokio::test] + async fn execution_rejects_remote_workspaces_even_in_creative_mode() { + let mut remote = context("Creative"); + remote.workspace = Some(crate::agentic::WorkspaceBinding::new_remote( + None, + std::path::PathBuf::from("/srv/project"), + "connection-1".to_string(), + "Remote".to_string(), + crate::service::remote_ssh::workspace_state::WorkspaceSessionIdentity { + hostname: "remote.example".to_string(), + logical_workspace_path: "/srv/project".to_string(), + remote_connection_id: Some("connection-1".to_string()), + }, + )); + + let error = FrontendWorkbenchTool::new() + .call_impl(&json!({"action": "status"}), &remote) + .await + .expect_err("remote Creative calls must fail"); + assert!(error.to_string().contains("remote workspace")); + } + + #[test] + fn apply_always_requires_fresh_approval() { + let intents = FrontendWorkbenchTool::new() + .permission_intents( + &json!({"action": "apply", "draft_id": "draft-1"}), + &context("Creative"), + ) + .expect("permission intent"); + assert!(intents[0].save_resources.is_empty()); + assert_eq!( + intents[0].display_metadata.get("requiresFreshApproval"), + Some(&Value::Bool(true)) + ); + } +} 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 c137a00831..bf8e3208b0 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/mod.rs @@ -31,6 +31,8 @@ pub mod exec_command; pub mod file_edit_tool; pub mod file_read_tool; pub mod file_write_tool; +#[cfg(feature = "tools-creation")] +pub mod frontend_workbench_tool; #[cfg(feature = "tools-miniapp")] pub mod generative_ui_tool; #[cfg(feature = "tools-git")] @@ -103,6 +105,8 @@ pub use exec_command::{ExecCommandTool, ExecControlTool, WriteStdinTool}; pub use file_edit_tool::FileEditTool; pub use file_read_tool::FileReadTool; pub use file_write_tool::FileWriteTool; +#[cfg(feature = "tools-creation")] +pub use frontend_workbench_tool::FrontendWorkbenchTool; #[cfg(feature = "tools-miniapp")] pub use generative_ui_tool::GenerativeUITool; #[cfg(feature = "tools-git")] diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs b/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs index fab15fbc46..9a09286464 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/skills/catalog.rs @@ -22,6 +22,10 @@ mod tests { Some("debugging") ); assert_eq!(builtin_skill_group_key("miniapp-dev"), Some("miniapp")); + assert_eq!( + builtin_skill_group_key("bitfun-frontend-dev"), + Some("creation") + ); assert_eq!(builtin_skill_group_key("writing-skills"), Some("meta")); assert_eq!( builtin_skill_group_key("agent-browser"), diff --git a/src/crates/assembly/core/src/agentic/tools/mod.rs b/src/crates/assembly/core/src/agentic/tools/mod.rs index ab56b0e9b1..a7b4275315 100644 --- a/src/crates/assembly/core/src/agentic/tools/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/mod.rs @@ -12,6 +12,8 @@ pub(crate) mod file_permissions; pub mod file_read_state_runtime; pub mod file_tool_guidance; pub mod framework; +#[cfg(feature = "tools-creation")] +pub mod frontend_workbench_host; pub mod image_context; pub mod implementations; pub mod manifest_resolver; 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 bbd589c5b5..084430ad02 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 @@ -73,6 +73,7 @@ const PRODUCT_TOOL_REGISTRATION_ORDER: &[&str] = &[ "InitMiniApp", "FinalizeMiniApp", "PublishMiniApp", + "FrontendWorkbench", "PublishAppearance", "PageDeploy", "PagePublish", @@ -183,6 +184,8 @@ impl StaticToolProviderFactory for ProductConcreteToolFactory { "FinalizeMiniApp" => Some(Arc::new(FinalizeMiniAppTool::new())), #[cfg(feature = "tools-miniapp")] "PublishMiniApp" => Some(Arc::new(PublishMiniAppTool::new())), + #[cfg(feature = "tools-creation")] + "FrontendWorkbench" => Some(Arc::new(FrontendWorkbenchTool::new())), #[cfg(feature = "tools-miniapp")] "PublishAppearance" => Some(Arc::new(PublishAppearanceTool::new())), #[cfg(feature = "tools-miniapp")] diff --git a/src/crates/assembly/core/src/agentic/tools/registry.rs b/src/crates/assembly/core/src/agentic/tools/registry.rs index c98226785e..f09143e07b 100644 --- a/src/crates/assembly/core/src/agentic/tools/registry.rs +++ b/src/crates/assembly/core/src/agentic/tools/registry.rs @@ -611,6 +611,7 @@ mod tests { "InitMiniApp", "FinalizeMiniApp", "PublishMiniApp", + "FrontendWorkbench", "PublishAppearance", "PageDeploy", "PagePublish", @@ -677,6 +678,7 @@ mod tests { "core.computer-use", "core.review", "core.miniapp", + "core.creation", "core.canvas", ], "provider groups must preserve the reviewed atomic ownership order" diff --git a/src/crates/assembly/product-capabilities/src/lib.rs b/src/crates/assembly/product-capabilities/src/lib.rs index 5c451482ce..df963609c0 100644 --- a/src/crates/assembly/product-capabilities/src/lib.rs +++ b/src/crates/assembly/product-capabilities/src/lib.rs @@ -22,6 +22,7 @@ pub enum ProductCapabilityId { DeepReview, DeepResearch, MiniApp, + Creation, Canvas, VoiceInput, } @@ -33,6 +34,7 @@ impl ProductCapabilityId { Self::DeepReview => "deep-review", Self::DeepResearch => "deep-research", Self::MiniApp => "miniapp", + Self::Creation => "creation", Self::Canvas => "canvas", Self::VoiceInput => "voice-input", } @@ -55,6 +57,7 @@ pub enum ProductFeatureGroup { ComputerUse, ImageAnalysis, MiniApp, + Creation, Canvas, AgentControl, } @@ -69,6 +72,7 @@ impl ProductFeatureGroup { Self::ComputerUse => "computer-use", Self::ImageAnalysis => "image-analysis", Self::MiniApp => "miniapp", + Self::Creation => "creation", Self::Canvas => "canvas", Self::AgentControl => "agent-control", } @@ -91,6 +95,7 @@ impl From for ProductFeatureGroup { ToolPackFeatureGroup::ComputerUse => Self::ComputerUse, ToolPackFeatureGroup::ImageAnalysis => Self::ImageAnalysis, ToolPackFeatureGroup::MiniApp => Self::MiniApp, + ToolPackFeatureGroup::Creation => Self::Creation, ToolPackFeatureGroup::Canvas => Self::Canvas, ToolPackFeatureGroup::AgentControl => Self::AgentControl, } @@ -107,6 +112,7 @@ impl From for ToolPackFeatureGroup { ProductFeatureGroup::ComputerUse => Self::ComputerUse, ProductFeatureGroup::ImageAnalysis => Self::ImageAnalysis, ProductFeatureGroup::MiniApp => Self::MiniApp, + ProductFeatureGroup::Creation => Self::Creation, ProductFeatureGroup::Canvas => Self::Canvas, ProductFeatureGroup::AgentControl => Self::AgentControl, } @@ -1005,6 +1011,7 @@ const CODE_AGENT_TOOL_GROUPS: &[&str] = &[ const DEEP_REVIEW_TOOL_GROUPS: &[&str] = &["core.review"]; const DEEP_RESEARCH_TOOL_GROUPS: &[&str] = &["core.web", "core.mcp"]; const MINIAPP_TOOL_GROUPS: &[&str] = &["core.miniapp"]; +const CREATION_TOOL_GROUPS: &[&str] = &["core.creation", "core.miniapp"]; const CANVAS_TOOL_GROUPS: &[&str] = &["core.canvas"]; const CODE_AGENT_IDS: &[&str] = &[ @@ -1032,6 +1039,7 @@ const DEEP_REVIEW_AGENT_IDS: &[&str] = &[ "DeepReview", ]; const DEEP_RESEARCH_AGENT_IDS: &[&str] = &["DeepResearch", "ResearchSpecialist"]; +const CREATION_AGENT_IDS: &[&str] = &["Creative"]; const NO_PRODUCT_AGENTS: &[&str] = &[]; const CODE_AGENT_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::new( @@ -1058,6 +1066,12 @@ const MINIAPP_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::ne MINIAPP_TOOL_GROUPS, NO_PRODUCT_AGENTS, ); +const CREATION_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::new( + ProductCapabilityId::Creation, + MINIAPP_SERVICES, + CREATION_TOOL_GROUPS, + CREATION_AGENT_IDS, +); const CANVAS_CAPABILITY_PACK: ProductCapabilityPack = ProductCapabilityPack::new( ProductCapabilityId::Canvas, CANVAS_SERVICES, @@ -1072,6 +1086,7 @@ const DEFAULT_PRODUCT_CAPABILITY_PACKS: &[ProductCapabilityPack] = &[ DEEP_REVIEW_CAPABILITY_PACK, DEEP_RESEARCH_CAPABILITY_PACK, MINIAPP_CAPABILITY_PACK, + CREATION_CAPABILITY_PACK, CANVAS_CAPABILITY_PACK, VOICE_INPUT_CAPABILITY_PACK, ]; diff --git a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs index 5d141beace..874ab0462e 100644 --- a/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs +++ b/src/crates/assembly/product-capabilities/tests/product_capability_contracts/product_capabilities.rs @@ -142,6 +142,7 @@ fn default_capability_registry_preserves_product_tool_provider_order() { "core.computer-use", "core.review", "core.miniapp", + "core.creation", "core.canvas", ] ); @@ -163,6 +164,7 @@ fn capability_packs_describe_service_and_tool_requirements() { "deep-review", "deep-research", "miniapp", + "creation", "canvas", "voice-input" ] @@ -181,6 +183,7 @@ fn product_assembly_plan_keeps_full_capabilities_only_for_core_compatibility_pro "deep-review", "deep-research", "miniapp", + "creation", "canvas", ]; let full_tool_groups = vec![ @@ -193,6 +196,7 @@ fn product_assembly_plan_keeps_full_capabilities_only_for_core_compatibility_pro "core.computer-use", "core.review", "core.miniapp", + "core.creation", "core.canvas", ]; @@ -457,6 +461,7 @@ fn product_assembly_plan_exposes_build_feature_groups_explicitly() { ProductFeatureGroup::Mcp, ProductFeatureGroup::ComputerUse, ProductFeatureGroup::MiniApp, + ProductFeatureGroup::Creation, ProductFeatureGroup::Canvas, ] ); @@ -471,6 +476,7 @@ fn product_assembly_plan_exposes_build_feature_groups_explicitly() { "mcp", "computer-use", "miniapp", + "creation", "canvas", ] ); @@ -768,6 +774,7 @@ fn default_capability_assembly_keeps_service_and_tool_facts_together() { "deep-review", "deep-research", "miniapp", + "creation", "canvas", "voice-input" ] @@ -805,6 +812,7 @@ fn default_capability_assembly_keeps_service_and_tool_facts_together() { "core.computer-use", "core.review", "core.miniapp", + "core.creation", "core.canvas", ] ); diff --git a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json index 0a2369df6e..56cba8a10f 100644 --- a/src/crates/contracts/product-domains/src/generated/product-control-catalog.json +++ b/src/crates/contracts/product-domains/src/generated/product-control-catalog.json @@ -4,7 +4,7 @@ "title": "BitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "45868ac0809aca71acdf49b9ed64e7e139f15cd8a1504d1330fdaf29ccf9ca61", + "digest": "b915ffcb329182de3e033d8e48186a1dbd5c683d975484567d9dfe9b9c595c13", "ownerDigest": "a6a8c5337fb877e641b66f5774740d5213caed4800fe9b006eb53632f7eb1c2d", "searchAcceptance": [ { @@ -138,10 +138,10 @@ "features": 22, "settings": 16, "userFacing": 38, - "documentedItems": 315, + "documentedItems": 316, "controlCoverage": { "direct": 49, - "delegated": 61, + "delegated": 62, "interactive": 205, "unsupported": 0 } @@ -192,6 +192,7 @@ "create-session", "session-lifecycle", "model-mode-permissions", + "creative-frontend-authoring", "turn-control", "permission-mailbox", "history-search", @@ -383,6 +384,54 @@ "sceneId": "session" } }, + { + "id": "feature.ai-assistant:delegate:creative-frontend-authoring", + "capabilityId": "feature.ai-assistant", + "itemIds": [ + "creative-frontend-authoring" + ], + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": true + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "delegateTools": [ + "FrontendWorkbench", + "Read", + "Write", + "Edit" + ], + "presentationTarget": { + "kind": "scene", + "sceneId": "session" + } + }, { "id": "feature.ai-assistant:open:turn-control", "capabilityId": "feature.ai-assistant", @@ -18181,7 +18230,9 @@ "新建会话", "继续任务", "权限", - "目标" + "目标", + "创造模式", + "前端定制" ], "keywordsEn": [ "chat", @@ -18190,17 +18241,21 @@ "new session", "continue task", "permission", - "goal" + "goal", + "Creative mode", + "frontend customization" ], "highlightsZh": [ "创建、归档、恢复和分叉会话", "暂停、继续或引导正在运行的任务", - "查看工具调用、权限请求和用量" + "查看工具调用、权限请求和用量", + "用创造模式安全定制正在运行的 BitFun 前端" ], "highlightsEn": [ "Create, archive, restore, and fork sessions", "Pause, continue, or steer running work", - "Inspect tool calls, permission requests, and usage" + "Inspect tool calls, permission requests, and usage", + "Safely customize the running BitFun frontend in Creative mode" ], "items": [ { @@ -18237,6 +18292,28 @@ "reasonEn": "“Choose the model, work mode, memory mode, and permission mode per session” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "creative-frontend-authoring", + "titleZh": "让创造模式安全修改正在运行的 BitFun 前端", + "titleEn": "Let Creative mode safely customize the running BitFun frontend", + "control": { + "kind": "delegate", + "tools": [ + "FrontendWorkbench", + "Read", + "Write", + "Edit" + ], + "workflowZh": [ + "仅在本地桌面的创造模式中调用 FrontendWorkbench prepare,随后只编辑返回的草稿目录", + "用原草稿 ID 调用 apply 后,告知用户必须在 15 秒内通过不可变确认窗口保留修改;再次查询 status,只有不再 pending 才算完成" + ], + "workflowEn": [ + "Call FrontendWorkbench prepare only from Creative mode on the local desktop, then edit only the returned draft directory", + "Apply with the original draft ID, tell the user to keep the change through the immutable confirmation window within 15 seconds, and treat it as complete only after status is no longer pending" + ] + } + }, { "id": "turn-control", "titleZh": "开始、引导、打断、取消或恢复一次 Agent 运行", @@ -18453,6 +18530,8 @@ "继续任务", "权限", "目标", + "创造模式", + "前端定制", "chat", "conversation", "session", @@ -18460,18 +18539,24 @@ "continue task", "permission", "goal", + "Creative mode", + "frontend customization", "创建、归档、恢复和分叉会话", "暂停、继续或引导正在运行的任务", "查看工具调用、权限请求和用量", + "用创造模式安全定制正在运行的 BitFun 前端", "Create, archive, restore, and fork sessions", "Pause, continue, or steer running work", "Inspect tool calls, permission requests, and usage", + "Safely customize the running BitFun frontend in Creative mode", "打开并新建一个 AI 会话", "Open and create a new AI session", "重命名、归档、恢复、删除、分叉与导出会话", "Rename, archive, restore, delete, fork, and export sessions", "为会话选择模型、工作模式、记忆模式与权限模式", "Choose the model, work mode, memory mode, and permission mode per session", + "让创造模式安全修改正在运行的 BitFun 前端", + "Let Creative mode safely customize the running BitFun frontend", "开始、引导、打断、取消或恢复一次 Agent 运行", "Start, steer, interrupt, cancel, or recover an agent turn", "查看并逐个或批量回答等待中的权限请求", @@ -18498,9 +18583,16 @@ "打开 AI 助手,我想继续刚才的任务", "Start a new session so we can debug this build error", "Open the AI assistant; I want to continue my previous task", + "FrontendWorkbench", + "Read", + "Write", + "Edit", + "仅在本地桌面的创造模式中调用 FrontendWorkbench prepare,随后只编辑返回的草稿目录", + "用原草稿 ID 调用 apply 后,告知用户必须在 15 秒内通过不可变确认窗口保留修改;再次查询 status,只有不再 pending 才算完成", + "Call FrontendWorkbench prepare only from Creative mode on the local desktop, then edit only the returned draft directory", + "Apply with the original draft ID, tell the user to keep the change through the immutable confirmation window within 15 seconds, and treat it as complete only after status is no longer pending", "SessionControl", "SessionHistory", - "Read", "Grep", "先用 SessionControl 的 list 找到目标会话,再用 SessionHistory 导出带索引的会话记录", "先只读取导出文件的索引区,再用 Read 或 Grep 定位并读取需要的轮次,避免把完整历史塞进上下文", diff --git a/src/crates/execution/agent-runtime/src/agents.rs b/src/crates/execution/agent-runtime/src/agents.rs index aa4bbe38c4..7cc5be00b6 100644 --- a/src/crates/execution/agent-runtime/src/agents.rs +++ b/src/crates/execution/agent-runtime/src/agents.rs @@ -50,6 +50,7 @@ pub fn mode_presentation_rank(mode_id: &str) -> u8 { "Multitask" => 3, "DeepResearch" => 4, "Ultra" => 5, + "Creative" => 6, _ => 99, } } @@ -84,6 +85,12 @@ pub fn builtin_agent_definition_specs() -> Vec { builtin_agent_spec("minimal", Mode, "auto", SubagentVisibilityPolicy::default()), builtin_agent_spec("agentic", Mode, "auto", SubagentVisibilityPolicy::default()), builtin_agent_spec("Cowork", Mode, "auto", SubagentVisibilityPolicy::default()), + builtin_agent_spec( + "Creative", + Mode, + "auto", + SubagentVisibilityPolicy::default(), + ), builtin_agent_spec( "Multitask", Mode, @@ -188,8 +195,8 @@ pub fn builtin_agent_definition_specs() -> Vec { pub fn default_model_id_for_builtin_agent(agent_type: &str) -> &'static str { match agent_type { - "minimal" | "agentic" | "Cowork" | "ComputerUse" | "Plan" | "Claw" | "DeepResearch" - | "Multitask" | "Ultra" => "auto", + "minimal" | "agentic" | "Cowork" | "Creative" | "ComputerUse" | "Plan" | "Claw" + | "DeepResearch" | "Multitask" | "Ultra" => "auto", "Explore" | "CodeReview" | "GeneralPurpose" | "MemoryPhase2" | "SwarmPlanner" | "SwarmWorker" => "primary", "GenerateDoc" diff --git a/src/crates/execution/agent-runtime/src/skills/catalog.rs b/src/crates/execution/agent-runtime/src/skills/catalog.rs index 5a946021dd..9d84df4ece 100644 --- a/src/crates/execution/agent-runtime/src/skills/catalog.rs +++ b/src/crates/execution/agent-runtime/src/skills/catalog.rs @@ -3,6 +3,7 @@ pub(super) enum BuiltinSkillGroup { Office, Meta, MiniApp, + Creation, ComputerUse, Canvas, Debugging, @@ -15,6 +16,7 @@ impl BuiltinSkillGroup { Self::Office => "office", Self::Meta => "meta", Self::MiniApp => "miniapp", + Self::Creation => "creation", Self::ComputerUse => "computer-use", Self::Canvas => "canvas", Self::Debugging => "debugging", @@ -62,6 +64,10 @@ pub(super) const BUILTIN_SKILL_SPECS: &[BuiltinSkillSpec] = &[ dir_name: "miniapp-dev", group: BuiltinSkillGroup::MiniApp, }, + BuiltinSkillSpec { + dir_name: "bitfun-frontend-dev", + group: BuiltinSkillGroup::Creation, + }, BuiltinSkillSpec { dir_name: "gstack-autoplan", group: BuiltinSkillGroup::Gstack, diff --git a/src/crates/execution/agent-runtime/src/skills/policy.rs b/src/crates/execution/agent-runtime/src/skills/policy.rs index b99a8cf592..939351d1c0 100644 --- a/src/crates/execution/agent-runtime/src/skills/policy.rs +++ b/src/crates/execution/agent-runtime/src/skills/policy.rs @@ -6,6 +6,7 @@ enum SkillModeId { CodingShared, Cowork, Claw, + Creative, ComputerUse, DeepResearch, Other, @@ -17,6 +18,7 @@ impl SkillModeId { SHARED_CODING_MODE_CONFIG_PROFILE_ID => Self::CodingShared, "Cowork" => Self::Cowork, "Claw" => Self::Claw, + "Creative" => Self::Creative, "ComputerUse" => Self::ComputerUse, "DeepResearch" => Self::DeepResearch, _ => Self::Other, @@ -63,6 +65,16 @@ const DISABLE_GSTACK: SkillPolicyRule = SkillPolicyRule { effect: PolicyEffect::Disable, }; +const DISABLE_MINIAPP: SkillPolicyRule = SkillPolicyRule { + selector: SkillSelector::Group(BuiltinSkillGroup::MiniApp), + effect: PolicyEffect::Disable, +}; + +const DISABLE_CREATION: SkillPolicyRule = SkillPolicyRule { + selector: SkillSelector::Group(BuiltinSkillGroup::Creation), + effect: PolicyEffect::Disable, +}; + // ControlHub's browser domain is the single default browser-automation path. // The computer-use skill group (agent-browser) stays opt-in in every mode so the // model never sees two parallel browser stacks; users can still enable it via @@ -88,6 +100,17 @@ const OPEN_META_ONLY_POLICY: ModeSkillPolicy = ModeSkillPolicy { }; const AGENTIC_POLICY: ModeSkillPolicy = ModeSkillPolicy { + builtin_default: PolicyEffect::Enable, + rules: &[ + DISABLE_OFFICE, + DISABLE_GSTACK, + DISABLE_COMPUTER_USE, + DISABLE_MINIAPP, + DISABLE_CREATION, + ], +}; + +const CREATIVE_POLICY: ModeSkillPolicy = ModeSkillPolicy { builtin_default: PolicyEffect::Enable, rules: &[DISABLE_OFFICE, DISABLE_GSTACK, DISABLE_COMPUTER_USE], }; @@ -101,6 +124,7 @@ fn policy_for_mode(mode_id: &str) -> ModeSkillPolicy { let policy_scope = resolve_mode_config_profile_id(mode_id); match SkillModeId::parse(policy_scope.as_ref()) { SkillModeId::CodingShared | SkillModeId::Claw => AGENTIC_POLICY, + SkillModeId::Creative => CREATIVE_POLICY, SkillModeId::Cowork => COWORK_POLICY, SkillModeId::ComputerUse | SkillModeId::DeepResearch | SkillModeId::Other => { OPEN_META_ONLY_POLICY @@ -146,6 +170,7 @@ mod tests { "Multitask", "coding_shared", "Claw", + "Creative", "Cowork", "ComputerUse", "DeepResearch", @@ -193,4 +218,28 @@ mod tests { ); } } + + #[test] + fn product_creation_skills_default_only_in_creative_mode() { + for skill in ["miniapp-dev", "bitfun-frontend-dev"] { + for mode_id in [ + "agentic", + "Plan", + "Multitask", + "coding_shared", + "Claw", + "Creative", + "Cowork", + "ComputerUse", + "DeepResearch", + "SomeUnknownMode", + ] { + assert_eq!( + resolve_builtin_default_enabled(skill, mode_id), + Some(mode_id == "Creative"), + "creation skill {skill} has unexpected default exposure in {mode_id}" + ); + } + } + } } diff --git a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs index 65c9de9e29..4aa80dce0d 100644 --- a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/agent_registry_contracts.rs @@ -182,6 +182,7 @@ fn mode_presentation_and_shared_context_policy_match_existing_mode_contract() { assert_eq!(mode_presentation_rank("agentic"), 0); assert_eq!(mode_presentation_rank("Cowork"), 1); assert_eq!(mode_presentation_rank("Ultra"), 5); + assert_eq!(mode_presentation_rank("Creative"), 6); assert_eq!(mode_presentation_rank("unknown"), 99); assert_eq!( @@ -200,6 +201,7 @@ fn builtin_agent_definition_catalog_preserves_order_categories_models_and_visibi "minimal", "agentic", "Cowork", + "Creative", "Multitask", "Plan", "Claw", diff --git a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs index 05f1a8e66d..960bfbfd9f 100644 --- a/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_definition_contracts/skill_contracts.rs @@ -287,6 +287,10 @@ fn builtin_skill_catalog_and_mode_policy_are_runtime_owned() { assert_eq!(builtin_skill_group_key("create-bitfun-skin"), Some("meta")); assert_eq!(builtin_skill_group_key("find-skills"), Some("meta")); assert_eq!(builtin_skill_group_key("miniapp-dev"), Some("miniapp")); + assert_eq!( + builtin_skill_group_key("bitfun-frontend-dev"), + Some("creation") + ); assert_eq!( builtin_skill_group_key("agent-browser"), Some("computer-use") @@ -316,7 +320,7 @@ fn builtin_skill_catalog_and_mode_policy_are_runtime_owned() { ); assert_eq!( resolve_builtin_default_enabled("miniapp-dev", "agentic"), - Some(true) + Some(false) ); assert_eq!( resolve_builtin_default_enabled("miniapp-dev", "Cowork"), @@ -326,6 +330,18 @@ fn builtin_skill_catalog_and_mode_policy_are_runtime_owned() { resolve_builtin_default_enabled("miniapp-dev", "DeepResearch"), Some(false) ); + assert_eq!( + resolve_builtin_default_enabled("miniapp-dev", "Creative"), + Some(true) + ); + assert_eq!( + resolve_builtin_default_enabled("bitfun-frontend-dev", "Creative"), + Some(true) + ); + assert_eq!( + resolve_builtin_default_enabled("bitfun-frontend-dev", "agentic"), + Some(false) + ); assert_eq!( resolve_builtin_default_enabled("agent-browser", "coding_shared"), Some(false) diff --git a/src/crates/execution/tool-provider-groups/Cargo.toml b/src/crates/execution/tool-provider-groups/Cargo.toml index 7d4d81ac48..894bbf499a 100644 --- a/src/crates/execution/tool-provider-groups/Cargo.toml +++ b/src/crates/execution/tool-provider-groups/Cargo.toml @@ -18,9 +18,10 @@ browser-web = [] computer-use = [] image-analysis = [] miniapp = [] +creation = [] canvas = [] agent-control = [] -product-full = ["basic", "git", "mcp", "browser-web", "computer-use", "image-analysis", "miniapp", "canvas", "agent-control"] +product-full = ["basic", "git", "mcp", "browser-web", "computer-use", "image-analysis", "miniapp", "creation", "canvas", "agent-control"] [lints] workspace = true diff --git a/src/crates/execution/tool-provider-groups/src/lib.rs b/src/crates/execution/tool-provider-groups/src/lib.rs index 66715505fc..11f217d552 100644 --- a/src/crates/execution/tool-provider-groups/src/lib.rs +++ b/src/crates/execution/tool-provider-groups/src/lib.rs @@ -16,6 +16,7 @@ pub enum ToolPackFeatureGroup { ComputerUse, ImageAnalysis, MiniApp, + Creation, Canvas, AgentControl, } @@ -30,6 +31,7 @@ impl ToolPackFeatureGroup { Self::ComputerUse => "computer-use", Self::ImageAnalysis => "image-analysis", Self::MiniApp => "miniapp", + Self::Creation => "creation", Self::Canvas => "canvas", Self::AgentControl => "agent-control", } @@ -44,6 +46,7 @@ pub const ALL_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ ToolPackFeatureGroup::ComputerUse, ToolPackFeatureGroup::ImageAnalysis, ToolPackFeatureGroup::MiniApp, + ToolPackFeatureGroup::Creation, ToolPackFeatureGroup::Canvas, ToolPackFeatureGroup::AgentControl, ]; @@ -70,6 +73,7 @@ pub fn enabled_feature_groups() -> Vec { ToolPackFeatureGroup::ImageAnalysis, ), (cfg!(feature = "miniapp"), ToolPackFeatureGroup::MiniApp), + (cfg!(feature = "creation"), ToolPackFeatureGroup::Creation), (cfg!(feature = "canvas"), ToolPackFeatureGroup::Canvas), ( cfg!(feature = "agent-control"), @@ -98,6 +102,7 @@ pub fn tool_feature_group(tool_name: &str) -> Option { | "PublishAppearance" | "PageDeploy" | "PagePublish" | "Playbook" => { Some(ToolPackFeatureGroup::MiniApp) } + "FrontendWorkbench" => Some(ToolPackFeatureGroup::Creation), "CreateCanvas" | "ReadCanvas" | "UpdateCanvas" | "PatchCanvas" => { Some(ToolPackFeatureGroup::Canvas) } @@ -158,6 +163,7 @@ const CORE_MCP_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup: const CORE_COMPUTER_USE_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::ComputerUse]; const CORE_MINIAPP_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::MiniApp]; +const CORE_CREATION_FEATURE_GROUPS: &[ToolPackFeatureGroup] = &[ToolPackFeatureGroup::Creation]; const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ ToolProviderGroupPlan { @@ -258,6 +264,11 @@ const PRODUCT_TOOL_PROVIDER_GROUP_PLAN: &[ToolProviderGroupPlan] = &[ "Playbook", ], }, + ToolProviderGroupPlan { + provider_id: "core.creation", + feature_groups: CORE_CREATION_FEATURE_GROUPS, + tool_names: &["FrontendWorkbench"], + }, ToolProviderGroupPlan { provider_id: "core.canvas", feature_groups: CORE_CANVAS_FEATURE_GROUPS, @@ -338,6 +349,7 @@ mod tests { "computer-use", "image-analysis", "miniapp", + "creation", "canvas", "agent-control" ] @@ -376,6 +388,10 @@ mod tests { groups.contains(&ToolPackFeatureGroup::MiniApp), cfg!(feature = "miniapp") ); + assert_eq!( + groups.contains(&ToolPackFeatureGroup::Creation), + cfg!(feature = "creation") + ); assert_eq!( groups.contains(&ToolPackFeatureGroup::Canvas), cfg!(feature = "canvas") @@ -454,6 +470,7 @@ mod tests { assert_eq!(ToolPackFeatureGroup::ComputerUse.id(), "computer-use"); assert_eq!(ToolPackFeatureGroup::ImageAnalysis.id(), "image-analysis"); assert_eq!(ToolPackFeatureGroup::MiniApp.id(), "miniapp"); + assert_eq!(ToolPackFeatureGroup::Creation.id(), "creation"); assert_eq!(ToolPackFeatureGroup::Canvas.id(), "canvas"); assert_eq!(ToolPackFeatureGroup::AgentControl.id(), "agent-control"); } @@ -477,6 +494,7 @@ mod tests { "core.computer-use", "core.review", "core.miniapp", + "core.creation", "core.canvas", ] ); @@ -550,6 +568,7 @@ mod tests { "PageDeploy", "PagePublish", "Playbook", + "FrontendWorkbench", "CreateCanvas", "ReadCanvas", "UpdateCanvas", @@ -586,6 +605,7 @@ mod tests { ("core.computer-use", vec!["computer-use"]), ("core.review", vec!["agent-control"]), ("core.miniapp", vec!["miniapp"]), + ("core.creation", vec!["creation"]), ("core.canvas", vec!["canvas"]), ] ); diff --git a/src/shared/interactive-capabilities/catalog.json b/src/shared/interactive-capabilities/catalog.json index 1b65951cde..5ead54c5b8 100644 --- a/src/shared/interactive-capabilities/catalog.json +++ b/src/shared/interactive-capabilities/catalog.json @@ -251,7 +251,9 @@ "新建会话", "继续任务", "权限", - "目标" + "目标", + "创造模式", + "前端定制" ], "keywordsEn": [ "chat", @@ -260,17 +262,21 @@ "new session", "continue task", "permission", - "goal" + "goal", + "Creative mode", + "frontend customization" ], "highlightsZh": [ "创建、归档、恢复和分叉会话", "暂停、继续或引导正在运行的任务", - "查看工具调用、权限请求和用量" + "查看工具调用、权限请求和用量", + "用创造模式安全定制正在运行的 BitFun 前端" ], "highlightsEn": [ "Create, archive, restore, and fork sessions", "Pause, continue, or steer running work", - "Inspect tool calls, permission requests, and usage" + "Inspect tool calls, permission requests, and usage", + "Safely customize the running BitFun frontend in Creative mode" ], "items": [ { @@ -329,6 +335,34 @@ "command:update_active_turn_permission_mode" ] }, + { + "id": "creative-frontend-authoring", + "titleZh": "让创造模式安全修改正在运行的 BitFun 前端", + "titleEn": "Let Creative mode safely customize the running BitFun frontend", + "control": { + "kind": "delegate", + "tools": [ + "FrontendWorkbench", + "Read", + "Write", + "Edit" + ], + "workflowZh": [ + "仅在本地桌面的创造模式中调用 FrontendWorkbench prepare,随后只编辑返回的草稿目录", + "用原草稿 ID 调用 apply 后,告知用户必须在 15 秒内通过不可变确认窗口保留修改;再次查询 status,只有不再 pending 才算完成" + ], + "workflowEn": [ + "Call FrontendWorkbench prepare only from Creative mode on the local desktop, then edit only the returned draft directory", + "Apply with the original draft ID, tell the user to keep the change through the immutable confirmation window within 15 seconds, and treat it as complete only after status is no longer pending" + ] + }, + "evidence": [ + "source:src/crates/assembly/core/src/agentic/tools/implementations/frontend_workbench_tool.rs#FrontendWorkbench", + "source:src/apps/desktop/src/frontend_workbench.rs#FrontendWorkbenchManager", + "command:confirm_frontend_update", + "command:rollback_frontend_update" + ] + }, { "id": "turn-control", "titleZh": "开始、引导、打断、取消或恢复一次 Agent 运行", @@ -8854,6 +8888,9 @@ "agentic": { "capabilityId": "feature.ai-assistant" }, + "frontend_workbench": { + "capabilityId": "feature.ai-assistant" + }, "remote_connect": { "capabilityId": "feature.remote-connect" }, diff --git a/src/web-ui/index.html b/src/web-ui/index.html index cfc92fa139..f2c5de3289 100644 --- a/src/web-ui/index.html +++ b/src/web-ui/index.html @@ -346,6 +346,9 @@ } } + + + diff --git a/src/web-ui/public/bitfun-creation.css b/src/web-ui/public/bitfun-creation.css new file mode 100644 index 0000000000..4342f9fb01 --- /dev/null +++ b/src/web-ui/public/bitfun-creation.css @@ -0,0 +1,5 @@ +/* + * Stable Creative-mode override surface for the packaged BitFun frontend. + * Keep empty by default. Creative agents should prefer changes here when CSS + * alone can implement the requested customization. + */ diff --git a/src/web-ui/public/bitfun-creation.js b/src/web-ui/public/bitfun-creation.js new file mode 100644 index 0000000000..edf776a7d4 --- /dev/null +++ b/src/web-ui/public/bitfun-creation.js @@ -0,0 +1,4 @@ +/* + * Stable Creative-mode override surface for the packaged BitFun frontend. + * Keep side effects reversible and preserve the Tauri bridge and recovery UI. + */ diff --git a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss index 85181a069d..e3a32c4cb6 100644 --- a/src/web-ui/src/app/global-search/GlobalSearchRoot.scss +++ b/src/web-ui/src/app/global-search/GlobalSearchRoot.scss @@ -714,16 +714,17 @@ } // The modal presentation follows the independent design-system contract. The -// embedded presentation above intentionally keeps its compact product layout. +// embedded presentation above intentionally keeps its compact product layout; +// identity tones remain theme-aware through the existing Appearance palette. .global-search--modal { --global-search-surface: var(--bf-color-surface-raised); --global-search-raised-surface: var(--bf-color-action-neutral-surface); - --_global-search-command-red: #ec221f; - --_global-search-command-orange: #ff8c00; - --_global-search-command-green: #009951; - --_global-search-command-cyan: #059cb0; - --_global-search-command-blue: #3271d7; - --_global-search-command-purple: #9e54ff; + --_global-search-command-red: var(--bf-appearance-token-color-error); + --_global-search-command-orange: var(--bf-appearance-token-color-warning); + --_global-search-command-green: var(--bf-appearance-token-color-success); + --_global-search-command-cyan: var(--bf-appearance-token-color-cyan-500); + --_global-search-command-blue: var(--bf-appearance-token-color-accent-500); + --_global-search-command-purple: var(--bf-appearance-token-color-purple-500); width: 100%; flex: 1 1 auto; diff --git a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json index 9dd56180a0..a2e30e237a 100644 --- a/src/web-ui/src/app/global-search/generated/interactive-capabilities.json +++ b/src/web-ui/src/app/global-search/generated/interactive-capabilities.json @@ -4,7 +4,7 @@ "title": "BitFun Playbook", "origin": "https://playbook.openbitfun.com", "source": "src/shared/interactive-capabilities/catalog.json", - "digest": "45868ac0809aca71acdf49b9ed64e7e139f15cd8a1504d1330fdaf29ccf9ca61", + "digest": "b915ffcb329182de3e033d8e48186a1dbd5c683d975484567d9dfe9b9c595c13", "ownerDigest": "a6a8c5337fb877e641b66f5774740d5213caed4800fe9b006eb53632f7eb1c2d", "searchAcceptance": [ { @@ -138,10 +138,10 @@ "features": 22, "settings": 16, "userFacing": 38, - "documentedItems": 315, + "documentedItems": 316, "controlCoverage": { "direct": 49, - "delegated": 61, + "delegated": 62, "interactive": 205, "unsupported": 0 } @@ -192,6 +192,7 @@ "create-session", "session-lifecycle", "model-mode-permissions", + "creative-frontend-authoring", "turn-control", "permission-mailbox", "history-search", @@ -383,6 +384,54 @@ "sceneId": "session" } }, + { + "id": "feature.ai-assistant:delegate:creative-frontend-authoring", + "capabilityId": "feature.ai-assistant", + "itemIds": [ + "creative-frontend-authoring" + ], + "kind": "delegate", + "risk": "execute", + "executionHost": "workspaceHost", + "availability": { + "desktop": { + "available": true + }, + "cli": { + "available": true + }, + "peer": { + "available": true, + "requiredCapabilities": [ + "product_control_v1" + ] + }, + "remoteControl": { + "available": true + }, + "detachedDispatch": { + "available": true + } + }, + "inputSchema": { + "type": "object", + "additionalProperties": true + }, + "outputSchema": { + "type": "object", + "additionalProperties": true + }, + "delegateTools": [ + "FrontendWorkbench", + "Read", + "Write", + "Edit" + ], + "presentationTarget": { + "kind": "scene", + "sceneId": "session" + } + }, { "id": "feature.ai-assistant:open:turn-control", "capabilityId": "feature.ai-assistant", @@ -18181,7 +18230,9 @@ "新建会话", "继续任务", "权限", - "目标" + "目标", + "创造模式", + "前端定制" ], "keywordsEn": [ "chat", @@ -18190,17 +18241,21 @@ "new session", "continue task", "permission", - "goal" + "goal", + "Creative mode", + "frontend customization" ], "highlightsZh": [ "创建、归档、恢复和分叉会话", "暂停、继续或引导正在运行的任务", - "查看工具调用、权限请求和用量" + "查看工具调用、权限请求和用量", + "用创造模式安全定制正在运行的 BitFun 前端" ], "highlightsEn": [ "Create, archive, restore, and fork sessions", "Pause, continue, or steer running work", - "Inspect tool calls, permission requests, and usage" + "Inspect tool calls, permission requests, and usage", + "Safely customize the running BitFun frontend in Creative mode" ], "items": [ { @@ -18237,6 +18292,28 @@ "reasonEn": "“Choose the model, work mode, memory mode, and permission mode per session” spans multiple live-state-dependent steps and currently has no single structured Command that can deterministically complete the whole workflow; the Agent opens the exact entry and keeps the remaining interaction visible to the user." } }, + { + "id": "creative-frontend-authoring", + "titleZh": "让创造模式安全修改正在运行的 BitFun 前端", + "titleEn": "Let Creative mode safely customize the running BitFun frontend", + "control": { + "kind": "delegate", + "tools": [ + "FrontendWorkbench", + "Read", + "Write", + "Edit" + ], + "workflowZh": [ + "仅在本地桌面的创造模式中调用 FrontendWorkbench prepare,随后只编辑返回的草稿目录", + "用原草稿 ID 调用 apply 后,告知用户必须在 15 秒内通过不可变确认窗口保留修改;再次查询 status,只有不再 pending 才算完成" + ], + "workflowEn": [ + "Call FrontendWorkbench prepare only from Creative mode on the local desktop, then edit only the returned draft directory", + "Apply with the original draft ID, tell the user to keep the change through the immutable confirmation window within 15 seconds, and treat it as complete only after status is no longer pending" + ] + } + }, { "id": "turn-control", "titleZh": "开始、引导、打断、取消或恢复一次 Agent 运行", @@ -18449,6 +18526,8 @@ "继续任务", "权限", "目标", + "创造模式", + "前端定制", "chat", "conversation", "session", @@ -18456,18 +18535,24 @@ "continue task", "permission", "goal", + "Creative mode", + "frontend customization", "创建、归档、恢复和分叉会话", "暂停、继续或引导正在运行的任务", "查看工具调用、权限请求和用量", + "用创造模式安全定制正在运行的 BitFun 前端", "Create, archive, restore, and fork sessions", "Pause, continue, or steer running work", "Inspect tool calls, permission requests, and usage", + "Safely customize the running BitFun frontend in Creative mode", "打开并新建一个 AI 会话", "Open and create a new AI session", "重命名、归档、恢复、删除、分叉与导出会话", "Rename, archive, restore, delete, fork, and export sessions", "为会话选择模型、工作模式、记忆模式与权限模式", "Choose the model, work mode, memory mode, and permission mode per session", + "让创造模式安全修改正在运行的 BitFun 前端", + "Let Creative mode safely customize the running BitFun frontend", "开始、引导、打断、取消或恢复一次 Agent 运行", "Start, steer, interrupt, cancel, or recover an agent turn", "查看并逐个或批量回答等待中的权限请求", @@ -18494,9 +18579,16 @@ "打开 AI 助手,我想继续刚才的任务", "Start a new session so we can debug this build error", "Open the AI assistant; I want to continue my previous task", + "FrontendWorkbench", + "Read", + "Write", + "Edit", + "仅在本地桌面的创造模式中调用 FrontendWorkbench prepare,随后只编辑返回的草稿目录", + "用原草稿 ID 调用 apply 后,告知用户必须在 15 秒内通过不可变确认窗口保留修改;再次查询 status,只有不再 pending 才算完成", + "Call FrontendWorkbench prepare only from Creative mode on the local desktop, then edit only the returned draft directory", + "Apply with the original draft ID, tell the user to keep the change through the immutable confirmation window within 15 seconds, and treat it as complete only after status is no longer pending", "SessionControl", "SessionHistory", - "Read", "Grep", "先用 SessionControl 的 list 找到目标会话,再用 SessionHistory 导出带索引的会话记录", "先只读取导出文件的索引区,再用 Read 或 Grep 定位并读取需要的轮次,避免把完整历史塞进上下文", diff --git a/src/web-ui/src/app/global-search/globalSearchArchitecture.test.ts b/src/web-ui/src/app/global-search/globalSearchArchitecture.test.ts index 841ff4bf6f..8db7d4f726 100644 --- a/src/web-ui/src/app/global-search/globalSearchArchitecture.test.ts +++ b/src/web-ui/src/app/global-search/globalSearchArchitecture.test.ts @@ -45,12 +45,12 @@ describe('global search ownership', () => { expect(globalSearchStyles).toMatch( /\.global-search--modal\s*\{[\s\S]*\.global-search__action-icon\s*\{/, ); - expect(globalSearchStyles).toContain('--_global-search-command-red: #ec221f'); - expect(globalSearchStyles).toContain('--_global-search-command-orange: #ff8c00'); - expect(globalSearchStyles).toContain('--_global-search-command-green: #009951'); - expect(globalSearchStyles).toContain('--_global-search-command-cyan: #059cb0'); - expect(globalSearchStyles).toContain('--_global-search-command-blue: #3271d7'); - expect(globalSearchStyles).toContain('--_global-search-command-purple: #9e54ff'); + expect(globalSearchStyles).toContain('--_global-search-command-red: var(--bf-appearance-token-color-error)'); + expect(globalSearchStyles).toContain('--_global-search-command-orange: var(--bf-appearance-token-color-warning)'); + expect(globalSearchStyles).toContain('--_global-search-command-green: var(--bf-appearance-token-color-success)'); + expect(globalSearchStyles).toContain('--_global-search-command-cyan: var(--bf-appearance-token-color-cyan-500)'); + expect(globalSearchStyles).toContain('--_global-search-command-blue: var(--bf-appearance-token-color-accent-500)'); + expect(globalSearchStyles).toContain('--_global-search-command-purple: var(--bf-appearance-token-color-purple-500)'); expect(globalSearchStyles).toContain("&[data-icon-tone='red']"); expect(globalSearchStyles).toContain("&[data-icon-tone='orange']"); expect(globalSearchStyles).toContain("&[data-icon-tone='green']"); diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts index 08a4056198..720babb31a 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.presentation.test.ts @@ -16,8 +16,8 @@ describe('Mini App card presentation', () => { const rootEnd = stylesheet.indexOf('&:hover {', rootStart); const rootGeometry = stylesheet.slice(rootStart, rootEnd); - expect(rootGeometry).toContain('max-width: var(--miniapp-card-max-inline-size, 400px);'); - expect(rootGeometry).toContain('min-height: var(--miniapp-card-min-block-size, 152px);'); + expect(rootGeometry).toContain('max-width: 400px;'); + expect(rootGeometry).toContain('min-height: 152px;'); expect(rootGeometry).not.toContain('aspect-ratio:'); expect(stylesheet).toContain('grid-template-columns: clamp(60px, 18%, 72px) minmax(0, 1fr);'); expect(stylesheet).toMatch(/&__footer \{[\s\S]*?margin-top: auto;/); @@ -29,11 +29,12 @@ describe('Mini App card presentation', () => { expect(source).toContain('const MINIAPP_CARD_MIN_WIDTH = 280;'); expect(source.match(/minCardWidth=\{MINIAPP_CARD_MIN_WIDTH\}/g)).toHaveLength(3); - expect(stylesheet).toContain('--miniapp-card-max-inline-size: 400px;'); - expect(stylesheet).toContain('--miniapp-card-min-block-size: 152px;'); expect(stylesheet).toMatch(/&__card-grid \{\s+justify-items: start;/); expect(stylesheet).toMatch( - /&__card-grid\.gallery-grid--skeleton \.gallery-skeleton-card \{[\s\S]*?max-width: var\(--miniapp-card-max-inline-size\);[\s\S]*?height: var\(--miniapp-card-min-block-size\);/, + /&__card-grid\.gallery-grid--skeleton \.gallery-skeleton-card \{[\s\S]*?max-width: 400px;[\s\S]*?height: 152px;/, + ); + expect(stylesheet).toMatch( + /@media \(max-width: 480px\) \{[\s\S]*?\.miniapp-card,[\s\S]*?\.gallery-skeleton-card \{\s+max-width: 100%;/, ); expect(stylesheet).not.toContain('aspect-ratio: 12 / 5;'); }); @@ -43,8 +44,7 @@ describe('Mini App card presentation', () => { const stylesheet = readRelative('../views/MiniAppMarketView.scss'); expect(source.match(/className="miniapp-market-native__card-grid"/g)).toHaveLength(2); - expect(stylesheet).toContain('--miniapp-market-card-max-inline-size: 360px;'); - expect(stylesheet).toContain('max-width: var(--miniapp-market-card-max-inline-size, 360px);'); + expect(stylesheet).toContain('max-width: 360px;'); expect(stylesheet).toMatch(/&__card-grid \{\s+justify-items: center;/); expect(stylesheet).toMatch(/&__visual \{[\s\S]*?aspect-ratio: 16 \/ 9;/); }); diff --git a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.scss b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.scss index c096efd408..72340059f0 100644 --- a/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.scss +++ b/src/web-ui/src/app/scenes/miniapps/components/MiniAppCard.scss @@ -3,9 +3,9 @@ .miniapp-card { width: 100%; - max-width: var(--miniapp-card-max-inline-size, 400px); + max-width: 400px; min-width: 0; - min-height: var(--miniapp-card-min-block-size, 152px); + min-height: 152px; box-sizing: border-box; border: 1px solid var(--bf-appearance-token-border-subtle); border-radius: $size-radius-lg; diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.scss b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.scss index f5ec2ac348..b15844afff 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.scss +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.scss @@ -2,9 +2,6 @@ @use '../../../../component-library/styles/overlay-surfaces' as surfaces; .miniapp-gallery { - --miniapp-card-max-inline-size: 400px; - --miniapp-card-min-block-size: 152px; - background: var(--bf-appearance-token-color-bg-scene); .gallery-page-header { @@ -93,9 +90,9 @@ &__card-grid.gallery-grid--skeleton .gallery-skeleton-card { width: 100%; - max-width: var(--miniapp-card-max-inline-size); - height: var(--miniapp-card-min-block-size); - min-height: var(--miniapp-card-min-block-size); + max-width: 400px; + height: 152px; + min-height: 152px; } } @@ -160,6 +157,9 @@ @media (max-width: 480px) { .miniapp-gallery { - --miniapp-card-max-inline-size: 100%; + .miniapp-card, + &__card-grid.gallery-grid--skeleton .gallery-skeleton-card { + max-width: 100%; + } } } diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx index e391b97608..4202152bb2 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppGalleryView.tsx @@ -9,6 +9,10 @@ import { } from 'lucide-react'; import { open } from '@tauri-apps/plugin-dialog'; import { useSceneManager } from '@/app/hooks/useSceneManager'; +import { useApp } from '@/app/hooks/useApp'; +import { flowChatSessionConfigForCurrentWorkspace } from '@/app/utils/projectSessionWorkspace'; +import { flowChatManager } from '@/flow_chat/services/FlowChatManager'; +import { isRemoteWorkspace } from '@/shared/types'; import { isImeOwnedKeyboardEvent } from '@/shared/utils/ime'; import MiniAppCard from '../components/MiniAppCard'; import MiniAppDetailModal from '../components/MiniAppDetailModal'; @@ -56,8 +60,9 @@ const MiniAppGalleryView: React.FC = () => { const setMarketOrigins = useMiniAppStore((state) => state.setMarketOrigins); const setRunningWorkerIds = useMiniAppStore((state) => state.setRunningWorkerIds); const markWorkerStopped = useMiniAppStore((state) => state.markWorkerStopped); - const { workspacePath } = useCurrentWorkspace(); + const { workspace, workspacePath } = useCurrentWorkspace(); const notification = useNotification(); + const { switchLeftPanelTab } = useApp(); const { openScene, activateScene, closeScene, openTabs } = useSceneManager(); const { t, currentLanguage } = useI18n('scenes/miniapp'); const miniAppActivities = useMiniAppActivity(); @@ -71,7 +76,7 @@ const MiniAppGalleryView: React.FC = () => { inspection: MarketPackageInspection; } | null>(null); const [importMenuOpen, setImportMenuOpen] = useState(false); - const [creationModeNoticeOpen, setCreationModeNoticeOpen] = useState(false); + const [creatingWithCreative, setCreatingWithCreative] = useState(false); const importTriggerRef = useRef(null); const importMenuRef = useRef(null); @@ -322,6 +327,43 @@ const MiniAppGalleryView: React.FC = () => { } }; + const handleCreateWithCreative = useCallback(async () => { + if (creatingWithCreative) return; + if (!workspace) { + notification.error(t('creationMode.noWorkspace')); + return; + } + if (isRemoteWorkspace(workspace)) { + notification.error(t('creationMode.remoteUnsupported')); + return; + } + + setCreatingWithCreative(true); + closeImportMenu(); + openScene('session'); + switchLeftPanelTab('sessions'); + try { + await flowChatManager.createChatSession( + flowChatSessionConfigForCurrentWorkspace(workspace), + 'Creative', + ); + notification.success(t('creationMode.started')); + } catch (error) { + log.error('Failed to start Creative MiniApp session', error); + notification.error(t('creationMode.startFailed')); + } finally { + setCreatingWithCreative(false); + } + }, [ + closeImportMenu, + creatingWithCreative, + notification, + openScene, + switchLeftPanelTab, + t, + workspace, + ]); + const renderGrid = () => { if (loading && apps.length === 0) { return ( @@ -442,10 +484,8 @@ const MiniAppGalleryView: React.FC = () => { { - closeImportMenu(); - setCreationModeNoticeOpen(true); - }} + onClick={() => void handleCreateWithCreative()} + loading={creatingWithCreative} title={t('creationMode.action')} aria-label={t('creationMode.action')} data-testid="miniapp-create-action" @@ -530,17 +570,6 @@ const MiniAppGalleryView: React.FC = () => { onStop={handleStopRunning} /> - setCreationModeNoticeOpen(false)} - onConfirm={() => setCreationModeNoticeOpen(false)} - title={t('creationMode.unavailableTitle')} - message={t('creationMode.unavailableMessage')} - type="info" - showCancel={false} - confirmText={t('creationMode.acknowledge')} - /> - setPendingDeleteId(null)} diff --git a/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.scss b/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.scss index f3741f50a8..8533ca375d 100644 --- a/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.scss +++ b/src/web-ui/src/app/scenes/miniapps/views/MiniAppMarketView.scss @@ -1,8 +1,6 @@ @use '../../../../component-library/styles/tokens' as *; .miniapp-market-native { - --miniapp-market-card-max-inline-size: 360px; - &__header-actions { display: flex; align-items: center; @@ -29,13 +27,13 @@ &__card-grid.gallery-grid--skeleton .gallery-skeleton-card { width: 100%; - max-width: var(--miniapp-market-card-max-inline-size); + max-width: 360px; } } .miniapp-market-card { width: 100%; - max-width: var(--miniapp-market-card-max-inline-size, 360px); + max-width: 360px; min-width: 0; overflow: hidden; padding: 0; diff --git a/src/web-ui/src/component-library/styles/overlay-surfaces.contract.test.ts b/src/web-ui/src/component-library/styles/overlay-surfaces.contract.test.ts index 805f098172..98689bd13f 100644 --- a/src/web-ui/src/component-library/styles/overlay-surfaces.contract.test.ts +++ b/src/web-ui/src/component-library/styles/overlay-surfaces.contract.test.ts @@ -185,8 +185,8 @@ describe('overlay surface contracts', () => { 'design-system/packages/ui/src/components/Modal/Modal.module.css', ); - expect(modalStyles).toContain('padding-inline-end: var(--bf-overlay-modal-edge-gutter);'); - expect(modalStyles).toContain('margin-block: var(--bf-overlay-modal-edge-gutter);'); + expect(modalStyles).toContain('padding-inline-end: var(--bf-overlay-modal-header-padding-inline);'); + expect(modalStyles).toContain('margin-block-start: var(--bf-overlay-modal-header-padding-block-start);'); expect(modalStyles).not.toContain('margin-inline-end: var(--bf-overlay-modal-edge-gutter);'); }); diff --git a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx index a5ba4092df..0f243372fb 100644 --- a/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchInstallDialog.test.tsx @@ -63,8 +63,7 @@ vi.mock('@/infrastructure/api/service-api/GitAPI', () => ({ gitAPI: { resolveRevision: mocks.resolveRevision }, })); -vi.mock('@/component-library', () => ({ - Alert: ({ message }: { message: string }) =>
{message}
, +vi.mock('@bitfun/ui', () => ({ Button: ({ children, disabled, @@ -77,27 +76,6 @@ vi.mock('@/component-library', () => ({ {children} ), - Input: ({ - disabled, - onChange, - onKeyDown, - placeholder, - value, - }: { - disabled?: boolean; - onChange?: React.ChangeEventHandler; - onKeyDown?: React.KeyboardEventHandler; - placeholder?: string; - value?: string; - }) => ( - - ), Modal: ({ children, closeOnOverlayClick, @@ -119,6 +97,10 @@ vi.mock('@/component-library', () => ({ }, })); +vi.mock('@/component-library', () => ({ + Alert: ({ message }: { message: string }) =>
{message}
, +})); + function createDeferred() { let resolve!: (value: T) => void; let reject!: (reason?: unknown) => void; diff --git a/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx b/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx index ec21bffdae..cde2f34532 100644 --- a/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx +++ b/src/web-ui/src/features/dispatch/DispatchResultDialog.test.tsx @@ -21,8 +21,7 @@ vi.mock('@/infrastructure/i18n', () => ({ useI18n: () => ({ t: (key: string) => key }), })); -vi.mock('@/component-library', () => ({ - Alert: ({ message }: { message: string }) =>
{message}
, +vi.mock('@bitfun/ui', () => ({ Button: ({ children, disabled, @@ -32,8 +31,13 @@ vi.mock('@/component-library', () => ({ {children} ), - Modal: ({ children, isOpen }: React.PropsWithChildren<{ isOpen: boolean }>) => - isOpen ?
{children}
: null, + Modal: ({ children, isOpen }: React.PropsWithChildren<{ isOpen: boolean }>) => ( + isOpen ?
{children}
: null + ), +})); + +vi.mock('@/component-library', () => ({ + Alert: ({ message }: { message: string }) =>
{message}
, })); const SYNCED = { diff --git a/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx b/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx index da39d83902..38a9370f82 100644 --- a/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx +++ b/src/web-ui/src/features/market-account/MarketAccountControls.test.tsx @@ -45,14 +45,17 @@ vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ success: mocks.success, error: mocks.error }), })); -vi.mock('@/component-library', () => ({ - Avatar: ({ src, alt }: any) => {alt}, +vi.mock('@bitfun/ui', () => ({ Button: ({ children, ...props }: any) => , Modal: ({ isOpen, title, children }: any) => isOpen ? (
{children}
) : null, })); +vi.mock('@/component-library', () => ({ + Avatar: ({ src, alt }: any) => {alt}, +})); + describe('MarketAccountControls', () => { let container: HTMLDivElement; let root: ReturnType; diff --git a/src/web-ui/src/features/ssh-remote/PortForwardDialog.test.tsx b/src/web-ui/src/features/ssh-remote/PortForwardDialog.test.tsx index 71a05d4009..0a90741ad3 100644 --- a/src/web-ui/src/features/ssh-remote/PortForwardDialog.test.tsx +++ b/src/web-ui/src/features/ssh-remote/PortForwardDialog.test.tsx @@ -38,10 +38,10 @@ vi.mock('@/shared/utils/logger', () => ({ createLogger: () => ({ error: vi.fn(), warn: vi.fn(), info: vi.fn(), debug: vi.fn() }), })); -vi.mock('@/component-library', () => ({ - Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, - Modal: ({ isOpen, children }: React.PropsWithChildren<{ isOpen: boolean }>) => - isOpen ?
{children}
: null, +vi.mock('@bitfun/ui', () => ({ + Modal: ({ isOpen, children }: React.PropsWithChildren<{ isOpen: boolean }>) => ( + isOpen ?
{children}
: null + ), Button: ({ children, onClick, @@ -95,6 +95,10 @@ vi.mock('@/component-library', () => ({ data-testid={testId} /> )), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, Checkbox: ({ checked, onChange, diff --git a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx index 87212e26f7..1de89cb504 100644 --- a/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx +++ b/src/web-ui/src/features/ssh-remote/SSHConnectionDialog.test.tsx @@ -53,8 +53,7 @@ vi.mock('./SSHAuthPromptDialog', () => ({ SSHAuthPromptDialog: () => null, })); -vi.mock('@/component-library', () => ({ - Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, +vi.mock('@bitfun/ui', () => ({ Modal: ({ isOpen, children, @@ -121,6 +120,31 @@ vi.mock('@/component-library', () => ({ {suffix} ), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, + Input: ({ + label, + value, + onChange, + className, + placeholder, + suffix, + }: { + label?: string; + value?: string; + onChange?: React.ChangeEventHandler; + className?: string; + placeholder?: string; + suffix?: React.ReactNode; + }) => ( + + ), Select: ({ options, value, diff --git a/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx index 063b9c5865..b3b055f0b3 100644 --- a/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx +++ b/src/web-ui/src/flow_chat/components/DeepReviewConsentDialog.test.tsx @@ -18,7 +18,7 @@ vi.mock('react-i18next', async () => { }; }); -vi.mock('@/component-library', () => ({ +vi.mock('@bitfun/ui', () => ({ Button: ({ children, onClick, @@ -26,24 +26,6 @@ vi.mock('@/component-library', () => ({ children: React.ReactNode; onClick?: () => void; }) => , - Checkbox: ({ - checked, - label, - onChange, - }: { - checked: boolean; - label: string; - onChange: (event: React.ChangeEvent) => void; - }) => ( - - ), Modal: ({ ariaLabel, children, @@ -53,6 +35,9 @@ vi.mock('@/component-library', () => ({ children: React.ReactNode; isOpen: boolean; }) => (isOpen ?
{children}
: null), +})); + +vi.mock('@/component-library', () => ({ PopupCloseButton: ({ 'aria-label': ariaLabel, onClick, diff --git a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx index 78471809fb..5b90982e2f 100644 --- a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx +++ b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.test.tsx @@ -160,7 +160,7 @@ describe('HarnessProfileSelector', () => { const creative = rows[3]; expect(creative?.querySelector('.bitfun-harness-selector__density-core')).toBeNull(); expect(creative?.querySelector('[data-bf-icon="harness-creative"]')).not.toBeNull(); - expect(creative?.dataset.bfState).toBe('coming-soon'); + expect(creative?.dataset.bfState).toBe('available'); const other = rows[4]; expect(other?.querySelector('.bitfun-harness-selector__density-core')).toBeNull(); expect(other?.querySelector('.lucide-bot')).not.toBeNull(); @@ -223,7 +223,7 @@ describe('HarnessProfileSelector', () => { ).toBe('Deep Research · Multitask'); }); - it('activates all implemented profiles while reporting Creative as unavailable', async () => { + it('activates every implemented profile including Creative', async () => { const onSelectProfile = vi.fn(); await act(async () => { root.render(); @@ -247,8 +247,9 @@ describe('HarnessProfileSelector', () => { document.querySelector('[data-testid="harness-profile-creative"]') ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(notify.info).toHaveBeenCalledTimes(1); - expect(onSelectProfile).toHaveBeenCalledTimes(1); + expect(notify.info).not.toHaveBeenCalled(); + expect(onSelectProfile).toHaveBeenCalledTimes(2); + expect(onSelectProfile).toHaveBeenLastCalledWith('creative'); await act(async () => { container.querySelector('[data-testid="harness-profile-selector"]') @@ -258,7 +259,7 @@ describe('HarnessProfileSelector', () => { document.querySelector('[data-testid="harness-profile-minimal"]') ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); }); - expect(onSelectProfile).toHaveBeenCalledTimes(2); + expect(onSelectProfile).toHaveBeenCalledTimes(3); expect(onSelectProfile).toHaveBeenLastCalledWith('minimal'); expect(onSelectProfile).toHaveBeenCalledWith('minimal'); expect(document.querySelector('.bitfun-harness-selector__menu')).toBeNull(); @@ -383,7 +384,7 @@ describe('HarnessProfileSelector', () => { }); it.each(['creative'] as const)( - 'does not present a persisted %s profile as active', + 'presents a persisted %s profile as active', async (profileId) => { await act(async () => { root.render(); @@ -396,8 +397,8 @@ describe('HarnessProfileSelector', () => { const profile = document.querySelector( `[data-testid="harness-profile-${profileId}"]`, ); - expect(profile?.dataset.bfState).toBe('coming-soon'); - expect(profile?.getAttribute('aria-checked')).toBe('false'); + expect(profile?.dataset.bfState).toBe('current'); + expect(profile?.getAttribute('aria-checked')).toBe('true'); }, ); diff --git a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx index a56336da69..39218e78da 100644 --- a/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx +++ b/src/web-ui/src/flow_chat/components/HarnessProfileSelector.tsx @@ -26,7 +26,7 @@ export type KnownHarnessProfileId = | 'ultimate' | 'creative' | 'other'; -export type SelectableHarnessProfileId = 'minimal' | 'balanced' | 'ultimate'; +export type SelectableHarnessProfileId = 'minimal' | 'balanced' | 'ultimate' | 'creative'; export interface HarnessAgentOption { id: string; @@ -83,11 +83,7 @@ function isDensityProfile(profile: KnownHarnessProfileId): profile is DensityHar function isSelectableProfile( profile: KnownHarnessProfileId, ): profile is SelectableHarnessProfileId { - return isDensityProfile(profile); -} - -function isProfileInDevelopment(profile: KnownHarnessProfileId): boolean { - return profile === 'creative'; + return isDensityProfile(profile) || profile === 'creative'; } function sameAgent(left: string | null | undefined, right: string | null | undefined): boolean { @@ -213,14 +209,6 @@ export const HarnessProfileSelector: React.FC = ({ }, [close, open]); const handleSelectProfile = useCallback((profileId: KnownHarnessProfileId) => { - if (isProfileInDevelopment(profileId)) { - const name = t(`chatInput.harness.profiles.${profileId}.name`); - notificationService.info(t('chatInput.harness.comingSoonNotice', { name }), { - duration: 3200, - }); - close(); - return; - } if (profileId === 'other') { setPage('agents'); return; @@ -240,7 +228,7 @@ export const HarnessProfileSelector: React.FC = ({ void onSelectProfile(profileId); } close(); - }, [close, fixedSession, onSelectProfile, onStartNewSession, selectedProfile, t]); + }, [close, fixedSession, onSelectProfile, onStartNewSession, selectedProfile]); const handleSelectAgent = useCallback((agent: HarnessAgentOption) => { if (agent.available === false) { @@ -411,13 +399,10 @@ export const HarnessProfileSelector: React.FC = ({ const name = t(`chatInput.harness.profiles.${id}.name`); const connected = !creatingNewSession && id === selectedProfile - && !legacySession - && !isProfileInDevelopment(id); + && !legacySession; const state = connected ? 'current' - : isProfileInDevelopment(id) - ? 'coming-soon' - : 'available'; + : 'available'; return ( ), Modal: ({ isOpen, title, titleExtra, children }: any) => isOpen ? (
{titleExtra}{children}
) : null, - Search: ({ value, onChange, onSearch, inputAriaLabel }: any) => ( + SearchField: ({ value, onValueChange, onSearch, 'aria-label': ariaLabel }: any) => ( onChange(event.target.value)} + onChange={event => onValueChange(event.target.value)} onKeyDown={event => event.key === 'Enter' && onSearch(event.currentTarget.value)} /> ), +})); + +vi.mock('@/component-library', () => ({ Select: () =>
, Input: ({ label, ...props }: any) => , Textarea: ({ label, showCount: _showCount, ...props }: any) => ( diff --git a/src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.test.tsx b/src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.test.tsx index 01807afa53..0c01926060 100644 --- a/src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/GlobalPermissionRulesDialog.test.tsx @@ -33,8 +33,7 @@ vi.mock('react-i18next', () => ({ }), })); -vi.mock('@/component-library', () => ({ - Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, +vi.mock('@bitfun/ui', () => ({ Modal: ({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) => ( isOpen ?
{children}
: null ), @@ -53,6 +52,18 @@ vi.mock('@/component-library', () => ({ }) => ( ), + Input: ({ value, disabled, onChange, 'aria-label': ariaLabel }: { + value: string; + disabled?: boolean; + onChange?: (event: React.ChangeEvent) => void; + 'aria-label'?: string; + }) => ( + + ), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, Select: ({ value, options, disabled, onChange, 'aria-label': ariaLabel }: { value: string; options: Array<{ value: string; label: string }>; @@ -69,14 +80,6 @@ vi.mock('@/component-library', () => ({ {options.map((option) => )} ), - Input: ({ value, disabled, onChange, 'aria-label': ariaLabel }: { - value: string; - disabled?: boolean; - onChange?: (event: React.ChangeEvent) => void; - 'aria-label'?: string; - }) => ( - - ), })); describe('GlobalPermissionRulesDialog', () => { diff --git a/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx index 38701cf1eb..3e2e8927d2 100644 --- a/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/HooksConfig.test.tsx @@ -26,11 +26,10 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: translateMock }), })); -vi.mock('@/component-library', () => ({ +vi.mock('@bitfun/ui', () => ({ Button: ({ children, disabled, onClick }: React.ButtonHTMLAttributes) => ( ), - ConfigPageLoading: ({ text }: { text: string }) =>
{text}
, Switch: ({ checked, disabled, onChange }: React.InputHTMLAttributes) => ( ), @@ -39,6 +38,10 @@ vi.mock('@/component-library', () => ({ isOpen: boolean; title?: string; }) => (isOpen ?
{children}
: null), +})); + +vi.mock('@/component-library', () => ({ + ConfigPageLoading: ({ text }: { text: string }) =>
{text}
, ConfirmDialog: ({ confirmText, isOpen, message, onConfirm, title }: { confirmText?: string; isOpen: boolean; diff --git a/src/web-ui/src/infrastructure/config/components/MemorySettingsPage.tsx b/src/web-ui/src/infrastructure/config/components/MemorySettingsPage.tsx index ffdda2d9a1..16165c962a 100644 --- a/src/web-ui/src/infrastructure/config/components/MemorySettingsPage.tsx +++ b/src/web-ui/src/infrastructure/config/components/MemorySettingsPage.tsx @@ -376,17 +376,20 @@ const MemorySettingsPage: React.FC = () => { title={t('sections.advanced.title')} description={t('sections.advanced.description')} extra={( - setAdvancedOpen((open) => !open)} - tooltip={t(advancedOpen ? 'actions.collapseAdvanced' : 'actions.expandAdvanced')} - aria-label={t(advancedOpen ? 'actions.collapseAdvanced' : 'actions.expandAdvanced')} - aria-expanded={advancedOpen} + - {advancedOpen ? : } - + setAdvancedOpen((open) => !open)} + aria-label={t(advancedOpen ? 'actions.collapseAdvanced' : 'actions.expandAdvanced')} + aria-expanded={advancedOpen} + icon={advancedOpen ? : } + /> + )} > {advancedOpen && ( diff --git a/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx index 5ee5a41ec7..9741ac2023 100644 --- a/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/UsageStatisticsConfig.test.tsx @@ -33,44 +33,41 @@ vi.mock('@/infrastructure/i18n', () => ({ }), })); -vi.mock('@/component-library', () => ({ - Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, - ConfigPageLoading: ({ text }: { text?: string }) =>
{text}
, - ConfigPageMessage: ({ - message, - }: { - message: { type: string; text: string } | null; - }) => message ? ( -
{message.text}
- ) : null, - ConfigPageRefreshButton: () => , + icon?: React.ReactNode; + }) => , Input: ({ - prefix, - suffix, - inputSize: _inputSize, + leading, + trailing, ...props }: React.InputHTMLAttributes & { - prefix?: React.ReactNode; - suffix?: React.ReactNode; - inputSize?: string; + leading?: React.ReactNode; + trailing?: React.ReactNode; }) => (
- {prefix} + {leading} - {suffix} + {trailing}
), +})); + +vi.mock('@/component-library', () => ({ + Tooltip: ({ children }: React.PropsWithChildren) => <>{children}, + ConfigPageLoading: ({ text }: { text?: string }) =>
{text}
, + ConfigPageMessage: ({ + message, + }: { + message: { type: string; text: string } | null; + }) => message ? ( +
{message.text}
+ ) : null, + ConfigPageRefreshButton: () =>