From fd72465c7bdf779ec1948d69ae0982835aad7967 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Thu, 16 Jul 2026 10:42:17 +0800 Subject: [PATCH 001/174] fix(acp): support Codex Fast mode --- src/apps/cli/src/acp_cli.rs | 7 +- src/apps/desktop/src/api/acp_client_api.rs | 30 +++- src/apps/desktop/src/lib.rs | 1 + .../acp/src/client/builtin_clients.rs | 112 +++++++++++++- .../interfaces/acp/src/client/manager.rs | 124 +++++++++++++++- src/crates/interfaces/acp/src/client/mod.rs | 7 +- .../interfaces/acp/src/client/requirements.rs | 29 +++- .../acp/src/client/session_options.rs | 139 ++++++++++++++++++ .../workspaceAcpMenuClients.test.ts | 6 +- .../flow_chat/components/ModelSelector.scss | 32 ++++ .../flow_chat/components/ModelSelector.tsx | 70 ++++++++- .../flow_chat/utils/acpSessionConfig.test.ts | 48 ++++++ .../src/flow_chat/utils/acpSessionConfig.ts | 51 +++++++ .../api/service-api/ACPClientAPI.ts | 45 ++++++ .../components/AcpAgentsConfig.test.tsx | 14 +- .../config/components/AcpAgentsConfig.tsx | 8 +- src/web-ui/src/locales/en-US/flow-chat.json | 2 + src/web-ui/src/locales/zh-CN/flow-chat.json | 2 + src/web-ui/src/locales/zh-TW/flow-chat.json | 2 + 19 files changed, 688 insertions(+), 41 deletions(-) create mode 100644 src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts create mode 100644 src/web-ui/src/flow_chat/utils/acpSessionConfig.ts diff --git a/src/apps/cli/src/acp_cli.rs b/src/apps/cli/src/acp_cli.rs index 5c36088e4b..840fa6d4b7 100644 --- a/src/apps/cli/src/acp_cli.rs +++ b/src/apps/cli/src/acp_cli.rs @@ -52,9 +52,12 @@ impl ExternalAcpClient { Self::Opencode => ("opencode", vec!["acp"]), Self::ClaudeCode => ( "npx", - vec!["--yes", "@zed-industries/claude-code-acp@latest"], + vec!["--yes", "@agentclientprotocol/claude-agent-acp@latest"], + ), + Self::Codex => ( + "npx", + vec!["--yes", "@agentclientprotocol/codex-acp@latest"], ), - Self::Codex => ("npx", vec!["--yes", "@zed-industries/codex-acp@latest"]), }; AcpClientConfig { name: Some(self.display_name().to_string()), diff --git a/src/apps/desktop/src/api/acp_client_api.rs b/src/apps/desktop/src/api/acp_client_api.rs index 1db3a7b300..83d685add8 100644 --- a/src/apps/desktop/src/api/acp_client_api.rs +++ b/src/apps/desktop/src/api/acp_client_api.rs @@ -6,7 +6,8 @@ use crate::startup_trace::DesktopStartupTrace; use bitfun_acp::client::{ AcpAvailableCommand, AcpClientInfo, AcpClientPermissionResponse, AcpClientRequirementProbe, AcpClientStreamEvent, AcpSessionOptions, CreateAcpFlowSessionRecordResponse, - SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, + SetAcpSessionConfigOptionRequest, SetAcpSessionModelRequest, + SubmitAcpPermissionResponseRequest, }; use serde::{Deserialize, Serialize}; use std::time::Instant; @@ -662,6 +663,33 @@ pub async fn set_acp_session_model( .map_err(|e| e.to_string()) } +#[tauri::command] +pub async fn set_acp_session_config_option( + state: State<'_, AppState>, + request: SetAcpSessionConfigOptionRequest, +) -> Result { + let service = state + .acp_client_service + .as_ref() + .ok_or_else(|| "ACP client service not initialized".to_string())?; + let session_storage_path = match request.workspace_path.as_deref() { + Some(workspace_path) => Some( + desktop_effective_session_storage_path( + &state, + workspace_path, + request.remote_connection_id.as_deref(), + request.remote_ssh_host.as_deref(), + ) + .await, + ), + None => None, + }; + service + .set_session_config_option(request, session_storage_path) + .await + .map_err(|e| e.to_string()) +} + #[tauri::command] pub async fn stop_acp_client( state: State<'_, AppState>, diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index 3894d36f0c..bbaba74cc0 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -1134,6 +1134,7 @@ pub async fn run() { get_acp_session_options, get_acp_session_commands, set_acp_session_model, + set_acp_session_config_option, lsp_initialize, lsp_start_server_for_file, lsp_stop_server, diff --git a/src/crates/interfaces/acp/src/client/builtin_clients.rs b/src/crates/interfaces/acp/src/client/builtin_clients.rs index e709a1476d..e1aafbb8ec 100644 --- a/src/crates/interfaces/acp/src/client/builtin_clients.rs +++ b/src/crates/interfaces/acp/src/client/builtin_clients.rs @@ -1,6 +1,13 @@ use std::collections::HashMap; -use super::config::{AcpClientConfig, AcpClientPermissionMode}; +use super::config::{AcpClientConfig, AcpClientConfigFile, AcpClientPermissionMode}; + +const CLAUDE_ACP_PACKAGE: &str = "@agentclientprotocol/claude-agent-acp"; +const CLAUDE_ACP_ARGS: &[&str] = &["--yes", "@agentclientprotocol/claude-agent-acp@latest"]; +const LEGACY_CLAUDE_ACP_ARGS: &[&str] = &["--yes", "@zed-industries/claude-code-acp@latest"]; +const CODEX_ACP_PACKAGE: &str = "@agentclientprotocol/codex-acp"; +const CODEX_ACP_ARGS: &[&str] = &["--yes", "@agentclientprotocol/codex-acp@latest"]; +const LEGACY_CODEX_ACP_ARGS: &[&str] = &["--yes", "@zed-industries/codex-acp@latest"]; pub(crate) struct BuiltinAcpClientPreset { pub(crate) id: &'static str, @@ -43,19 +50,19 @@ const BUILTIN_ACP_CLIENT_PRESETS: &[BuiltinAcpClientPreset] = &[ BuiltinAcpClientPreset { id: "claude-code", command: "npx", - args: &["--yes", "@zed-industries/claude-code-acp@latest"], + args: CLAUDE_ACP_ARGS, tool_command: "claude", install_package: Some("@anthropic-ai/claude-code"), - adapter_package: Some("@zed-industries/claude-code-acp"), - adapter_bin: Some("claude-code-acp"), + adapter_package: Some(CLAUDE_ACP_PACKAGE), + adapter_bin: Some("claude-agent-acp"), }, BuiltinAcpClientPreset { id: "codex", command: "npx", - args: &["--yes", "@zed-industries/codex-acp@latest"], + args: CODEX_ACP_ARGS, tool_command: "codex", install_package: Some("@openai/codex"), - adapter_package: Some("@zed-industries/codex-acp"), + adapter_package: Some(CODEX_ACP_PACKAGE), adapter_bin: Some("codex-acp"), }, ]; @@ -89,6 +96,29 @@ pub(crate) fn default_config_for_builtin_client(client_id: &str) -> Option, + #[serde(default)] + pub remote_connection_id: Option, + #[serde(default)] + pub remote_ssh_host: Option, + pub config_id: String, + pub value: AcpSessionConfigValue, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum AcpSessionConfigValue { + Select { value: String }, + Boolean { value: bool }, +} + +impl AcpSessionConfigValue { + fn into_protocol_value(self) -> SessionConfigOptionValue { + match self { + Self::Select { value } => SessionConfigOptionValue::value_id(value), + Self::Boolean { value } => SessionConfigOptionValue::boolean(value), + } + } +} + pub struct AcpClientService { config_service: Arc, session_persistence: AcpSessionPersistence, @@ -1007,6 +1044,66 @@ impl AcpClientService { )) } + pub async fn set_session_config_option( + self: &Arc, + request: SetAcpSessionConfigOptionRequest, + session_storage_path: Option, + ) -> BitFunResult { + let resolved = self + .resolve_or_create_client_session( + &request.client_id, + request.workspace_path, + request.remote_connection_id.as_deref(), + &request.session_id, + ) + .await?; + + let mut session = resolved.session.lock().await; + self.ensure_remote_session( + &resolved.client, + &resolved.session_key, + &resolved.cwd, + &request.session_id, + session_storage_path.as_deref(), + &mut session, + ) + .await?; + let active = session + .active + .as_ref() + .ok_or_else(|| BitFunError::service("ACP session was not initialized"))?; + let remote_session_id = active.session_id().to_string(); + let connection = active.connection(); + + if !session + .config_options + .iter() + .any(|option| option.id.to_string() == request.config_id) + { + return Err(BitFunError::NotFound(format!( + "ACP session config option not found: {}", + request.config_id + ))); + } + + let response = connection + .send_request(SetSessionConfigOptionRequest::new( + remote_session_id, + request.config_id, + request.value.into_protocol_value(), + )) + .block_task() + .await + .map_err(protocol_error)?; + session.config_options = response.config_options; + + Ok(session_options_from_state( + session.models.as_ref(), + &session.config_options, + session.context_usage.as_ref(), + )) + } + pub async fn prompt_agent( self: &Arc, client_id: &str, @@ -1845,7 +1942,7 @@ async fn wait_for_client_connection( } fn parse_config_value(value: serde_json::Value) -> BitFunResult { - if value.get("acpClients").is_some() { + let mut config = if value.get("acpClients").is_some() { serde_json::from_value(value) .map_err(|error| BitFunError::config(format!("Invalid ACP client config: {}", error))) } else if value.is_object() { @@ -1856,7 +1953,9 @@ fn parse_config_value(value: serde_json::Value) -> BitFunResult String { @@ -2459,6 +2558,21 @@ mod tests { ); } + #[test] + fn maps_session_config_values_to_acp_protocol_values() { + let select = AcpSessionConfigValue::Select { + value: "on".to_string(), + } + .into_protocol_value(); + assert_eq!( + select.as_value_id().map(ToString::to_string).as_deref(), + Some("on") + ); + + let boolean = AcpSessionConfigValue::Boolean { value: true }.into_protocol_value(); + assert_eq!(boolean.as_bool(), Some(true)); + } + #[test] fn renders_remote_client_command_from_config() { let config = AcpClientConfig { @@ -2492,7 +2606,7 @@ mod tests { command: "npx".to_string(), args: vec![ "--yes".to_string(), - "@zed-industries/codex-acp@latest".to_string(), + "@agentclientprotocol/codex-acp@latest".to_string(), ], env: HashMap::from([("BASE".to_string(), "1".to_string())]), enabled: true, @@ -2508,7 +2622,7 @@ mod tests { assert_eq!(resolved.command, "npx"); assert_eq!( resolved.args, - vec!["--yes", "@zed-industries/codex-acp@latest"] + vec!["--yes", "@agentclientprotocol/codex-acp@latest"] ); assert_eq!(resolved.env.get("BASE").map(String::as_str), Some("1")); assert!(resolved.enabled); diff --git a/src/crates/interfaces/acp/src/client/mod.rs b/src/crates/interfaces/acp/src/client/mod.rs index b5a42015e6..12fce4ead6 100644 --- a/src/crates/interfaces/acp/src/client/mod.rs +++ b/src/crates/interfaces/acp/src/client/mod.rs @@ -17,11 +17,12 @@ pub use config::{ RemoteAcpClientRequirementSnapshot, }; pub use manager::{ - AcpClientPermissionResponse, AcpClientService, CreateAcpFlowSessionRecordResponse, + AcpClientPermissionResponse, AcpClientService, AcpSessionConfigValue, + CreateAcpFlowSessionRecordResponse, SetAcpSessionConfigOptionRequest, SetAcpSessionModelRequest, SubmitAcpPermissionResponseRequest, }; pub use session_options::{ - AcpAvailableCommand, AcpPlanEntry, AcpSessionContextUsage, AcpSessionModelOption, - AcpSessionOptions, + AcpAvailableCommand, AcpPlanEntry, AcpSessionConfigKind, AcpSessionConfigOption, + AcpSessionConfigSelectOption, AcpSessionContextUsage, AcpSessionModelOption, AcpSessionOptions, }; pub use stream::AcpClientStreamEvent; diff --git a/src/crates/interfaces/acp/src/client/requirements.rs b/src/crates/interfaces/acp/src/client/requirements.rs index 54b9db4a57..df6eb8aba4 100644 --- a/src/crates/interfaces/acp/src/client/requirements.rs +++ b/src/crates/interfaces/acp/src/client/requirements.rs @@ -637,12 +637,12 @@ mod tests { #[test] fn npm_adapter_argument_builders_preserve_cli_order() { assert_eq!( - npm_offline_probe_args("@zed-industries/codex-acp", "codex-acp"), + npm_offline_probe_args("@agentclientprotocol/codex-acp", "codex-acp"), [ "exec", "--offline", "--yes", - "--package=@zed-industries/codex-acp", + "--package=@agentclientprotocol/codex-acp", "--", "codex-acp", "--help", @@ -650,11 +650,11 @@ mod tests { .map(str::to_string) ); assert_eq!( - npm_predownload_args("@zed-industries/codex-acp", "codex-acp"), + npm_predownload_args("@agentclientprotocol/codex-acp", "codex-acp"), [ "exec", "--yes", - "--package=@zed-industries/codex-acp", + "--package=@agentclientprotocol/codex-acp", "--", "codex-acp", "--help", @@ -663,6 +663,21 @@ mod tests { ); } + #[test] + fn builtin_adapter_specs_use_maintained_agent_client_protocol_packages() { + let claude = acp_requirement_spec("claude-code", None) + .adapter + .expect("Claude adapter"); + assert_eq!(claude.package, "@agentclientprotocol/claude-agent-acp"); + assert_eq!(claude.bin, "claude-agent-acp"); + + let codex = acp_requirement_spec("codex", None) + .adapter + .expect("Codex adapter"); + assert_eq!(codex.package, "@agentclientprotocol/codex-acp"); + assert_eq!(codex.bin, "codex-acp"); + } + #[test] fn command_search_paths_keep_configured_path_first() { let configured_paths = env::join_paths([ @@ -698,9 +713,9 @@ mod tests { #[test] fn npx_adapter_probe_item_marks_auto_install_available() { - let item = npx_adapter_probe_item("@zed-industries/codex-acp"); + let item = npx_adapter_probe_item("@agentclientprotocol/codex-acp"); - assert_eq!(item.name, "@zed-industries/codex-acp"); + assert_eq!(item.name, "@agentclientprotocol/codex-acp"); assert!(item.installed); assert_eq!(item.path.as_deref(), Some("npx auto-install")); assert!(item.error.is_none()); @@ -717,7 +732,7 @@ mod tests { let runtime = tokio::runtime::Runtime::new().expect("tokio runtime should be created"); let item = runtime.block_on(probe_npm_adapter_with_path( - "@zed-industries/codex-acp", + "@agentclientprotocol/codex-acp", "codex-acp", Some(test_dir.as_os_str()), )); diff --git a/src/crates/interfaces/acp/src/client/session_options.rs b/src/crates/interfaces/acp/src/client/session_options.rs index 5b45585671..6d8b2493cc 100644 --- a/src/crates/interfaces/acp/src/client/session_options.rs +++ b/src/crates/interfaces/acp/src/client/session_options.rs @@ -96,6 +96,8 @@ pub struct AcpSessionOptions { #[serde(default)] pub model_config_id: Option, #[serde(default)] + pub config_options: Vec, + #[serde(default)] pub context_usage: Option, } @@ -108,12 +110,52 @@ pub struct AcpSessionModelOption { pub description: Option, } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionConfigOption { + pub id: String, + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(flatten)] + pub kind: AcpSessionConfigKind, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde( + tag = "type", + rename_all = "snake_case", + rename_all_fields = "camelCase" +)] +pub enum AcpSessionConfigKind { + Select { + current_value: String, + options: Vec, + }, + Boolean { + current_value: bool, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcpSessionConfigSelectOption { + pub value: String, + pub name: String, + #[serde(default)] + pub description: Option, +} + pub(super) fn session_options_from_state( models: Option<&SessionModelState>, config_options: &[SessionConfigOption], context_usage: Option<&AcpSessionContextUsage>, ) -> AcpSessionOptions { let context_usage = context_usage.cloned(); + let session_config_options = config_options + .iter() + .filter_map(session_config_option_from_protocol) + .collect(); if let Some(models) = models.filter(|models| !models.available_models.is_empty()) { return AcpSessionOptions { current_model_id: Some(models.current_model_id.to_string()), @@ -123,6 +165,7 @@ pub(super) fn session_options_from_state( .map(model_option_from_model_info) .collect(), model_config_id: None, + config_options: session_config_options, context_usage, }; } @@ -133,16 +176,68 @@ pub(super) fn session_options_from_state( current_model_id, available_models, model_config_id: Some(option.id.to_string()), + config_options: session_config_options, context_usage, }; } AcpSessionOptions { + config_options: session_config_options, context_usage, ..Default::default() } } +fn session_config_option_from_protocol( + option: &SessionConfigOption, +) -> Option { + let kind = match &option.kind { + SessionConfigKind::Select(select) => { + let options = match &select.options { + SessionConfigSelectOptions::Ungrouped(options) => options + .iter() + .map(session_config_select_option_from_protocol) + .collect(), + SessionConfigSelectOptions::Grouped(groups) => groups + .iter() + .flat_map(|group| { + group + .options + .iter() + .map(session_config_select_option_from_protocol) + }) + .collect(), + _ => Vec::new(), + }; + AcpSessionConfigKind::Select { + current_value: select.current_value.to_string(), + options, + } + } + SessionConfigKind::Boolean(boolean) => AcpSessionConfigKind::Boolean { + current_value: boolean.current_value, + }, + _ => return None, + }; + + Some(AcpSessionConfigOption { + id: option.id.to_string(), + name: option.name.clone(), + description: option.description.clone(), + kind, + }) +} + +fn session_config_select_option_from_protocol( + option: &agent_client_protocol::schema::SessionConfigSelectOption, +) -> AcpSessionConfigSelectOption { + AcpSessionConfigSelectOption { + value: option.value.to_string(), + name: option.name.clone(), + description: option.description.clone(), + } +} + pub(super) fn model_config_id(config_options: &[SessionConfigOption]) -> Option { model_config_option(config_options).map(|option| option.id.to_string()) } @@ -262,6 +357,50 @@ mod tests { ); } + #[test] + fn exposes_select_and_boolean_session_config_options() { + let select = SessionConfigOption::select( + "fast-mode", + "Fast mode", + "off", + vec![ + agent_client_protocol::schema::SessionConfigSelectOption::new("off", "Off"), + agent_client_protocol::schema::SessionConfigSelectOption::new("on", "On"), + ], + ) + .description("1.5x speed, increased usage"); + let boolean = SessionConfigOption::boolean("web-search", "Web search", true); + + let options = session_options_from_state(None, &[select, boolean], None); + + assert_eq!(options.config_options.len(), 2); + assert_eq!(options.config_options[0].id, "fast-mode"); + assert_eq!( + options.config_options[0].kind, + AcpSessionConfigKind::Select { + current_value: "off".to_string(), + options: vec![ + AcpSessionConfigSelectOption { + value: "off".to_string(), + name: "Off".to_string(), + description: None, + }, + AcpSessionConfigSelectOption { + value: "on".to_string(), + name: "On".to_string(), + description: None, + }, + ], + } + ); + assert_eq!( + options.config_options[1].kind, + AcpSessionConfigKind::Boolean { + current_value: true, + } + ); + } + #[test] fn converts_available_command_with_and_without_input() { use agent_client_protocol::schema::{ diff --git a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts index 3a26fdb7be..306709896f 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts +++ b/src/web-ui/src/app/components/NavPanel/sections/workspaces/workspaceAcpMenuClients.test.ts @@ -58,7 +58,7 @@ describe('loadWorkspaceAcpMenuClients', () => { { id: 'claude-code', tool: { name: 'claude', installed: false }, - adapter: { name: '@zed-industries/claude-code-acp', installed: true }, + adapter: { name: '@agentclientprotocol/claude-agent-acp', installed: true }, runnable: false, notes: ['claude is not available on remote PATH'], }, @@ -126,7 +126,7 @@ describe('loadWorkspaceAcpMenuClients', () => { { id: 'codex', tool: { name: 'codex', installed: true }, - adapter: { name: '@zed-industries/codex-acp', installed: false }, + adapter: { name: '@agentclientprotocol/codex-acp', installed: false }, runnable: false, notes: ['npx is not available on remote PATH'], }, @@ -135,7 +135,7 @@ describe('loadWorkspaceAcpMenuClients', () => { { id: 'codex', tool: { name: 'codex', installed: true }, - adapter: { name: '@zed-industries/codex-acp', installed: true }, + adapter: { name: '@agentclientprotocol/codex-acp', installed: true }, runnable: true, notes: [], }, diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.scss b/src/web-ui/src/flow_chat/components/ModelSelector.scss index bc0117510b..a80ab90b5f 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.scss +++ b/src/web-ui/src/flow_chat/components/ModelSelector.scss @@ -71,6 +71,11 @@ color: var(--color-text-muted); flex-shrink: 0; } + + &__fast-icon { + color: var(--color-warning); + flex-shrink: 0; + } &__badge { display: inline-flex; @@ -180,6 +185,33 @@ background: var(--border-subtle); margin: 4px 12px; } + + &__config-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 8px 12px 10px; + } + + &__config-copy { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + } + + &__config-name { + color: var(--color-text-primary); + font-size: var(--flowchat-font-size-xs); + font-weight: 500; + } + + &__config-description { + color: var(--color-text-muted); + font-size: var(--flowchat-font-size-2xs); + line-height: 1.35; + } &__list { max-height: 240px; diff --git a/src/web-ui/src/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 949c62f42e..07eb00e7ec 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -11,7 +11,7 @@ import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react'; import { createPortal } from 'react-dom'; -import { Brain, ChevronDown, Check } from 'lucide-react'; +import { Brain, ChevronDown, Check, Zap } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; @@ -20,10 +20,11 @@ import { getProviderDisplayName } from '@/infrastructure/config/services/modelCo import { getEffectiveReasoningMode, isReasoningVisiblyEnabled } from '@/infrastructure/config/utils/reasoning'; import { globalEventBus } from '@/infrastructure/event-bus'; import type { AIModelConfig, DefaultModelsConfig } from '@/infrastructure/config/types'; -import { Tooltip } from '@/component-library'; +import { Switch, Tooltip } from '@/component-library'; import { FlowChatStore } from '../store/FlowChatStore'; import { getModelMaxTokens } from '../services/flow-chat-manager/SessionModule'; import { acpClientIdFromAgentType } from '../utils/acpSession'; +import { buildAcpFastModeValue, resolveAcpFastModeState } from '../utils/acpSessionConfig'; import { buildContextUsageTooltip, type ContextUsageSource, @@ -386,6 +387,11 @@ export const ModelSelector: React.FC = ({ provider: 'acp', }; }, [acpAvailableModels, acpClientId, acpOptions?.currentModelId, isAcpSession]); + + const acpFastMode = useMemo( + () => resolveAcpFastModeState(acpOptions?.configOptions ?? []), + [acpOptions?.configOptions], + ); const getCurrentModelId = useCallback((): string => { // Session-owned model takes priority so that each session remembers @@ -549,6 +555,41 @@ export const ModelSelector: React.FC = ({ loading, sessionId, ]); + + const handleSetAcpFastMode = useCallback(async (enabled: boolean) => { + if (loading || !acpFastMode || !acpClientId || !sessionId) return; + const value = buildAcpFastModeValue(acpFastMode.option, enabled); + if (!value) return; + + setLoading(true); + try { + const options = await ACPClientAPI.setSessionConfigOption({ + sessionId, + clientId: acpClientId, + workspacePath: activeSession?.workspacePath || activeSession?.config.workspacePath, + remoteConnectionId: activeSession?.remoteConnectionId, + remoteSshHost: activeSession?.remoteSshHost, + configId: acpFastMode.option.id, + value, + }); + setAcpOptions(options); + syncAcpContextUsageToStore(sessionId, options); + log.info('ACP Fast mode updated', { sessionId, acpClientId, enabled }); + } catch (error) { + log.error('Failed to update ACP Fast mode', error); + } finally { + setLoading(false); + } + }, [ + activeSession?.config.workspacePath, + activeSession?.remoteConnectionId, + activeSession?.remoteSshHost, + activeSession?.workspacePath, + acpClientId, + acpFastMode, + loading, + sessionId, + ]); const tokenPercentage = useMemo(() => { if (!maxTokens || maxTokens <= 0 || !currentTokens) return 0; @@ -602,6 +643,9 @@ export const ModelSelector: React.FC = ({ {getModelDisplayLabel(acpCurrentModel, currentAcpModelId)} + {acpFastMode?.enabled && ( + + )} {tokenPercentage > 0 && ( · {tokenPercentage}% @@ -652,6 +696,28 @@ export const ModelSelector: React.FC = ({ ); })} + {acpFastMode && ( + <> +
+
+
+ + {t('modelSelector.fastMode')} + + + {t('modelSelector.fastModeDescription')} + +
+ { void handleSetAcpFastMode(event.target.checked); }} + /> +
+ + )}
, document.body )} diff --git a/src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts b/src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts new file mode 100644 index 0000000000..511df65a9d --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/acpSessionConfig.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import type { AcpSessionConfigOption } from '@/infrastructure/api/service-api/ACPClientAPI'; +import { buildAcpFastModeValue, resolveAcpFastModeState } from './acpSessionConfig'; + +describe('ACP Fast mode config', () => { + it('resolves and toggles the select fallback exposed by Codex ACP', () => { + const option: AcpSessionConfigOption = { + id: 'fast-mode', + name: 'Fast mode', + type: 'select', + currentValue: 'off', + options: [ + { value: 'off', name: 'Off' }, + { value: 'on', name: 'On' }, + ], + }; + + expect(resolveAcpFastModeState([option])).toEqual({ option, enabled: false }); + expect(buildAcpFastModeValue(option, true)).toEqual({ type: 'select', value: 'on' }); + }); + + it('supports ACP boolean config options', () => { + const option: AcpSessionConfigOption = { + id: 'fast-mode', + name: 'Fast mode', + type: 'boolean', + currentValue: true, + }; + + expect(resolveAcpFastModeState([option])).toEqual({ option, enabled: true }); + expect(buildAcpFastModeValue(option, false)).toEqual({ type: 'boolean', value: false }); + }); + + it('ignores unrelated or malformed options', () => { + const malformed: AcpSessionConfigOption = { + id: 'fast-mode', + name: 'Fast mode', + type: 'select', + currentValue: 'standard', + options: [{ value: 'standard', name: 'Standard' }], + }; + + expect(resolveAcpFastModeState([])).toBeNull(); + expect(resolveAcpFastModeState([malformed])).toBeNull(); + expect(buildAcpFastModeValue(malformed, true)).toBeNull(); + }); +}); diff --git a/src/web-ui/src/flow_chat/utils/acpSessionConfig.ts b/src/web-ui/src/flow_chat/utils/acpSessionConfig.ts new file mode 100644 index 0000000000..8730df68dd --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/acpSessionConfig.ts @@ -0,0 +1,51 @@ +import type { + AcpSessionConfigOption, + AcpSessionConfigValue, +} from '@/infrastructure/api/service-api/ACPClientAPI'; + +const FAST_MODE_CONFIG_ID = 'fast-mode'; +const FAST_MODE_ON_VALUE = 'on'; +const FAST_MODE_OFF_VALUE = 'off'; + +export interface AcpFastModeState { + option: AcpSessionConfigOption; + enabled: boolean; +} + +export function resolveAcpFastModeState( + options: AcpSessionConfigOption[], +): AcpFastModeState | null { + const option = options.find(candidate => candidate.id === FAST_MODE_CONFIG_ID); + if (!option) return null; + + if (option.type === 'boolean') { + return { option, enabled: option.currentValue }; + } + + const values = new Set(option.options.map(candidate => candidate.value)); + if (!values.has(FAST_MODE_ON_VALUE) || !values.has(FAST_MODE_OFF_VALUE)) { + return null; + } + if (option.currentValue !== FAST_MODE_ON_VALUE && option.currentValue !== FAST_MODE_OFF_VALUE) { + return null; + } + + return { + option, + enabled: option.currentValue === FAST_MODE_ON_VALUE, + }; +} + +export function buildAcpFastModeValue( + option: AcpSessionConfigOption, + enabled: boolean, +): AcpSessionConfigValue | null { + if (option.type === 'boolean') { + return { type: 'boolean', value: enabled }; + } + + const value = enabled ? FAST_MODE_ON_VALUE : FAST_MODE_OFF_VALUE; + return option.options.some(candidate => candidate.value === value) + ? { type: 'select', value } + : null; +} diff --git a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts index b7cd173f88..010dc9b712 100644 --- a/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/ACPClientAPI.ts @@ -91,12 +91,50 @@ export interface SetAcpSessionModelRequest { modelId: string; } +export type AcpSessionConfigValue = + | { type: 'select'; value: string } + | { type: 'boolean'; value: boolean }; + +export interface SetAcpSessionConfigOptionRequest { + sessionId: string; + clientId: string; + workspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + configId: string; + value: AcpSessionConfigValue; +} + export interface AcpSessionModelOption { id: string; name: string; description?: string; } +export interface AcpSessionConfigSelectOption { + value: string; + name: string; + description?: string; +} + +interface AcpSessionConfigOptionBase { + id: string; + name: string; + description?: string; +} + +export type AcpSessionConfigOption = AcpSessionConfigOptionBase & ( + | { + type: 'select'; + currentValue: string; + options: AcpSessionConfigSelectOption[]; + } + | { + type: 'boolean'; + currentValue: boolean; + } +); + export interface AcpContextUsage { used: number; size: number; @@ -110,6 +148,7 @@ export interface AcpSessionOptions { currentModelId?: string; availableModels: AcpSessionModelOption[]; modelConfigId?: string; + configOptions: AcpSessionConfigOption[]; contextUsage?: AcpContextUsage; } @@ -323,6 +362,12 @@ export class ACPClientAPI { return api.invoke('set_acp_session_model', { request }); } + static async setSessionConfigOption( + request: SetAcpSessionConfigOptionRequest + ): Promise { + return api.invoke('set_acp_session_config_option', { request }); + } + static onAvailableCommandsUpdated( callback: (event: AcpAvailableCommandsUpdatedEvent) => void ): () => void { diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx index ee57f6cfbf..e0eb9f9bb6 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx @@ -241,14 +241,14 @@ describe('AcpAgentsConfig', () => { { id: 'claude-code', tool: { name: 'claude', installed: true }, - adapter: { name: '@zed-industries/claude-code-acp', installed: false }, + adapter: { name: '@agentclientprotocol/claude-agent-acp', installed: false }, runnable: false, notes: [], }, { id: 'codex', tool: { name: 'codex', installed: true }, - adapter: { name: '@zed-industries/codex-acp', installed: false }, + adapter: { name: '@agentclientprotocol/codex-acp', installed: false }, runnable: false, notes: [], }, @@ -298,7 +298,7 @@ describe('AcpAgentsConfig', () => { { id: 'claude-code', tool: { name: 'claude', installed: true }, - adapter: { name: '@zed-industries/claude-code-acp', installed: true }, + adapter: { name: '@agentclientprotocol/claude-agent-acp', installed: true }, runnable: true, notes: [], }, @@ -326,7 +326,7 @@ describe('AcpAgentsConfig', () => { 'claude-code': { name: 'Claude Code', command: 'npx', - args: ['--yes', '@zed-industries/claude-code-acp@latest'], + args: ['--yes', '@agentclientprotocol/claude-agent-acp@latest'], env: {}, enabled: true, readonly: false, @@ -335,7 +335,7 @@ describe('AcpAgentsConfig', () => { codex: { name: 'Codex', command: 'npx', - args: ['--yes', '@zed-industries/codex-acp@latest'], + args: ['--yes', '@agentclientprotocol/codex-acp@latest'], env: {}, enabled: true, readonly: false, @@ -385,7 +385,7 @@ describe('AcpAgentsConfig', () => { { id: 'claude-code', tool: { name: 'claude', installed: true }, - adapter: { name: '@zed-industries/claude-code-acp', installed: true }, + adapter: { name: '@agentclientprotocol/claude-agent-acp', installed: true }, runnable: true, notes: [], }, @@ -410,7 +410,7 @@ describe('AcpAgentsConfig', () => { { id: 'claude-code', tool: { name: 'claude', installed: true }, - adapter: { name: '@zed-industries/claude-code-acp', installed: true }, + adapter: { name: '@agentclientprotocol/claude-agent-acp', installed: true }, runnable: true, notes: [], }, diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx index 19f4297ce7..43bf0f4605 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -87,16 +87,16 @@ const PRESETS: AcpClientPreset[] = [ { id: 'claude-code', name: 'Claude Code', - description: 'Claude Code via the Zed ACP adapter.', + description: 'Claude Code via the official ACP adapter.', command: 'npx', - args: ['--yes', '@zed-industries/claude-code-acp@latest'], + args: ['--yes', '@agentclientprotocol/claude-agent-acp@latest'], }, { id: 'codex', name: 'Codex', - description: 'OpenAI Codex via the Zed ACP adapter.', + description: 'OpenAI Codex via the official ACP adapter.', command: 'npx', - args: ['--yes', '@zed-industries/codex-acp@latest'], + args: ['--yes', '@agentclientprotocol/codex-acp@latest'], }, ]; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index e98a841fdf..d179a01fb8 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -2075,6 +2075,8 @@ "autoModelDesc": "Automatically Routing", "primaryModel": "Primary Model", "fastModel": "Fast Model", + "fastMode": "Fast mode", + "fastModeDescription": "1.5x speed with higher credit usage", "modelNotConfigured": "Not Configured", "contextUsage": { "agentPrompt": "Last request prompt: {{usage}}", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 6a88d354f7..d15f4af096 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -2075,6 +2075,8 @@ "autoModelDesc": "自动路由最优模型", "primaryModel": "Primary 模型", "fastModel": "Fast 模型", + "fastMode": "Fast 模式", + "fastModeDescription": "1.5 倍速度,消耗更多额度", "modelNotConfigured": "未配置", "contextUsage": { "agentPrompt": "上次请求输入上下文: {{usage}}", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 33ca5a8a18..a5417fc70f 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -2075,6 +2075,8 @@ "autoModelDesc": "自動路由最優模型", "primaryModel": "Primary 模型", "fastModel": "Fast 模型", + "fastMode": "Fast 模式", + "fastModeDescription": "1.5 倍速度,消耗更多額度", "modelNotConfigured": "未設定", "contextUsage": { "agentPrompt": "上次請求輸入上下文: {{usage}}", From 487787fb8f3b9cbf0acec5af5b0d10f7a6238cb3 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Fri, 17 Jul 2026 17:16:15 +0800 Subject: [PATCH 002/174] feat(flow-chat): render view_image previews inline --- .../src/agentic/coordination/coordinator.rs | 2 + .../core/src/agentic/memories/transcript.rs | 4 + .../src/agentic/session/session_manager.rs | 3 + .../agentic/tools/pipeline/state_manager.rs | 7 + .../core/src/service/session_usage/service.rs | 2 + src/crates/contracts/events/src/agentic.rs | 27 +++ src/crates/contracts/events/src/lib.rs | 1 + .../execution/tool-execution/src/pipeline.rs | 36 +++- .../interfaces/acp/src/client/stream.rs | 2 + .../interfaces/acp/src/runtime/events.rs | 2 + .../services-core/src/session/types.rs | 8 +- .../src/flow_chat/services/EventBatcher.ts | 4 + .../flow-chat-manager/ToolEventModule.test.ts | 48 +++++ .../flow-chat-manager/ToolEventModule.ts | 1 + .../tool-cards/ViewImageToolCard.scss | 72 ++++++++ .../tool-cards/ViewImageToolCard.test.tsx | 84 +++++++++ .../tool-cards/ViewImageToolCard.tsx | 168 ++++++++++++++++++ src/web-ui/src/flow_chat/tool-cards/index.ts | 4 + .../flow_chat/tool-cards/toolCardMetadata.ts | 11 ++ src/web-ui/src/flow_chat/types/flow-chat.ts | 4 + src/web-ui/src/locales/en-US/flow-chat.json | 4 + src/web-ui/src/locales/zh-CN/flow-chat.json | 4 + src/web-ui/src/locales/zh-TW/flow-chat.json | 4 + .../src/shared/types/session-history.ts | 4 + 24 files changed, 504 insertions(+), 2 deletions(-) create mode 100644 src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss create mode 100644 src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.test.tsx create mode 100644 src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.tsx diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 0664e19cc8..fbc6b80a4b 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -1128,6 +1128,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet }), success: true, result_for_assistant: None, + image_attachments: None, error: None, duration_ms: Some(outcome.duration_ms), }), @@ -1203,6 +1204,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet result: serde_json::Value::Null, success: false, result_for_assistant: None, + image_attachments: None, error: Some(error.to_string()), duration_ms: None, }), diff --git a/src/crates/assembly/core/src/agentic/memories/transcript.rs b/src/crates/assembly/core/src/agentic/memories/transcript.rs index 2655154e0c..0b17e83d31 100644 --- a/src/crates/assembly/core/src/agentic/memories/transcript.rs +++ b/src/crates/assembly/core/src/agentic/memories/transcript.rs @@ -427,6 +427,7 @@ mod tests { result_for_assistant: Some( "Fetched page with token=ghp_abcdefghijklmnopqrstuvwxyz".to_string(), ), + image_attachments: None, error: None, duration_ms: Some(1), }), @@ -499,6 +500,7 @@ mod tests { r#"{"description":"full schema definition","input_schema":{"type":"object"}}"# .to_string(), ), + image_attachments: None, error: None, duration_ms: Some(1), }), @@ -558,6 +560,7 @@ mod tests { result_for_assistant: Some( "external page content that should not enter memory extraction".to_string(), ), + image_attachments: None, error: None, duration_ms: Some(1), }), @@ -617,6 +620,7 @@ mod tests { result: json!({"content": "r".repeat(TOOL_RESULT_TOKEN_LIMIT * 5)}), success: true, result_for_assistant: None, + image_attachments: None, error: Some("e".repeat(TOOL_ERROR_TOKEN_LIMIT * 5)), duration_ms: Some(1), }), diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index c09b570232..452f2df309 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -4984,6 +4984,7 @@ impl SessionManager { tool_id, result, result_for_assistant, + image_attachments, is_error, .. } = &msg.content @@ -4998,6 +4999,7 @@ impl SessionManager { result: result.clone(), success: !is_error, result_for_assistant: assistant_text, + image_attachments: image_attachments.clone(), error: if *is_error { serde_json::to_string(result).ok() } else { @@ -7474,6 +7476,7 @@ mod tests { }), success: true, result_for_assistant: Some(assistant_output.clone()), + image_attachments: None, error: None, duration_ms: Some(1), }), diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs index ad1e00e266..4f5f2327bf 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/state_manager.rs @@ -197,6 +197,13 @@ impl ToolStateManager { } => result_for_assistant.clone(), _ => None, }, + image_attachments: match result { + crate::agentic::tools::framework::ToolResult::Result { + image_attachments, + .. + } => image_attachments.clone(), + _ => None, + }, duration_ms: *duration_ms, queue_wait_ms: *queue_wait_ms, preflight_ms: *preflight_ms, diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index b1fe869811..ba2aeeb6e2 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -2321,6 +2321,7 @@ mod tests { }), success: false, result_for_assistant: None, + image_attachments: None, error: Some("operation timed out".to_string()), duration_ms: Some(95_000), }); @@ -2783,6 +2784,7 @@ mod tests { result: serde_json::json!({}), success, result_for_assistant: None, + image_attachments: None, error: (!success).then(|| "tool failed".to_string()), duration_ms: Some(duration_ms), }), diff --git a/src/crates/contracts/events/src/agentic.rs b/src/crates/contracts/events/src/agentic.rs index 788d411199..ed05c66892 100644 --- a/src/crates/contracts/events/src/agentic.rs +++ b/src/crates/contracts/events/src/agentic.rs @@ -1,5 +1,6 @@ //! Agentic Events Definition pub use bitfun_core_types::errors::{AiErrorDetail, ErrorCategory}; +use bitfun_core_types::ToolImageAttachment; use serde::{Deserialize, Serialize}; use std::time::SystemTime; @@ -444,6 +445,8 @@ pub enum ToolEventData { result: serde_json::Value, #[serde(skip_serializing_if = "Option::is_none")] result_for_assistant: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + image_attachments: Option>, duration_ms: u64, #[serde(default, skip_serializing_if = "Option::is_none")] queue_wait_ms: Option, @@ -727,6 +730,7 @@ mod tests { identity: ToolEventIdentity::direct("tool-1", "write_file"), result: serde_json::json!({ "ok": true }), result_for_assistant: None, + image_attachments: None, duration_ms: 120, queue_wait_ms: Some(10), preflight_ms: Some(20), @@ -764,6 +768,29 @@ mod tests { assert_eq!(decoded.effective_tool_name(), "CreatePlan"); } + #[test] + fn completed_tool_serializes_image_attachments() { + let event = ToolEventData::Completed { + identity: ToolEventIdentity::direct("tool-image-1", "view_image"), + result: serde_json::json!({ "path": "preview.png" }), + result_for_assistant: Some("Image attached".to_string()), + image_attachments: Some(vec![bitfun_core_types::ToolImageAttachment { + mime_type: "image/png".to_string(), + data_base64: "AAAA".to_string(), + }]), + duration_ms: 12, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: Some(12), + }; + + let json = serde_json::to_value(&event).expect("serialize tool event"); + + assert_eq!(json["image_attachments"][0]["mime_type"], "image/png"); + assert_eq!(json["image_attachments"][0]["data_base64"], "AAAA"); + } + #[test] fn failed_tool_reports_best_effort_total_duration() { let event = ToolEventData::Failed { diff --git a/src/crates/contracts/events/src/lib.rs b/src/crates/contracts/events/src/lib.rs index a6b9e5507a..35c21f99bb 100644 --- a/src/crates/contracts/events/src/lib.rs +++ b/src/crates/contracts/events/src/lib.rs @@ -19,6 +19,7 @@ pub use backend::{ BackgroundCommandLifecycleInfo, ToolExecutionCompletedInfo, ToolExecutionErrorInfo, ToolExecutionProgressInfo, ToolExecutionStartedInfo, ToolTerminalReadyInfo, }; +pub use bitfun_core_types::ToolImageAttachment; pub use emitter::EventEmitter; pub use frontend_projection::{project_agentic_frontend_event, AgenticFrontendEvent}; pub use types::*; diff --git a/src/crates/execution/tool-execution/src/pipeline.rs b/src/crates/execution/tool-execution/src/pipeline.rs index e74380fdd0..bf255d4fc7 100644 --- a/src/crates/execution/tool-execution/src/pipeline.rs +++ b/src/crates/execution/tool-execution/src/pipeline.rs @@ -1,6 +1,6 @@ //! Provider-neutral tool pipeline planning helpers. -use bitfun_events::{ToolEventData, ToolEventIdentity}; +use bitfun_events::{ToolEventData, ToolEventIdentity, ToolImageAttachment}; use dashmap::DashMap; use std::sync::Arc; use tokio_util::sync::CancellationToken; @@ -111,6 +111,7 @@ pub enum ToolStateEventKind { Completed { result: serde_json::Value, result_for_assistant: Option, + image_attachments: Option>, duration_ms: u64, queue_wait_ms: Option, preflight_ms: Option, @@ -305,6 +306,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { ToolStateEventKind::Completed { result, result_for_assistant, + image_attachments, duration_ms, queue_wait_ms, preflight_ms, @@ -314,6 +316,7 @@ pub fn tool_state_event_data(facts: ToolStateEventFacts) -> ToolEventData { identity, result: sanitize_tool_result_for_event(&result), result_for_assistant, + image_attachments, duration_ms, queue_wait_ms, preflight_ms, @@ -383,6 +386,36 @@ mod tests { use bitfun_events::{ToolEventData, ToolEventIdentity}; use serde_json::json; + #[test] + fn completed_event_preserves_image_attachments() { + let data = tool_state_event_data(ToolStateEventFacts { + identity: ToolEventIdentity::direct("tool-image-1", "view_image"), + state: ToolStateEventKind::Completed { + result: json!({ "path": "preview.png" }), + result_for_assistant: Some("Image attached".to_string()), + image_attachments: Some(vec![bitfun_events::ToolImageAttachment { + mime_type: "image/png".to_string(), + data_base64: "AAAA".to_string(), + }]), + duration_ms: 10, + queue_wait_ms: None, + preflight_ms: None, + confirmation_wait_ms: None, + execution_ms: Some(10), + }, + }); + + let ToolEventData::Completed { + image_attachments, .. + } = data + else { + panic!("expected completed event"); + }; + let attachments = image_attachments.expect("image attachments"); + assert_eq!(attachments[0].mime_type, "image/png"); + assert_eq!(attachments[0].data_base64, "AAAA"); + } + #[test] fn completed_event_redacts_data_urls_recursively() { let data = tool_state_event_data(ToolStateEventFacts { @@ -393,6 +426,7 @@ mod tests { "nested": [{ "data_url": "data:image/png;base64,BBBB" }] }), result_for_assistant: Some("done".to_string()), + image_attachments: None, duration_ms: 10, queue_wait_ms: Some(1), preflight_ms: None, diff --git a/src/crates/interfaces/acp/src/client/stream.rs b/src/crates/interfaces/acp/src/client/stream.rs index 054573c087..a1eda1d93a 100644 --- a/src/crates/interfaces/acp/src/client/stream.rs +++ b/src/crates/interfaces/acp/src/client/stream.rs @@ -261,6 +261,7 @@ fn acp_tool_call_events( Some(tool_call.locations), ), result_for_assistant: None, + image_attachments: None, duration_ms: 0, queue_wait_ms: None, preflight_ms: None, @@ -320,6 +321,7 @@ fn acp_tool_call_update_events( update.fields.locations, ), result_for_assistant: None, + image_attachments: None, duration_ms: 0, queue_wait_ms: None, preflight_ms: None, diff --git a/src/crates/interfaces/acp/src/runtime/events.rs b/src/crates/interfaces/acp/src/runtime/events.rs index bab463a2f4..35bab00709 100644 --- a/src/crates/interfaces/acp/src/runtime/events.rs +++ b/src/crates/interfaces/acp/src/runtime/events.rs @@ -566,6 +566,7 @@ mod tests { identity: identity("Bash"), result: serde_json::json!({ "stdout": "ok" }), result_for_assistant: Some("done".to_string()), + image_attachments: None, duration_ms: 42, queue_wait_ms: None, preflight_ms: None, @@ -880,6 +881,7 @@ mod tests { "success": true, }), result_for_assistant: None, + image_attachments: None, duration_ms: 15, queue_wait_ms: None, preflight_ms: None, diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 4289b3f7b9..1f11572330 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -1,6 +1,6 @@ //! Types for session persistence -use bitfun_core_types::SessionKind; +use bitfun_core_types::{SessionKind, ToolImageAttachment}; use serde::{Deserialize, Serialize}; pub const SESSION_STORAGE_SCHEMA_VERSION: u32 = 2; @@ -759,6 +759,12 @@ pub struct ToolResultData { alias = "result_for_assistant" )] pub result_for_assistant: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + alias = "image_attachments" + )] + pub image_attachments: Option>, #[serde(skip_serializing_if = "Option::is_none")] pub error: Option, #[serde(skip_serializing_if = "Option::is_none", alias = "duration_ms")] diff --git a/src/web-ui/src/flow_chat/services/EventBatcher.ts b/src/web-ui/src/flow_chat/services/EventBatcher.ts index 66b2d0c27a..5c026e8772 100644 --- a/src/web-ui/src/flow_chat/services/EventBatcher.ts +++ b/src/web-ui/src/flow_chat/services/EventBatcher.ts @@ -309,6 +309,10 @@ export type RejectedToolEvent = BaseToolEvent<'Rejected'>; export interface CompletedToolEvent extends BaseToolEvent<'Completed'> { result: unknown; result_for_assistant?: string; + image_attachments?: Array<{ + mime_type: string; + data_base64: string; + }>; duration_ms: number; queue_wait_ms?: number; preflight_ms?: number; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts index aefb44211c..e792aa9b5b 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.test.ts @@ -566,6 +566,54 @@ describe('processToolEvent rejected event behavior', () => { }); }); +describe('processToolEvent completed image behavior', () => { + afterEach(() => { + resetStore(); + }); + + it('preserves image attachments on the completed tool item', () => { + const tool: FlowToolItem = { + id: 'tool-image-1', + type: 'tool', + toolName: 'view_image', + timestamp: 1001, + status: 'running', + toolCall: { + id: 'tool-image-1', + input: { path: 'screenshots/preview.png' }, + }, + }; + + FlowChatStore.getInstance().setState(() => ({ + sessions: new Map([['session-1', createSessionWithTool(tool)]]), + activeSessionId: 'session-1', + })); + + processToolEvent( + makeToolContext(), + 'session-1', + 'turn-1', + 'round-1', + { + event_type: 'Completed', + tool_id: 'tool-image-1', + tool_name: 'view_image', + result: { path: 'screenshots/preview.png', width: 80, height: 60 }, + image_attachments: [{ mime_type: 'image/png', data_base64: 'AAAA' }], + duration_ms: 12, + }, + ); + + const updatedTool = FlowChatStore.getInstance() + .findToolItem('session-1', 'turn-1', 'tool-image-1') as FlowToolItem; + + expect(updatedTool.status).toBe('completed'); + expect(updatedTool.toolResult?.imageAttachments).toEqual([ + { mime_type: 'image/png', data_base64: 'AAAA' }, + ]); + }); +}); + describe('processToolEvent AskUserQuestion retry superseded handling', () => { afterEach(() => { resetStore(); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts index 5a0859a73b..4d57ce5f5f 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/ToolEventModule.ts @@ -539,6 +539,7 @@ function handleCompleted( result: toolEvent.result, success: true, resultForAssistant: toolEvent.result_for_assistant, + imageAttachments: toolEvent.image_attachments, duration_ms: toolEvent.duration_ms }, status: 'completed' as const, diff --git a/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss new file mode 100644 index 0000000000..9e57bac26e --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.scss @@ -0,0 +1,72 @@ +.view-image-tool-card__content { + display: flex; + justify-content: flex-start; + min-width: 0; + margin: 8px 0 0 28px; +} + +.view-image-tool-card__preview-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 160px; + max-width: calc(100vw - 76px); + aspect-ratio: 1; + padding: 0; + overflow: hidden; + appearance: none; + background: var(--color-bg-secondary); + border: 1px solid var(--border-base); + border-radius: 8px; + cursor: zoom-in; + + &:focus-visible { + outline: 2px solid var(--color-accent-600); + outline-offset: 2px; + } + + img { + display: block; + max-width: 100%; + max-height: 100%; + object-fit: contain; + } +} + +.view-image-tool-card__error { + width: 100%; + padding: 12px; + color: var(--color-error); + font-size: var(--flowchat-font-size-base); + text-align: center; +} + +.view-image-tool-card__lightbox { + display: flex; + align-items: center; + justify-content: center; + min-height: 240px; + max-height: 80vh; + overflow: auto; + + img { + display: block; + max-width: 100%; + max-height: 76vh; + object-fit: contain; + } +} + +@media (max-width: 640px) { + .view-image-tool-card__content { + margin-left: 24px; + } + + .view-image-tool-card__preview-button { + width: 144px; + } + + .view-image-tool-card__lightbox { + min-height: 160px; + } +} diff --git a/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.test.tsx b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.test.tsx new file mode 100644 index 0000000000..dc1e731a48 --- /dev/null +++ b/src/web-ui/src/flow_chat/tool-cards/ViewImageToolCard.test.tsx @@ -0,0 +1,84 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ToolCardProps } from '../types/flow-chat'; +import { ViewImageToolCard } from './ViewImageToolCard'; + +vi.mock('@/infrastructure/i18n', async () => { + const { createTestI18nT } = await import('@/test/i18nTestUtils'); + return { + useI18n: () => ({ t: createTestI18nT('flow-chat') }), + }; +}); + +vi.mock('@/component-library', () => ({ + Modal: ({ isOpen, children }: { isOpen: boolean; children: React.ReactNode }) => ( + isOpen ?
{children}
: null + ), +})); + +function makeProps(mimeType = 'image/png'): ToolCardProps { + return { + toolItem: { + id: 'tool-image-1', + type: 'tool', + toolName: 'view_image', + timestamp: 1, + status: 'completed', + toolCall: { + id: 'tool-image-1', + input: { path: 'screenshots/preview.png' }, + }, + toolResult: { + success: true, + result: { + path: '/workspace/screenshots/preview.png', + width: 899, + height: 949, + mime_type: 'image/png', + }, + imageAttachments: [{ + mime_type: mimeType, + data_base64: 'AAAA', + }], + }, + }, + config: { + toolName: 'view_image', + displayName: 'View Image', + requiresConfirmation: false, + resultDisplayType: 'detailed', + displayMode: 'compact', + }, + }; +} + +describe('ViewImageToolCard', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal('window', {}); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('renders a completed image attachment inline with stable dimensions', () => { + const html = renderToStaticMarkup(); + + expect(html).toContain('data:image/png;base64,AAAA'); + expect(html).toContain('width="899"'); + expect(html).toContain('height="949"'); + expect(html).toContain('Viewed 1 image'); + expect(html).not.toContain('toolCards.viewImage.viewedImages'); + expect(html).toContain('view-image-tool-card__preview-button'); + }); + + it('does not render an unsupported attachment type', () => { + const html = renderToStaticMarkup(); + + expect(html).not.toContain('data:image/svg+xml'); + expect(html).not.toContain('; + return { + path: typeof value.path === 'string' && value.path.trim() ? value.path : null, + width: typeof value.width === 'number' && value.width > 0 ? value.width : null, + height: typeof value.height === 'number' && value.height > 0 ? value.height : null, + }; +} + +function imageSource(toolItem: ToolCardProps['toolItem']): string | null { + const attachment = toolItem.toolResult?.imageAttachments?.[0]; + if (!attachment) return null; + + const mimeType = attachment.mime_type?.toLowerCase(); + if (!SUPPORTED_IMAGE_MIME_TYPES.has(mimeType) || !attachment.data_base64) return null; + + return `data:${mimeType};base64,${attachment.data_base64}`; +} + +function fileName(path: string | null): string { + if (!path) return 'view_image'; + return path.split(/[\\/]/).filter(Boolean).pop() ?? path; +} + +export const ViewImageToolCard: React.FC = ({ toolItem, onExpand }) => { + const { t } = useI18n('flow-chat'); + const result = useMemo(() => parseResult(toolItem.toolResult?.result), [toolItem.toolResult?.result]); + const source = useMemo(() => imageSource(toolItem), [toolItem]); + const [isExpanded, setIsExpanded] = useState(Boolean(source)); + const [isLightboxOpen, setIsLightboxOpen] = useState(false); + const [imageFailed, setImageFailed] = useState(false); + const didAutoExpand = useRef(Boolean(source)); + const toolId = toolItem.id ?? toolItem.toolCall?.id; + const { cardRootRef, applyExpandedState } = useToolCardHeightContract({ + toolId, + toolName: toolItem.toolName, + }); + + useEffect(() => { + if (!source || didAutoExpand.current) return; + didAutoExpand.current = true; + applyExpandedState(isExpanded, true, setIsExpanded, { reason: 'auto' }); + }, [applyExpandedState, isExpanded, source]); + + useEffect(() => { + setImageFailed(false); + }, [source]); + + const handleToggle = () => { + if (!source) return; + applyExpandedState(isExpanded, !isExpanded, setIsExpanded, { onExpand }); + }; + + const path = result.path + ?? (typeof toolItem.toolCall?.input?.path === 'string' ? toolItem.toolCall.input.path : null); + const title = fileName(path); + const imageCount = toolItem.toolResult?.imageAttachments?.length ?? 1; + const viewedImagesText = t('toolCards.viewImage.viewedImages', { count: imageCount }); + const viewingText = t('toolCards.viewImage.viewing'); + const statusText = toolItem.status === 'error' + ? toolItem.toolResult?.error ?? t('toolCards.default.failed') + : toolItem.status === 'completed' + ? viewedImagesText === 'toolCards.viewImage.viewedImages' + ? t('toolCards.default.completed') + : viewedImagesText + : viewingText === 'toolCards.viewImage.viewing' + ? t('toolCards.default.executing') + : viewingText; + + return ( +
+ } + /> + )} + action={statusText} + rightStatusIcon={source ? (isExpanded ? : ) : undefined} + /> + )} + /> + + + {source ? ( +
+ {imageFailed ? ( +
+ {t('toolCards.default.failed')} +
+ ) : ( + + )} +
+ ) : null} +
+ + setIsLightboxOpen(false)} + title={title} + size="large" + > +
+ {title} +
+
+
+ ); +}; diff --git a/src/web-ui/src/flow_chat/tool-cards/index.ts b/src/web-ui/src/flow_chat/tool-cards/index.ts index 716a8d2df7..71f59a8c65 100644 --- a/src/web-ui/src/flow_chat/tool-cards/index.ts +++ b/src/web-ui/src/flow_chat/tool-cards/index.ts @@ -52,6 +52,7 @@ import { ReviewSessionSummaryCard } from './ReviewSessionSummaryCard'; import { SessionControlToolCard } from './SessionControlToolCard'; import { SessionMessageToolCard } from './SessionMessageToolCard'; import { ComputerUseToolCard } from './ComputerUseToolCard'; +import { ViewImageToolCard } from './ViewImageToolCard'; // Tool card component map - uses backend tool names export const TOOL_CARD_COMPONENTS = { @@ -122,6 +123,9 @@ export const TOOL_CARD_COMPONENTS = { // Computer use (desktop automation) 'ComputerUse': ComputerUseToolCard, + // Model vision image preview + 'view_image': ViewImageToolCard, + // BitFun Canvas tools 'CreateCanvas': CanvasToolCard, 'ReadCanvas': CanvasToolCard, diff --git a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts index 7ed8009910..0693f03429 100644 --- a/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts +++ b/src/web-ui/src/flow_chat/tool-cards/toolCardMetadata.ts @@ -343,6 +343,17 @@ export const TOOL_CARD_CONFIGS: Record = { primaryColor: 'var(--color-accent-600)' }, + 'view_image': { + toolName: 'view_image', + displayName: 'view_image', + icon: 'IMG', + requiresConfirmation: false, + resultDisplayType: 'detailed', + description: '', + displayMode: 'compact', + primaryColor: 'var(--color-accent-600)' + }, + // BitFun Canvas tools 'CreateCanvas': { toolName: 'CreateCanvas', diff --git a/src/web-ui/src/flow_chat/types/flow-chat.ts b/src/web-ui/src/flow_chat/types/flow-chat.ts index ecb83c9eda..2971ac2c07 100644 --- a/src/web-ui/src/flow_chat/types/flow-chat.ts +++ b/src/web-ui/src/flow_chat/types/flow-chat.ts @@ -64,6 +64,10 @@ export interface FlowToolItem extends FlowItem { result: any; success: boolean; resultForAssistant?: string; + imageAttachments?: Array<{ + mime_type: string; + data_base64: string; + }>; error?: string; duration_ms?: number; }; diff --git a/src/web-ui/src/locales/en-US/flow-chat.json b/src/web-ui/src/locales/en-US/flow-chat.json index e98a841fdf..c9b37d8356 100644 --- a/src/web-ui/src/locales/en-US/flow-chat.json +++ b/src/web-ui/src/locales/en-US/flow-chat.json @@ -1480,6 +1480,10 @@ "openSettings": "Open System Settings", "openSettingsFailed": "Could not open System Settings" }, + "viewImage": { + "viewing": "Viewing image...", + "viewedImages": "Viewed {{count}} image" + }, "webFetch": { "parsingUrl": "Parsing URL...", "readLabel": "Read Webpage:", diff --git a/src/web-ui/src/locales/zh-CN/flow-chat.json b/src/web-ui/src/locales/zh-CN/flow-chat.json index 6a88d354f7..5b95e90358 100644 --- a/src/web-ui/src/locales/zh-CN/flow-chat.json +++ b/src/web-ui/src/locales/zh-CN/flow-chat.json @@ -1480,6 +1480,10 @@ "openSettings": "打开系统设置", "openSettingsFailed": "无法打开系统设置" }, + "viewImage": { + "viewing": "正在查看图像...", + "viewedImages": "已查看 {{count}} 张图像" + }, "webFetch": { "parsingUrl": "解析链接中...", "readLabel": "读取网页:", diff --git a/src/web-ui/src/locales/zh-TW/flow-chat.json b/src/web-ui/src/locales/zh-TW/flow-chat.json index 33ca5a8a18..016c22aabf 100644 --- a/src/web-ui/src/locales/zh-TW/flow-chat.json +++ b/src/web-ui/src/locales/zh-TW/flow-chat.json @@ -1480,6 +1480,10 @@ "openSettings": "打開系統設定", "openSettingsFailed": "無法打開系統設定" }, + "viewImage": { + "viewing": "正在查看圖像...", + "viewedImages": "已查看 {{count}} 張圖像" + }, "webFetch": { "parsingUrl": "解析連結中...", "readLabel": "讀取網頁:", diff --git a/src/web-ui/src/shared/types/session-history.ts b/src/web-ui/src/shared/types/session-history.ts index 7a9a1eafcc..357685696c 100644 --- a/src/web-ui/src/shared/types/session-history.ts +++ b/src/web-ui/src/shared/types/session-history.ts @@ -270,6 +270,10 @@ export interface ToolResultData { result: any; success: boolean; resultForAssistant?: string; + imageAttachments?: Array<{ + mime_type: string; + data_base64: string; + }>; error?: string; durationMs?: number; } From b1a681179e033689b2c819650448dedb54569311 Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Sat, 18 Jul 2026 10:52:20 +0800 Subject: [PATCH 003/174] feat(ppt-live): export editable PPTX via a single native scene pipeline (#1602) Replace the image/fallback export path with EditableSlideScene normalization so PPT Live emits native OOXML text, shapes, lines, and tables. Harden authoring contracts and cover ring shadows, clipped bleed bands, unique cNvPr ids, and CJK-safe fonts so generated decks open without repair. --- package-lock.json | 38 + .../core/builtin_skills/ppt-design/SKILL.md | 62 +- .../data-information-visualization.md | 53 +- .../ppt-design/references/editable-pptx.md | 393 +- .../ppt-design/references/slide-decks.md | 36 +- .../product-domains/src/miniapp/builtin.rs | 2 +- .../miniapp/builtin/assets/ppt-live/README.md | 55 +- .../builtin/assets/ppt-live/bundle.json | 2 +- .../builtin/assets/ppt-live/dist/ui.bundle.js | 5753 ++++++++++++----- .../miniapp/builtin/assets/ppt-live/meta.json | 2 +- .../assets/ppt-live/src/agent-prompt.js | 29 +- .../ppt-live/src/editable-slide-normalize.js | 1298 ++++ .../ppt-live/src/editable-slide-scene.js | 781 +++ .../ppt-live/src/export-deck-browser.js | 58 +- .../assets/ppt-live/src/export-deck-host.js | 3 +- .../assets/ppt-live/src/export-diagnostics.js | 112 +- .../ppt-live/src/export-slide-browser.js | 355 +- .../ppt-live/src/fallback-layer-render.js | 516 -- .../assets/ppt-live/src/html2pptx-dom-core.js | 776 ++- .../builtin/assets/ppt-live/src/i18n.js | 16 - .../ppt-live/src/pptx-element-export.js | 559 +- .../assets/ppt-live/src/pptx-html-build.js | 499 +- .../ppt-live/src/preflight-slide-audit.js | 820 +++ .../builtin/assets/ppt-live/src/render.js | 3 +- .../ppt-live/src/sanitize-slide-html.js | 31 +- .../miniapp/builtin/assets/ppt-live/style.css | 13 + .../test/dom-repair-diagnostics.test.mjs | 2473 ++++--- .../test/editable-export-pipeline.test.mjs | 754 +++ .../test/fixtures/editable-slide-01.html | 92 + .../test/fixtures/editable-slide-02.html | 104 + .../test/fixtures/editable-slide-03.html | 136 + .../test/generated-file-protocol.test.mjs | 126 + .../test/pptx-ooxml-artifact.test.mjs | 2231 +++++-- .../src/miniapp/builtin/assets/ppt-live/ui.js | 67 +- 34 files changed, 12852 insertions(+), 5396 deletions(-) create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-normalize.js create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-scene.js delete mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/fallback-layer-render.js create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/preflight-slide-audit.js create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/editable-export-pipeline.test.mjs create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/fixtures/editable-slide-01.html create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/fixtures/editable-slide-02.html create mode 100644 src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/fixtures/editable-slide-03.html diff --git a/package-lock.json b/package-lock.json index 189b1be3eb..85ba03c794 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "0.2.13", "hasInstallScript": true, "dependencies": { + "jszip": "^3.10.1", + "pdf-lib": "^1.17.1", "pnpm": "^10.32.1", "pptxgenjs": "^4.0.1" }, @@ -1618,6 +1620,24 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/@pdf-lib/standard-fonts": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz", + "integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.6" + } + }, + "node_modules/@pdf-lib/upng": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@pdf-lib/upng/-/upng-1.0.1.tgz", + "integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==", + "license": "MIT", + "dependencies": { + "pako": "^1.0.10" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.27", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", @@ -2992,6 +3012,24 @@ "node": ">=8" } }, + "node_modules/pdf-lib": { + "version": "1.17.1", + "resolved": "https://registry.npmjs.org/pdf-lib/-/pdf-lib-1.17.1.tgz", + "integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==", + "license": "MIT", + "dependencies": { + "@pdf-lib/standard-fonts": "^1.0.0", + "@pdf-lib/upng": "^1.0.1", + "pako": "^1.0.11", + "tslib": "^1.11.1" + } + }, + "node_modules/pdf-lib/node_modules/tslib": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", + "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", + "license": "0BSD" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/SKILL.md b/src/crates/assembly/core/builtin_skills/ppt-design/SKILL.md index 95308771bf..4e2821c808 100644 --- a/src/crates/assembly/core/builtin_skills/ppt-design/SKILL.md +++ b/src/crates/assembly/core/builtin_skills/ppt-design/SKILL.md @@ -1,6 +1,6 @@ --- name: ppt-design -description: 使用 HTML 设计与生成高质量演讲幻灯片(PPT/Deck)。当用户希望生成、设计、修改幻灯片、deck、slides、presentation、汇报、提案、pitch、课件时触发。主干:960×540pt 可编辑 HTML 幻灯片(4 条 OOXML 硬约束)+ 可选 1920×1080 高保真演讲版 + 数据/信息可视化意识 + 5 种设计哲学 + 反 AI slop + 单页自包含 `slide-XX.html`。不生图;缺图用版式/SVG/占位并标注建议补图。 +description: 使用 HTML 设计与生成高质量演讲幻灯片(PPT/Deck)。当用户希望生成、设计、修改幻灯片、deck、slides、presentation、汇报、提案、pitch、课件时触发。主干:1280×720px 可编辑 HTML 幻灯片 + 唯一 editable HTML→EditableSlideScene→OOXML 管线 + 数据/信息可视化意识 + 5 种设计哲学 + 反 AI slop + 单页自包含 `slide-XX.html`。不生图;缺图用可编辑原语或占位并标注建议补图。 license: MIT --- @@ -16,7 +16,7 @@ Agent 启动时的**当前工作区根目录就是当前 deck 根目录**,不 - **页文件**:`slides/slide-01.html` …(两位数编号,与 `outline[].slide_id` 对应) - **大纲**:`project.json` → `outline[]`:`{ id, title, bullets[], slide_id }`;规划时 `status: "planning"`,仅在全部引用页面完整写入后改为 `status: "complete"` -- **品牌图**:`brand/` +- **品牌图输入**:可放 `brand/` 供读取,但写入 slide 时必须转为允许的内联 base64 raster;HTML 不得引用该目录路径 ## 核心原则(严格遵守) @@ -51,17 +51,39 @@ Agent 启动时的**当前工作区根目录就是当前 deck 根目录**,不 ### 5. 缺图(不生图) -1. 文字+留白完成信息 → 2. SVG/Unicode 几何 → 3. outline 标注 `[建议补图:…]`,图进 `brand/` +1. 文字+留白完成信息 → 2. preset shape/line/Unicode 等可编辑原语 → 3. outline 标注 `[建议补图:…]`。不得用外部图片、SVG 整图或截图补位。 ## 画布与交付目标 -**默认(可编辑 PPTX + 本仓库 PPT 生成)**:`body { width: 960pt; height: 540pt; }`,全程遵守 `references/editable-pptx.md` 四条硬约束。 +**默认(PPT Live + 可编辑 PPTX)**:画布严格为 **1280px × 720px**,写作 `body { width: 1280px; height: 720px; }`。不得使用其他画布尺寸、响应式画布或缩放替代。 -| 用户目标 | HTML 怎么写 | -|----------|----------------| -| 要在 PowerPoint 里改字 | 960×540pt + 四条硬约束;写时遵守 editable-pptx 规则 | -| 只要演讲/视觉自由、不改 pptx | 可用 1920×1080px;复杂 CSS/渐变可保留 | -| 既要复杂视觉又要可改字 pptx | **不可兼得**——说明限制;保留 1920 演讲版或另做简化 960 版 | +### Authoring subset(生成规则) + +- 唯一导出链路是 **editable HTML → EditableSlideScene → OOXML**。每页严格为 **1280px × 720px**,不得建议或设计第二条导出链路。 +- 只使用 solid color;不得生成 CSS gradient 或 `background-image`。背景、border、圆角只放在 `div` 等几何容器,文字标签不得承载 background、border 或 shadow。 +- `box-shadow` 只支持单层 outer、非 inset、zero spread;多层、inset、blur+spread 等不支持形态必须 blocking。`text-shadow` 任何非 `none` 形态均必须 blocking。 +- HTML 文字只可放在 `

`、`

`–`

`、`
  • ` 中;`span` 只作这些标签内的文本 run,不得生成 `div` 裸文字。 +- 禁止 CSS `filter`、`mask`、generated content、animation、外部资源和复杂/filled SVG path;禁止任意顶点/非严格对称 polygon,仅允许严格对称 triangle/diamond。 +- 线与曲线优先直接生成 `line` 或 `polyline`;只有确有必要时才写兼容边界所列的 path 子集。 +- Authoring 流程箭头只由 editable line + CSS border triangle,或 SVG line + strict symmetric triangle polygon 构成。 +- 表格必须是真实 `` 并导出为 native `a:tbl`;图表、流程箭头、虚线和曲线必须使用支持的可编辑原语。 +- intentional 图片只允许内联 base64 PNG、JPEG、WebP,且不得承载文字、图表或几何;禁止 GIF,因为无法证明其为静态内容。 +- 禁止任何正向 rasterize、screenshot 或 fallback 建议;无法表示时停止生成并报告具体元素。 + +### Converter legacy rewrite boundary(兼容边界,不是生成建议) + +- 本边界只用于兼容既有输入,不是生成许可;authoring agent 仍必须遵守上面的更严格 subset。 +- SVG `text` 是 converter 支持的 SVG 原语;`div` 裸文字仅属 repair 兼容,authoring 不应生成。 +- path 仅支持 `M/L/H/V/C/S/Q/T/Z`,必须 `fill:none`;`Z` 可以闭合 path,但拒绝 `A` 和任何 path/ancestor `transform`。 +- `C/S/Q/T` 曲线由 converter 采样为多段 editable line,不是 PowerPoint curve;authoring 优先 `line`/`polyline`,确需 path 时才使用上述子集。 +- SVG polygon 只识别严格对称的 triangle 和 diamond;任意顶点或非严格对称 polygon 都会被拒绝。 +- legacy CSS 仅兼容受限 `linear-gradient`:角度接受 `deg`、`turn`、`rad`、`grad` 与方向关键字;位置只接受 percentage stop,缺省 stop 均匀分配。 +- converter 拒绝 `radial-gradient`、px/em stop、double-position stop、color hint、不支持颜色和非法 alpha;合法 gradient 被采样为 editable solid strips,这不是生成建议。 +- legacy 单层 hard ring `box-shadow`(`0 0 0 Npx`、非 inset、blur=0)会被重写为同心可编辑 shape;authoring 仍应优先 zero-spread outer shadow,不得依赖 ring rewrite。 + + + +`references/editable-pptx.md` 是完整约束;其他 references 与 style presets 只提供视觉意图,冲突时本 authoring contract 优先。 ``` 当前工作区根目录/ @@ -72,15 +94,15 @@ Agent 启动时的**当前工作区根目录就是当前 deck 根目录**,不 └── versions/ # 系统快照 ``` -架构选型(多文件 vs 单文件 deck-stage)、聚合 `index.html`、grammar showcase → `references/slide-decks.md`。 +架构选型(多文件 vs 单文件 deck-stage)、聚合 `index.html`、grammar showcase → `references/slide-decks.md`;其中可编辑交付同样强制 1280×720 editable-only 契约。 ## 防溢出预算(写前心算,一次写对) -溢出 = 渲染后内容超出 960×540pt 画布,是最常见的硬伤。写每页 HTML 前先做**垂直预算心算**,一次写对: +溢出 = 渲染后内容超出 1280×720px 画布,是最常见的硬伤。写每页 HTML 前先做**垂直预算心算**,一次写对: -- 可用高度恒等式:`540pt = 标题区 + 正文区 + 底部脚注 + 安全边距`。标题区按 70–95pt、脚注行按 20–25pt、**底部安全边距 ≥ 36pt(0.5in,PPTX 导出校验线)** 预留,正文区实际只有约 **390–420pt**。 +- 可用高度恒等式:`720px = 标题区 + 正文区 + 底部脚注 + 安全边距`。标题区按 93–127px、脚注行按 27–33px、**底部安全边距 ≥ 48px(0.5in,PPTX 导出校验线)** 预留,正文区实际只有约 **520–560px**。 - 预算估算法:正文每行 ≈ `font-size × line-height`(如 12px × 1.5 ≈ 13.5pt/行);表格每行 ≈ 字号 + 上下 padding;卡片 = 内容行数 × 行高 + padding × 2 + 间距。**所有块的预算之和必须 ≤ 正文区高度**,估不下就删行、合栏或拆页,禁止靠缩字号硬塞。 -- 兜底结构:`body { overflow: hidden; }`,根容器 `display: flex; flex-direction: column; height: 540pt;`,可伸缩区给 `flex: 1; min-height: 0; overflow: hidden;`——即使估错也只在容器内裁切,不撑破画布。 +- 兜底结构:`body { overflow: hidden; }`,根容器 `display: flex; flex-direction: column; height: 720px;`,可伸缩区给 `flex: 1; min-height: 0; overflow: hidden;`——即使估错也只在容器内裁切,不撑破画布。 - 高风险元素单独检查:满版表格(行数 × 行高先算再写)、多行卡片网格、长 bullet 列表、流程图标注。一个元素预算超了就整体减行,而不是指望浏览器挤一挤。 - 任何文本框(>12px 字号)的底边必须离画布底部 ≥ 0.5in,否则 PPTX 导出会判为越界。 @@ -121,8 +143,8 @@ Agent 启动时的**当前工作区根目录就是当前 deck 根目录**,不 - 一个主视觉通常更利于聚焦,但对仪表盘、对照分析、教学拆解等页面可使用多个协调视图。 - 数据图应说明单位、时间、口径、来源或「估算」;没有可靠数字时不得编造。 - 用强调色引导结论,但保持全 deck 的对象颜色与视觉语义一致。 -- 3D 图、表盘、雷达图、图标阵列等并非绝对禁用;只有在它们确实改善理解且不扭曲数据时使用。 -- 可编辑 PPTX 的 CSS gradient 等限制是技术硬约束;复杂图形无法稳定转译时,可简化、换表达,或仅用于 1920 HTML 演讲版。 +- 只选择能由受支持的 editable shape、line、table 和文本组成的图表;表现形式不得以 3D、复杂 path、图片化图表或其他不可映射视觉为前提。 +- 可编辑 PPTX 的表示范围是技术硬约束;复杂图形必须改写为受支持的 shape、line、table 和文本组合,无法表示时停止生成并报告具体元素,不得转图片或建议 fallback。 - 更多选择思路、信息图模式和实现提示 → `references/data-information-visualization.md`;把它当作设计词汇库,不是逐条执行清单。 ## 5 种风格 @@ -142,7 +164,7 @@ DNA 与样例 → `references/design-styles.md`。 当输入里出现 `style.stylePreset`(或用户点名某个预设名)时,按以下流程套用预设: 1. `Read references/style-presets/.md`(路径相对本 skill 目录)。文件定义该预设的视觉系统、配色、排版、推荐版式、CSS 实现要点与禁忌,**必须逐条落实到每页 HTML**。 -2. 预设只接管「视觉身份」:配色、字体气质、装饰语言、版式偏好。本 skill 的核心原则全部继续生效——断言式标题、单页一结论、信息密度、反 AI slop、960×540pt 画布、可编辑 PPTX 四条硬约束、不许溢出。 +2. 预设只接管「视觉身份」:配色、字体气质、装饰语言、版式偏好。本 skill 的核心原则全部继续生效——断言式标题、单页一结论、信息密度、反 AI slop、1280×720px 画布、可编辑 authoring contract、不许溢出。 3. 从上面 5 种设计哲学中选最接近的一种作为版式骨架(structural grammar),预设负责皮肤;两者冲突时以信息传达优先、弱化装饰。 4. 预设文件缺失或 key 未知时,回退到 5 种哲学中最接近的一种,并沿用输入提供的 palette。 @@ -163,13 +185,13 @@ DNA 与样例 → `references/design-styles.md`。 1. **假设 + 纲**:先写 `project.json`,设置 `status: "planning"` 并完成 `outline[]` / `slide_order`,再开始任何 `slides/slide-NN.html`;顺带识别可能受益于图表、图示或更强视觉表达的页面,先不批量写 HTML。**若主题涉及技术方案/工程/系统/项目分析,必须在 outline 阶段就识别出哪些页是「系统组成」「处理流程」「多角色协作」「根因分析」,并标注用架构图/流程图/泳道图/因果链**(参考上方「技术方案类内容的特别触发」)。 2. **≥5 页先打样**:做 2 页视觉差异最大的 showcase,定 grammar 再批量(见 slide-decks.md)。 -3. **逐页 HTML**:封面 `slide-01`(标题/副标题/作者或日期)→ 按 outline 生成其余页;根据内容与风格自由选择图表、图示、文字、图片或混合构图,每页完整内联 CSS。**写每页时一次写对**:写之前先做垂直预算心算(见「防溢出预算」),写的时候同时遵守四条 OOXML 硬约束(文字用 p/h\* 包裹、纯色无渐变、背景/边框只在 DIV、不用 background-image),写完即止。 +3. **逐页 HTML**:封面 `slide-01`(标题/副标题/作者或日期)→ 按 outline 生成其余页;根据内容与风格选择可编辑图表、图示、文字或 intentional 内联图片,每页完整内联 CSS。**写每页时一次写对**:写之前先做垂直预算心算(见「防溢出预算」),写的时候同时遵守完整 authoring contract,写完即止。 4. **有界完成检查**:最后一页写完后仅检查一次生成文件协议:`outline[].slide_id` 与 `slide_order` 一一对应、每个 `slides/slide-NN.html` 存在且完整。只补写缺失或不完整文件,不做逐页内容审计,不用 Grep 批量检查设计约束;全部满足后将 `project.json.status` 改为 `"complete"` 并结束。 5. **改稿范围**(输入里若有 `scope`): - `deck`:可改 outline 与任意 `slides/*.html` - `current_slide` / `slide_index`:**只改指定页**,不动其他 slide 文件 -## 单页模板(960×540pt · Pentagram) +## 单页模板(1280×720px · Pentagram) ```html @@ -178,7 +200,7 @@ DNA 与样例 → `references/design-styles.md`。 - - - - -
    -

    标题用断言句,不是主题词

    -

    副标题补充说明

    -
    - - -
    -

    要点一

    -

    简短说明文字

    -
    - - -
    -
      -
    • 第一条要点
    • -
    • 第二条要点
    • -
    • 第三条要点
    • -
    -
    - - - - - - +
    + + + + + + + + + + + + +

    指标

    结果

    转化率

    42%

    ``` ---- - -## 常见错误速查 - -| 错误信息 | 原因 | 修复方法 | -|---------|------|---------| -| `DIV element contains unwrapped text "XXX"` | div 里有裸文字 | 把文字包进 `

    ` 或 `

    `-`

    ` | -| `CSS gradients are not supported` | 用了 linear/radial-gradient | 改为纯色,或用 flex 子元素分段 | -| `Text element

    has background` | `

    ` 标签加了背景色 | 外套 `

    ` 承载背景,`

    ` 只写文字 | -| `Background images on DIV elements are not supported` | div 用了 background-image | 改为 `` 标签 | -| `HTML content overflows body by Xpt vertically` | 内容超出 540pt | 减少内容或缩小字号,或 `overflow: hidden` 截断 | -| `HTML dimensions don't match presentation layout` | body 尺寸和 pres layout 对不上 | body 用 `960pt × 540pt` 配 `LAYOUT_WIDE`;或 defineLayout 自定义尺寸 | -| `Text box "XXX" ends too close to bottom edge` | 大字号 `

    ` 距离 body 底边 < 0.5 inch | 往上挪,留足下边距;PPT 底部本身就会被遮住一部分 | +- 导出结果必须是 native `a:tbl`,单元格文字、fill、border、宽度和对齐都保持可编辑。 +- 不得用 div grid、独立矩形集合、SVG、截图或图片伪装表格。 +- 只使用导出器支持的规则;不确定的 `rowspan`/`colspan` 应改写为无合并单元格的真实表格。 ---- +## 7. Intentional 图片 -## 基本工作流(3 步出 PPTX) +intentional 图片只可使用 `` 和内联 base64 raster,例如: -### Step 1:按约束写每页独立 HTML - -``` -我的Deck/ -├── slides/ -│ ├── 01-cover.html # 每个文件都是完整 960×540pt HTML -│ ├── 02-agenda.html -│ └── ... -└── illustration/ # 所有 引用的图片 - ├── chart1.png - └── ... -``` - -### Step 2:在 PowerPoint 里抽查 - -- 双击文字应能直接编辑(若变成整页图片 → 检查规则 1) -- 无文字溢出画布(`validateDimensions` / 肉眼) - ---- - -## 这条路径 vs 其他选项(什么时候选什么) - -| 需求 | 选什么 | -|------|------| -| 同事会改 PPTX 里的文字 / 发给非技术人员继续编辑 | **本文路径**(editable,需从头按 4 条约束写 HTML) | -| 只是演讲用 / 发存档,不再改 | 保持 1920 演讲 HTML,不必迁就 PPTX 四条约束 | -| 视觉自由度优先(动画、web component、CSS 渐变、复杂 SVG),接受不可编辑 | **1920 演讲 HTML**(或单独存档版),不要硬转 editable pptx | - -**绝不要在视觉自由写好的 HTML 上硬跑 html2pptx**——实测 pass 率 < 30%,应保留演讲版或重写 960pt 简化版,不要硬挤 PPTX。 - ---- - -## Fallback:已有视觉稿但用户坚持要 editable PPTX - -偶尔会遇到:已有视觉驱动的 1920 HTML(渐变、web component、复杂 SVG),用户仍坚持要可编辑 PPTX。 - -**不要硬跑 `html2pptx` 期待它 pass**——实测视觉驱动 HTML 在 html2pptx 上 pass 率 <30%,剩下 70% 会报错或走样。正确的 fallback 是: - -### Step 1 · 先告知局限性(透明沟通) - -一句话跟用户说清三件事: - -> 「你现在的 HTML 用了 [具体列出:渐变 / web component / 复杂 SVG / ...],直接转 editable PPTX 会 fail。我有两个方案: -> - A. **保留 1920 演讲 HTML**(推荐)——视觉完整,不在 PowerPoint 里改字 -> - B. **以视觉稿为蓝本,重写一版 960pt editable HTML**(保留色彩/布局/文案,按四条硬约束重组结构,**牺牲**渐变、web component、复杂 SVG) +```html +用户提供的产品实拍 -> 你选哪个?」 - -不要把 B 方案说得云淡风轻——明确告知**会丢失什么**。让用户做取舍。 - -### Step 2 · 如果用户选 B:AI 主动改写,不要求用户自己写 - -这里的 doctrine 是:**用户给的是设计意图,你负责翻译成合规实现**。不是让用户去学 4 条硬约束然后自己重写。 - -改写时的遵循原则: -- **保留**:色彩系统(主色/辅色/中性色)、信息层级(标题/副标题/正文/注解)、核心文案、layout 骨架(上中下 / 左右分栏 / 网格)、页面节奏 -- **降级**:CSS 渐变 → 纯色或 flex 分段、web component → 段落级 HTML、复杂 SVG → 简化的 `` 或纯色几何、阴影 → 删除或降为极弱、自定义字体 → 向系统字体靠齐 -- **重写**:裸文字 → 包进 `

    ` / ``、`background-image` → `` 标签、`

    ` 上的背景边框 → 外层 div 承载 - -### Step 3 · 产出对照清单(透明交付) - -改写完成后给用户一份 before/after 对照,让他知道哪些视觉细节被简化了: - -``` -原设计 → editable 版调整 -- 标题区紫色渐变 → 主色 #5B3DE8 纯色背景 -- 数据卡片阴影 → 删除(改为 2pt 描边区分) -- 复杂 SVG 折线图 → 简化为 PNG(从 HTML 截图生成) -- Hero 区 web component 动效 → 静态首帧(web component 无法翻译) ``` -### Step 4 · 双版本交付(可选) +- 可用 PNG、JPEG 或 WebP data URL;不得使用外部 URL、相对路径、绝对路径、blob URL 或 SVG data URL。 +- intentional 图片只承载本来就是照片、实拍、扫描件或用户明确提供的位图内容,且不得承载文字、图表或几何。 +- 不得把生成内容先转成位图再以内联图片绕过 authoring contract。 -- **演讲版**:保留原 1920×1080 视觉 HTML -- **可编辑版**:单独目录或文件集的 960pt 简化 HTML -- 向用户说明两版分工,避免混在同一文件里互相破坏约束 +## 8. 图表、流程与关系图 -### 什么情况下直接拒绝 B 方案 +- 图表、流程箭头、虚线和曲线必须使用支持的可编辑原语。 +- 条形、柱形、堆叠条和热力矩阵用纯色 shape;点图用圆/椭圆;坐标轴、目标线和连接线用 line。 +- 流程节点用支持的基础 shape;流程箭头用 editable line + CSS border triangle,或 SVG line + strict symmetric triangle polygon,方向必须明确。 +- 虚线使用 line 的 dash 属性对应写法,不得用点状图片或重复背景。 +- 折线和曲线优先直接拆成 editable line/polyline;必要 path 由 converter 采样成多个 editable line,不得使用 filled path。 +- 所有标签和数值都作为独立文本对象,不得烘焙进 SVG 或图片。 -个别场景下改写代价过高,应该劝用户放弃 editable PPTX: -- HTML 核心价值是动画或交互(改写后只剩静态首帧,信息量损失 50%+) -- 页数 > 30,改写成本超过 2 小时 -- 视觉设计深度依赖精确 SVG / 自定义 filter(改写后和原图几乎无关) +## 9. 合并文本框 -此时告诉用户:「改写代价过高,建议保留 1920 演讲版;若坚持可编辑 pptx,需接受视觉大幅简化。」 +需要在 PowerPoint 中连续编辑多段文字时,可给容器添加 `data-pptx-merge="true"`: ---- +```html +

    +

    标题

    +

    第一段。

    +

    第二段。

    +
    +``` -## 为什么 4 条约束不是 Bug 而是物理约束 +- merge 容器不能嵌套。 +- 容器的纯色 background、border 和圆角仍作为独立 shape。 +- 合并段落必须使用统一对齐和行距;需要不同对齐时拆成多个文本框。 -这 4 条不是 `html2pptx.cjs` 作者偷懒——它们是 **PowerPoint 文件格式(OOXML)本身的约束**投射到 HTML 上的结果: +## 10. 写入前检查 -- PPTX 里文字必须在 text frame(``),对应段落级 HTML 元素 -- PPTX 的 shape 和 text frame 是两个对象,无法在同一 element 上同时画背景和写文字 -- PPTX 的 shape fill 对 gradient 支持有限(仅某些 preset gradients,不支持 CSS 任意角度渐变) -- PPTX 的 picture 对象必须引用真实图片文件,不是 CSS 属性 +每页写入时一次满足: -理解这点后,**不要期待工具变聪明** —— 是 HTML 写法要适配 PPTX 格式,不是反过来。 +1. body 严格为 1280px × 720px。 +2. 所有文字使用规定标签,且无 generated content。 +3. 所有视觉都能映射为 text、shape、line、native `a:tbl` 或 intentional image。 +4. 无 filter、mask、background-image、复杂/filled SVG path、非严格对称 triangle/diamond 的 SVG polygon、外部资源和 animation。 +5. 无 rasterize、screenshot 或 fallback 路径。 +6. 表格为真实 ``,图片为允许的内联 base64 raster。 +7. 内容无溢出,所有对象可被唯一场景链路序列化。 diff --git a/src/crates/assembly/core/builtin_skills/ppt-design/references/slide-decks.md b/src/crates/assembly/core/builtin_skills/ppt-design/references/slide-decks.md index 9342cb150b..fa05f16063 100644 --- a/src/crates/assembly/core/builtin_skills/ppt-design/references/slide-decks.md +++ b/src/crates/assembly/core/builtin_skills/ppt-design/references/slide-decks.md @@ -4,7 +4,7 @@ **能力覆盖**: - **HTML 演示版(默认必做)** → 每页独立 HTML + `deck_index.html` 聚合,浏览器全屏翻页 -- **可编辑 PPTX** → HTML 从第一行遵守 `editable-pptx.md` 四条 + 960×540pt +- **可编辑 PPTX(PPT Live 默认)** → 每页严格 1280px × 720px,并从第一行遵守 `editable-pptx.md` 的 editable-only authoring contract - **高保真演讲版** → 1920×1080px,视觉自由;与可编辑 pptx 不可混为同一套 HTML > **HTML 是源。** 先做好 `slides/*.html`(+ 可选聚合 `index.html`),再谈文件格式。每页可单独打开验证;改内容只改 HTML。 @@ -26,25 +26,25 @@ │ ├── 还要 PDF(打印 / 发群 / 存档) → HTML 写法自由(1920 演讲版即可) │ - └── 还要可编辑 PPTX(同事要改文字) → 从第一行就按 4 条硬约束 + 960×540pt - 牺牲渐变 / web component / 复杂 SVG + └── 还要可编辑 PPTX(同事要改文字) → 从第一行就按 1280×720 editable-only 契约 + 只使用可映射的 text / shape / line / table / intentional image ``` ### 需求确认(动手前一句) > 我会先做可在浏览器里翻页的 HTML 幻灯片。请确认:**同事是否要在 PowerPoint 里改文字?** > - **否**(演讲/存档为主)→ 可用 1920 版,视觉自由 -> - **是** → 全程 960×540pt + 四条硬约束,牺牲渐变 / web component / 复杂 SVG +> - **是** → 全程 1280px × 720px + editable-only authoring contract ### 为什么「要 PPTX 就得从头走 4 条硬约束」 -PPTX 可编辑的前提是 `html2pptx.cjs` 能把 DOM 逐元素翻译为 PowerPoint 对象。它需要 **4 条硬约束**: +PPTX 可编辑的前提是 PPT Live 把 DOM 规范化为 `EditableSlideScene`,再序列化为 OOXML。唯一链路是 **editable HTML → EditableSlideScene → OOXML**,没有视觉保底或降级成功路径: -1. body 固定 960pt × 540pt(匹配 `LAYOUT_WIDE`,13.333″ × 7.5″,不是 1920×1080px) +1. body 固定 1280px × 720px(匹配 `LAYOUT_WIDE`,13.333″ × 7.5″) 2. 所有文字包在 `

    `/`

    `-`

    ` 里(禁止 div 直接放文字,禁止用 `` 承载主文字) 3. `

    `/`` 自身不能有 background/border/shadow(放外层 div) 4. `

    ` 不能用 `background-image`(用 `` 标签) -5. 不用 CSS gradient、不用 web component、不用复杂 SVG 装饰 +5. authoring 只使用 solid color 与受支持的 SVG/CSS 原语;无法表示时停止生成并报告具体元素 **本 skill 默认的 HTML 视觉自由度高**——大量 span、嵌套 flex、复杂 SVG、web component(如 ``)、CSS 渐变——**几乎没有一条能天然过 html2pptx 的约束**(实测视觉驱动的 HTML 直接上 html2pptx,pass 率 < 30%)。 @@ -53,7 +53,7 @@ PPTX 可编辑的前提是 `html2pptx.cjs` 能把 DOM 逐元素翻译为 PowerPo | 路径 | 做法 | 结果 | 代价 | |------|------|------|------| | ❌ **先自由写 HTML,事后补救 PPTX** | 单文件 deck-stage + 大量 SVG/span 装饰 | 要可编辑 PPTX 只剩两条路:
    A. 手写 pptxgenjs 几百行 hardcode 坐标
    B. 重写 17 页 HTML 成 Path A 格式 | 2-3 小时返工,且手写版**维护成本永续**(HTML 改一个字,PPTX 要再人肉同步) | -| ✅ **从第一步按 Path A 约束写** | 每页独立 HTML + 4 条硬约束 + 960×540pt | 可转可编辑 PPTX,也能浏览器演讲 | 多花几分钟把文字包进 `

    `,零返工 | +| ✅ **从第一步按 editable-only 约束写** | 每页独立 1280×720 HTML + 完整 authoring contract | 可转可编辑 PPTX,也能浏览器演讲 | 多花几分钟使用可映射原语,零返工 | ### 混合交付怎么办 @@ -61,15 +61,9 @@ PPTX 可编辑的前提是 `html2pptx.cjs` 能把 DOM 逐元素翻译为 PowerPo 用户说「我要 PPTX **和** 动画 / web component」——**这是真矛盾**。告诉用户:要可编辑 PPTX 就得牺牲这些视觉能力。让他做取舍,不要偷偷做手写 pptxgenjs 方案(会变成永续维护债)。 -### 事后才知道要 PPTX 怎么办(紧急补救) +### 事后才知道要 PPTX 怎么办 -极个别情况:HTML 已经写好了才发现要 PPTX。推荐走 **fallback 流程**(完整说明见 `references/editable-pptx.md` 末尾「Fallback:已有视觉稿但用户坚持要 editable PPTX」): - -1. **首选:保留 1920 演讲 HTML**(视觉完整)—— 若对方只需看/讲、不改字,不必重做 960pt 版 -2. **次选:以视觉稿为蓝本,重写一版 960pt editable HTML** —— 保留色彩/布局/文案,牺牲渐变、web component、复杂 SVG -3. **不推荐:手写 pptxgenjs 重建**——位置、字体、对齐都要手调,维护成本高,且后续 HTML 改一个字都得再人肉同步一次 - -永远把选择告诉用户,让他决定。**永远不要第一反应就开始手写 pptxgenjs**——那是最后的兜底手段。 +若既有 HTML 不符合 editable-only contract,必须以原稿为内容与视觉参考,重写为 1280×720 的受支持 text、shape、line、table 与 intentional image 组合。任何元素无法无损映射时,停止导出并报告页码与源元素;禁止截图、栅格化、静默丢失或标记为降级成功。 --- @@ -542,20 +536,22 @@ Deck 需要 **intentional variety**: ## 可编辑 PPTX:HTML 硬性约束 要在 PowerPoint 里改字时,HTML 须满足下列约束(详见 `references/editable-pptx.md`): +- 每页严格为 **1280px × 720px**,唯一导出链路为 **editable HTML → EditableSlideScene → OOXML** - 所有文字必须在 `

    `/`

    `-`

    `/`
      `/`
        ` 里(禁止裸文本 div) - `

        `/`` 标签自身不能有 background/border/shadow(放外层 div) - 不用 `::before`/`::after` 插入装饰文字(伪元素提不出来) - inline 元素(span/em/strong)不能有 margin -- 不用 CSS gradient(不可渲染) +- authoring 不生成 CSS gradient;兼容重写能力不构成生成许可 - div 不用 `background-image`(用 ``) +- 无法表示时停止生成并报告具体元素;禁止截图、栅格化或成功丢失 脚本已内置**自动预处理器**——把 "叶子 div 里的裸文本" 自动包成 `

        `(保留 class)。这解决了最常见的违规(裸文本)。但其他违规(p 上有 border、span 上有 margin 等)仍需 HTML 源头合规。 **字体回落 caveat**: - 测量环境与 PowerPoint/Keynote 本机字体可能不一致 → **溢出或错位**,导出后要肉眼过 -- 建议目标机器装好 HTML 里用的字体,或 fallback 到 `system-ui` +- 建议目标机器装好 HTML 里用的字体,或明确回退到 `system-ui` -**视觉优先 + 要可改字 pptx** → 不可兼得;保留 1920 演讲 HTML,另做简化版或接受 PPTX 会丢视觉效果。 +**视觉优先 + 要可改字 pptx** → 若视觉超出 editable subset,必须重构为受支持原语或阻止导出,不能接受成功产物丢失视觉效果。 ### 从一开始就让 HTML 对导出友好 @@ -582,7 +578,7 @@ Deck 需要 **intentional variety**: | 场景 | 推荐 | |------|------| | 给主办方/档案存档 | **1920 演讲 HTML** 或浏览器打印稿 | -| 发给协作者改字 | **960pt + 四条约束**(可编辑 pptx 管线) | +| 发给协作者改字 | **1280×720 editable-only contract**(可编辑 pptx 管线) | | 现场演讲、不改内容 | **1920 演讲 HTML** + 聚合翻页 | | HTML 是首选呈现媒介 | 直接浏览器播放 | diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index 24102cf780..156212f468 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin.rs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin.rs @@ -151,7 +151,7 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[ }, BuiltinMiniAppBundle { id: "builtin-ppt-live", - version: 217, + version: 231, meta_json: include_str!("builtin/assets/ppt-live/meta.json"), html: include_str!("builtin/assets/ppt-live/index.html"), css: include_str!("builtin/assets/ppt-live/style.css"), diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/README.md b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/README.md index c6a38d6509..be8b6b36a8 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/README.md +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/README.md @@ -19,10 +19,13 @@ ppt-live/ │ └── ui.bundle.js # 唯一的运行时 JS(由 builtin.rs 加载) └── src/ ├── export-deck-host.js # 导出函数的 re-export 壳(ui.js 通过它引入导出能力) - ├── export-deck-browser.js # PPTX/PDF/PNG 导出实现(依赖 pptxgenjs, pdf-lib, jszip) - ├── export-slide-browser.js # 幻灯片预处理编排(挂载 DOM → sanitize → 提取 slideData) - ├── html2pptx-dom-core.js # HTML→slideData 提取核心(Stage 1) - ├── pptx-html-build.js # slideData→PPTX 构建核心(Stage 2) + ├── export-deck-browser.js # EditableSlideScene/PDF/PNG 导出实现 + ├── export-slide-browser.js # 幻灯片预处理编排(挂载 DOM → sanitize → scene) + ├── preflight-slide-audit.js # sanitizer 前可见元素、CSS paint/text-shadow fail-close + ├── editable-slide-normalize.js # HTML/SVG/table → EditableSlideScene + ├── editable-slide-scene.js # 可编辑场景契约、校验与结构化错误 + ├── html2pptx-dom-core.js # DOM 几何与原生元素提取辅助 + ├── pptx-html-build.js # 唯一 EditableSlideScene→PPTX serializer ├── sanitize-slide-html.js # 导出前 HTML 净化/修复 ├── render.js # 幻灯片渲染(编辑器、缩略图、预览) ├── deck-ai.js # AI 生成对接 @@ -101,32 +104,16 @@ node build-bitfun.mjs ## 导出管线 -PPT Live 的核心功能是将 AI 生成的 HTML 幻灯片导出为 PPTX。导出管线分两个阶段: +PPT Live 只有一条 PPTX 导出管线:HTML 幻灯片和旧 element-model 幻灯片都先归一化为 +`EditableSlideScene`,再由 `buildSlideFromScene(scene, pres)` 这个唯一 serializer +映射为 PowerPoint 原生文本、形状、线、表格和有明确用户图片意图的 picture。 -### Stage 1: HTML → slideData 提取(`html2pptx-dom-core.js`) +`EditableSlideScene` 不接受栅格兜底、整页截图、SVG 图片层或降级成功状态。可确定重写的 +CSS/SVG 构造会转换为原生节点;无法表示的输入会携带页码、source id 和错误码阻止导出。 +表格通过 `addTable` 写为原生 `a:tbl`,生成的几何不得产生 `p:pic` 或媒体关系。 -`extractSlideDataFromDocument(doc)` 遍历 DOM 文档,产出结构化数据: - -``` -slideData = { - background: { type, value/path }, - elements: [ - { type, position: {x, y, w, h (inches)}, style: {...}, text }, - ... - ], - placeholders: [...], - errors: [...] -} -``` - -- 位置来自 `getBoundingClientRect()`(border-box),通过 `pxToInch = px / 96` 转为英寸 -- 元素的 CSS padding 被提取为 PPTX `margin`(内部 inset),防止文字偏移 -- 垂直对齐从 CSS flex/grid `align-items` 或 `line-height` 比例推断 - -### Stage 2: slideData → PPTX 构建(`pptx-html-build.js`) - -`buildSlideFromExtracted(slideData, bodyDimensions, pres)` 将每个 element 映射为 -pptxgenjs API 调用(`addText`、`addImage`、`addShape`)。 +DOM 几何来自 `getBoundingClientRect()`(border-box),以 `px / 96` 转换为英寸。 +CSS padding 会成为文本框或表格单元格 inset;垂直对齐由可表示的布局属性推导。 关键设计: - `WIDTH_SAFETY_IN = 0.15"` — 文本框加宽 0.15 英寸以吸收浏览器与 PowerPoint @@ -139,17 +126,17 @@ pptxgenjs API 调用(`addText`、`addImage`、`addShape`)。 ### 编排流程(`export-slide-browser.js`) ``` -prepareSlidesForPptxExport(slides, options) +prepareEditableSlides(slides, options) → loadHtmlInExportRoot(html) // 挂载到离屏 shadow-DOM div (1280×720) → sanitizeSlideDocumentRoot(doc) // 净化 HTML → waitForExportPaint() // 等待两帧渲染 - → measureBodyDimensions(doc) // 检测溢出 - → extractSlideDataFromDocument(doc) // Stage 1 - → options.renderRaster(html) // 可选:栅格化背景层(文字隐藏) - → 返回 prepared slides + → normalizeDocumentToEditableScene(doc) + → 返回 EditableSlideScene[] ``` -然后 `exportPptxPrepared(deck, preparedSlides)` 调用 Stage 2 生成最终 PPTX。 +旧 element-model 页面通过 `normalizeElementSlideToEditableScene` 进入同一 scene 契约。 +`exportEditablePptx(deck, scenes)` 逐页调用唯一 serializer 生成最终 PPTX;流程为顺序执行, +不插入人工延时。 ## 版本号协议 diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json index 41c0d008f9..21d2ce8189 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/bundle.json @@ -1,5 +1,5 @@ { "schemaVersion": 1, "id": "builtin-ppt-live", - "version": 217 + "version": 231 } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js index 414dc6ad7e..32a434f5ee 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/dist/ui.bundle.js @@ -30,9 +30,9 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge mod )); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/utils/common.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/utils/common.js var require_common = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/utils/common.js"(exports) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/utils/common.js"(exports) { "use strict"; var TYPED_OK = typeof Uint8Array !== "undefined" && typeof Uint16Array !== "undefined" && typeof Int32Array !== "undefined"; function _has(obj, key) { @@ -121,9 +121,9 @@ var require_common = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/trees.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/trees.js var require_trees = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/trees.js"(exports) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/trees.js"(exports) { "use strict"; var utils = require_common(); var Z_FIXED = 4; @@ -763,9 +763,9 @@ var require_trees = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/adler32.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/adler32.js var require_adler32 = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/adler32.js"(exports, module) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/adler32.js"(exports, module) { "use strict"; function adler32(adler, buf, len, pos) { var s1 = adler & 65535 | 0, s2 = adler >>> 16 & 65535 | 0, n = 0; @@ -785,9 +785,9 @@ var require_adler32 = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/crc32.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/crc32.js var require_crc32 = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/crc32.js"(exports, module) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/crc32.js"(exports, module) { "use strict"; function makeTable() { var c, table = []; @@ -813,9 +813,9 @@ var require_crc32 = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/messages.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/messages.js var require_messages = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/messages.js"(exports, module) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/messages.js"(exports, module) { "use strict"; module.exports = { 2: "need dictionary", @@ -840,9 +840,9 @@ var require_messages = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/deflate.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/deflate.js var require_deflate = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/deflate.js"(exports) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/deflate.js"(exports) { "use strict"; var utils = require_common(); var trees = require_trees(); @@ -1889,9 +1889,9 @@ var require_deflate = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/utils/strings.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/utils/strings.js var require_strings = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/utils/strings.js"(exports) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/utils/strings.js"(exports) { "use strict"; var utils = require_common(); var STR_APPLY_OK = true; @@ -2031,9 +2031,9 @@ var require_strings = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/zstream.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/zstream.js var require_zstream = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/zstream.js"(exports, module) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/zstream.js"(exports, module) { "use strict"; function ZStream() { this.input = null; @@ -2053,9 +2053,9 @@ var require_zstream = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/deflate.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/deflate.js var require_deflate2 = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/deflate.js"(exports) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/deflate.js"(exports) { "use strict"; var zlib_deflate = require_deflate(); var utils = require_common(); @@ -2214,9 +2214,9 @@ var require_deflate2 = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inffast.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inffast.js var require_inffast = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inffast.js"(exports, module) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inffast.js"(exports, module) { "use strict"; var BAD = 30; var TYPE = 12; @@ -2443,9 +2443,9 @@ var require_inffast = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inftrees.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inftrees.js var require_inftrees = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inftrees.js"(exports, module) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inftrees.js"(exports, module) { "use strict"; var utils = require_common(); var MAXBITS = 15; @@ -2759,9 +2759,9 @@ var require_inftrees = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inflate.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inflate.js var require_inflate = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inflate.js"(exports) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/inflate.js"(exports) { "use strict"; var utils = require_common(); var adler32 = require_adler32(); @@ -3998,9 +3998,9 @@ var require_inflate = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/constants.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/constants.js var require_constants = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/constants.js"(exports, module) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/constants.js"(exports, module) { "use strict"; module.exports = { /* Allowed flush values; see deflate() and inflate() below for details */ @@ -4045,9 +4045,9 @@ var require_constants = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/gzheader.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/gzheader.js var require_gzheader = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/gzheader.js"(exports, module) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/zlib/gzheader.js"(exports, module) { "use strict"; function GZheader() { this.text = 0; @@ -4065,9 +4065,9 @@ var require_gzheader = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/inflate.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/inflate.js var require_inflate2 = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/inflate.js"(exports) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/lib/inflate.js"(exports) { "use strict"; var zlib_inflate = require_inflate(); var utils = require_common(); @@ -4239,9 +4239,9 @@ var require_inflate2 = __commonJS({ } }); -// node_modules/.pnpm/pako@1.0.11/node_modules/pako/index.js +// ../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/index.js var require_pako = __commonJS({ - "node_modules/.pnpm/pako@1.0.11/node_modules/pako/index.js"(exports, module) { + "../../../../../../../../../node_modules/.pnpm/pako@1.0.11/node_modules/pako/index.js"(exports, module) { "use strict"; var assign = require_common().assign; var deflate = require_deflate2(); @@ -4253,9 +4253,9 @@ var require_pako = __commonJS({ } }); -// node_modules/.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js +// ../../../../../../../../../node_modules/.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js var require_jszip_min = __commonJS({ - "node_modules/.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js"(exports, module) { + "../../../../../../../../../node_modules/.pnpm/jszip@3.10.1/node_modules/jszip/dist/jszip.min.js"(exports, module) { !(function(e) { if ("object" == typeof exports && "undefined" != typeof module) module.exports = e(); else if ("function" == typeof define && define.amd) define([], e); @@ -6631,7 +6631,7 @@ var require_node_https = __commonJS({ } }); -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js +// src/i18n.js var STRINGS = { "en-US": { eyebrow: "AI Deck Studio", @@ -6894,17 +6894,9 @@ var STRINGS = { exportPptxFailed: "PPTX export failed:", exportDiagnosticsSummary: "Exported with visual adjustments: {{counts}}. {{locations}}", exportDiagnosticsRepaired: "{{count}} automatic repairs", - exportDiagnosticsSvg: "{{count}} local SVG layers", - exportDiagnosticsLocalPng: "{{count}} local PNG layers", - exportDiagnosticsPageVisual: "{{count}} page visual layers", - exportDiagnosticsFullPage: "{{count}} full-page fallbacks", exportDiagnosticsBlocking: "{{count}} blocking failures", exportDiagnosticsLocation: "Slide {{slide}}, element {{source}} ({{phase}}): {{reason}}", exportDiagnosticsPhaseRepair: "automatic repair", - exportDiagnosticsPhaseSvg: "local SVG", - exportDiagnosticsPhaseLocalPng: "local PNG", - exportDiagnosticsPhasePageVisual: "page visual fallback", - exportDiagnosticsPhaseFullPage: "full-page fallback", exportDiagnosticsPhaseBlocking: "blocking failure", exportPdfWorking: "Rendering PDF...", exportPdfDone: "PDF downloaded.", @@ -7284,17 +7276,9 @@ var STRINGS = { exportPptxFailed: "PPTX \u5BFC\u51FA\u5931\u8D25\uFF1A", exportDiagnosticsSummary: "\u5DF2\u5B8C\u6210\u5BFC\u51FA\uFF0C\u5E76\u8FDB\u884C\u4E86\u89C6\u89C9\u8C03\u6574\uFF1A{{counts}}\u3002{{locations}}", exportDiagnosticsRepaired: "\u81EA\u52A8\u4FEE\u590D {{count}} \u5904", - exportDiagnosticsSvg: "\u5C40\u90E8 SVG \u5C42 {{count}} \u4E2A", - exportDiagnosticsLocalPng: "\u5C40\u90E8 PNG \u5C42 {{count}} \u4E2A", - exportDiagnosticsPageVisual: "\u9875\u9762\u89C6\u89C9\u5C42 {{count}} \u4E2A", - exportDiagnosticsFullPage: "\u6574\u9875\u515C\u5E95 {{count}} \u9875", exportDiagnosticsBlocking: "\u963B\u65AD\u5931\u8D25 {{count}} \u5904", exportDiagnosticsLocation: "\u7B2C {{slide}} \u9875\uFF0C\u5143\u7D20 {{source}}\uFF08{{phase}}\uFF09\uFF1A{{reason}}", exportDiagnosticsPhaseRepair: "\u81EA\u52A8\u4FEE\u590D", - exportDiagnosticsPhaseSvg: "\u5C40\u90E8 SVG", - exportDiagnosticsPhaseLocalPng: "\u5C40\u90E8 PNG", - exportDiagnosticsPhasePageVisual: "\u9875\u9762\u89C6\u89C9\u515C\u5E95", - exportDiagnosticsPhaseFullPage: "\u6574\u9875\u515C\u5E95", exportDiagnosticsPhaseBlocking: "\u963B\u65AD\u5931\u8D25", exportPdfWorking: "\u6B63\u5728\u6E32\u67D3 PDF...", exportPdfDone: "PDF \u5DF2\u4E0B\u8F7D\u3002", @@ -7429,7 +7413,7 @@ function translate(key, params = {}) { return value2; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/style-presets.js +// src/style-presets.js var STYLE_PRESET_UI_KEYS = ["clean-business", "insight-report"]; var STYLE_PRESETS = { // === 商务/极简 === @@ -7696,7 +7680,7 @@ function getAllStylePresets(locale) { }); } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/state.js +// src/state.js var STORAGE_KEY = "pptLiveStudioStateV6"; var HISTORY_KEY = "pptLiveDeckHistoryV1"; var SCHEMA_VERSION = 6; @@ -8437,7 +8421,7 @@ function pointsFor(title, index, state2) { return picks; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/flat-select.js +// src/flat-select.js var OPEN_SELECTS = /* @__PURE__ */ new Set(); function closeSelect(wrap) { const menu = wrap.querySelector(".ppt-flat-select__menu"); @@ -8618,7 +8602,7 @@ if (typeof window !== "undefined" && typeof document !== "undefined" && !window. document.addEventListener("scroll", handleOutsideScroll, true); } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/element-model-html.js +// src/element-model-html.js var escapeHtml2 = (value2) => String(value2 ?? "").replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'"); var DEFAULT_THEME = { background: "#ffffff", @@ -8761,7 +8745,7 @@ function buildElementSlideHtml(slide = {}) { `; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/sanitize-slide-markup.js +// src/sanitize-slide-markup.js var SVG_NAMESPACE = "http://www.w3.org/2000/svg"; var DELETE_WITH_CONTENT_TAGS = /* @__PURE__ */ new Set([ "script", @@ -9195,7 +9179,7 @@ function sanitizeSlideMarkup(markup) { return `${parsed.documentElement.outerHTML}`; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/render.js +// src/render.js function applyI18n() { document.documentElement.lang = getLocale(); document.querySelectorAll("[data-i18n]").forEach((node) => { @@ -9355,7 +9339,6 @@ function writeSandboxIframeDocument(frame, sanitizedHtml) { function mountSandboxIframeHtml(frame, html, onMounted) { const sanitizedHtml = sanitizeSlideMarkup(normalizeSlideDocument(html)); frame.setAttribute("sandbox", "allow-same-origin"); - frame.srcdoc = sanitizedHtml; frame.src = "about:blank"; const mount = () => { if (!writeSandboxIframeDocument(frame, sanitizedHtml)) return false; @@ -10418,7 +10401,7 @@ function round(value2) { return Math.round(Number(value2) * 10) / 10; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/sanitize-slide-html.js +// src/sanitize-slide-html.js function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { const document2 = doc; const view = document2.defaultView || window; @@ -10430,7 +10413,7 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { const textContainerSelector = "p,h1,h2,h3,h4,h5,h6,li"; const manualBulletPattern = /^(\s*)([•●○▪‣·▸◆◇■□–—*-])\s+/u; const ambiguousBulletSymbols = /* @__PURE__ */ new Set(["-", "*", "\u2013", "\u2014"]); - function sourceIdOf(element2) { + function sourceIdOf3(element2) { return element2?.dataset?.pptxSourceId || element2?.id || null; } function assignSourceIds() { @@ -10478,7 +10461,7 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { }); } function derivedSourceId(element2, suffix) { - const base = sourceIdOf(element2) || "pptx-source"; + const base = sourceIdOf3(element2) || "pptx-source"; let candidate = `${base}-${suffix}`; let sequence = 2; while (document2.querySelector(`[data-pptx-source-id="${candidate}"]`)) { @@ -10488,7 +10471,7 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { return candidate; } function addDiagnostic(severity, code, message, element2 = null) { - const sourceId = sourceIdOf(element2); + const sourceId = sourceIdOf3(element2); const key = `${severity}:${code}:${sourceId || ""}`; if (seenDiagnostics.has(key)) return; seenDiagnostics.add(key); @@ -10560,7 +10543,7 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { }); }; copyOuterAttributes(current); - current.dataset.pptxSourceId = sourceIdOf(outer); + current.dataset.pptxSourceId = sourceIdOf3(outer); [...outer.childNodes].forEach((node) => { if (node.nodeType === view.Node.ELEMENT_NODE && node.matches(textContainerSelector)) { if (current.textContent.trim() || current.children.length) fragments.push(current); @@ -10639,7 +10622,7 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { const block = document2.createElement("p"); if (span.className) block.className = span.className; if (span.getAttribute("style")) block.setAttribute("style", span.getAttribute("style")); - block.dataset.pptxSourceId = sourceIdOf(span); + block.dataset.pptxSourceId = sourceIdOf3(span); while (span.firstChild) block.appendChild(span.firstChild); span.replaceWith(block); addDiagnostic( @@ -10654,25 +10637,25 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { const symbol = element2.textContent.match(manualBulletPattern)?.[2]; if (!symbol) return; const walker = document2.createTreeWalker(element2, view.NodeFilter.SHOW_TEXT); - let textNode = walker.nextNode(); + let textNode2 = walker.nextNode(); let removed = false; - while (textNode) { + while (textNode2) { if (!removed) { - const symbolIndex = textNode.textContent.search(/\S/u); + const symbolIndex = textNode2.textContent.search(/\S/u); if (symbolIndex >= 0) { - if (textNode.textContent.slice(symbolIndex).startsWith(symbol)) { - textNode.textContent = textNode.textContent.slice(symbolIndex + symbol.length).replace(/^\s+/u, ""); + if (textNode2.textContent.slice(symbolIndex).startsWith(symbol)) { + textNode2.textContent = textNode2.textContent.slice(symbolIndex + symbol.length).replace(/^\s+/u, ""); removed = true; - if (textNode.textContent) return; + if (textNode2.textContent) return; } else { return; } } - } else if (textNode.textContent) { - textNode.textContent = textNode.textContent.replace(/^\s+/u, ""); + } else if (textNode2.textContent) { + textNode2.textContent = textNode2.textContent.replace(/^\s+/u, ""); return; } - textNode = walker.nextNode(); + textNode2 = walker.nextNode(); } } function normalizeManualBulletBlocks(root) { @@ -10700,7 +10683,7 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { item.setAttribute(attribute.name, attribute.value); } }); - item.dataset.pptxSourceId = sourceIdOf(block); + item.dataset.pptxSourceId = sourceIdOf3(block); while (block.firstChild) item.appendChild(block.firstChild); removeManualBullet(item); list.appendChild(item); @@ -10742,24 +10725,24 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { } }); } - function collectFallbackDiagnostics(root) { + function collectEditableExportDiagnostics(root) { root.querySelectorAll("*").forEach((element2) => { const computed = view.getComputedStyle(element2); const backgroundImage = String(computed.backgroundImage || element2.style?.backgroundImage || ""); if (backgroundImage.includes("gradient")) { addDiagnostic( - "fallback", + "rewrite", "css_gradient", - "CSS gradient requires fallback rendering; native gradient mapping is not available.", + "CSS gradient will be rewritten as editable solid strips.", element2 ); } const filter = String(computed.filter || element2.style?.filter || ""); if (filter && filter !== "none") { addDiagnostic( - "fallback", + "blocking", "css_filter", - "CSS filter requires fallback rendering.", + "CSS filter cannot be represented as editable objects.", element2 ); } @@ -10767,16 +10750,16 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { root.querySelectorAll("svg").forEach((svg) => { if (svg.querySelector("filter,mask,foreignObject,use,pattern,textPath,clipPath,image")) { addDiagnostic( - "fallback", - "complex_svg_raster", - "SVG filter, mask, or foreignObject requires local browser raster rendering.", + "blocking", + "complex_svg_unsupported", + "SVG filter, mask, or foreignObject cannot be represented as editable objects.", svg ); } else if (svg.querySelector("path")) { addDiagnostic( - "fallback", - "complex_svg_vector", - "Complex SVG geometry will be preserved as a local SVG image.", + "rewrite", + "svg_path_rewrite", + "SVG path geometry will be rewritten as editable line segments.", svg ); } @@ -10800,9 +10783,9 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { return; } matches.forEach((element2) => addDiagnostic( - "fallback", + "blocking", "generated_content", - "Pseudo-element generated content requires fallback rendering.", + "Pseudo-element generated content cannot be represented as editable objects.", element2 )); }); @@ -10975,8 +10958,11 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { `; (root.head || root.documentElement).appendChild(style); } + document2.querySelectorAll("[style]").forEach((element2) => { + element2.dataset.pptxAuthoredStyle = element2.getAttribute("style"); + }); assignSourceIds(); - collectFallbackDiagnostics(document2); + collectEditableExportDiagnostics(document2); ensureExportCanvas(); repairNestedParagraphs(document2); promoteDecoratedSpans(document2); @@ -11004,7 +10990,7 @@ function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { return { diagnostics }; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/paint-order.js +// src/paint-order.js function buildDomPaintOrderMap(doc) { const map = /* @__PURE__ */ new Map(); if (!doc?.body) return map; @@ -11015,8 +11001,84 @@ function buildDomPaintOrderMap(doc) { return map; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/html2pptx-dom-core.js +// src/html2pptx-dom-core.js var PT_PER_PX = 0.75; +var PX_PER_IN = 96; +function createSvgViewportMapper(svg, svgRect) { + const values2 = String(svg.getAttribute("viewBox") || `0 0 ${svgRect.width} ${svgRect.height}`).trim().split(/[\s,]+/).map(Number); + const [viewBoxX = 0, viewBoxY = 0, viewBoxWidth = svgRect.width, viewBoxHeight = svgRect.height] = values2; + const raw = String(svg.getAttribute("preserveAspectRatio") || "xMidYMid meet").trim(); + const parts = raw.split(/\s+/).filter(Boolean); + const align = parts.find((part) => /^(?:none|x(?:Min|Mid|Max)Y(?:Min|Mid|Max))$/.test(part)) || "xMidYMid"; + const mode = parts.includes("slice") ? "slice" : "meet"; + const naturalXScale = svgRect.width / (viewBoxWidth || svgRect.width); + const naturalYScale = svgRect.height / (viewBoxHeight || svgRect.height); + let xScale = naturalXScale; + let yScale = naturalYScale; + let offsetX = 0; + let offsetY = 0; + if (align !== "none") { + const scale2 = mode === "slice" ? Math.max(naturalXScale, naturalYScale) : Math.min(naturalXScale, naturalYScale); + xScale = scale2; + yScale = scale2; + const remainingX = svgRect.width - viewBoxWidth * scale2; + const remainingY = svgRect.height - viewBoxHeight * scale2; + offsetX = align.startsWith("xMid") ? remainingX / 2 : align.startsWith("xMax") ? remainingX : 0; + offsetY = align.includes("YMid") ? remainingY / 2 : align.includes("YMax") ? remainingY : 0; + } + return { + xScale, + yScale, + map(point) { + return { + x: svgRect.left + offsetX + (point.x - viewBoxX) * xScale, + y: svgRect.top + offsetY + (point.y - viewBoxY) * yScale + }; + } + }; +} +function classifySvgPresetPolygon(points) { + if (!Array.isArray(points)) return null; + const tolerance = 1e-6; + if (points.length === 3) { + for (let left = 0; left < points.length; left += 1) { + for (let right = left + 1; right < points.length; right += 1) { + const apex = points.find((_point, index) => index !== left && index !== right); + const baseMidpoint = { + x: (points[left].x + points[right].x) / 2, + y: (points[left].y + points[right].y) / 2 + }; + if (Math.abs(points[left].x - points[right].x) <= tolerance && Math.abs(apex.y - baseMidpoint.y) <= tolerance && Math.abs(apex.x - baseMidpoint.x) > tolerance) { + return { shapeType: "triangle", rotate: apex.x > baseMidpoint.x ? 90 : 270 }; + } + if (Math.abs(points[left].y - points[right].y) <= tolerance && Math.abs(apex.x - baseMidpoint.x) <= tolerance && Math.abs(apex.y - baseMidpoint.y) > tolerance) { + return { shapeType: "triangle", rotate: apex.y > baseMidpoint.y ? 180 : 0 }; + } + } + } + return null; + } + if (points.length !== 4) return null; + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const expected = [ + { x: centerX, y: minY }, + { x: maxX, y: centerY }, + { x: centerX, y: maxY }, + { x: minX, y: centerY } + ]; + const matches = expected.every((target) => points.some((point) => Math.abs(point.x - target.x) <= tolerance && Math.abs(point.y - target.y) <= tolerance)); + return matches ? { shapeType: "diamond", rotate: 0 } : null; +} +function clipsOverflowAxis(value2) { + return value2 === "hidden" || value2 === "clip"; +} function measureBodyDimensions(doc = document) { const view = doc.defaultView || window; const body = doc.body; @@ -11032,11 +11094,17 @@ function measureBodyDimensions(doc = document) { const heightOverflowPx = Math.max(0, bodyDimensions.scrollHeight - bodyDimensions.height - 1); const widthOverflowPt = widthOverflowPx * PT_PER_PX; const heightOverflowPt = heightOverflowPx * PT_PER_PX; - if (widthOverflowPt > 0 || heightOverflowPt > 0) { + const overflowX = style.overflowX || style.overflow || "visible"; + const overflowY = style.overflowY || style.overflow || "visible"; + const clipsX = clipsOverflowAxis(overflowX); + const clipsY = clipsOverflowAxis(overflowY); + const reportWidth = widthOverflowPx > 0 && !clipsX; + const reportHeight = heightOverflowPx > 0 && !clipsY; + if (reportWidth || reportHeight) { const directions = []; - if (widthOverflowPt > 0) directions.push(`${widthOverflowPt.toFixed(1)}pt horizontally`); - if (heightOverflowPt > 0) directions.push(`${heightOverflowPt.toFixed(1)}pt vertically`); - const reminder = heightOverflowPt > 0 ? ' (Remember: leave 0.5" margin at bottom of slide)' : ""; + if (reportWidth) directions.push(`${widthOverflowPt.toFixed(1)}pt horizontally`); + if (reportHeight) directions.push(`${heightOverflowPt.toFixed(1)}pt vertically`); + const reminder = reportHeight ? ' (Remember: leave 0.5" margin at bottom of slide)' : ""; errors.push(`HTML content overflows body by ${directions.join(" and ")}${reminder}`); } return { ...bodyDimensions, errors }; @@ -11103,6 +11171,13 @@ function extractSlideDataFromDocument(doc = document) { const pxToPoints = (pxStr) => parseFloat(pxStr) * PT_PER_PX2; const rgbToHex2 = (rgbStr) => { if (rgbStr === "rgba(0, 0, 0, 0)" || rgbStr === "transparent") return "FFFFFF"; + const hex2 = String(rgbStr || "").trim().match(/^#([\da-f]{3,8})$/i)?.[1]; + if (hex2) { + if (hex2.length === 3 || hex2.length === 4) { + return hex2.slice(0, 3).split("").map((channel) => channel.repeat(2)).join(""); + } + return hex2.slice(0, 6); + } const match = rgbStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (!match) return "FFFFFF"; return match.slice(1).map((n) => parseInt(n).toString(16).padStart(2, "0")).join(""); @@ -11207,20 +11282,23 @@ function extractSlideDataFromDocument(doc = document) { }; const parseBoxShadow = (boxShadow) => { if (!boxShadow || boxShadow === "none") return null; + let nesting = 0; + for (const character of boxShadow) { + if (character === "(") nesting += 1; + else if (character === ")") nesting = Math.max(0, nesting - 1); + else if (character === "," && nesting === 0) return null; + } const insetMatch = boxShadow.match(/inset/); if (insetMatch) return null; - const colorMatch = boxShadow.match(/rgba?\([^)]+\)/); - const parts = boxShadow.match(/([-\d.]+)(px|pt)/g); + const colorMatch = boxShadow.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/); + const withoutColor = boxShadow.replace(/rgba?\([^)]+\)/g, " ").replace(/#[0-9a-fA-F]{3,8}/g, " "); + const parts = withoutColor.match(/-?\d*\.?\d+(?:px|pt)?/g); if (!parts || parts.length < 2) return null; const offsetX = parseFloat(parts[0]); const offsetY = parseFloat(parts[1]); const blur = parts.length > 2 ? parseFloat(parts[2]) : 0; - let angle = 0; - if (offsetX !== 0 || offsetY !== 0) { - angle = Math.atan2(offsetY, offsetX) * (180 / Math.PI); - if (angle < 0) angle += 360; - } - const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY) * PT_PER_PX2; + const spread = parts.length > 3 ? parseFloat(parts[3]) : 0; + if (![offsetX, offsetY, blur, spread].every(Number.isFinite) || blur < 0) return null; let opacity = 0.5; if (colorMatch) { const opacityMatch = colorMatch[0].match(/[\d.]+\)$/); @@ -11228,16 +11306,38 @@ function extractSlideDataFromDocument(doc = document) { opacity = parseFloat(opacityMatch[0].replace(")", "")); } } + if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) return null; + const color = colorMatch ? rgbToHex2(colorMatch[0]) : "000000"; + if (!color) return null; + if (spread !== 0) { + if (offsetX === 0 && offsetY === 0 && blur === 0 && spread > 0) { + return { + mode: "ring", + ring: { spreadPx: spread, color, opacity } + }; + } + return null; + } + let angle = 0; + if (offsetX !== 0 || offsetY !== 0) { + angle = Math.atan2(offsetY, offsetX) * (180 / Math.PI); + if (angle < 0) angle += 360; + } + const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY) * PT_PER_PX2; return { - type: "outer", - angle: Math.round(angle), - blur: blur * 0.75, - // Convert to points - color: colorMatch ? rgbToHex2(colorMatch[0]) : "000000", - offset, - opacity + mode: "outer", + shadow: { + type: "outer", + angle: Math.round(angle), + blur: blur * 0.75, + // Convert to points + color, + offset, + opacity + } }; }; + const outerShadowOf = (effect) => effect?.mode === "outer" ? effect.shadow : null; const parseInlineFormatting = (element2, baseOptions = {}, runs = [], baseTextTransform = (x) => x) => { let prevNodeIsText = false; element2.childNodes.forEach((node) => { @@ -11261,8 +11361,8 @@ function extractSlideDataFromDocument(doc = document) { if (computed.textDecoration && computed.textDecoration.includes("underline")) options.underline = true; if (computed.color && computed.color !== "rgb(0, 0, 0)") { options.color = rgbToHex2(computed.color); - const transparency2 = extractAlpha(computed.color); - if (transparency2 !== null) options.transparency = transparency2; + const transparency = extractAlpha(computed.color); + if (transparency !== null) options.transparency = transparency; } if (computed.fontSize) options.fontSize = pxToPoints(computed.fontSize); if (computed.textTransform && computed.textTransform !== "none") { @@ -11270,16 +11370,16 @@ function extractSlideDataFromDocument(doc = document) { textTransform = (text2) => applyTextTransform(text2, transformStr); } if (computed.marginLeft && parseFloat(computed.marginLeft) > 0) { - addDiagnostic("fallback", "inline_margin", `Inline element <${node.tagName.toLowerCase()}> has margin-left; fallback layout may be needed.`, node); + addDiagnostic("blocking", "inline_margin", `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-left.`, node); } if (computed.marginRight && parseFloat(computed.marginRight) > 0) { - addDiagnostic("fallback", "inline_margin", `Inline element <${node.tagName.toLowerCase()}> has margin-right; fallback layout may be needed.`, node); + addDiagnostic("blocking", "inline_margin", `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-right.`, node); } if (computed.marginTop && parseFloat(computed.marginTop) > 0) { - addDiagnostic("fallback", "inline_margin", `Inline element <${node.tagName.toLowerCase()}> has margin-top; fallback layout may be needed.`, node); + addDiagnostic("blocking", "inline_margin", `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-top.`, node); } if (computed.marginBottom && parseFloat(computed.marginBottom) > 0) { - addDiagnostic("fallback", "inline_margin", `Inline element <${node.tagName.toLowerCase()}> has margin-bottom; fallback layout may be needed.`, node); + addDiagnostic("blocking", "inline_margin", `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-bottom.`, node); } parseInlineFormatting(node, options, runs, textTransform); } @@ -11293,6 +11393,16 @@ function extractSlideDataFromDocument(doc = document) { return runs.filter((r) => r.text.length > 0); }; const isTransparentBg = (color) => !color || color === "transparent" || color === "rgba(0, 0, 0, 0)"; + const authoredStyleValue2 = (element2, property) => { + const authored = element2?.dataset?.pptxAuthoredStyle || ""; + const match = authored.match(new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, "i")); + return match?.[1]?.trim() || ""; + }; + const elementBoxShadow = (element2, computed) => [computed?.boxShadow, element2?.style?.boxShadow, authoredStyleValue2(element2, "box-shadow")].find((value2) => value2 && value2 !== "none") || "none"; + const elementBackgroundColor = (element2, computed) => { + if (!isTransparentBg(computed.backgroundColor)) return computed.backgroundColor; + return element2.style.backgroundColor || authoredStyleValue2(element2, "background-color") || element2.style.background || authoredStyleValue2(element2, "background") || computed.backgroundColor; + }; const resolveSlideBackground = (rootBody) => { const candidates = [ rootBody, @@ -11411,6 +11521,37 @@ function extractSlideDataFromDocument(doc = document) { } elements.push(entry); }; + const pushCssRingBackdrop = (el, rectPx, rectRadius, ring) => { + if (!ring || !rectPx || !(rectPx.width > 0) || !(rectPx.height > 0)) return; + const spreadIn = pxToInch(ring.spreadPx); + if (!(spreadIn > 0)) return; + const ringRadius = rectRadius === 1 || !Number.isFinite(rectRadius) || rectRadius <= 0 ? rectRadius : rectRadius + spreadIn; + const transparency = Math.round((1 - ring.opacity) * 100); + pushElement({ + type: "shape", + text: "", + rewrite: "css_box_shadow_ring", + position: { + x: pxToInch(rectPx.left) - spreadIn, + y: pxToInch(rectPx.top) - spreadIn, + w: pxToInch(rectPx.width) + spreadIn * 2, + h: pxToInch(rectPx.height) + spreadIn * 2 + }, + shape: { + fill: ring.color, + transparency: Number.isFinite(transparency) ? transparency : null, + line: null, + rectRadius: ringRadius || 0, + shadow: null + } + }, el); + addDiagnostic( + "rewrite", + "css_box_shadow_ring", + "CSS ring box-shadow was rewritten as a concentric editable shape.", + el + ); + }; const resolveListBulletColor = (ul, liElements, textHex) => { const ulComputed = view.getComputedStyle(ul); const listColor = ulComputed.listStyleColor; @@ -11433,16 +11574,15 @@ function extractSlideDataFromDocument(doc = document) { const bgResolved = resolveSlideBackground(body); if (bgResolved.gradient) { addDiagnostic( - "fallback", + "rewrite", "css_gradient", - "CSS gradient requires fallback rendering; native gradient mapping is not available.", + "CSS gradient will be rewritten as editable solid strips.", bgResolved.element || body ); } const background = bgResolved.gradient ? { type: "color", value: "FFFFFF" } : { type: bgResolved.type, ...bgResolved.path ? { path: bgResolved.path } : { value: bgResolved.value } }; const slideBackdropHex = background.value || "0E0E12"; const elements = []; - const slideDataFallbackLayers = []; const placeholders = []; const textTags = ["P", "H1", "H2", "H3", "H4", "H5", "H6", "UL", "OL", "LI"]; const genericInlineTextTags = /* @__PURE__ */ new Set([ @@ -11483,8 +11623,9 @@ function extractSlideDataFromDocument(doc = document) { return !Array.from(el.children).some((child) => containerTags.has(child.tagName)); }; const resolveTextAlign = (computed) => { - if (computed.textAlign && !["start", "auto"].includes(computed.textAlign)) { - return computed.textAlign === "end" ? "right" : computed.textAlign; + if (computed.textAlign === "end") return "right"; + if (["left", "center", "right", "justify"].includes(computed.textAlign)) { + return computed.textAlign; } if (computed.display.includes("flex")) { if (computed.justifyContent === "center") return "center"; @@ -11512,12 +11653,13 @@ function extractSlideDataFromDocument(doc = document) { }; const resolveLineSpacing = (computed) => { const lh = computed.lineHeight; + const fontSize = pxToPoints(computed.fontSize) || 12; if (!lh || lh === "normal") { - return pxToPoints(computed.fontSize) * 1.2; + return fontSize * 1.2; } const parsed = parseFloat(lh); - if (Number.isNaN(parsed)) return pxToPoints(computed.fontSize) * 1.2; - return pxToPoints(lh); + if (Number.isNaN(parsed)) return fontSize * 1.2; + return pxToPoints(lh) || fontSize * 1.2; }; const emitTextElement = (el, type = el.tagName.toLowerCase(), exactFrame = false, rectOverride = null) => { const rect = rectOverride || rectFor(el); @@ -11525,7 +11667,7 @@ function extractSlideDataFromDocument(doc = document) { if (rect.width === 0 || rect.height === 0 || !text2) return false; if (type !== "text" && el.tagName !== "LI" && /^[•●○▪‣·▸◆◇■□]\s/u.test(text2.trimStart())) { addDiagnostic( - "fallback", + "blocking", "manual_bullet_unrepaired", `Text element <${el.tagName.toLowerCase()}> still starts with a manual bullet; exporting as editable text.`, el @@ -11543,10 +11685,10 @@ function extractSlideDataFromDocument(doc = document) { const padR = parseFloat(computed.paddingRight) || 0; const padT = parseFloat(computed.paddingTop) || 0; const padB = parseFloat(computed.paddingBottom) || 0; - const textInset = [padL, padR, padT, padB].some((v) => v > 0) ? [pxToInch(padL), pxToInch(padR), pxToInch(padB), pxToInch(padT)] : 0; + const textInset = [padT, padR, padB, padL].some((v) => v > 0) ? [pxToInch(padT), pxToInch(padR), pxToInch(padB), pxToInch(padL)] : 0; const baseStyle = { - fontSize: pxToPoints(computed.fontSize), - fontFace: computed.fontFamily.split(",")[0].replace(/['"]/g, "").trim(), + fontSize: pxToPoints(computed.fontSize) || 12, + fontFace: computed.fontFamily.split(",")[0].replace(/['"]/g, "").trim() || "Arial", color: resolveTextColor(computed, el), align: resolveTextAlign(computed), valign: resolveVerticalAlign(computed, rect), @@ -11555,8 +11697,8 @@ function extractSlideDataFromDocument(doc = document) { paraSpaceAfter: 0, margin: textInset }; - const transparency2 = extractAlpha(computed.color); - if (transparency2 !== null) baseStyle.transparency = transparency2; + const transparency = extractAlpha(computed.color); + if (transparency !== null) baseStyle.transparency = transparency; if (rotation !== null) baseStyle.rotate = rotation; if (textDirection !== null) baseStyle.vert = textDirection; const hasFormatting = el.querySelector("b, i, u, strong, em, span, small, label, a, code, mark, sub, sup, br"); @@ -11622,6 +11764,13 @@ function extractSlideDataFromDocument(doc = document) { const parsed = parseFloat(String(value2 || "")); return Number.isFinite(parsed) ? parsed : fallback; }; + const svgDashStyle = (value2) => { + const values2 = String(value2 || "").match(/[-+]?(?:\d*\.)?\d+/g)?.map(Number).filter((item) => Number.isFinite(item) && item >= 0) || []; + if (!values2.length) return null; + if (values2.length >= 4 && values2[2] <= values2[0] * 0.5) return "dashDot"; + if (values2.length >= 2 && values2[0] <= values2[1] * 0.5) return "dot"; + return "dash"; + }; const identityMatrix = () => [1, 0, 0, 1, 0, 0]; const multiplyMatrix = (left, right) => [ left[0] * right[0] + left[2] * right[1], @@ -11775,74 +11924,6 @@ function extractSlideDataFromDocument(doc = document) { } return points; }; - const svgStyleProperties = [ - "fill", - "fill-opacity", - "fill-rule", - "stroke", - "stroke-width", - "stroke-opacity", - "stroke-linecap", - "stroke-linejoin", - "stroke-dasharray", - "stroke-dashoffset", - "opacity", - "color", - "vector-effect" - ]; - const resolvedSvgStyle = (element2, property) => { - let current = element2; - while (current) { - const value2 = String( - view.getComputedStyle(current).getPropertyValue(property) || "" - ).trim(); - if (value2 && !["inherit", "unset", "initial"].includes(value2)) return value2; - if (["opacity", "vector-effect"].includes(property)) break; - current = current.parentElement; - } - return ""; - }; - const inlineComputedSvgStyles = (original, clone2) => { - svgStyleProperties.forEach((property) => { - const value2 = resolvedSvgStyle(original, property); - if (value2) clone2.style.setProperty(property, value2); - }); - const originalChildren = [...original.children]; - const cloneChildren = [...clone2.children]; - originalChildren.forEach((child, index) => { - if (cloneChildren[index]) inlineComputedSvgStyles(child, cloneChildren[index]); - }); - }; - const serializeSvgVisual = (svg, node) => { - const rootClone = svg.cloneNode(false); - rootClone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); - if (!rootClone.getAttribute("width")) rootClone.setAttribute("width", "100%"); - if (!rootClone.getAttribute("height")) rootClone.setAttribute("height", "100%"); - inlineComputedSvgStyles(svg, rootClone); - [...svg.querySelectorAll("defs")].forEach((defs) => { - const defsClone = defs.cloneNode(true); - inlineComputedSvgStyles(defs, defsClone); - rootClone.appendChild(defsClone); - }); - const ancestors = []; - let current = node.parentElement; - while (current && current !== svg) { - ancestors.unshift(current); - current = current.parentElement; - } - let targetParent = rootClone; - ancestors.forEach((ancestor) => { - const ancestorClone = ancestor.cloneNode(false); - inlineComputedSvgStyles(ancestor, ancestorClone); - targetParent.appendChild(ancestorClone); - targetParent = ancestorClone; - }); - const targetClone = node.cloneNode(true); - inlineComputedSvgStyles(node, targetClone); - targetClone.querySelectorAll?.("text").forEach((text2) => text2.remove()); - targetParent.appendChild(targetClone); - return `data:image/svg+xml,${encodeURIComponent(rootClone.outerHTML)}`; - }; const boundsOfPoints = (points) => { const xs = points.map((point) => point.x); const ys = points.map((point) => point.y); @@ -11853,30 +11934,23 @@ function extractSlideDataFromDocument(doc = document) { height: Math.max(...ys) - Math.min(...ys) }; }; - const isDiamondPoints = (points) => { - if (points.length !== 4) return false; - const bounds = boundsOfPoints(points); - const cx2 = bounds.left + bounds.width / 2; - const cy2 = bounds.top + bounds.height / 2; - const tolerance = Math.max(bounds.width, bounds.height) * 0.08 + 0.01; - return points.every((point) => Math.abs(point.x - cx2) <= tolerance || Math.abs(point.y - cy2) <= tolerance); - }; const emitNativeSvg = (svg) => { const svgRect = rectFor(svg); if (svgRect.width <= 0 || svgRect.height <= 0) return; - const viewBox = String(svg.getAttribute("viewBox") || `0 0 ${svgRect.width} ${svgRect.height}`).trim().split(/[\s,]+/).map(Number); - const [vbX = 0, vbY = 0, vbW = svgRect.width, vbH = svgRect.height] = viewBox; - const xScale = svgRect.width / (vbW || svgRect.width); - const yScale = svgRect.height / (vbH || svgRect.height); - const fallbackLayers = slideDataFallbackLayers; - const toSlidePoint = (point) => ({ - x: svgRect.left + (point.x - vbX) * xScale, - y: svgRect.top + (point.y - vbY) * yScale - }); + const viewportMapper = createSvgViewportMapper(svg, svgRect); + const { xScale, yScale } = viewportMapper; + const toSlidePoint = (point) => viewportMapper.map(point); const pushLine = (start, end, node, stroke2, width, coordinateSpace = "viewBox") => { const toLinePoint = (point) => coordinateSpace === "viewport" ? { x: svgRect.left + point.x, y: svgRect.top + point.y } : toSlidePoint(point); const first = toLinePoint(start); const second = toLinePoint(end); + const dashArray = node.getAttribute?.("stroke-dasharray") || view.getComputedStyle(node).strokeDasharray; + const dash = svgDashStyle(dashArray); + const lineStyle = { + color: stroke2 || "000000", + width, + ...dash ? { dash } : {} + }; pushElement({ type: "line", kind: "native", @@ -11884,65 +11958,23 @@ function extractSlideDataFromDocument(doc = document) { y1: pxToInch(first.y), x2: pxToInch(second.x), y2: pxToInch(second.y), - color: stroke2 || "000000", - width + color: lineStyle.color, + width: lineStyle.width, + style: lineStyle }, node); }; - const hasBrowserOnlySvg = Boolean( - svg.querySelector("filter,mask,foreignObject,use,pattern,textPath,clipPath,image") - ); - const canSerializeSvgVisual = (node) => !svg.querySelector("script") && !/(?:href|src)\s*=\s*["']\s*(?:https?:)?\/\//i.test(node.outerHTML || ""); - const pushSvgImageLayer = (node, code, message) => { - if (!canSerializeSvgVisual(node)) { - addDiagnostic( - "fallback", - "complex_svg_raster", - "Complex SVG cannot be safely serialized and requires local browser raster rendering.", - svg - ); - return; - } - const paintMetadata = nextPaintMetadata(node); - fallbackLayers.push({ - sourceId: paintMetadata.sourceId, - zIndex: readZIndex(node), - paintOrder: paintMetadata.paintOrder, - subOrder: paintMetadata.subOrder, - kind: "svg-image", - captureStrategy: "local-svg", - bbox: { - x: pxToInch(svgRect.left), - y: pxToInch(svgRect.top), - w: pxToInch(svgRect.width), - h: pxToInch(svgRect.height) - }, - data: serializeSvgVisual(svg, node), - diagnostics: [{ - severity: "fallback", - code, - message, - sourceId: node.dataset?.pptxSourceId || node.id || null - }] - }); - }; svg.querySelectorAll("rect,circle,ellipse,line,polyline,polygon,text,path").forEach((node) => { const tag = node.tagName.toLowerCase(); if (tag === "path") { - if (!hasBrowserOnlySvg) { - pushSvgImageLayer( - node, - "complex_svg_vector", - "Complex SVG geometry is preserved as a local movable vector image." - ); - } return; } const transform = transformForSvgNode(node, svg); if (!transform.reliable) { - pushSvgImageLayer( - node, - "svg_transform_vector", - "SVG transform cannot be represented reliably as a native shape; preserving it as SVG." + addDiagnostic( + "blocking", + "svg_transform_unsupported", + "SVG transform cannot be represented reliably as an editable native shape.", + node ); return; } @@ -11987,6 +12019,13 @@ function extractSlideDataFromDocument(doc = document) { ]); common.position = { x: pxToInch(layoutBounds.left), y: pxToInch(layoutBounds.top), w: pxToInch(layoutBounds.width), h: pxToInch(layoutBounds.height) }; common.bbox = { x: pxToInch(bounds.left), y: pxToInch(bounds.top), w: pxToInch(bounds.width), h: pxToInch(bounds.height) }; + const rx = svgNumber(node.getAttribute("rx")); + const ry = svgNumber(node.getAttribute("ry")); + if (rx > 0 || ry > 0) { + common.svgType = "roundRect"; + common.shape.rectRadius = pxToInch(Math.max(rx, ry)); + common.shape.radius = pxToInch(rx * xScale); + } if (transform.rotation) common.shape.rotate = transform.rotation; } else if (tag === "circle" || tag === "ellipse") { const rx = tag === "circle" ? svgNumber(node.getAttribute("r")) : svgNumber(node.getAttribute("rx")); @@ -12023,15 +12062,64 @@ function extractSlideDataFromDocument(doc = document) { common.text = node.textContent || ""; const fontSize = svgNumber(node.getAttribute("font-size"), 16); const origin = mapPoint(node.getAttribute("x"), svgNumber(node.getAttribute("y")) - fontSize); - common.position = { x: pxToInch(origin.x), y: pxToInch(origin.y), w: pxToInch(Math.max(1, svgRect.width)), h: pxToInch(Math.max(1, fontSize * yScale * 1.3)) }; - common.style = { fontSize: svgNumber(node.getAttribute("font-size"), 16) * 0.75, fontFace: "Arial", color: fill2 || "000000", align: "left" }; + const textAnchor = node.getAttribute("text-anchor") || view.getComputedStyle(node).textAnchor || "start"; + let measuredBox = null; + try { + const bbox = node.getBBox?.(); + if (bbox && [bbox.x, bbox.y, bbox.width, bbox.height].every(Number.isFinite) && bbox.width > 0 && bbox.height > 0) { + const bounds = boundsOfPoints([ + mapPoint(bbox.x, bbox.y), + mapPoint(bbox.x + bbox.width, bbox.y + bbox.height) + ]); + measuredBox = { + x: bounds.left, + y: bounds.top, + width: bounds.width, + height: bounds.height + }; + } + } catch { + } + let userWidth = 0; + if (!measuredBox) { + try { + userWidth = Number(node.getComputedTextLength?.()) || 0; + } catch { + userWidth = 0; + } + if (!(userWidth > 0)) { + userWidth = [...common.text].reduce((sum2, character) => sum2 + (/[\u1100-\u11ff\u2e80-\u9fff\uf900-\ufaff\uff01-\uff60\uffe0-\uffe6]/u.test(character) ? fontSize : fontSize * 0.6), 0); + } + const textWidth = Math.max(1, userWidth * xScale); + const anchorOffset = textAnchor === "middle" ? textWidth / 2 : textAnchor === "end" ? textWidth : 0; + measuredBox = { + x: Math.max(0, origin.x - anchorOffset), + y: origin.y, + width: textWidth, + height: Math.max(1, fontSize * yScale * 1.3) + }; + } + common.position = { + x: pxToInch(measuredBox.x), + y: pxToInch(measuredBox.y), + w: pxToInch(measuredBox.width), + h: pxToInch(measuredBox.height) + }; + common.style = { + fontSize: svgNumber(node.getAttribute("font-size"), 16) * 0.75, + fontFace: "Arial", + color: fill2 || "000000", + align: { start: "left", middle: "center", end: "right" }[textAnchor] || "left" + }; if (transform.rotation) common.style.rotate = transform.rotation; } else { - const points = parseSvgPoints(node.getAttribute("points")).map((point) => transformPoint(point, transform.matrix)); - if (tag === "polygon" && (points.length === 3 || isDiamondPoints(points))) { + const rawPoints = parseSvgPoints(node.getAttribute("points")); + const points = rawPoints.map((point) => transformPoint(point, transform.matrix)); + const preset = tag === "polygon" ? classifySvgPresetPolygon(points) : null; + if (preset) { const slidePoints = points.map(transformedToSlidePoint); const bounds = boundsOfPoints(slidePoints); - common.svgType = points.length === 3 ? "triangle" : "diamond"; + common.svgType = preset.shapeType; common.position = { x: pxToInch(bounds.left), y: pxToInch(bounds.top), @@ -12039,7 +12127,7 @@ function extractSlideDataFromDocument(doc = document) { h: pxToInch(bounds.height) }; common.bbox = { ...common.position }; - if (transform.rotation) common.shape.rotate = transform.rotation; + common.shape.rotate = preset.rotate; } else { const closed = tag === "polygon" && points.length > 2 ? [...points, points[0]] : points; for (let index = 0; index + 1 < closed.length; index += 1) { @@ -12052,29 +12140,6 @@ function extractSlideDataFromDocument(doc = document) { transform.coordinateSpace ); } - if (tag === "polygon" && fill2 && points.length > 2) { - const sourceId = node.dataset?.pptxSourceId || node.id || null; - fallbackLayers.push({ - sourceId, - zIndex: readZIndex(node), - paintOrder: domPaintOrder.get(sourceId) ?? unmappedPaintOrder++, - subOrder: -1, - kind: "svg-image", - bbox: { - x: pxToInch(svgRect.left), - y: pxToInch(svgRect.top), - w: pxToInch(svgRect.width), - h: pxToInch(svgRect.height) - }, - data: serializeSvgVisual(svg, node), - diagnostics: [{ - severity: "fallback", - code: "svg_polygon_fill", - message: "Polygon fill is preserved as a local SVG layer over an editable outline.", - sourceId: node.dataset?.pptxSourceId || node.id || null - }] - }); - } return; } } @@ -12097,9 +12162,9 @@ function extractSlideDataFromDocument(doc = document) { } if (el.querySelector('[data-pptx-merge="true"]')) { addDiagnostic( - "fallback", + "blocking", "nested_merge_container", - "Nested data-pptx-merge containers require fallback handling.", + "Nested data-pptx-merge containers cannot be represented as editable text.", el ); processed.add(el); @@ -12108,9 +12173,9 @@ function extractSlideDataFromDocument(doc = document) { const mergeComputed = view.getComputedStyle(el); if (mergeComputed.backgroundImage && mergeComputed.backgroundImage !== "none") { addDiagnostic( - "fallback", + "blocking", "merge_background_image", - "Background image on data-pptx-merge requires fallback rendering.", + "Background image on data-pptx-merge cannot be represented as editable objects.", el ); } @@ -12124,6 +12189,22 @@ function extractSlideDataFromDocument(doc = document) { const mHasBorder = mBorders.some((b) => b > 0); const mHasUniformBorder = mHasBorder && mBorders.every((b) => b === mBorders[0]); if (mHasBg || mHasUniformBorder) { + const mergeRectRadius = (() => { + const radius = mergeComputed.borderRadius; + const radiusValue = parseFloat(radius); + if (radiusValue === 0) return 0; + if (radius.includes("%")) { + if (radiusValue >= 50) return 1; + const minDim = Math.min(containerRect.width, containerRect.height); + return radiusValue / 100 * pxToInch(minDim); + } + if (radius.includes("pt")) return radiusValue / 72; + return radiusValue / PX_PER_IN2; + })(); + const mergeShadowEffect = parseBoxShadow(elementBoxShadow(el, mergeComputed)); + if (mergeShadowEffect?.mode === "ring") { + pushCssRingBackdrop(el, containerRect, mergeRectRadius, mergeShadowEffect.ring); + } pushElement({ type: "shape", text: "", @@ -12140,26 +12221,15 @@ function extractSlideDataFromDocument(doc = document) { color: rgbToHex2(mergeComputed.borderColor), width: pxToPoints(mergeComputed.borderWidth) } : null, - rectRadius: (() => { - const radius = mergeComputed.borderRadius; - const radiusValue = parseFloat(radius); - if (radiusValue === 0) return 0; - if (radius.includes("%")) { - if (radiusValue >= 50) return 1; - const minDim = Math.min(containerRect.width, containerRect.height); - return radiusValue / 100 * pxToInch(minDim); - } - if (radius.includes("pt")) return radiusValue / 72; - return radiusValue / PX_PER_IN2; - })(), - shadow: parseBoxShadow(mergeComputed.boxShadow) + rectRadius: mergeRectRadius, + shadow: outerShadowOf(mergeShadowEffect) } }, el); } const textDescendants = Array.from(el.querySelectorAll("p, h1, h2, h3, h4, h5, h6")); if (textDescendants.length === 0) { addDiagnostic( - "fallback", + "blocking", "empty_merge_container", "data-pptx-merge container has no semantic text children.", el @@ -12173,14 +12243,14 @@ function extractSlideDataFromDocument(doc = document) { const mPadT = pxToInch(parseFloat(mergeComputed.paddingTop) || 0); const mPadB = pxToInch(parseFloat(mergeComputed.paddingBottom) || 0); const baseStyle = { - fontSize: pxToPoints(firstComputed.fontSize), - fontFace: firstComputed.fontFamily.split(",")[0].replace(/['"]/g, "").trim(), + fontSize: pxToPoints(firstComputed.fontSize) || 12, + fontFace: firstComputed.fontFamily.split(",")[0].replace(/['"]/g, "").trim() || "Arial", color: rgbToHex2(firstComputed.color), - align: firstComputed.textAlign === "start" ? "left" : firstComputed.textAlign, + align: !firstComputed.textAlign || firstComputed.textAlign === "start" ? "left" : firstComputed.textAlign, lineSpacing: resolveLineSpacing(firstComputed), paraSpaceBefore: 0, paraSpaceAfter: 0, - margin: [mPadL, mPadR, mPadB, mPadT].some((v) => v > 0) ? [mPadL, mPadR, mPadB, mPadT] : 0 + margin: [mPadT, mPadR, mPadB, mPadL].some((v) => v > 0) ? [mPadT, mPadR, mPadB, mPadL] : 0 }; const baseTransparency = extractAlpha(firstComputed.color); if (baseTransparency !== null) baseStyle.transparency = baseTransparency; @@ -12189,8 +12259,8 @@ function extractSlideDataFromDocument(doc = document) { const isLast = idx === textDescendants.length - 1; const tComputed = view.getComputedStyle(textEl); const transformStr = tComputed.textTransform; - const elemFontSize = pxToPoints(tComputed.fontSize); - const elemFontFace = tComputed.fontFamily.split(",")[0].replace(/['"]/g, "").trim(); + const elemFontSize = pxToPoints(tComputed.fontSize) || baseStyle.fontSize; + const elemFontFace = tComputed.fontFamily.split(",")[0].replace(/['"]/g, "").trim() || baseStyle.fontFace; const elemColor = rgbToHex2(tComputed.color); const elemBold = tComputed.fontWeight === "bold" || parseInt(tComputed.fontWeight) >= 600; const elemItalic = tComputed.fontStyle === "italic"; @@ -12260,6 +12330,20 @@ function extractSlideDataFromDocument(doc = document) { if (solid.fill || hasUniformBorder) { const radius = computed.borderRadius; const radiusValue = parseFloat(radius); + const decoRectRadius = (() => { + if (!radiusValue) return 0; + if (radius.includes("%")) { + if (radiusValue >= 50) return 1; + const minDim = Math.min(decoRect.width, decoRect.height); + return radiusValue / 100 * pxToInch(minDim); + } + if (radius.includes("pt")) return radiusValue / 72; + return radiusValue / PX_PER_IN2; + })(); + const decoShadowEffect = parseBoxShadow(elementBoxShadow(el, computed)); + if (decoShadowEffect?.mode === "ring") { + pushCssRingBackdrop(el, decoRect, decoRectRadius, decoShadowEffect.ring); + } pushElement({ type: "shape", text: "", @@ -12276,17 +12360,8 @@ function extractSlideDataFromDocument(doc = document) { color: rgbToHex2(computed.borderColor), width: pxToPoints(computed.borderWidth) } : null, - rectRadius: (() => { - if (!radiusValue) return 0; - if (radius.includes("%")) { - if (radiusValue >= 50) return 1; - const minDim = Math.min(decoRect.width, decoRect.height); - return radiusValue / 100 * pxToInch(minDim); - } - if (radius.includes("pt")) return radiusValue / 72; - return radiusValue / PX_PER_IN2; - })(), - shadow: parseBoxShadow(computed.boxShadow) + rectRadius: decoRectRadius, + shadow: outerShadowOf(decoShadowEffect) } }, el); } @@ -12297,7 +12372,7 @@ function extractSlideDataFromDocument(doc = document) { const rect = rectFor(el); if (rect.width === 0 || rect.height === 0) { addDiagnostic( - "fallback", + "blocking", "unmeasurable_placeholder", `Placeholder "${el.id || "unnamed"}" has ${rect.width === 0 ? "width: 0" : "height: 0"}.`, el @@ -12340,11 +12415,21 @@ function extractSlideDataFromDocument(doc = document) { color: computed[`border${side}Color`] })); const opaqueSides = sides.filter((side) => side.width > 0 && !isTransparentBg(side.color)); - if (isZeroBox && opaqueSides.length === 1 && sides.filter((side) => side.width > 0).length >= 3) { - const active = opaqueSides[0]; + const transparentSides = sides.filter((side) => side.width > 0 && isTransparentBg(side.color)); + const perpendicularSides = { + Bottom: ["Left", "Right"], + Left: ["Top", "Bottom"], + Top: ["Left", "Right"], + Right: ["Top", "Bottom"] + }; + const active = opaqueSides[0]; + const requiredTransparentSides = active ? perpendicularSides[active.side] : []; + const hasNoVisibleContent = !el.textContent.replace(/\s+/g, "").length && el.children.length === 0; + const hasTransparentBackground = isTransparentBg(elementBackgroundColor(el, computed)); + const formsDirectionalTriangle = requiredTransparentSides.length === 2 && requiredTransparentSides.every((side) => transparentSides.some((candidate) => candidate.side === side)); + const isSemanticCssTriangle = hasNoVisibleContent && hasTransparentBackground && opaqueSides.length === 1 && transparentSides.length >= 2 && formsDirectionalTriangle; + if (isSemanticCssTriangle) { const rect = rectFor(el); - const horizontal = sides.find((side) => side.side === "Left").width + sides.find((side) => side.side === "Right").width; - const vertical = sides.find((side) => side.side === "Top").width + sides.find((side) => side.side === "Bottom").width; const rotations = { Bottom: 0, Left: 90, Top: 180, Right: 270 }; pushElement({ type: "svg-shape", @@ -12352,10 +12437,10 @@ function extractSlideDataFromDocument(doc = document) { kind: "native", text: "", position: { - x: pxToInch(rect.left - sides.find((side) => side.side === "Left").width), - y: pxToInch(rect.top - sides.find((side) => side.side === "Top").width), - w: pxToInch(Math.max(1, horizontal)), - h: pxToInch(Math.max(1, vertical)) + x: pxToInch(rect.left), + y: pxToInch(rect.top), + w: pxToInch(Math.max(1, rect.width)), + h: pxToInch(Math.max(1, rect.height)) }, shape: { fill: rgbToHex2(active.color), @@ -12372,13 +12457,14 @@ function extractSlideDataFromDocument(doc = document) { const isContainer = containerTags.has(el.tagName); if (isContainer) { const computed = view.getComputedStyle(el); - const hasBg = computed.backgroundColor && computed.backgroundColor !== "rgba(0, 0, 0, 0)"; + const backgroundColor = elementBackgroundColor(el, computed); + const hasBg = !isTransparentBg(backgroundColor); const bgImage = computed.backgroundImage; if (bgImage && bgImage !== "none") { addDiagnostic( - "fallback", + "blocking", "container_background_image", - "Container background image requires fallback rendering.", + "Container background image cannot be represented as editable objects.", el ); } @@ -12457,24 +12543,47 @@ function extractSlideDataFromDocument(doc = document) { return; } if (rect.width > 0 && rect.height > 0) { - const shadow = parseBoxShadow(computed.boxShadow); + const shadowEffect = parseBoxShadow(elementBoxShadow(el, computed)); + const rotation = getRotation(computed.transform) || getRotation(el.style?.transform) || getRotation(authoredStyleValue2(el, "transform")); + const placed = getPositionAndSize(el, rect, rotation); if (hasBg || hasUniformBorder) { - const solid = hasBg ? resolveSolidFill(computed.backgroundColor, slideBackdropHex) : { fill: null, transparency: null }; + const solid = hasBg ? resolveSolidFill(backgroundColor, slideBackdropHex) : { fill: null, transparency: null }; let fillHex = solid.fill; let fillTransparency = solid.transparency; if (!fillHex && hasUniformBorder) { fillHex = rgbToHex2(computed.borderColor) || "2A2A30"; fillTransparency = fillTransparency ?? 88; } + const shapeRectRadius = (() => { + const radius = computed.borderRadius || el.style.borderRadius || authoredStyleValue2(el, "border-radius"); + const radiusValue = parseFloat(radius); + if (radiusValue === 0) return 0; + if (radius.includes("%")) { + if (radiusValue >= 50) return 1; + const minDim = Math.min(placed.w, placed.h); + return radiusValue / 100 * pxToInch(minDim); + } + if (radius.includes("pt")) return radiusValue / 72; + return radiusValue / PX_PER_IN2; + })(); + const placedRect = { + left: placed.x, + top: placed.y, + width: placed.w, + height: placed.h + }; + if (shadowEffect?.mode === "ring") { + pushCssRingBackdrop(el, placedRect, shapeRectRadius, shadowEffect.ring); + } pushElement({ type: "shape", text: "", // Shape only - child text elements render on top position: { - x: pxToInch(rect.left), - y: pxToInch(rect.top), - w: pxToInch(rect.width), - h: pxToInch(rect.height) + x: pxToInch(placed.x), + y: pxToInch(placed.y), + w: pxToInch(placed.w), + h: pxToInch(placed.h) }, shape: { fill: fillHex, @@ -12483,23 +12592,9 @@ function extractSlideDataFromDocument(doc = document) { color: rgbToHex2(computed.borderColor), width: pxToPoints(computed.borderWidth) } : null, - // Convert border-radius to rectRadius (in inches) - // % values: 50%+ = circle (1), <50% = percentage of min dimension - // pt values: divide by 72 (72pt = 1 inch) - // px values: divide by 96 (96px = 1 inch) - rectRadius: (() => { - const radius = computed.borderRadius; - const radiusValue = parseFloat(radius); - if (radiusValue === 0) return 0; - if (radius.includes("%")) { - if (radiusValue >= 50) return 1; - const minDim = Math.min(rect.width, rect.height); - return radiusValue / 100 * pxToInch(minDim); - } - if (radius.includes("pt")) return radiusValue / 72; - return radiusValue / PX_PER_IN2; - })(), - shadow + rectRadius: shapeRectRadius, + shadow: outerShadowOf(shadowEffect), + ...rotation != null ? { rotate: rotation } : {} } }, el); } @@ -12559,17 +12654,17 @@ function extractSlideDataFromDocument(doc = document) { h: pxToInch(listFrame.h) }, style: { - fontSize: pxToPoints(computed.fontSize), - fontFace: computed.fontFamily.split(",")[0].replace(/['"]/g, "").trim(), + fontSize: pxToPoints(computed.fontSize) || 12, + fontFace: computed.fontFamily.split(",")[0].replace(/['"]/g, "").trim() || "Arial", color: textHex, bulletColor, transparency: extractAlpha(computed.color), - align: computed.textAlign === "start" ? "left" : computed.textAlign, + align: resolveTextAlign(computed), lineSpacing: resolveLineSpacing(computed), paraSpaceBefore: 0, - paraSpaceAfter: pxToPoints(computed.marginBottom), - // PptxGenJS margin array is [left, right, bottom, top] - margin: [marginLeft, ulPadR, ulPadB, ulPadT] + paraSpaceAfter: pxToPoints(computed.marginBottom) || 0, + // PptxGenJS margin array is [top, right, bottom, left]. + margin: [ulPadT, ulPadR, ulPadB, marginLeft / 72] } }, el); liElements.forEach((li) => processed.add(li)); @@ -12592,515 +12687,3092 @@ function extractSlideDataFromDocument(doc = document) { const computed = view.getComputedStyle(element2); const filter = String(computed.filter || element2.style?.filter || ""); if (filter && filter !== "none") { - addDiagnostic("fallback", "css_filter", "CSS filter requires fallback rendering.", element2); + addDiagnostic("blocking", "css_filter", "CSS filter cannot be represented as editable objects.", element2); } if (String(element2.tagName).toUpperCase() === "SVG") { if (element2.querySelector("filter,mask,foreignObject,use,pattern,textPath,clipPath,image")) { addDiagnostic( - "fallback", - "complex_svg_raster", - "SVG filter, mask, or foreignObject requires local browser raster rendering.", + "blocking", + "complex_svg_unsupported", + "SVG filter, mask, or foreignObject cannot be represented as editable objects.", element2 ); } else if (element2.querySelector("path")) { addDiagnostic( - "fallback", - "complex_svg_vector", - "Complex SVG geometry is preserved as a local SVG image.", + "rewrite", + "svg_path_rewrite", + "SVG path geometry will be rewritten as editable line segments.", element2 ); } } }); const blockingErrors = diagnostics.filter((diagnostic) => diagnostic.severity === "blocking").map((diagnostic) => diagnostic.message); - slideDataFallbackLayers.sort((a, b) => { - const z = (a.zIndex ?? 0) - (b.zIndex ?? 0); - if (z !== 0) return z; - const order = (a.paintOrder ?? 0) - (b.paintOrder ?? 0); - if (order !== 0) return order; - const sub = (a.subOrder ?? 0) - (b.subOrder ?? 0); - if (sub !== 0) return sub; - return (a.stableOrder ?? 0) - (b.stableOrder ?? 0); - }); return { background, elements, - fallbackLayers: slideDataFallbackLayers, placeholders, diagnostics, errors: blockingErrors }; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/fallback-layer-render.js -var EXPORT_WIDTH = 1280; -var EXPORT_HEIGHT = 720; -var LOCAL_RASTER_CODES = /* @__PURE__ */ new Set([ - "css_gradient", - "css_filter", - "computed_gradient", - "generated_content", - "container_background_image", - "merge_background_image", - "complex_svg_raster" +// src/editable-slide-scene.js +var EDITABLE_NODE_TYPES = /* @__PURE__ */ new Set([ + "text", + "shape", + "line", + "table", + "image" ]); -function rasterLayerZIndex(element2, view) { - let current = element2; - while (current) { - const raw = view.getComputedStyle(current).zIndex; - if (raw && raw !== "auto") { - const parsed = parseInt(raw, 10); - if (Number.isFinite(parsed)) return parsed; +var EDITABLE_SHAPE_TYPES = /* @__PURE__ */ new Set([ + "rect", + "roundRect", + "ellipse", + "triangle", + "diamond", + "rightArrow", + "leftArrow", + "upArrow", + "downArrow", + "chevron", + "parallelogram", + "trapezoid", + "hexagon" +]); +var FORBIDDEN_FALLBACK_FIELDS = [ + "captureStrategy", + "phase", + "canvas", + "sourceIds", + "buildFailure", + "diagnostics", + "html", + "buildHtml", + "rasterHtml", + "rasterBase64", + "rasterOnly", + "renderRaster" +]; +var LEGACY_FALLBACK_KINDS = /* @__PURE__ */ new Set([ + "raster", + "svg-image", + "fallback", + "page-visual", + "full-page" +]); +var EditableExportError = class extends Error { + constructor(diagnostic) { + const blockingDiagnostic = { + severity: "blocking", + kind: "blocking", + ...diagnostic + }; + super(`[${blockingDiagnostic.code}] ${blockingDiagnostic.message}`); + this.name = "EditableExportError"; + this.diagnostic = blockingDiagnostic; + this.diagnostics = [blockingDiagnostic]; + this.slideNumber = blockingDiagnostic.slideNumber; + this.sourceId = blockingDiagnostic.sourceId; + this.code = blockingDiagnostic.code; + if (blockingDiagnostic.cause !== void 0) this.cause = blockingDiagnostic.cause; + } +}; +function sceneSourceId(slideNumber) { + return Number.isInteger(slideNumber) && slideNumber > 0 ? `slide-${slideNumber}` : "slide"; +} +function editableSceneError(scene, code, message, sourceId = null) { + const slideNumber = scene?.slideNumber ?? null; + return new EditableExportError({ + slideNumber, + sourceId: sourceId || sceneSourceId(slideNumber), + code, + message + }); +} +function isObject(value2) { + return Boolean(value2) && !Array.isArray(value2) && typeof value2 === "object"; +} +function hasOnlyFields(value2, fields) { + return isObject(value2) && Object.keys(value2).every((field) => fields.has(field)); +} +function isColor(value2) { + return typeof value2 === "string" && /^[0-9A-F]{6}$/i.test(value2); +} +function isOptionalColor(value2) { + return value2 === void 0 || value2 === null || isColor(value2); +} +function isNonNegativeFinite(value2) { + return Number.isFinite(value2) && value2 >= 0; +} +function isPercentage(value2) { + return Number.isFinite(value2) && value2 >= 0 && value2 <= 100; +} +var TEXT_STYLE_FIELDS = /* @__PURE__ */ new Set([ + "fontSize", + "fontFace", + "color", + "bold", + "italic", + "underline", + "align", + "valign", + "lineSpacing", + "paraSpaceBefore", + "paraSpaceAfter", + "margin", + "rotate", + "vert", + "transparency", + "bulletColor" +]); +var TEXT_RUN_OPTION_FIELDS = /* @__PURE__ */ new Set([ + "fontSize", + "fontFace", + "color", + "bold", + "italic", + "underline", + "breakLine", + "bullet", + "transparency" +]); +var BULLET_FIELDS = /* @__PURE__ */ new Set(["type", "indent"]); +var LINE_STYLE_FIELDS = /* @__PURE__ */ new Set([ + "color", + "width", + "dash", + "dashType", + "transparency" +]); +var SHAPE_STYLE_FIELDS = /* @__PURE__ */ new Set([ + "fill", + "line", + "transparency", + "rotate", + "radius", + "shadow" +]); +var SHADOW_FIELDS = /* @__PURE__ */ new Set(["type", "angle", "blur", "color", "offset", "opacity"]); +var TABLE_NODE_FIELDS = /* @__PURE__ */ new Set([ + "type", + "sourceId", + "x", + "y", + "w", + "h", + "columnWidths", + "rows", + "paintOrder", + "subOrder", + "zIndex" +]); +var TABLE_ROW_FIELDS = /* @__PURE__ */ new Set(["height", "cells"]); +var TABLE_CELL_FIELDS = /* @__PURE__ */ new Set(["text", "style", "rowspan", "colspan"]); +var TABLE_CELL_STYLE_FIELDS = /* @__PURE__ */ new Set([ + "fill", + "border", + "align", + "valign", + "padding", + "fontFamily", + "fontSize", + "fontColor", + "bold" +]); +var TABLE_BORDER_FIELDS = /* @__PURE__ */ new Set(["color", "width"]); +var TABLE_BORDER_SIDES = /* @__PURE__ */ new Set(["top", "right", "bottom", "left"]); +var IMAGE_NODE_FIELDS = /* @__PURE__ */ new Set([ + "type", + "intent", + "sourceId", + "x", + "y", + "w", + "h", + "src", + "data", + "paintOrder", + "subOrder", + "zIndex" +]); +var SCENE_FIELDS = /* @__PURE__ */ new Set(["slideNumber", "width", "height", "nodes"]); +var REWRITE_TYPES = /* @__PURE__ */ new Set(["css_gradient", "svg_path_rewrite", "css_box_shadow_ring"]); +var DASH_TYPES = /* @__PURE__ */ new Set(["dash", "dot", "dashDot"]); +var TEXT_ALIGNS = /* @__PURE__ */ new Set(["left", "center", "right", "justify"]); +var TEXT_VALIGNS = /* @__PURE__ */ new Set(["top", "mid", "bottom"]); +var TEXT_VERTS = /* @__PURE__ */ new Set(["horz", "vert", "vert270", "wordArtVert", "eaVert", "mongolianVert"]); +function hasValidMargin(value2) { + return value2 === void 0 || isNonNegativeFinite(value2) || Array.isArray(value2) && value2.length === 4 && value2.every(isNonNegativeFinite); +} +function hasValidTextStyle(style) { + return hasOnlyFields(style, TEXT_STYLE_FIELDS) && (style.fontSize === void 0 || Number.isFinite(style.fontSize) && style.fontSize > 0) && (style.fontFace === void 0 || typeof style.fontFace === "string" && Boolean(style.fontFace.trim())) && isOptionalColor(style.color) && isOptionalColor(style.bulletColor) && ["bold", "italic"].every((field) => style[field] === void 0 || typeof style[field] === "boolean") && (style.underline === void 0 || typeof style.underline === "boolean" || typeof style.underline === "string") && (style.align === void 0 || TEXT_ALIGNS.has(style.align)) && (style.valign === void 0 || TEXT_VALIGNS.has(style.valign)) && ["lineSpacing", "paraSpaceBefore", "paraSpaceAfter"].every((field) => style[field] === void 0 || isNonNegativeFinite(style[field])) && hasValidMargin(style.margin) && (style.rotate === void 0 || Number.isFinite(style.rotate)) && (style.vert === void 0 || TEXT_VERTS.has(style.vert)) && (style.transparency === void 0 || style.transparency === null || isPercentage(style.transparency)); +} +function hasValidTextRunOptions(options) { + if (options === void 0) return true; + return hasOnlyFields(options, TEXT_RUN_OPTION_FIELDS) && (options.fontSize === void 0 || Number.isFinite(options.fontSize) && options.fontSize > 0) && (options.fontFace === void 0 || typeof options.fontFace === "string" && Boolean(options.fontFace.trim())) && isOptionalColor(options.color) && ["bold", "italic", "breakLine"].every((field) => options[field] === void 0 || typeof options[field] === "boolean") && (options.underline === void 0 || typeof options.underline === "boolean" || typeof options.underline === "string") && (options.transparency === void 0 || isPercentage(options.transparency)) && (options.bullet === void 0 || hasOnlyFields(options.bullet, BULLET_FIELDS) && (options.bullet.type === void 0 || options.bullet.type === "bullet") && (options.bullet.indent === void 0 || isNonNegativeFinite(options.bullet.indent)) && (options.bullet.type === "bullet" || options.bullet.indent !== void 0)); +} +function hasValidTextValue(value2, allowEmpty = false) { + if (!isTextValue(value2, allowEmpty)) return false; + return !Array.isArray(value2) || value2.every((run) => hasValidTextRunOptions(run.options)); +} +function hasValidLineStyle(style, { allowNull = false } = {}) { + if (allowNull && (style === null || style === void 0)) return true; + return hasOnlyFields(style, LINE_STYLE_FIELDS) && isColor(style.color) && (style.width === void 0 || isNonNegativeFinite(style.width)) && (style.dash === void 0 || DASH_TYPES.has(style.dash)) && (style.dashType === void 0 || DASH_TYPES.has(style.dashType)) && (style.dash === void 0 || style.dashType === void 0 || style.dash === style.dashType) && (style.transparency === void 0 || isPercentage(style.transparency)); +} +function hasValidShapeStyle(style, shapeType) { + return hasOnlyFields(style, SHAPE_STYLE_FIELDS) && isOptionalColor(style.fill) && hasValidLineStyle(style.line, { allowNull: true }) && (style.transparency === void 0 || style.transparency === null || isPercentage(style.transparency)) && (style.rotate === void 0 || Number.isFinite(style.rotate)) && (style.radius === void 0 || shapeType === "roundRect" && Number.isFinite(style.radius) && style.radius > 0) && (style.shadow === void 0 || hasValidShadow(style.shadow)); +} +function hasValidShadow(shadow) { + return hasOnlyFields(shadow, SHADOW_FIELDS) && shadow.type === "outer" && Number.isFinite(shadow.angle) && shadow.angle >= 0 && shadow.angle < 360 && isNonNegativeFinite(shadow.blur) && isColor(shadow.color) && isNonNegativeFinite(shadow.offset) && Number.isFinite(shadow.opacity) && shadow.opacity >= 0 && shadow.opacity <= 1; +} +function findForbiddenFallbackMetadata(value2, { + allowDirectData = false, + skipKeys = /* @__PURE__ */ new Set(), + seen = /* @__PURE__ */ new Set() +} = {}) { + if (!value2 || typeof value2 !== "object" || seen.has(value2)) return null; + seen.add(value2); + if (Array.isArray(value2)) { + for (const item of value2) { + const nestedField = findForbiddenFallbackMetadata(item, { seen }); + if (nestedField) return nestedField; } - current = current.parentElement; + return null; } - return 0; + for (const [field, nestedValue] of Object.entries(value2)) { + if (skipKeys.has(field)) continue; + const normalizedField = field.toLowerCase(); + if (normalizedField.includes("fallback") || normalizedField === ["suppressed", "native", "visual", "ids"].join("") || FORBIDDEN_FALLBACK_FIELDS.includes(field)) return field; + if (field === "data" && !allowDirectData) return field; + if (field === "kind" && LEGACY_FALLBACK_KINDS.has(nestedValue)) return field; + const nestedField = findForbiddenFallbackMetadata(nestedValue, { seen }); + if (nestedField) return nestedField; + } + return null; } -function escapedAttributeValue(value2) { - return String(value2).replace(/["\\]/g, "\\$&"); -} -function serializeRasterTargetDocument(doc, targetSpecs) { - const targets = (Array.isArray(targetSpecs) ? targetSpecs : [targetSpecs]).map((target) => typeof target === "string" ? { sourceId: target, captureStrategy: "visual-subtree" } : target); - const sourceRoot = doc._exportRoot || doc.documentElement; - const sourceBody = doc.body; - const bodyClone = sourceBody.cloneNode(true); - let targetCount = 0; - targets.forEach(({ sourceId, captureStrategy }) => { - const target = bodyClone.querySelector( - `[data-pptx-source-id="${escapedAttributeValue(sourceId)}"]` - ) || (bodyClone.dataset?.pptxSourceId === sourceId ? bodyClone : null); - if (!target) return; - target.setAttribute("data-pptx-raster-target", "1"); - target.setAttribute("data-pptx-capture-strategy", captureStrategy); - targetCount += 1; - }); - if (!targetCount) return null; - const authorStyles = [...sourceRoot.querySelectorAll("style")].map((style) => style.textContent || "").join("\n"); - const bodyAttributes = [...bodyClone.attributes].filter((attribute) => !["class", "style"].includes(attribute.name)).map((attribute) => ` ${attribute.name}="${String(attribute.value).replace(/"/g, """)}"`).join(""); - const isolationCss = ` -html, body, .ppt-export-root, .ppt-export-body { - margin: 0 !important; - padding: 0 !important; - width: ${EXPORT_WIDTH}px !important; - height: ${EXPORT_HEIGHT}px !important; - overflow: hidden !important; - background: transparent !important; - background-color: transparent !important; -} -.ppt-export-body [data-pptx-source-id] { - visibility: hidden !important; -} -.ppt-export-body [data-pptx-raster-target="1"], -.ppt-export-body [data-pptx-capture-strategy="visual-subtree"] * { - visibility: visible !important; -} -.ppt-export-body [data-pptx-capture-strategy="self-decoration"] > *, -.ppt-export-body [data-pptx-capture-strategy="pseudo-only"] > * { - visibility: hidden !important; -} -.ppt-export-body [data-pptx-capture-strategy="pseudo-only"] { - background: none !important; - background-image: none !important; - border-color: transparent !important; - box-shadow: none !important; - filter: none !important; -} -.ppt-export-body [data-pptx-raster-target="1"] :is(p,h1,h2,h3,h4,h5,h6,li,span,a,small,label,code,b,strong,i,em,u,mark,sub,sup), -.ppt-export-body [data-pptx-raster-target="1"]:is(p,h1,h2,h3,h4,h5,h6,li,span,a,small,label,code,b,strong,i,em,u,mark,sub,sup) { - color: transparent !important; - -webkit-text-fill-color: transparent !important; - text-shadow: none !important; -} -.ppt-export-body [data-pptx-raster-target="1"] ::marker { - color: transparent !important; - -webkit-text-fill-color: transparent !important; -} -.ppt-export-body [data-pptx-raster-target="1"] svg text, -.ppt-export-body svg[data-pptx-raster-target="1"] text { - visibility: hidden !important; +function isTextValue(value2, allowEmpty = false) { + if (typeof value2 === "string") return allowEmpty || Boolean(value2.trim()); + if (!Array.isArray(value2) || !value2.length) return false; + if (!value2.every((run) => hasOnlyFields(run, /* @__PURE__ */ new Set(["text", "options"])) && typeof run.text === "string" && (run.options === void 0 || isObject(run.options)))) return false; + return allowEmpty || Boolean(value2.map((run) => run.text).join("").trim()); } -`; - return `

        ${bodyClone.innerHTML}
        `; +function hasValidBoxGeometry(node, { allowNegativeOrigin = false } = {}) { + return Number.isFinite(node.x) && Number.isFinite(node.y) && Number.isFinite(node.w) && Number.isFinite(node.h) && (allowNegativeOrigin || node.x >= 0) && (allowNegativeOrigin || node.y >= 0) && node.w > 0 && node.h > 0; } -function buildRasterFallbackRequests(doc, diagnostics = []) { - const bodyRect = doc.body.getBoundingClientRect(); - const view = doc.defaultView || globalThis.window; - const sourceOrder = buildDomPaintOrderMap(doc); - const grouped = /* @__PURE__ */ new Map(); - diagnostics.forEach((diagnostic) => { - if (!LOCAL_RASTER_CODES.has(diagnostic.code) || !diagnostic.sourceId) return; - const captureStrategy = diagnostic.code === "generated_content" ? "pseudo-only" : ["css_filter", "complex_svg_raster"].includes(diagnostic.code) ? "visual-subtree" : "self-decoration"; - const key = `${diagnostic.sourceId}:${captureStrategy}`; - if (!grouped.has(key)) { - grouped.set(key, { - sourceId: diagnostic.sourceId, - captureStrategy, - diagnostics: [] - }); +function hasValidLineGeometry(node, { allowNegativeOrigin = false } = {}) { + return Number.isFinite(node.x1) && Number.isFinite(node.y1) && Number.isFinite(node.x2) && Number.isFinite(node.y2) && (allowNegativeOrigin || node.x1 >= 0) && (allowNegativeOrigin || node.y1 >= 0) && (allowNegativeOrigin || node.x2 >= 0) && (allowNegativeOrigin || node.y2 >= 0) && (node.x1 !== node.x2 || node.y1 !== node.y2); +} +function isPositiveInteger(value2) { + return Number.isInteger(value2) && value2 > 0; +} +function hasValidTableBorderSide(side) { + return hasOnlyFields(side, TABLE_BORDER_FIELDS) && /^[0-9A-F]{6}$/i.test(side.color) && Number.isFinite(side.width) && side.width >= 0; +} +function hasValidTableBorder(border) { + if (!isObject(border)) return false; + if (hasOnlyFields(border, TABLE_BORDER_SIDES) && [...TABLE_BORDER_SIDES].every((side) => hasValidTableBorderSide(border[side]))) { + return true; + } + return hasOnlyFields(border, TABLE_BORDER_FIELDS) && /^[0-9A-F]{6}$/i.test(border.color) && Number.isFinite(border.width) && border.width >= 0; +} +function hasValidTableCellStyle(style) { + return hasOnlyFields(style, TABLE_CELL_STYLE_FIELDS) && (style.fill === null || /^[0-9A-F]{6}$/i.test(style.fill)) && hasValidTableBorder(style.border) && ["left", "center", "right"].includes(style.align) && (style.valign === void 0 || ["top", "mid", "bottom"].includes(style.valign)) && (style.padding === void 0 || Array.isArray(style.padding) && style.padding.length === 4 && style.padding.every((value2) => Number.isFinite(value2) && value2 >= 0)) && (style.fontFamily === void 0 || typeof style.fontFamily === "string" && Boolean(style.fontFamily.trim())) && (style.fontSize === void 0 || Number.isFinite(style.fontSize) && style.fontSize > 0) && (style.fontColor === void 0 || /^[0-9A-F]{6}$/i.test(style.fontColor)) && (style.bold === void 0 || typeof style.bold === "boolean"); +} +function hasValidTableCell(cell) { + return hasOnlyFields(cell, TABLE_CELL_FIELDS) && Object.prototype.hasOwnProperty.call(cell, "text") && hasValidTextValue(cell.text, true) && hasValidTableCellStyle(cell.style) && (cell.rowspan === void 0 || isPositiveInteger(cell.rowspan)) && (cell.colspan === void 0 || isPositiveInteger(cell.colspan)); +} +function hasValidTableGrid(rows, columnCount) { + let activeRowspans = Array(columnCount).fill(0); + for (const row of rows) { + const occupied = activeRowspans.map((remaining) => remaining > 0); + const nextRowspans = activeRowspans.map((remaining) => Math.max(0, remaining - 1)); + for (const cell of row.cells) { + const colspan = cell.colspan || 1; + const rowspan = cell.rowspan || 1; + const startColumn = occupied.indexOf(false); + if (startColumn < 0 || startColumn + colspan > columnCount) return false; + for (let column = startColumn; column < startColumn + colspan; column += 1) { + if (occupied[column]) return false; + occupied[column] = true; + if (rowspan > 1) nextRowspans[column] = Math.max(nextRowspans[column], rowspan - 1); + } + } + if (occupied.some((isOccupied) => !isOccupied)) return false; + activeRowspans = nextRowspans; + } + return activeRowspans.every((remaining) => remaining === 0); +} +function hasValidTablePayload(node) { + if (!Array.isArray(node.columnWidths) || !node.columnWidths.length || !node.columnWidths.every((width) => Number.isFinite(width) && width > 0) || !Array.isArray(node.rows) || !node.rows.length) { + return false; + } + const hasValidRows = node.rows.every((row) => hasOnlyFields(row, TABLE_ROW_FIELDS) && Array.isArray(row.cells) && row.cells.length > 0 && row.cells.every(hasValidTableCell) && Number.isFinite(row.height) && row.height > 0); + if (!hasValidRows || !hasValidTableGrid(node.rows, node.columnWidths.length)) return false; + const nearlyEqual = (left, right) => Math.abs(left - right) <= Math.max(1e-3, Math.abs(right) * 1e-4); + const totalWidth = node.columnWidths.reduce((sum2, width) => sum2 + width, 0); + const totalHeight = node.rows.reduce((sum2, row) => sum2 + row.height, 0); + return nearlyEqual(totalWidth, node.w) && nearlyEqual(totalHeight, node.h); +} +function readUint32BigEndian(bytes, offset) { + return (bytes[offset] << 24 >>> 0) + (bytes[offset + 1] << 16) + (bytes[offset + 2] << 8) + bytes[offset + 3] >>> 0; +} +function readUint32LittleEndian(bytes, offset) { + return bytes[offset] + (bytes[offset + 1] << 8) + (bytes[offset + 2] << 16) + (bytes[offset + 3] << 24 >>> 0) >>> 0; +} +function bytesEqualAscii(bytes, offset, value2) { + return [...value2].every((character, index) => bytes[offset + index] === character.charCodeAt(0)); +} +function pngCrc32(bytes, start, end) { + let crc = 4294967295; + for (let index = start; index < end; index += 1) { + crc ^= bytes[index]; + for (let bit = 0; bit < 8; bit += 1) { + crc = crc >>> 1 ^ (crc & 1 ? 3988292384 : 0); + } + } + return (crc ^ 4294967295) >>> 0; +} +function hasValidPngContainer(bytes) { + const signature = [137, 80, 78, 71, 13, 10, 26, 10]; + if (bytes.length < 45 || !signature.every((byte, index) => bytes[index] === byte)) { + return false; + } + let offset = 8; + let chunkIndex = 0; + let foundIdat = false; + let idatClosed = false; + let foundIend = false; + while (offset < bytes.length) { + if (offset + 12 > bytes.length) return false; + const length = readUint32BigEndian(bytes, offset); + const typeOffset = offset + 4; + const dataOffset = offset + 8; + const chunkEnd = dataOffset + length + 4; + if (!Number.isSafeInteger(chunkEnd) || chunkEnd > bytes.length) return false; + const type = String.fromCharCode(...bytes.slice(typeOffset, typeOffset + 4)); + if (pngCrc32(bytes, typeOffset, dataOffset + length) !== readUint32BigEndian(bytes, dataOffset + length)) return false; + if (chunkIndex === 0) { + if (type !== "IHDR" || length !== 13) return false; + const width = readUint32BigEndian(bytes, dataOffset); + const height = readUint32BigEndian(bytes, dataOffset + 4); + if (width === 0 || height === 0) return false; + const bitDepth = bytes[dataOffset + 8]; + const colorType = bytes[dataOffset + 9]; + const validDepths = { + 0: /* @__PURE__ */ new Set([1, 2, 4, 8, 16]), + 2: /* @__PURE__ */ new Set([8, 16]), + 3: /* @__PURE__ */ new Set([1, 2, 4, 8]), + 4: /* @__PURE__ */ new Set([8, 16]), + 6: /* @__PURE__ */ new Set([8, 16]) + }; + if (!validDepths[colorType]?.has(bitDepth) || bytes[dataOffset + 10] !== 0 || bytes[dataOffset + 11] !== 0 || ![0, 1].includes(bytes[dataOffset + 12])) return false; + } else if (type === "IHDR") { + return false; + } + if (type === "PLTE" && foundIdat) return false; + if (type === "IDAT") { + if (length === 0 || idatClosed) return false; + foundIdat = true; + } else if (foundIdat && type !== "IEND") { + idatClosed = true; + } + offset = chunkEnd; + chunkIndex += 1; + if (type === "IEND") { + if (length !== 0 || !foundIdat || offset !== bytes.length) return false; + foundIend = true; + break; } - grouped.get(key).diagnostics.push(diagnostic); + } + return foundIend; +} +var JPEG_SOF_MARKERS = /* @__PURE__ */ new Set([ + 192, + 193, + 194, + 195, + 197, + 198, + 199, + 201, + 202, + 203, + 205, + 206, + 207 +]); +function hasValidJpegContainer(bytes) { + if (bytes.length < 8 || bytes[0] !== 255 || bytes[1] !== 216) return false; + let offset = 2; + let foundSof = false; + let foundScan = false; + while (offset < bytes.length) { + if (bytes[offset] !== 255) return false; + while (offset < bytes.length && bytes[offset] === 255) offset += 1; + if (offset >= bytes.length) return false; + const marker = bytes[offset]; + offset += 1; + if (marker === 217) return foundSof && foundScan && offset === bytes.length; + if (marker === 0 || marker === 216 || marker >= 208 && marker <= 215) return false; + if (offset + 2 > bytes.length) return false; + const segmentLength = (bytes[offset] << 8) + bytes[offset + 1]; + if (segmentLength < 2 || offset + segmentLength > bytes.length) return false; + if (JPEG_SOF_MARKERS.has(marker)) { + if (segmentLength < 8) return false; + foundSof = true; + } + offset += segmentLength; + if (marker !== 218) continue; + foundScan = true; + while (offset < bytes.length) { + if (bytes[offset] !== 255) { + offset += 1; + continue; + } + let markerOffset = offset + 1; + while (markerOffset < bytes.length && bytes[markerOffset] === 255) markerOffset += 1; + if (markerOffset >= bytes.length) return false; + const scanMarker = bytes[markerOffset]; + if (scanMarker === 0 || scanMarker >= 208 && scanMarker <= 215) { + offset = markerOffset + 1; + continue; + } + break; + } + } + return false; +} +function hasValidWebpContainer(bytes) { + if (bytes.length < 30 || !bytesEqualAscii(bytes, 0, "RIFF") || !bytesEqualAscii(bytes, 8, "WEBP") || readUint32LittleEndian(bytes, 4) + 8 !== bytes.length) { + return false; + } + let offset = 12; + let foundImageDataChunk = false; + let foundExtendedHeader = false; + let chunkIndex = 0; + while (offset < bytes.length) { + if (offset + 8 > bytes.length) return false; + const chunkType = String.fromCharCode(...bytes.slice(offset, offset + 4)); + const chunkLength = readUint32LittleEndian(bytes, offset + 4); + const dataEnd = offset + 8 + chunkLength; + const paddedEnd = dataEnd + chunkLength % 2; + if (!Number.isSafeInteger(paddedEnd) || paddedEnd > bytes.length) return false; + if (["VP8 ", "VP8L", "VP8X"].includes(chunkType)) { + const dataOffset = offset + 8; + const validImageChunk = chunkType === "VP8 " && chunkLength >= 10 && bytes[dataOffset + 3] === 157 && bytes[dataOffset + 4] === 1 && bytes[dataOffset + 5] === 42 || chunkType === "VP8L" && chunkLength >= 5 && bytes[dataOffset] === 47 || chunkType === "VP8X" && chunkLength === 10; + if (!validImageChunk) return false; + if (chunkType === "VP8X") { + if (chunkIndex !== 0 || foundExtendedHeader) return false; + foundExtendedHeader = true; + } else { + foundImageDataChunk = true; + } + } + offset = paddedEnd; + chunkIndex += 1; + } + return offset === bytes.length && foundImageDataChunk; +} +function hasValidImagePayload(node) { + if (!hasOnlyFields(node, IMAGE_NODE_FIELDS)) return false; + if (node.intent !== "user-image") return false; + const imageSources = ["src", "data"].filter((field) => typeof node[field] === "string" && Boolean(node[field].trim())); + if (imageSources.length !== 1 || Object.hasOwn(node, "path")) return false; + const source = node[imageSources[0]].trim(); + const match = source.match( + /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/i + ); + if (!match || match[2].length % 4 !== 0) return false; + let bytes; + try { + const binary = typeof atob === "function" ? atob(match[2]) : Buffer.from(match[2], "base64").toString("binary"); + bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + } catch { + return false; + } + const mime = match[1].toLowerCase(); + if (mime === "png") return hasValidPngContainer(bytes); + if (mime === "jpeg") return hasValidJpegContainer(bytes); + return hasValidWebpContainer(bytes); +} +function hasValidCommonNodeFields(node) { + return (node.zIndex === void 0 || Number.isSafeInteger(node.zIndex)) && (node.paintOrder === void 0 || Number.isSafeInteger(node.paintOrder)) && (node.subOrder === void 0 || Number.isSafeInteger(node.subOrder)) && (node.rewrite === void 0 || REWRITE_TYPES.has(node.rewrite)) && (node.order === void 0 || Number.isSafeInteger(node.order) && node.order >= 0); +} +function hasValidNodePayload(node) { + if (!hasValidCommonNodeFields(node)) return false; + switch (node.type) { + case "text": + return hasOnlyFields(node, /* @__PURE__ */ new Set([ + "type", + "sourceId", + "x", + "y", + "w", + "h", + "text", + "style", + "paintOrder", + "subOrder", + "zIndex", + "rewrite" + ])) && hasValidTextValue(node.text) && hasValidTextStyle(node.style); + case "shape": + return hasOnlyFields(node, /* @__PURE__ */ new Set([ + "type", + "sourceId", + "x", + "y", + "w", + "h", + "shapeType", + "style", + "paintOrder", + "subOrder", + "zIndex", + "rewrite", + "order" + ])) && EDITABLE_SHAPE_TYPES.has(node.shapeType) && hasValidShapeStyle(node.style, node.shapeType); + case "line": + return hasOnlyFields(node, /* @__PURE__ */ new Set([ + "type", + "sourceId", + "x1", + "y1", + "x2", + "y2", + "style", + "paintOrder", + "subOrder", + "zIndex", + "rewrite" + ])) && hasValidLineStyle(node.style); + case "table": + return hasOnlyFields(node, TABLE_NODE_FIELDS) && hasValidTablePayload(node); + case "image": + return hasValidImagePayload(node); + default: + return false; + } +} +function validateEditableSlideScene(scene) { + if (!scene || Array.isArray(scene) || typeof scene !== "object") { + throw editableSceneError( + scene, + "editable_scene_invalid", + "Editable slide scene must be an object." + ); + } + const fallbackField = findForbiddenFallbackMetadata(scene, { + skipKeys: /* @__PURE__ */ new Set(["nodes"]) }); - const requests = []; - grouped.forEach(({ sourceId, captureStrategy, diagnostics: sourceDiagnostics }) => { - const element2 = doc.body.dataset?.pptxSourceId === sourceId ? doc.body : doc.body.querySelector( - `[data-pptx-source-id="${escapedAttributeValue(sourceId)}"]` + if (fallbackField) { + throw editableSceneError( + scene, + "editable_scene_fallback_forbidden", + `Editable slide scene must not contain ${fallbackField}.` ); - const failureRequest = (code, reason, details = {}) => ({ - sourceId, - zIndex: details.zIndex ?? 0, - paintOrder: sourceOrder.get(sourceId) ?? requests.length, - subOrder: 0, - kind: "raster", - phase: "local-visual", - canvas: "full-page", - captureStrategy, - suppressedNativeVisualIds: [], - bbox: details.bbox || { x: 0, y: 0, w: 0, h: 0 }, - diagnostics: sourceDiagnostics, - buildFailure: { - code, - stage: "request-build", - reason - } + } + if (!hasOnlyFields(scene, SCENE_FIELDS)) { + throw editableSceneError( + scene, + "editable_scene_invalid", + "Editable slide scene contains unknown top-level fields." + ); + } + if (!Number.isInteger(scene.slideNumber) || scene.slideNumber <= 0) { + throw editableSceneError( + scene, + "editable_scene_invalid", + "Editable slide scene requires a positive integer slideNumber." + ); + } + if (!Number.isFinite(scene.width) || scene.width <= 0 || !Number.isFinite(scene.height) || scene.height <= 0) { + throw editableSceneError( + scene, + "editable_scene_geometry_invalid", + "Editable slide scene width and height must be finite positive numbers." + ); + } + if (!Array.isArray(scene.nodes)) { + throw editableSceneError( + scene, + "editable_scene_invalid", + "Editable slide scene nodes must be an array." + ); + } + scene.nodes.forEach((node, index) => { + const defaultSourceId = `node-${index + 1}`; + if (!node || Array.isArray(node) || typeof node !== "object") { + throw editableSceneError( + scene, + "editable_scene_node_invalid", + "Editable slide scene nodes must be objects.", + defaultSourceId + ); + } + const hasValidSourceId = typeof node.sourceId === "string" && Boolean(node.sourceId.trim()); + const sourceId = hasValidSourceId ? node.sourceId : defaultSourceId; + const nodeFallbackField = findForbiddenFallbackMetadata(node, { + allowDirectData: node.type === "image" }); - if (!element2) { - requests.push(failureRequest( - "local_raster_target_missing", - `Raster fallback source "${sourceId}" is not present in the export document.` - )); - return; + if (nodeFallbackField) { + throw editableSceneError( + scene, + "editable_scene_fallback_forbidden", + `Editable scene nodes must not contain ${nodeFallbackField}.`, + sourceId + ); } - let rect; - try { - rect = element2.getBoundingClientRect(); - } catch (error2) { - requests.push(failureRequest( - "local_raster_serialize_failed", - `Raster fallback source "${sourceId}" could not be measured: ${String(error2?.message || error2)}`, - { zIndex: rasterLayerZIndex(element2, view) } - )); - return; + if (!EDITABLE_NODE_TYPES.has(node.type)) { + throw editableSceneError( + scene, + "editable_scene_node_type_unsupported", + `Editable scene node type "${String(node.type)}" is not supported.`, + sourceId + ); } - const bbox = { - x: (rect.left - bodyRect.left) / 96, - y: (rect.top - bodyRect.top) / 96, - w: rect.width / 96, - h: rect.height / 96 - }; - if (!(rect.width > 0) || !(rect.height > 0)) { - requests.push(failureRequest( - "local_raster_unmeasurable", - `Raster fallback source "${sourceId}" has no measurable width or height.`, - { zIndex: rasterLayerZIndex(element2, view), bbox } - )); - return; + if (!hasValidSourceId) { + throw editableSceneError( + scene, + "editable_scene_source_id_invalid", + "Editable scene nodes require a non-empty sourceId.", + sourceId + ); + } + const allowNegativeOrigin = node.type === "shape" || node.type === "image" || node.type === "line"; + const hasValidGeometry = node.type === "line" ? hasValidLineGeometry(node, { allowNegativeOrigin }) : hasValidBoxGeometry(node, { allowNegativeOrigin }); + if (!hasValidGeometry) { + throw editableSceneError( + scene, + "editable_scene_geometry_invalid", + `Editable scene node "${sourceId}" has invalid geometry.`, + sourceId + ); + } + if (!hasValidNodePayload(node)) { + throw editableSceneError( + scene, + "editable_scene_payload_invalid", + `Editable scene node "${sourceId}" has an invalid ${node.type} payload.`, + sourceId + ); + } + }); + return scene; +} + +// src/editable-slide-normalize.js +var GRADIENT_GRID_SIZE = 16; +var CURVE_MAX_SEGMENTS = 64; +function sourceIdOf(element2, fallback = "slide-document") { + return element2?.dataset?.pptxSourceId || element2?.id || fallback; +} +function fail(slideNumber, element2, code, message) { + throw new EditableExportError({ + slideNumber, + sourceId: sourceIdOf(element2, `slide-${slideNumber}`), + code, + message + }); +} +function failSource(slideNumber, sourceId, code, message) { + throw new EditableExportError({ + slideNumber, + sourceId: sourceId || `slide-${slideNumber}`, + code, + message + }); +} +function isTransparent(value2) { + return !value2 || value2 === "none" || value2 === "transparent" || /^rgba\([^)]*,\s*0(?:\.0+)?\s*\)$/i.test(value2); +} +function parseColor(value2) { + const raw = String(value2 || "").trim().toLowerCase(); + const named = { + black: "000000", + white: "FFFFFF", + red: "FF0000", + green: "008000", + blue: "0000FF", + yellow: "FFFF00", + gray: "808080", + grey: "808080" + }; + if (raw === "transparent") return { hex: "000000", alpha: 0 }; + if (named[raw]) return { hex: named[raw], alpha: 1 }; + const hex2 = raw.match(/^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i)?.[1]; + if (hex2) { + const expanded = hex2.length <= 4 ? hex2.split("").map((channel) => channel.repeat(2)).join("") : hex2; + return { + hex: expanded.slice(0, 6).toUpperCase(), + alpha: expanded.length === 8 ? parseInt(expanded.slice(6, 8), 16) / 255 : 1 + }; + } + const rgb2 = raw.match( + /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i + ); + if (rgb2) { + const channels = rgb2.slice(1, 4).map(Number); + if (channels.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 255)) { + return null; } + let alpha = 1; + if (rgb2[4] != null) { + alpha = rgb2[4].endsWith("%") ? Number.parseFloat(rgb2[4]) / 100 : Number(rgb2[4]); + if (!Number.isFinite(alpha) || alpha < 0 || alpha > 1) return null; + } + return { + hex: channels.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("").toUpperCase(), + alpha + }; + } + return null; +} +function colorToHex(value2, fallback = null) { + return parseColor(value2)?.hex || fallback; +} +function elementBox(element2, bodyRect) { + const rect = element2.getBoundingClientRect(); + return { + x: Math.max(0, rect.left - bodyRect.left) / PX_PER_IN, + y: Math.max(0, rect.top - bodyRect.top) / PX_PER_IN, + w: Math.max(1, rect.width) / PX_PER_IN, + h: Math.max(1, rect.height) / PX_PER_IN + }; +} +function generatedContentOwner(doc, selector) { + const raw = String(selector || "").trim(); + const candidates = [raw]; + const simpleSelectors = raw.match(/(?:[.#][\w-]+|\[[^\]]+\]|[a-z][\w-]*)/gi); + if (simpleSelectors?.length) candidates.push(simpleSelectors.at(-1)); + for (const candidate of candidates) { try { - if (!serializeRasterTargetDocument(doc, { sourceId, captureStrategy })) { - throw new Error("Raster target was not retained in the export document."); + const owner = doc.querySelector(candidate); + if (owner) return owner; + } catch { + } + } + return null; +} +function parsePolygonPoints(element2) { + const values2 = String(element2.getAttribute("points") || "").trim().split(/[\s,]+/).map(Number).filter(Number.isFinite); + const points = []; + for (let index = 0; index + 1 < values2.length; index += 2) { + points.push({ x: values2[index], y: values2[index + 1] }); + } + return points; +} +function validateSvgPresetGeometry(doc, slideNumber) { + for (const polygon of doc.querySelectorAll("svg polygon")) { + if (!classifySvgPresetPolygon(parsePolygonPoints(polygon))) { + fail( + slideNumber, + polygon, + "svg_polygon_unsupported", + "SVG polygon geometry does not exactly match an editable PowerPoint triangle or diamond preset." + ); + } + } + for (const rect of doc.querySelectorAll("svg rect[rx], svg rect[ry]")) { + const width = Number(rect.getAttribute("width")); + const height = Number(rect.getAttribute("height")); + const rawRx = rect.hasAttribute("rx") ? Number(rect.getAttribute("rx")) : null; + const rawRy = rect.hasAttribute("ry") ? Number(rect.getAttribute("ry")) : null; + const rx = rawRx ?? rawRy; + const ry = rawRy ?? rawRx; + const maxRadius = Math.min(width, height) / 2; + const tolerance = Math.max(1, Math.min(width, height)) * 1e-6; + const svg = rect.closest("svg"); + const viewportMapper = createSvgViewportMapper(svg, svg.getBoundingClientRect()); + const hasNonuniformRadiusScale = Math.abs(viewportMapper.xScale - viewportMapper.yScale) > Math.max(viewportMapper.xScale, viewportMapper.yScale, 1) * 1e-6; + if (![width, height, rx, ry, maxRadius].every(Number.isFinite) || width <= 0 || height <= 0 || rx <= 0 || ry <= 0 || Math.abs(rx - ry) > tolerance || rx - maxRadius > tolerance || hasNonuniformRadiusScale) { + fail( + slideNumber, + rect, + "svg_round_rect_radius_unsupported", + "SVG rect radius must be equal on both axes and within half the shorter side." + ); + } + } +} +function editableErrorPreflight(doc, slideNumber) { + const view = doc.defaultView; + for (const element2 of [doc.body, ...doc.querySelectorAll("*")]) { + const computed = view.getComputedStyle(element2); + const backgroundImage = String(computed.backgroundImage || "").trim(); + if (backgroundImage && backgroundImage !== "none" && !backgroundImage.includes("gradient(")) { + fail( + slideNumber, + element2, + "background_image_unsupported", + "CSS background images cannot be represented as intentional editable picture nodes; use an img element." + ); + } + const filter = String(computed.filter || element2.style?.filter || "").trim(); + if (filter && filter !== "none") { + fail(slideNumber, element2, "css_filter", "CSS filters cannot be represented by editable PowerPoint objects."); + } + const animationName = String(computed.animationName || element2.style?.animationName || "").trim(); + const animation = String(element2.style?.animation || "").trim(); + if (animationName && animationName !== "none" || animation) { + fail(slideNumber, element2, "animation_unsupported", "CSS animation cannot be represented in a static editable slide."); + } + const mask = [ + computed.mask, + computed.maskImage, + element2.style?.getPropertyValue?.("mask"), + element2.style?.getPropertyValue?.("mask-image"), + element2.getAttribute?.("style")?.match(/(?:^|;)\s*(?:-webkit-)?mask(?:-image)?\s*:\s*([^;]+)/i)?.[1] + ].find((value2) => value2 && value2 !== "none"); + if (mask) { + fail(slideNumber, element2, "css_mask", "CSS masks cannot be represented by editable PowerPoint objects."); + } + for (const attribute of ["src", "href", "xlink:href"]) { + const value2 = element2.getAttribute?.(attribute); + if (value2 && /^(?:https?:|\/\/|file:|blob:)/i.test(value2.trim())) { + fail(slideNumber, element2, "external_resource", "External resources are not allowed in editable slide export."); } - } catch (error2) { - requests.push(failureRequest( - "local_raster_serialize_failed", - `Raster fallback source "${sourceId}" could not be serialized: ${String(error2?.message || error2)}`, - { zIndex: rasterLayerZIndex(element2, view), bbox } - )); - return; } - const suppressedNativeVisualIds = captureStrategy === "pseudo-only" ? [] : captureStrategy === "self-decoration" ? [sourceId] : [element2, ...element2.querySelectorAll("[data-pptx-source-id]")].map((item) => item.dataset?.pptxSourceId).filter(Boolean); - requests.push({ - sourceId, - zIndex: rasterLayerZIndex(element2, view), - paintOrder: sourceOrder.get(sourceId) ?? requests.length, - subOrder: 0, - kind: "raster", - phase: "local-visual", - canvas: "full-page", - captureStrategy, - suppressedNativeVisualIds: [...new Set(suppressedNativeVisualIds)], - bbox, - buildHtml: () => serializeRasterTargetDocument(doc, { sourceId, captureStrategy }), - diagnostics: sourceDiagnostics - }); + const cssText = element2.getAttribute?.("style") || ""; + if (/url\(\s*["']?(?!#|data:image\/(?:png|jpeg|jpg|webp);base64,)/i.test(cssText)) { + fail(slideNumber, element2, "external_resource", "External CSS resources are not allowed in editable slide export."); + } + } + const defsGeometry = doc.querySelector( + "svg defs rect, svg defs circle, svg defs ellipse, svg defs line, svg defs polyline, svg defs polygon, svg defs path, svg defs text" + ); + if (defsGeometry) { + fail( + slideNumber, + defsGeometry, + "svg_defs_geometry_unsupported", + "SVG geometry inside defs cannot be emitted as editable slide objects." + ); + } + for (const element2 of doc.querySelectorAll("svg [fill], svg [stroke]")) { + const paint = ["fill", "stroke"].find((attribute) => /\burl\s*\(\s*["']?#[^)]+\)/i.test(element2.getAttribute(attribute) || "")); + if (paint) { + fail( + slideNumber, + element2, + "svg_paint_server_unsupported", + `SVG ${paint} paint servers cannot be represented as editable PowerPoint colors.` + ); + } + } + const masked = doc.querySelector("svg [mask]") || doc.querySelector("svg mask"); + if (masked) { + fail(slideNumber, masked, "svg_mask", "SVG masks cannot be represented by editable PowerPoint objects."); + } + const filtered = doc.querySelector("svg [filter], svg filter, svg foreignObject, svg pattern, svg use, svg image"); + if (filtered) { + const external = filtered.matches("image,use") && /^(?!#)(?:https?:|\/\/|file:|blob:|data:)/i.test( + filtered.getAttribute("href") || filtered.getAttribute("xlink:href") || "" + ); + fail( + slideNumber, + filtered, + external ? "external_resource" : "svg_filter_unsupported", + "This SVG resource construct cannot be represented by editable PowerPoint objects." + ); + } + const animated = doc.querySelector("animate, animateMotion, animateTransform, set"); + if (animated) { + fail( + slideNumber, + animated.parentElement || animated, + "animation_unsupported", + "SVG animation cannot be represented in a static editable slide." + ); + } + const inspectGeneratedContentRules = (rules) => { + for (const rule of rules || []) { + if (rule.cssRules) { + inspectGeneratedContentRules(rule.cssRules); + continue; + } + const selector = rule.selectorText || ""; + const content = rule.style?.content; + if (!/::?(?:before|after)\b/i.test(selector) || !content || ["none", "normal", '""', "''"].includes(content.trim())) continue; + const baseSelector = selector.replace(/::?(?:before|after)\b.*$/i, "").trim(); + const owner = generatedContentOwner(doc, baseSelector); + fail( + slideNumber, + owner || doc.body, + "generated_content", + "Generated pseudo-element content cannot be represented as an editable object." + ); + } + }; + for (const sheet of doc.styleSheets || []) { + try { + inspectGeneratedContentRules(sheet.cssRules); + } catch { + } + } + for (const styleElement of doc.querySelectorAll("style")) { + const cssText = styleElement.textContent || ""; + const pseudoRules = cssText.matchAll( + /([^{}]+)::?(?:before|after)\s*\{[^{}]*\bcontent\s*:\s*(?!none|normal)([^;}]+)/gi + ); + for (const match of pseudoRules) { + const baseSelector = match[1].trim().replace(/^.*[;}]\s*/, ""); + const owner = generatedContentOwner(doc, baseSelector); + fail( + slideNumber, + owner || doc.body, + "generated_content", + "Generated pseudo-element content cannot be represented as an editable object." + ); + } + } +} +function splitGradientArguments(value2) { + const inner = value2.slice(value2.indexOf("(") + 1, value2.lastIndexOf(")")); + const parts = []; + let depth = 0; + let start = 0; + for (let index = 0; index < inner.length; index += 1) { + if (inner[index] === "(") depth += 1; + else if (inner[index] === ")") depth -= 1; + else if (inner[index] === "," && depth === 0) { + parts.push(inner.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(inner.slice(start).trim()); + return parts; +} +function hexChannels(hex2) { + return [0, 2, 4].map((offset) => parseInt(hex2.slice(offset, offset + 2), 16)); +} +function interpolateColor(left, right, amount) { + const alpha = left.alpha + (right.alpha - left.alpha) * amount; + const leftChannels = hexChannels(left.hex); + const rightChannels = hexChannels(right.hex); + const channels = leftChannels.map((channel, index) => { + if (alpha <= 0) return 0; + const premultiplied = channel * left.alpha + (rightChannels[index] * right.alpha - channel * left.alpha) * amount; + return Math.round(premultiplied / alpha); }); - return requests.sort((a, b) => a.zIndex - b.zIndex || a.paintOrder - b.paintOrder); -} -function buildPageVisualFallbackRequest(doc, localRequests = []) { - const sourceIds = [...new Set(localRequests.map((request2) => request2.sourceId).filter(Boolean))]; - if (!sourceIds.length) return null; - const targetSpecs = localRequests.map((request2) => ({ - sourceId: request2.sourceId, - captureStrategy: request2.captureStrategy - })); - const request = { - sourceId: "slide-visuals", - sourceIds, - captureStrategy: "page-visual", - suppressedNativeVisualIds: [...new Set( - localRequests.flatMap((request2) => request2.suppressedNativeVisualIds || []) - )], - zIndex: Math.min(...localRequests.map((request2) => request2.zIndex ?? 0)), - paintOrder: Math.min(...localRequests.map((request2) => request2.paintOrder ?? 0)), - subOrder: Math.min(...localRequests.map((request2) => request2.subOrder ?? 0)), - kind: "raster", - phase: "page-visual", - canvas: "full-page", - bbox: { x: 0, y: 0, w: 13.333, h: 7.5 }, - diagnostics: localRequests.flatMap((request2) => request2.diagnostics || []) + return { + hex: channels.map((channel) => channel.toString(16).padStart(2, "0")).join("").toUpperCase(), + alpha }; - const missingSourceIds = localRequests.filter((localRequest) => localRequest.buildFailure?.code === "local_raster_target_missing").map((localRequest) => localRequest.sourceId); - if (missingSourceIds.length) { +} +function parseLinearGradient(value2) { + const args = splitGradientArguments(value2); + let angle = 180; + const angleMatch = args[0]?.match(/^(-?(?:\d*\.)?\d+)(deg|turn|rad|grad)$/i); + if (angleMatch) { + args.shift(); + const amount = Number(angleMatch[1]); + angle = { + deg: amount, + turn: amount * 360, + rad: amount * 180 / Math.PI, + grad: amount * 0.9 + }[angleMatch[2].toLowerCase()]; + } else if (/^to\s+/i.test(args[0])) { + const direction = args.shift().toLowerCase(); + const resolved = { + "to top": 0, + "to top right": 45, + "to right top": 45, + "to right": 90, + "to bottom right": 135, + "to right bottom": 135, + "to bottom": 180, + "to bottom left": 225, + "to left bottom": 225, + "to left": 270, + "to top left": 315, + "to left top": 315 + }[direction]; + if (resolved == null) return null; + angle = resolved; + } else if (/^-?(?:\d*\.)?\d/.test(args[0] || "")) { + return null; + } + const stops = args.map((stop) => { + const match = stop.match(/^(.*?)(?:\s+(-?(?:\d*\.)?\d+)%\s*)?$/); + const color = parseColor(match?.[1]); return { - ...request, - buildFailure: { - code: "page_visual_target_missing", - stage: "request-build", - reason: `Page-visual fallback cannot cover missing sources: ${missingSourceIds.join(", ")}.` + color, + offset: match?.[2] == null ? null : Number(match[2]) / 100 + }; + }); + if (stops.length < 2 || stops.some((stop) => !stop.color)) return null; + if (stops[0].offset == null) stops[0].offset = 0; + if (stops.at(-1).offset == null) stops.at(-1).offset = 1; + for (let index = 0; index < stops.length; ) { + if (stops[index].offset != null) { + stops[index].offset = Math.max(index ? stops[index - 1].offset : 0, stops[index].offset); + index += 1; + continue; + } + const start = index - 1; + let end = index; + while (end < stops.length && stops[end].offset == null) end += 1; + const span = end - start; + for (let cursor = index; cursor < end; cursor += 1) { + stops[cursor].offset = stops[start].offset + (stops[end].offset - stops[start].offset) * (cursor - start) / span; + } + index = end; + } + return { angle: (angle % 360 + 360) % 360, stops }; +} +function gradientValueForElement(element2, view) { + const computed = String(view.getComputedStyle(element2).backgroundImage || ""); + if (computed.includes("gradient(")) return computed; + const inline = element2.getAttribute?.("style") || ""; + const start = inline.search(/(?:linear|radial)-gradient\(/i); + if (start < 0) return ""; + let depth = 0; + for (let index = start; index < inline.length; index += 1) { + if (inline[index] === "(") depth += 1; + else if (inline[index] === ")" && --depth === 0) return inline.slice(start, index + 1); + } + return inline.slice(start); +} +function gradientColorAt(stops, offset) { + const rightIndex = stops.findIndex((stop) => stop.offset >= offset); + if (rightIndex <= 0) return stops[0].color; + if (rightIndex < 0) return stops.at(-1).color; + const left = stops[rightIndex - 1]; + const right = stops[rightIndex]; + const range2 = right.offset - left.offset; + return interpolateColor(left.color, right.color, range2 > 0 ? (offset - left.offset) / range2 : 0); +} +function gradientNodes(doc, bodyRect, slideNumber, paintOrderBySource) { + const nodes = []; + for (const element2 of [doc.body, ...doc.querySelectorAll("*")]) { + if (element2.closest?.("table")) continue; + const backgroundImage = gradientValueForElement(element2, doc.defaultView); + if (!backgroundImage.includes("gradient(")) continue; + if (!backgroundImage.startsWith("linear-gradient(")) { + fail( + slideNumber, + element2, + "css_gradient_unsupported", + "Only linear gradients can be rewritten as editable solid strips." + ); + } + const gradient = parseLinearGradient(backgroundImage); + if (!gradient) { + fail(slideNumber, element2, "css_gradient_unsupported", "Linear gradient stops could not be parsed."); + } + const box = elementBox(element2, bodyRect); + const radians = gradient.angle * Math.PI / 180; + const direction = { x: Math.sin(radians), y: -Math.cos(radians) }; + const gradientLength = Math.abs(box.w * direction.x) + Math.abs(box.h * direction.y); + const isAxisAligned = gradient.angle % 90 === 0; + const columns = isAxisAligned && gradient.angle % 180 === 0 ? 1 : GRADIENT_GRID_SIZE; + const rows = isAxisAligned && gradient.angle % 180 !== 0 ? 1 : GRADIENT_GRID_SIZE; + const sourceId = sourceIdOf(element2); + const paintOrder = paintOrderBySource.get(sourceId) ?? 0; + const parsedZIndex = Number.parseInt(doc.defaultView.getComputedStyle(element2).zIndex, 10); + const zIndex = Number.isFinite(parsedZIndex) ? parsedZIndex : 0; + const total = columns * rows; + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const order = row * columns + column; + const centerX = box.w * (column + 0.5) / columns; + const centerY = box.h * (row + 0.5) / rows; + const projection = (centerX - box.w / 2) * direction.x + (centerY - box.h / 2) * direction.y; + const sample = gradientLength > 0 ? 0.5 + projection / gradientLength : 0.5; + const strip = { + x: box.x + box.w * column / columns, + y: box.y + box.h * row / rows, + w: box.w / columns, + h: box.h / rows + }; + const color = gradientColorAt(gradient.stops, sample); + nodes.push({ + type: "shape", + shapeType: "rect", + rewrite: "css_gradient", + sourceId, + ...strip, + order, + paintOrder, + subOrder: order - total, + zIndex, + style: { + fill: color.hex, + transparency: (1 - color.alpha) * 100, + line: null + } + }); } + } + } + return nodes; +} +function tableTextRuns(cell, view) { + const runs = []; + const blockTags = /* @__PURE__ */ new Set(["P", "DIV", "H1", "H2", "H3", "H4", "H5", "H6", "LI"]); + const textOptions = (element2) => { + const computed = view.getComputedStyle(element2 || cell); + const cellComputed = view.getComputedStyle(cell); + const fontWeightValue = computed.fontWeight || cellComputed.fontWeight; + const fontWeight = Number.parseInt(fontWeightValue, 10); + return { + fontFace: String(computed.fontFamily || cellComputed.fontFamily || "").split(",")[0].replace(/['"]/g, "").trim() || "Arial", + fontSize: (Number.parseFloat(computed.fontSize || cellComputed.fontSize) || 16) * 0.75, + color: colorToHex(computed.color || cellComputed.color, "000000"), + bold: fontWeightValue === "bold" || fontWeight >= 600, + italic: computed.fontStyle === "italic", + underline: String(computed.textDecoration || "").includes("underline") }; + }; + const appendLineBreak = (owner) => { + if (!runs.length || runs.at(-1).text.endsWith("\n")) return; + runs.push({ text: "\n", options: textOptions(owner) }); + }; + const significantSibling = (node, direction) => { + let sibling = node[direction]; + while (sibling) { + if (sibling.nodeType === view.Node.ELEMENT_NODE) return sibling; + if (sibling.nodeType === view.Node.TEXT_NODE && String(sibling.textContent || "").trim()) { + return sibling; + } + sibling = sibling[direction]; + } + return null; + }; + const isBlockNode = (node) => node?.nodeType === view.Node.ELEMENT_NODE && blockTags.has(node.tagName); + const visit = (node, owner) => { + if (node.nodeType === view.Node.TEXT_NODE) { + const raw = String(node.textContent || ""); + let text2 = raw.replace(/\s+/g, " "); + if (!text2.trim()) { + const previous = significantSibling(node, "previousSibling"); + const next = significantSibling(node, "nextSibling"); + if (previous && next && !isBlockNode(previous) && !isBlockNode(next)) { + runs.push({ text: " ", options: textOptions(owner) }); + } + return; + } + if (runs.at(-1)?.text.endsWith("\n")) text2 = text2.replace(/^ /, ""); + if (isBlockNode(significantSibling(node, "nextSibling"))) text2 = text2.replace(/ $/, ""); + if (text2) runs.push({ text: text2, options: textOptions(owner) }); + return; + } + if (node.nodeType !== view.Node.ELEMENT_NODE) return; + if (node.tagName === "BR") { + appendLineBreak(owner); + return; + } + if (blockTags.has(node.tagName)) appendLineBreak(node); + [...node.childNodes].forEach((child) => visit(child, node)); + if (blockTags.has(node.tagName)) appendLineBreak(node); + }; + [...cell.childNodes].forEach((child) => visit(child, cell)); + if (!runs.length) return ""; + runs[0].text = runs[0].text.replace(/^\s+/, ""); + runs.at(-1).text = runs.at(-1).text.replace(/\s+$/, ""); + return runs.filter((run) => run.text); +} +function authoredStyleValue(cssText, property) { + const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + return String(cssText || "").match( + new RegExp(`(?:^|;)\\s*${escaped}\\s*:\\s*([^;]+)`, "i") + )?.[1]?.trim() || ""; +} +function cssLengthToPoints(value2) { + const match = String(value2 || "").trim().match(/^(-?(?:\d*\.)?\d+)(pt|px)$/i); + if (!match) return 0; + const amount = Number(match[1]); + return match[2].toLowerCase() === "pt" ? amount : amount * 0.75; +} +function cssLengthToInches(value2) { + const match = String(value2 || "").trim().match(/^(-?(?:\d*\.)?\d+)(pt|px)$/i); + if (!match) return 0; + const amount = Number(match[1]); + return amount / (match[2].toLowerCase() === "pt" ? 72 : PX_PER_IN); +} +function authoredBoxValues(value2) { + const values2 = String(value2 || "").trim().split(/\s+/).filter(Boolean); + if (!values2.length || values2.length > 4) return null; + if (values2.length === 1) return [values2[0], values2[0], values2[0], values2[0]]; + if (values2.length === 2) return [values2[0], values2[1], values2[0], values2[1]]; + if (values2.length === 3) return [values2[0], values2[1], values2[2], values2[1]]; + return values2; +} +function tableBorderSide(computed, side, authoredStyle) { + const authoredBorder = authoredStyleValue(authoredStyle, `border-${side.toLowerCase()}`) || authoredStyleValue(authoredStyle, "border"); + const authoredWidth = authoredBorder.match(/-?(?:\d*\.)?\d+(?:pt|px)/i)?.[0]; + const authoredColor = authoredBorder.match(/#[\da-f]{3,8}|rgba?\([^)]+\)|[a-z]+$/i)?.[0]; + const width = authoredWidth ? cssLengthToPoints(authoredWidth) : Number.parseFloat(computed[`border${side}Width`]) * 0.75 || 0; + const style = authoredBorder ? /\bnone\b/i.test(authoredBorder) ? "none" : "solid" : computed[`border${side}Style`]; + return { + color: colorToHex(authoredColor || computed[`border${side}Color`], "000000"), + width: style === "none" ? 0 : width + }; +} +function tableCellStyle(cell, doc) { + const view = doc.defaultView; + const computed = view.getComputedStyle(cell); + const textOwner = cell.querySelector( + "p,h1,h2,h3,h4,h5,h6,span,strong,b,em,i,u,small,label,a,code,mark,sub,sup" + ) || cell; + const textComputed = view.getComputedStyle(textOwner); + const fontWeightValue = textComputed.fontWeight || computed.fontWeight; + const fontWeight = Number.parseInt(fontWeightValue, 10); + const align = String(textComputed.textAlign || computed.textAlign || "left"); + const verticalAlign = String(computed.verticalAlign || "").toLowerCase(); + const authoredStyle = cell.dataset.pptxAuthoredStyle || cell.getAttribute("style") || ""; + const authoredPadding = authoredBoxValues(authoredStyleValue(authoredStyle, "padding")); + const shorthandColor = String(cell.style.background || "").trim().match( + /#[\da-f]{3,8}|rgba?\([^)]+\)|^[a-z]+/i + )?.[0]; + const authoredBackground = cell.style.backgroundColor || shorthandColor || authoredStyleValue(authoredStyle, "background-color") || authoredStyleValue(authoredStyle, "background").match( + /#[\da-f]{3,8}|rgba?\([^)]+\)|^[a-z]+/i + )?.[0]; + const backgroundColor = !isTransparent(computed.backgroundColor) ? computed.backgroundColor : authoredBackground; + return { + fill: isTransparent(backgroundColor) ? null : colorToHex(backgroundColor, null), + border: { + top: tableBorderSide(computed, "Top", authoredStyle), + right: tableBorderSide(computed, "Right", authoredStyle), + bottom: tableBorderSide(computed, "Bottom", authoredStyle), + left: tableBorderSide(computed, "Left", authoredStyle) + }, + align: align === "center" ? "center" : align === "right" || align === "end" ? "right" : "left", + valign: verticalAlign === "middle" ? "mid" : verticalAlign === "bottom" ? "bottom" : "top", + fontFamily: String(textComputed.fontFamily || "").split(",")[0].replace(/['"]/g, "").trim() || "Arial", + fontSize: (Number.parseFloat(textComputed.fontSize) || 16) * 0.75, + fontColor: colorToHex(textComputed.color, "000000"), + bold: fontWeightValue === "bold" || fontWeight >= 600, + padding: authoredPadding ? authoredPadding.map(cssLengthToInches) : [ + Number.parseFloat(computed.paddingTop) || 0, + Number.parseFloat(computed.paddingRight) || 0, + Number.parseFloat(computed.paddingBottom) || 0, + Number.parseFloat(computed.paddingLeft) || 0 + ].map((value2) => value2 / PX_PER_IN) + }; +} +function tableZIndex(table, doc) { + let current = table; + while (current && current !== doc.body) { + const parsed = Number.parseInt(doc.defaultView.getComputedStyle(current).zIndex, 10); + if (Number.isFinite(parsed)) return parsed; + current = current.parentElement; } - try { - if (!serializeRasterTargetDocument(doc, targetSpecs)) { - return { - ...request, - buildFailure: { - code: "page_visual_target_missing", - stage: "request-build", - reason: `Page-visual fallback could not locate any requested sources: ${sourceIds.join(", ")}.` + return 0; +} +function effectiveTableRowspan(cell) { + const raw = cell.getAttribute("rowspan"); + if (raw != null && String(raw).trim() === "0") { + const row = cell.parentElement; + const rowGroup = row?.parentElement; + const groupRows = rowGroup && ["THEAD", "TBODY", "TFOOT"].includes(rowGroup.tagName) ? [...rowGroup.rows] : []; + const groupIndex = groupRows.indexOf(row); + return groupIndex >= 0 ? groupRows.length - groupIndex : 1; + } + return cell.rowSpan || 1; +} +function tableNodes(doc, bodyRect, slideNumber, paintOrderBySource) { + const nodes = []; + for (const table of doc.querySelectorAll("table")) { + if (table.closest("td,th")) { + fail( + slideNumber, + table, + "table_nested_unsupported", + "Nested tables cannot be represented as one editable PowerPoint table." + ); + } + const rect = table.getBoundingClientRect(); + const rows = [...table.rows]; + if (!(rect.width > 0) || !(rect.height > 0) || !rows.length) { + fail( + slideNumber, + table, + "table_geometry_unmeasurable", + "Editable table requires measurable table and row geometry." + ); + } + const rowRects = rows.map((row) => row.getBoundingClientRect()); + if (rowRects.some((rowRect) => !(rowRect.height > 0))) { + fail( + slideNumber, + table, + "table_geometry_unmeasurable", + "Editable table rows require measurable positive heights." + ); + } + const cellsByRow = rows.map((row) => [...row.cells]); + const cells = cellsByRow.flat(); + if (!cells.length) { + fail( + slideNumber, + table, + "table_grid_non_rectangular", + "Editable table must contain at least one cell." + ); + } + let maximumHorizontalBorder = 0; + for (const cell of cells) { + const computed = doc.defaultView.getComputedStyle(cell); + maximumHorizontalBorder = Math.max( + maximumHorizontalBorder, + parseFloat(computed.borderLeftWidth) || 0, + parseFloat(computed.borderRightWidth) || 0 + ); + if (computed.backgroundImage && computed.backgroundImage !== "none") { + fail( + slideNumber, + cell, + "table_cell_background_unsupported", + "Table cell background images and gradients cannot be represented natively." + ); + } + if (cell.querySelector("table,svg,img,canvas,video,audio")) { + fail( + slideNumber, + cell, + "table_cell_content_unsupported", + "Table cells may only contain editable text content." + ); + } + } + const tolerance = Math.max(0.75, maximumHorizontalBorder / 2 + 0.5); + const rawBoundaries = [ + rect.left, + rect.right, + ...cells.flatMap((cell) => { + const cellRect = cell.getBoundingClientRect(); + return [cellRect.left, cellRect.right]; + }) + ].sort((left, right) => left - right); + const boundaries = []; + rawBoundaries.forEach((value2) => { + const previous = boundaries.at(-1); + if (previous == null || Math.abs(value2 - previous) > tolerance) boundaries.push(value2); + else boundaries[boundaries.length - 1] = (previous + value2) / 2; + }); + if (boundaries.length < 2 || Math.abs(boundaries[0] - rect.left) > tolerance || Math.abs(boundaries.at(-1) - rect.right) > tolerance) { + fail( + slideNumber, + table, + "table_grid_non_rectangular", + "Rendered table cell boundaries do not cover the complete table width." + ); + } + boundaries[0] = rect.left; + boundaries[boundaries.length - 1] = rect.right; + const columnCount = boundaries.length - 1; + const grid = rows.map(() => Array(columnCount).fill(null)); + const nearestBoundary = (value2) => boundaries.findIndex((boundary) => Math.abs(boundary - value2) <= tolerance); + const normalizedRows = rows.map((row, rowIndex) => { + const normalizedCells = cellsByRow[rowIndex].map((cell) => { + const cellRect = cell.getBoundingClientRect(); + const startColumn = nearestBoundary(cellRect.left); + const endColumn = nearestBoundary(cellRect.right); + const colspan = cell.colSpan || 1; + const rowspan = effectiveTableRowspan(cell); + if (startColumn < 0 || endColumn <= startColumn || endColumn - startColumn !== colspan || rowIndex + rowspan > rows.length) { + fail( + slideNumber, + table, + "table_grid_non_rectangular", + "Rendered cell boundaries do not match the declared colspan or rowspan." + ); } + const expectedTop = rowRects[rowIndex].top; + const expectedBottom = rowRects[rowIndex + rowspan - 1].bottom; + if (Math.abs(cellRect.top - expectedTop) > tolerance || Math.abs(cellRect.bottom - expectedBottom) > tolerance) { + fail( + slideNumber, + table, + "table_grid_non_rectangular", + "Rendered row boundaries do not match the declared rowspan." + ); + } + for (let gridRow = rowIndex; gridRow < rowIndex + rowspan; gridRow += 1) { + for (let column = startColumn; column < endColumn; column += 1) { + if (grid[gridRow][column]) { + fail( + slideNumber, + table, + "table_grid_non_rectangular", + "Rendered table spans overlap and cannot form a rectangular grid." + ); + } + grid[gridRow][column] = cell; + } + } + return { + text: tableTextRuns(cell, doc.defaultView), + style: tableCellStyle(cell, doc), + ...colspan > 1 ? { colspan } : {}, + ...rowspan > 1 ? { rowspan } : {} + }; + }); + return { + height: rowRects[rowIndex].height / PX_PER_IN, + cells: normalizedCells }; + }); + if (grid.some((row) => row.some((cell) => !cell))) { + fail( + slideNumber, + table, + "table_grid_non_rectangular", + "Rendered table spans leave uncovered cells and cannot form a rectangular grid." + ); } + const sourceId = sourceIdOf(table); + const normalizedTableHeight = rowRects.reduce((sum2, rowRect) => sum2 + rowRect.height, 0) / PX_PER_IN; + nodes.push({ + type: "table", + sourceId, + x: Math.max(0, rect.left - bodyRect.left) / PX_PER_IN, + y: Math.max(0, rowRects[0].top - bodyRect.top) / PX_PER_IN, + w: rect.width / PX_PER_IN, + h: normalizedTableHeight, + columnWidths: boundaries.slice(1).map((boundary, index) => (boundary - boundaries[index]) / PX_PER_IN), + rows: normalizedRows, + zIndex: tableZIndex(table, doc), + paintOrder: paintOrderBySource.get(sourceId) ?? 0, + subOrder: 0 + }); + } + return nodes; +} +function pathTokens(data) { + return String(data || "").match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:e[-+]?\d+)?/gi) || []; +} +function pointLineDistance(point, start, end) { + const dx = end.x - start.x; + const dy = end.y - start.y; + const lengthSquared = dx * dx + dy * dy; + if (!lengthSquared) return Math.hypot(point.x - start.x, point.y - start.y); + const area = Math.abs(dy * point.x - dx * point.y + end.x * start.y - end.y * start.x); + return area / Math.sqrt(lengthSquared); +} +function midpoint(left, right) { + return { x: (left.x + right.x) / 2, y: (left.y + right.y) / 2 }; +} +function sampleQuadratic(start, control, end, tolerance = 0.5) { + const output = []; + const visit = (a, b, c, depth) => { + if (depth >= Math.log2(CURVE_MAX_SEGMENTS) || pointLineDistance(b, a, c) <= tolerance) { + output.push(c); + return; + } + const ab = midpoint(a, b); + const bc = midpoint(b, c); + const center = midpoint(ab, bc); + visit(a, ab, center, depth + 1); + visit(center, bc, c, depth + 1); + }; + visit(start, control, end, 0); + return output; +} +function sampleCubic(start, first, second, end, tolerance = 0.5) { + const output = []; + const visit = (a, b, c, d, depth) => { + const flatness = Math.max(pointLineDistance(b, a, d), pointLineDistance(c, a, d)); + if (depth >= Math.log2(CURVE_MAX_SEGMENTS) || flatness <= tolerance) { + output.push(d); + return; + } + const ab = midpoint(a, b); + const bc = midpoint(b, c); + const cd = midpoint(c, d); + const abc = midpoint(ab, bc); + const bcd = midpoint(bc, cd); + const center = midpoint(abc, bcd); + visit(a, ab, abc, center, depth + 1); + visit(center, bcd, cd, d, depth + 1); + }; + visit(start, first, second, end, 0); + return output; +} +function parseEditablePath(data, slideNumber, element2) { + const tokens = pathTokens(data); + const points = []; + let index = 0; + let command = ""; + let current = { x: 0, y: 0 }; + let subpathStart = null; + let lastCubicControl = null; + let lastQuadraticControl = null; + const number = () => Number(tokens[index++]); + const point = (relative) => { + const result = { x: number(), y: number() }; + return relative ? { x: current.x + result.x, y: current.y + result.y } : result; + }; + const append = (next) => { + points.push([current, next]); + current = next; + }; + while (index < tokens.length) { + if (/^[a-z]$/i.test(tokens[index])) command = tokens[index++]; + if (!command) fail(slideNumber, element2, "svg_path_invalid", "SVG path begins without a command."); + const relative = command === command.toLowerCase(); + switch (command.toUpperCase()) { + case "M": { + current = point(relative); + subpathStart = current; + command = relative ? "l" : "L"; + break; + } + case "L": + append(point(relative)); + break; + case "H": { + const x = number(); + append({ x: relative ? current.x + x : x, y: current.y }); + break; + } + case "V": { + const y = number(); + append({ x: current.x, y: relative ? current.y + y : y }); + break; + } + case "C": { + const start = current; + const first = point(relative); + const second = point(relative); + const end = point(relative); + sampleCubic(start, first, second, end).forEach(append); + lastCubicControl = second; + lastQuadraticControl = null; + break; + } + case "S": { + const start = current; + const first = lastCubicControl ? { x: 2 * current.x - lastCubicControl.x, y: 2 * current.y - lastCubicControl.y } : current; + const second = point(relative); + const end = point(relative); + sampleCubic(start, first, second, end).forEach(append); + lastCubicControl = second; + lastQuadraticControl = null; + break; + } + case "Q": { + const start = current; + const control = point(relative); + const end = point(relative); + sampleQuadratic(start, control, end).forEach(append); + lastQuadraticControl = control; + lastCubicControl = null; + break; + } + case "T": { + const start = current; + const control = lastQuadraticControl ? { x: 2 * current.x - lastQuadraticControl.x, y: 2 * current.y - lastQuadraticControl.y } : current; + const end = point(relative); + sampleQuadratic(start, control, end).forEach(append); + lastQuadraticControl = control; + lastCubicControl = null; + break; + } + case "Z": + if (subpathStart && (current.x !== subpathStart.x || current.y !== subpathStart.y)) append(subpathStart); + command = ""; + break; + default: + fail( + slideNumber, + element2, + "svg_path_command_unsupported", + `SVG path command "${command}" cannot be represented as editable lines.` + ); + } + if (!["C", "S"].includes(command.toUpperCase())) lastCubicControl = null; + if (!["Q", "T"].includes(command.toUpperCase())) lastQuadraticControl = null; + } + return points; +} +function dashStyle(value2) { + const values2 = String(value2 || "").match(/[-+]?(?:\d*\.)?\d+/g)?.map(Number).filter((item) => Number.isFinite(item) && item >= 0) || []; + if (!values2.length) return null; + if (values2.length >= 4 && values2[2] <= values2[0] * 0.5) return "dashDot"; + if (values2.length >= 2 && values2[0] <= values2[1] * 0.5) return "dot"; + return "dash"; +} +function hrNodes(doc, bodyRect, paintOrderBySource) { + return [...doc.querySelectorAll("hr")].map((element2) => { + const rect = element2.getBoundingClientRect(); + const computed = doc.defaultView.getComputedStyle(element2); + const borderCandidates = [ + ["Top", computed.borderTopStyle, computed.borderTopWidth, computed.borderTopColor], + ["Bottom", computed.borderBottomStyle, computed.borderBottomWidth, computed.borderBottomColor], + ["Left", computed.borderLeftStyle, computed.borderLeftWidth, computed.borderLeftColor], + ["Right", computed.borderRightStyle, computed.borderRightWidth, computed.borderRightColor] + ]; + const border = borderCandidates.find(([, style, width2]) => style && style !== "none" && (Number.parseFloat(width2) || 0) > 0); + const [, borderStyle = "solid", borderWidth = "1px", borderColor = computed.color] = border || []; + const width = Number.parseFloat(borderWidth) || 1; + const color = colorToHex(borderColor || computed.color); + const sourceId = sourceIdOf(element2); + const dash = borderStyle === "dotted" ? "dot" : borderStyle === "dashed" ? "dash" : null; return { - ...request, - buildHtml: () => serializeRasterTargetDocument(doc, targetSpecs) + type: "line", + sourceId, + x1: Math.max(0, rect.left - bodyRect.left) / PX_PER_IN, + y1: Math.max(0, rect.top - bodyRect.top) / PX_PER_IN, + x2: Math.max(0, rect.right - bodyRect.left) / PX_PER_IN, + y2: Math.max(0, rect.top - bodyRect.top) / PX_PER_IN, + paintOrder: paintOrderBySource.get(sourceId) ?? 0, + subOrder: 0, + zIndex: Number.parseInt(computed.zIndex, 10) || 0, + style: { + color, + width: width * 0.75, + ...dash ? { dash, dashType: dash } : {} + } }; - } catch (error2) { - return { - ...request, - buildFailure: { - code: "page_visual_serialize_failed", - stage: "request-build", - reason: `Page-visual fallback could not be serialized: ${String(error2?.message || error2)}` + }); +} +function pathNodes(doc, bodyRect, slideNumber, paintOrderBySource) { + const nodes = []; + for (const path of doc.querySelectorAll("svg path")) { + let transformedAncestor = path; + while (transformedAncestor && transformedAncestor.localName !== "svg") { + const computedTransform = doc.defaultView.getComputedStyle(transformedAncestor).transform; + if (transformedAncestor.hasAttribute("transform") || computedTransform && computedTransform !== "none") { + fail( + slideNumber, + path, + "svg_path_transform_unsupported", + "Transformed SVG paths cannot yet be safely decomposed into editable slide lines." + ); } + transformedAncestor = transformedAncestor.parentElement; + } + const segments = parseEditablePath(path.getAttribute("d"), slideNumber, path); + const computed = doc.defaultView.getComputedStyle(path); + const fill2 = path.hasAttribute("fill") ? path.getAttribute("fill") : computed.fill || "black"; + if (segments.length && !isTransparent(fill2)) { + fail( + slideNumber, + path, + "svg_path_fill_unsupported", + "A filled SVG path cannot be safely decomposed into editable line objects." + ); + } + const svg = path.closest("svg"); + const svgRect = svg.getBoundingClientRect(); + const viewportMapper = createSvgViewportMapper(svg, svgRect); + const map = (point) => { + const mapped = viewportMapper.map(point); + return { + x: Math.max(0, mapped.x - bodyRect.left) / PX_PER_IN, + y: Math.max(0, mapped.y - bodyRect.top) / PX_PER_IN + }; }; + const stroke2 = path.getAttribute("stroke") || computed.stroke || fill2; + const dashArray = path.getAttribute("stroke-dasharray") || computed.strokeDasharray; + const dash = dashStyle(dashArray); + const style = { + color: colorToHex(stroke2), + width: (Number.parseFloat(path.getAttribute("stroke-width") || computed.strokeWidth) || 1) * 0.75, + ...dash ? { dash, dashType: dash } : {} + }; + const sourceId = sourceIdOf(path); + segments.forEach(([start, end], subOrder) => { + const first = map(start); + const second = map(end); + if (first.x === second.x && first.y === second.y) return; + nodes.push({ + type: "line", + rewrite: "svg_path_rewrite", + sourceId, + x1: first.x, + y1: first.y, + x2: second.x, + y2: second.y, + paintOrder: paintOrderBySource.get(sourceId) ?? 0, + subOrder, + style: { ...style } + }); + }); } + return nodes; } -function buildWholePageVisualFallbackRequest(doc, diagnostics = [], suppressedNativeVisualIds = []) { - const bodySourceId = doc?.body?.dataset?.pptxSourceId; - if (!bodySourceId) { +function mapExtractedElement(element2, doc) { + const position = element2.position || element2.bbox; + const shapeRadius = element2.type === "shape" ? element2.shape?.rectRadius : element2.shape?.radius; + const source = element2.sourceId ? [...doc.querySelectorAll("[data-pptx-source-id]")].find((candidate) => candidate.dataset.pptxSourceId === element2.sourceId) : null; + if (element2.type === "line") { + const dashArray = source?.getAttribute("stroke-dasharray") || (source ? doc.defaultView.getComputedStyle(source).strokeDasharray : ""); + const dash = element2.style?.dash || dashStyle(dashArray); return { - sourceId: "slide-visuals", - phase: "page-visual", - kind: "raster", - bbox: { x: 0, y: 0, w: 13.333, h: 7.5 }, - diagnostics, - suppressedNativeVisualIds: [...new Set(suppressedNativeVisualIds)], - buildFailure: { - code: "page_visual_target_missing", - stage: "request-build", - reason: "Whole-page visual source is unavailable." + type: "line", + sourceId: element2.sourceId, + x1: element2.x1, + y1: element2.y1, + x2: element2.x2, + y2: element2.y2, + paintOrder: element2.paintOrder, + subOrder: element2.subOrder, + zIndex: element2.zIndex, + style: { + color: String(element2.color || "000000").toUpperCase(), + width: element2.width || 0.75, + ...dash ? { dash, dashType: dash } : {} + } + }; + } + if (element2.type === "image") { + return { + type: "image", + intent: "user-image", + sourceId: element2.sourceId, + x: position.x, + y: position.y, + w: position.w, + h: position.h, + src: element2.src, + paintOrder: element2.paintOrder, + subOrder: element2.subOrder, + zIndex: element2.zIndex + }; + } + if (element2.type === "shape" || element2.type === "svg-shape") { + const cssEllipse = element2.type === "shape" && element2.shape?.rectRadius === 1; + const rounded = element2.svgType === "roundRect" || source?.localName === "rect" && (Number(source.getAttribute("rx")) > 0 || Number(source.getAttribute("ry")) > 0); + return { + type: "shape", + shapeType: cssEllipse ? "ellipse" : rounded ? "roundRect" : { + rect: "rect", + circle: "ellipse", + ellipse: "ellipse", + triangle: "triangle", + diamond: "diamond" + }[element2.svgType] || (element2.shape?.rectRadius ? "roundRect" : "rect"), + sourceId: element2.sourceId, + x: position.x, + y: position.y, + w: position.w, + h: position.h, + paintOrder: element2.paintOrder, + subOrder: element2.subOrder, + zIndex: element2.zIndex, + ...element2.rewrite ? { rewrite: element2.rewrite } : {}, + style: { + fill: String(element2.shape?.fill || "FFFFFF").toUpperCase(), + line: element2.shape?.line, + transparency: element2.shape?.transparency, + rotate: element2.shape?.rotate ?? 0, + ...element2.shape?.shadow ? { shadow: element2.shape.shadow } : {}, + ...Number.isFinite(shapeRadius) && shapeRadius > 0 && !cssEllipse ? { radius: shapeRadius } : {} + } + }; + } + const text2 = element2.text ?? element2.items; + if (text2 != null) { + const anchor = source?.getAttribute("text-anchor"); + return { + type: "text", + sourceId: element2.sourceId, + x: position.x, + y: position.y, + w: position.w, + h: position.h, + text: text2, + paintOrder: element2.paintOrder, + subOrder: element2.subOrder, + zIndex: element2.zIndex, + style: { + ...element2.style || {}, + ...anchor ? { align: { start: "left", middle: "center", end: "right" }[anchor] || "left" } : {} } }; } - const request = buildPageVisualFallbackRequest(doc, [{ - sourceId: bodySourceId, - captureStrategy: "visual-subtree", - suppressedNativeVisualIds: [...new Set(suppressedNativeVisualIds)], - zIndex: 0, - paintOrder: 0, + return null; +} +function isSupportedEditablePolygon(element2) { + return Boolean(classifySvgPresetPolygon(parsePolygonPoints(element2))); +} +function auditExtractedDiagnostics(extracted, rewrittenSourceIds, slideNumber) { + for (const diagnostic of extracted.diagnostics || []) { + if (diagnostic.severity !== "blocking" || rewrittenSourceIds.has(diagnostic.sourceId)) continue; + failSource( + slideNumber, + diagnostic.sourceId, + diagnostic.code || "editable_input_unsupported", + diagnostic.message || "The source cannot be represented as editable PowerPoint objects." + ); + } +} +function normalizeDocumentToEditableScene(doc, { slideNumber, width, height } = {}) { + if (!doc?.body || !doc.defaultView) { + fail(slideNumber, null, "unreadable_document", "The slide document is not readable."); + } + editableErrorPreflight(doc, slideNumber); + validateSvgPresetGeometry(doc, slideNumber); + const bodyRect = doc.body.getBoundingClientRect(); + if (!(bodyRect.width > 0) || !(bodyRect.height > 0)) { + fail( + slideNumber, + doc.body, + "unmeasurable_canvas", + "The editable slide canvas must have measurable positive dimensions." + ); + } + if (Math.abs(bodyRect.width - width * PX_PER_IN) > 2 || Math.abs(bodyRect.height - height * PX_PER_IN) > 2) { + fail( + slideNumber, + doc.body, + "canvas_size", + "The source canvas dimensions do not match the editable slide dimensions." + ); + } + const bodyDimensions = measureBodyDimensions(doc); + if (bodyDimensions.errors?.length) { + fail(slideNumber, doc.body, "canvas_overflow", bodyDimensions.errors.join(" ")); + } + const extracted = extractSlideDataFromDocument(doc); + for (const element2 of doc.querySelectorAll("[data-pptx-source-id]")) { + const computed = doc.defaultView.getComputedStyle(element2); + const textShadow = [ + computed.textShadow, + element2.style?.textShadow, + authoredStyleValue(element2.dataset?.pptxAuthoredStyle, "text-shadow") + ].find((value2) => value2 && value2 !== "none"); + if (textShadow) { + fail( + slideNumber, + element2, + "text_shadow_unsupported", + "CSS text-shadow cannot be represented as editable PowerPoint text." + ); + } + const boxShadow = [ + computed.boxShadow, + element2.style?.boxShadow, + authoredStyleValue(element2.dataset?.pptxAuthoredStyle, "box-shadow") + ].find((value2) => value2 && value2 !== "none"); + if (!boxShadow) continue; + const sourceId = sourceIdOf(element2); + const nativeShadow = extracted.elements.some((candidate) => candidate.sourceId === sourceId && candidate.shape?.shadow); + const ringRewrite = extracted.elements.some((candidate) => candidate.sourceId === sourceId && candidate.rewrite === "css_box_shadow_ring"); + if (!nativeShadow && !ringRewrite) { + fail( + slideNumber, + element2, + "box_shadow_unsupported", + "CSS box-shadow cannot be represented as one native editable PowerPoint outer shadow." + ); + } + } + const paintOrderBySource = /* @__PURE__ */ new Map(); + [doc.body, ...doc.body.querySelectorAll("[data-pptx-source-id]")].forEach((element2, index) => { + paintOrderBySource.set(sourceIdOf(element2), index); + }); + extracted.elements.forEach((element2) => { + if (element2.sourceId && Number.isFinite(element2.paintOrder)) { + paintOrderBySource.set(element2.sourceId, element2.paintOrder); + } + }); + const tableOwnedSourceIds = new Set( + [...doc.querySelectorAll("table, table *")].map((element2) => element2.dataset?.pptxSourceId || element2.id || null).filter(Boolean) + ); + const hrSourceIds = new Set([...doc.querySelectorAll("hr")].map(sourceIdOf)); + const pathSourceIds = new Set([...doc.querySelectorAll("svg path")].map(sourceIdOf)); + const gradientSourceIds = new Set( + [doc.body, ...doc.querySelectorAll("*")].filter((element2) => !element2.closest?.("table") && gradientValueForElement(element2, doc.defaultView).includes("gradient(")).map(sourceIdOf) + ); + const rewrittenSourceIds = /* @__PURE__ */ new Set([ + ...pathSourceIds, + ...gradientSourceIds, + ...tableOwnedSourceIds, + ...hrSourceIds + ]); + doc.querySelectorAll("svg").forEach((svg) => { + const paths = [...svg.querySelectorAll("path")]; + if (paths.length && paths.every((path) => pathSourceIds.has(sourceIdOf(path)))) { + rewrittenSourceIds.add(sourceIdOf(svg)); + } + }); + doc.querySelectorAll("svg polygon").forEach((polygon) => { + const sourceId = sourceIdOf(polygon); + const nativePolygon = extracted.elements.some((element2) => element2.sourceId === sourceId && element2.type === "svg-shape" && ["triangle", "diamond"].includes(element2.svgType)); + if (isSupportedEditablePolygon(polygon) && nativePolygon) rewrittenSourceIds.add(sourceId); + }); + const normalizedPaths = pathNodes(doc, bodyRect, slideNumber, paintOrderBySource); + const normalizedTables = tableNodes(doc, bodyRect, slideNumber, paintOrderBySource); + auditExtractedDiagnostics(extracted, rewrittenSourceIds, slideNumber); + if (extracted.background?.type === "image") { + fail( + slideNumber, + doc.body, + "background_image_unsupported", + "Slide background images must be authored as intentional img elements." + ); + } + const backgroundNodes = extracted.background?.type === "color" ? [{ + type: "shape", + shapeType: "rect", + sourceId: `${sourceIdOf(doc.body)}-background`, + x: 0, + y: 0, + w: width, + h: height, + paintOrder: -1, subOrder: 0, - diagnostics - }]); + zIndex: -1, + style: { + fill: String(extracted.background.value || "FFFFFF").toUpperCase(), + line: null + } + }] : []; + const nativeNodes = extracted.elements.filter((element2) => !tableOwnedSourceIds.has(element2.sourceId) && !pathSourceIds.has(element2.sourceId) && !(gradientSourceIds.has(element2.sourceId) && ["shape", "svg-shape"].includes(element2.type))).map((element2) => mapExtractedElement(element2, doc)).filter(Boolean); + const scene = { + slideNumber, + width, + height, + nodes: [ + ...backgroundNodes, + ...nativeNodes, + ...gradientNodes(doc, bodyRect, slideNumber, paintOrderBySource), + ...normalizedPaths, + ...normalizedTables, + ...hrNodes(doc, bodyRect, paintOrderBySource) + ].sort((left, right) => (left.zIndex ?? 0) - (right.zIndex ?? 0) || (left.paintOrder ?? 0) - (right.paintOrder ?? 0) || (left.subOrder ?? 0) - (right.subOrder ?? 0)) + }; + return validateEditableSlideScene(scene); +} + +// src/pptx-element-export.js +var SLIDE_W = 13.333; +var SLIDE_H = 7.5; +function pct(value2) { + return Math.max(0, Math.min(100, Number(value2) || 0)) / 100; +} +function pxToPt(value2) { + return Math.max(6, Math.min(66, Math.round((Number(value2) || 22) * 0.58))); +} +function hex(value2, fallback = "111827") { + const raw = String(value2 || "").trim(); + if (/^#[0-9a-f]{6}$/i.test(raw)) return raw.slice(1).toUpperCase(); + if (/^[0-9a-f]{6}$/i.test(raw)) return raw.toUpperCase(); + if (/^#[0-9a-f]{3}$/i.test(raw)) { + return raw.slice(1).split("").map((part) => part + part).join("").toUpperCase(); + } + return fallback; +} +function themeFor(sourceSlide = {}) { + const theme = sourceSlide.theme || {}; return { - ...request, - sourceId: "slide-visuals", - sourceIds: [bodySourceId], - captureStrategy: "whole-page-visual" + background: hex(theme.background, "FBFCFF"), + ink: hex(theme.ink, "111827"), + muted: hex(theme.muted, "5B6575"), + primary: hex(theme.primary, "0F766E"), + accent: hex(theme.accent, "F97316"), + panel: hex(theme.panel, "FFFFFF") }; } -async function renderRasterFallbackLayers(requests, renderRaster, slideIndex) { - const layers = []; - const failures = []; - if (typeof renderRaster !== "function") { - return { layers, failures: [...requests] }; - } - for (const request of requests) { - if (request.buildFailure) { - failures.push({ - ...request, - error: request.buildFailure.reason - }); - continue; - } - try { - const html = typeof request.buildHtml === "function" ? request.buildHtml() : request.html; - if (!html) throw new Error("Raster fallback HTML could not be generated"); - const rendered = await renderRaster(html, slideIndex, { - sourceId: request.sourceId, - bbox: request.bbox, - zIndex: request.zIndex, - paintOrder: request.paintOrder, - subOrder: request.subOrder, - phase: request.phase, - captureStrategy: request.captureStrategy, - suppressedNativeVisualIds: request.suppressedNativeVisualIds || [] - }); - const raw = String(rendered || "").replace(/^data:.*;base64,/, ""); - if (!raw) throw new Error("Raster renderer returned no PNG data"); - layers.push({ - ...request, - buildHtml: void 0, - html: void 0, - data: `data:image/png;base64,${raw}` - }); - } catch (error2) { - failures.push({ ...request, error: String(error2?.message || error2) }); +function resolveColor(value2, theme) { + const key = String(value2 || "").replace(/^#/, "").toLowerCase(); + if (theme[key]) return theme[key]; + return hex(value2, theme.ink); +} +function elementBox2(element2) { + return { + x: pct(element2.x) * SLIDE_W, + y: pct(element2.y) * SLIDE_H, + w: Math.max(0.01, pct(element2.w) * SLIDE_W), + h: Math.max(0.01, pct(element2.h) * SLIDE_H) + }; +} +function nodeId(element2, index, suffix = "") { + const base = String(element2.id || element2.sourceId || `element-${index + 1}`); + return suffix ? `${base}-${suffix}` : base; +} +function shapeNode(sourceId, box, shapeType, fill2, paintOrder, extraStyle = {}) { + return { + type: "shape", + shapeType, + sourceId, + ...box, + zIndex: paintOrder, + paintOrder, + subOrder: 0, + style: { + fill: fill2, + line: null, + ...extraStyle } - } - return { layers, failures }; + }; } -function failureDiagnostic(code, failure, slideIndex, severity = "fallback") { - const reason = failure.buildFailure?.reason || failure.error; +function textNode(sourceId, box, text2, style, theme, paintOrder, subOrder = 1) { return { - severity, - kind: severity === "blocking" ? "blocking" : void 0, - code: failure.buildFailure?.code || code, - message: `${failure.phase || "raster"} fallback failed for ${failure.sourceId || "slide visual"}: ${reason}`, - sourceId: failure.sourceId || null, - slideNumber: slideIndex + 1, - phase: failure.phase || null, - stage: failure.buildFailure?.stage || "render", - reason + type: "text", + sourceId, + ...box, + zIndex: paintOrder, + text: text2, + paintOrder, + subOrder, + style: { + margin: 0.08, + fontFace: "Aptos", + fontSize: pxToPt(style.fontSize || 22), + bold: Number(style.fontWeight || 500) >= 700, + color: resolveColor(style.color || "ink", theme), + align: style.align || "left", + valign: "mid" + } }; } -async function renderRasterFallbackPlan(plan, renderRaster, slideIndex) { - const diagnostics = []; - if (plan.pageVisualRequest?.captureStrategy === "whole-page-visual") { - const pageResult = await renderRasterFallbackLayers( - [plan.pageVisualRequest], - renderRaster, - slideIndex +function elementModelError(slideNumber, sourceId, code, message) { + throw new EditableExportError({ + slideNumber, + sourceId, + code, + message + }); +} +function hasText(value2) { + return typeof value2 === "string" && Boolean(value2.trim()); +} +function normalizeElement2(element2, index, theme, paintOrder, slideNumber) { + const box = elementBox2(element2); + const style = element2.style || {}; + const sourceId = nodeId(element2, index); + const zIndex = Number.isFinite(element2.zIndex) ? element2.zIndex : paintOrder; + if ((element2.type === "image" || element2.type === "media") && (element2.src || element2.path || element2.data)) { + const nodes = [{ + type: "image", + intent: "user-image", + sourceId, + ...box, + zIndex, + paintOrder, + subOrder: 0, + ...element2.src ? { src: element2.src } : {}, + ...element2.path ? { path: element2.path } : {}, + ...element2.data ? { data: element2.data } : {} + }]; + validateEditableSlideScene({ + slideNumber, + width: SLIDE_W, + height: SLIDE_H, + nodes + }); + return nodes; + } + if (element2.type === "image" || element2.type === "media" || element2.type === "video") { + elementModelError( + slideNumber, + sourceId, + "element_model_media_unsupported", + `Element "${sourceId}" requires an inline base64 user image; media placeholders and video are not editable.` ); - if (pageResult.layers.length) { - return { - layers: pageResult.layers, - fullPageFallback: null, - diagnostics: [{ - severity: "fallback", - code: "page_visual_fallback", - message: `Slide ${slideIndex + 1} used a page visual fallback.`, - sourceId: "slide-visuals", - slideNumber: slideIndex + 1, - phase: "page-visual" - }], - blocking: false - }; + } + if (element2.type === "shape") { + const fill2 = resolveColor(style.background || "panel", theme); + const nodes = [ + shapeNode(sourceId, box, "roundRect", fill2, paintOrder, { radius: 0.08 }), + ...element2.text ? [textNode(`${sourceId}-text`, box, String(element2.text), style, theme, paintOrder)] : [] + ]; + nodes.forEach((node) => { + node.zIndex = zIndex; + }); + return nodes; + } + if (element2.type === "list") { + if (!Array.isArray(element2.items) || !element2.items.length || element2.items.some((item) => !hasText(item))) { + elementModelError( + slideNumber, + sourceId, + "element_model_payload_invalid", + `List element "${sourceId}" requires at least one non-empty item.` + ); } - diagnostics.push(...pageResult.failures.map((failure) => failureDiagnostic("page_visual_raster_failed", failure, slideIndex))); - const fullResult = await renderRasterFallbackLayers( - plan.fullPageRequest ? [plan.fullPageRequest] : [], - renderRaster, - slideIndex - ); - if (fullResult.layers.length) { - return { - layers: [], - fullPageFallback: fullResult.layers[0], - diagnostics: [ - ...diagnostics, - { - severity: "fallback", - code: "full_page_fallback", - message: `Slide ${slideIndex + 1} used a full-page fallback.`, - sourceId: fullResult.layers[0].sourceId, - slideNumber: slideIndex + 1, - phase: "full-page" - } - ], - blocking: false - }; + const runs = (element2.items || []).map((item) => ({ + text: String(item), + options: { bullet: { type: "bullet" }, breakLine: true } + })); + const nodes = [textNode(sourceId, box, runs, style, theme, paintOrder, 0)]; + nodes[0].zIndex = zIndex; + return nodes; + } + if (element2.type === "metric") { + if (!hasText(element2.text) || !hasText(element2.label)) { + elementModelError( + slideNumber, + sourceId, + "element_model_payload_invalid", + `Metric element "${sourceId}" requires non-empty text and label values.` + ); + } + const nodes = [ + shapeNode(`${sourceId}-panel`, box, "roundRect", resolveColor(style.background || "panel", theme), paintOrder, { + radius: 0.08 + }), + textNode(sourceId, { + ...box, + y: box.y + 0.08, + h: box.h * 0.48 + }, String(element2.text || ""), { + ...style, + color: style.color || "primary", + fontSize: style.fontSize || 42, + fontWeight: 700 + }, theme, paintOrder), + textNode(`${sourceId}-label`, { + ...box, + y: box.y + box.h * 0.56, + h: box.h * 0.34 + }, String(element2.label || ""), { + color: "muted", + fontSize: 17 + }, theme, paintOrder, 2) + ]; + nodes.forEach((node) => { + node.zIndex = zIndex; + }); + return nodes; + } + if (element2.type === "chart") { + if (!Array.isArray(element2.data) || !element2.data.length || element2.data.some((point) => !hasText(point?.label) || !Number.isFinite(Number(point?.value)))) { + elementModelError( + slideNumber, + sourceId, + "element_model_payload_invalid", + `Chart element "${sourceId}" requires labeled finite data points.` + ); } - diagnostics.push(...fullResult.failures.map((failure) => failureDiagnostic("full_page_raster_failed", failure, slideIndex, "blocking"))); - return { layers: [], fullPageFallback: null, diagnostics, blocking: true }; + const data = element2.data; + const max = Math.max(1, ...data.map((point) => Number(point.value) || 0)); + const chartX = box.x + 0.18; + const chartY = box.y + 0.68; + const chartW = box.w - 0.36; + const chartH = box.h - 0.95; + const gap = 0.1; + const barW = Math.max(0.08, (chartW - gap * (data.length - 1)) / data.length); + const nodes = [ + shapeNode(`${sourceId}-panel`, box, "roundRect", resolveColor(style.background || "panel", theme), paintOrder, { + radius: 0.08 + }), + textNode( + `${sourceId}-title`, + { ...box, y: box.y + 0.1, h: 0.32 }, + String(element2.text || ""), + { ...style, fontSize: 19, fontWeight: 700 }, + theme, + paintOrder, + 1 + ) + ]; + data.forEach((point, dataIndex) => { + const height = Math.max(0.15, (Number(point.value) || 0) / max * chartH); + const x = chartX + dataIndex * (barW + gap); + nodes.push(shapeNode(`${sourceId}-bar-${dataIndex + 1}`, { + x, + y: chartY + chartH - height, + w: barW, + h: height + }, "rect", dataIndex % 2 ? theme.accent : theme.primary, paintOrder, { + line: null + })); + nodes.at(-1).subOrder = 2 + dataIndex * 2; + nodes.push(textNode(`${sourceId}-label-${dataIndex + 1}`, { + x: x - 0.03, + y: chartY + chartH + 0.04, + w: barW + 0.06, + h: 0.2 + }, String(point.label || ""), { + color: "muted", + fontSize: 12, + align: "center" + }, theme, paintOrder, 3 + dataIndex * 2)); + }); + nodes.forEach((node) => { + node.zIndex = zIndex; + }); + return nodes; + } + if (element2.type === "text") { + if (!hasText(element2.text)) { + elementModelError( + slideNumber, + sourceId, + "element_model_payload_invalid", + `Text element "${sourceId}" requires non-empty text.` + ); + } + const nodes = []; + if (style.background && style.background !== "transparent") { + nodes.push(shapeNode( + `${sourceId}-background`, + box, + "roundRect", + resolveColor(style.background, theme), + paintOrder, + { radius: 0.08 } + )); + } + nodes.push(textNode(sourceId, box, element2.text, style, theme, paintOrder, nodes.length)); + nodes.forEach((node) => { + node.zIndex = zIndex; + }); + return nodes; } - const localResult = await renderRasterFallbackLayers( - plan.localRequests || [], - renderRaster, - slideIndex + elementModelError( + slideNumber, + sourceId, + "element_model_type_unsupported", + `Element type "${String(element2.type)}" is not supported by editable PPTX export.` ); - if (!localResult.failures.length) { - return { - layers: localResult.layers, - fullPageFallback: null, - diagnostics, - blocking: false +} +function normalizeElementSlideToEditableScene(sourceSlide = {}, { slideNumber = 1 } = {}) { + const theme = themeFor(sourceSlide); + const nodes = [ + shapeNode("legacy-slide-background", { + x: 0, + y: 0, + w: SLIDE_W, + h: SLIDE_H + }, "rect", theme.background, 0), + shapeNode("legacy-slide-accent", { + x: 0, + y: 0, + w: 0.12, + h: SLIDE_H + }, "rect", theme.primary, 1) + ]; + let paintOrder = 2; + if (sourceSlide.kicker) { + nodes.push(shapeNode("legacy-kicker-mark", { + x: 0.96, + y: 0.48, + w: 0.22, + h: 0.07 + }, "roundRect", theme.primary, paintOrder, { radius: 0.035 })); + nodes.push(textNode("legacy-kicker", { + x: 1.24, + y: 0.36, + w: 2.4, + h: 0.28 + }, String(sourceSlide.kicker).toUpperCase(), { + color: "primary", + fontSize: 12, + fontWeight: 700 + }, theme, paintOrder, 1)); + paintOrder += 1; + } + (sourceSlide.elements || []).forEach((element2, index) => { + nodes.push(...normalizeElement2(element2, index, theme, paintOrder, slideNumber)); + paintOrder += 1; + }); + if (sourceSlide.sourceNote) { + nodes.push(textNode("legacy-source-note", { + x: 0.96, + y: 7.05, + w: 10.7, + h: 0.2 + }, String(sourceSlide.sourceNote), { + color: "muted", + fontSize: 10 + }, theme, paintOrder)); + } + return validateEditableSlideScene({ + slideNumber, + width: SLIDE_W, + height: SLIDE_H, + nodes + }); +} + +// src/preflight-slide-audit.js +var VISIBLE_UNSUPPORTED_SELECTOR = [ + "canvas", + "video", + "audio", + "iframe", + "object", + "embed", + "frame", + "frameset", + "portal", + "applet", + "input", + "button", + "select", + "textarea", + "progress", + "meter", + "dialog", + 'link[rel~="stylesheet" i]' +].join(","); +function sourceIdOf2(element2, slideNumber) { + return element2?.getAttribute?.("data-pptx-source-id") || element2?.id || `slide-${slideNumber}`; +} +function fail2(slideNumber, element2, code, message) { + throw new EditableExportError({ + slideNumber, + sourceId: sourceIdOf2(element2, slideNumber), + code, + message + }); +} +function canonicalizeCssSyntax(value2) { + const input = String(value2 || ""); + let output = ""; + let quote = null; + for (let index = 0; index < input.length; ) { + const character = input[index]; + if (quote) { + output += character; + index += 1; + if (character === "\\" && index < input.length) { + output += input[index]; + index += 1; + } else if (character === quote) { + quote = null; + } + continue; + } + if (character === '"' || character === "'") { + quote = character; + output += character; + index += 1; + continue; + } + if (character === "/" && input[index + 1] === "*") { + const end = input.indexOf("*/", index + 2); + index = end < 0 ? input.length : end + 2; + continue; + } + if (character !== "\\") { + output += character; + index += 1; + continue; + } + index += 1; + if (index >= input.length) break; + if (input[index] === "\n" || input[index] === "\r" || input[index] === "\f") { + if (input[index] === "\r" && input[index + 1] === "\n") index += 1; + index += 1; + continue; + } + const hex2 = input.slice(index).match(/^[0-9a-f]{1,6}/i)?.[0]; + if (hex2) { + const codePoint = Number.parseInt(hex2, 16); + output += String.fromCodePoint( + codePoint === 0 || codePoint > 1114111 ? 65533 : codePoint + ); + index += hex2.length; + if (/[\t\n\f\r ]/.test(input[index] || "")) index += 1; + continue; + } + output += input[index]; + index += 1; + } + return output; +} +function cssValueHasUrlToken(value2) { + const css = canonicalizeCssSyntax(value2); + let quote = null; + for (let index = 0; index < css.length; ) { + const character = css[index]; + if (quote) { + if (character === "\\") index += 2; + else { + if (character === quote) quote = null; + index += 1; + } + continue; + } + if (character === '"' || character === "'") { + quote = character; + index += 1; + continue; + } + if (!/[a-z_-]/i.test(character)) { + index += 1; + continue; + } + const start = index; + while (index < css.length && /[a-z0-9_-]/i.test(css[index])) index += 1; + const identifier = css.slice(start, index).toLowerCase(); + while (index < css.length && /\s/.test(css[index])) index += 1; + if (identifier === "url" && css[index] === "(") return true; + } + return false; +} +function referencedCustomProperties(value2) { + const css = canonicalizeCssSyntax(value2); + const references = []; + let quote = null; + for (let index = 0; index < css.length; ) { + const character = css[index]; + if (quote) { + if (character === "\\") index += 2; + else { + if (character === quote) quote = null; + index += 1; + } + continue; + } + if (character === '"' || character === "'") { + quote = character; + index += 1; + continue; + } + const match = css.slice(index).match(/^var\s*\(\s*(--[a-z0-9_-]+)/i); + if (match) { + references.push(match[1]); + index += match[0].length; + continue; + } + index += 1; + } + return references; +} +function valueOrVariableHasUrl(value2, declarations, seen = /* @__PURE__ */ new Set()) { + if (cssValueHasUrlToken(value2)) return true; + return referencedCustomProperties(value2).some((property) => { + if (seen.has(property) || !declarations.has(property)) return false; + const nextSeen = new Set(seen); + nextSeen.add(property); + return valueOrVariableHasUrl(declarations.get(property), declarations, nextSeen); + }); +} +function textShadowIsVisible(value2, declarations, seen = /* @__PURE__ */ new Set()) { + const normalized = canonicalizeCssSyntax(value2).trim().toLowerCase(); + if (!normalized || normalized === "none") return false; + const references = referencedCustomProperties(value2); + if (!references.length) return true; + return references.some((property) => { + if (seen.has(property) || !declarations.has(property)) return false; + const nextSeen = new Set(seen); + nextSeen.add(property); + return textShadowIsVisible(declarations.get(property), declarations, nextSeen); + }); +} +function styleDeclarations(style) { + const declarations = /* @__PURE__ */ new Map(); + for (let index = 0; index < style.length; index += 1) { + const property = style[index].toLowerCase(); + declarations.set(property, { + value: style.getPropertyValue(property), + important: style.getPropertyPriority(property) === "important" + }); + } + return declarations; +} +function parsedDeclarations(doc, cssText) { + const probe = doc.createElement("div"); + probe.style.cssText = canonicalizeCssSyntax(cssText); + return styleDeclarations(probe.style); +} +function compareSpecificity(left, right) { + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} +function applyCascadeDeclaration(target, property, declaration) { + const current = target.get(property); + const shouldReplace = !current || Number(declaration.important) > Number(current.important) || declaration.important === current.important && (compareSpecificity(declaration.specificity, current.specificity) > 0 || compareSpecificity(declaration.specificity, current.specificity) === 0 && declaration.sourceOrder >= current.sourceOrder); + if (shouldReplace) target.set(property, declaration); +} +function splitSelectorList(selectorText) { + const selectors = []; + let start = 0; + let quote = null; + let squareDepth = 0; + let roundDepth = 0; + for (let index = 0; index < selectorText.length; index += 1) { + const character = selectorText[index]; + if (quote) { + if (character === "\\") index += 1; + else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === "[") squareDepth += 1; + else if (character === "]") squareDepth = Math.max(0, squareDepth - 1); + else if (character === "(") roundDepth += 1; + else if (character === ")") roundDepth = Math.max(0, roundDepth - 1); + else if (character === "," && squareDepth === 0 && roundDepth === 0) { + selectors.push(selectorText.slice(start, index).trim()); + start = index + 1; + } + } + selectors.push(selectorText.slice(start).trim()); + return selectors.filter(Boolean); +} +function matchingParenthesis(value2, openingIndex) { + let depth = 1; + let quote = null; + for (let index = openingIndex + 1; index < value2.length; index += 1) { + const character = value2[index]; + if (quote) { + if (character === "\\") index += 1; + else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === "(") depth += 1; + else if (character === ")") { + depth -= 1; + if (depth === 0) return index; + } + } + return value2.length - 1; +} +function replaceCssUrlsForProjection(value2) { + const css = canonicalizeCssSyntax(value2); + let output = ""; + let quote = null; + for (let index = 0; index < css.length; ) { + const character = css[index]; + if (quote) { + output += character; + index += 1; + if (character === "\\" && index < css.length) { + output += css[index]; + index += 1; + } else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") { + quote = character; + output += character; + index += 1; + continue; + } + if (!/[a-z_-]/i.test(character)) { + output += character; + index += 1; + continue; + } + const start = index; + while (index < css.length && /[a-z0-9_-]/i.test(css[index])) index += 1; + const identifier = css.slice(start, index); + let opening = index; + while (opening < css.length && /\s/.test(css[opening])) opening += 1; + if (identifier.toLowerCase() !== "url" || css[opening] !== "(") { + output += css.slice(start, index); + continue; + } + const closing = matchingParenthesis(css, opening); + output += 'url("#__pptx_paint_server_probe__")'; + index = closing + 1; + } + return output; +} +function stripCssImports(value2) { + const css = String(value2 || ""); + let output = ""; + let quote = null; + for (let index = 0; index < css.length; ) { + const character = css[index]; + if (quote) { + output += character; + index += 1; + if (character === "\\" && index < css.length) { + output += css[index]; + index += 1; + } else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") { + quote = character; + output += character; + index += 1; + continue; + } + const importMatch = css.slice(index).match(/^@import\b/i); + if (!importMatch) { + output += character; + index += 1; + continue; + } + index += importMatch[0].length; + let parentheses = 0; + let importQuote = null; + while (index < css.length) { + const importCharacter = css[index]; + if (importQuote) { + if (importCharacter === "\\") index += 2; + else { + if (importCharacter === importQuote) importQuote = null; + index += 1; + } + } else if (importCharacter === '"' || importCharacter === "'") { + importQuote = importCharacter; + index += 1; + } else if (importCharacter === "(") { + parentheses += 1; + index += 1; + } else if (importCharacter === ")") { + parentheses = Math.max(0, parentheses - 1); + index += 1; + } else if (importCharacter === ";" && parentheses === 0) { + index += 1; + break; + } else index += 1; + } + } + return output; +} +function maxSpecificity(selectors) { + return selectors.reduce((maximum, selector) => { + const specificity = selectorSpecificity(selector); + return compareSpecificity(specificity, maximum) > 0 ? specificity : maximum; + }, [0, 0, 0, 0]); +} +function selectorSpecificity(selector) { + const css = canonicalizeCssSyntax(selector); + const specificity = [0, 0, 0, 0]; + let expectsType = true; + for (let index = 0; index < css.length; ) { + const character = css[index]; + if (/\s/.test(character) || [">", "+", "~", ","].includes(character)) { + expectsType = true; + index += 1; + continue; + } + if (character === "#") { + specificity[1] += 1; + index += 1; + while (index < css.length && /[\w-]/.test(css[index])) index += 1; + expectsType = false; + continue; + } + if (character === ".") { + specificity[2] += 1; + index += 1; + while (index < css.length && /[\w-]/.test(css[index])) index += 1; + expectsType = false; + continue; + } + if (character === "[") { + specificity[2] += 1; + let quote = null; + index += 1; + while (index < css.length) { + if (quote) { + if (css[index] === "\\") index += 2; + else { + if (css[index] === quote) quote = null; + index += 1; + } + } else if (css[index] === '"' || css[index] === "'") { + quote = css[index]; + index += 1; + } else if (css[index] === "]") { + index += 1; + break; + } else index += 1; + } + expectsType = false; + continue; + } + if (character === ":") { + const pseudoElement = css[index + 1] === ":"; + index += pseudoElement ? 2 : 1; + const nameStart = index; + while (index < css.length && /[\w-]/.test(css[index])) index += 1; + const name = css.slice(nameStart, index).toLowerCase(); + const legacyPseudoElement = ["before", "after", "first-line", "first-letter"].includes(name); + if (pseudoElement || legacyPseudoElement) specificity[3] += 1; + else if (!["where", "is", "not", "has"].includes(name)) specificity[2] += 1; + if (css[index] === "(") { + const closing = matchingParenthesis(css, index); + const argument = css.slice(index + 1, closing); + if (["is", "not", "has"].includes(name)) { + const nested = maxSpecificity(splitSelectorList(argument)); + specificity[1] += nested[1]; + specificity[2] += nested[2]; + specificity[3] += nested[3]; + } else if (["nth-child", "nth-last-child"].includes(name)) { + const ofMatch = argument.match(/\bof\b([\s\S]*)$/i); + if (ofMatch) { + const nested = maxSpecificity(splitSelectorList(ofMatch[1])); + specificity[1] += nested[1]; + specificity[2] += nested[2]; + specificity[3] += nested[3]; + } + } + index = closing + 1; + } + expectsType = false; + continue; + } + if (character === "*") { + expectsType = false; + index += 1; + continue; + } + if (expectsType && /[a-z_-]/i.test(character)) { + specificity[3] += 1; + while (index < css.length && /[\w-]/.test(css[index])) index += 1; + expectsType = false; + continue; + } + index += 1; + } + return specificity; +} +function matchingSelectorSpecificity(element2, selectorText) { + const matchingSelectors = splitSelectorList(selectorText).filter((selector) => { + try { + return element2.matches(selector); + } catch { + return false; + } + }); + return matchingSelectors.length ? maxSpecificity(matchingSelectors) : null; +} +function walkCssRules(rules, visit) { + for (const rule of rules || []) { + if (rule.selectorText && rule.style) visit(rule); + if (rule.cssRules) walkCssRules(rule.cssRules, visit); + } +} +function findCssOpeningBrace(css, start) { + let quote = null; + let parentheses = 0; + for (let index = start; index < css.length; index += 1) { + const character = css[index]; + if (quote) { + if (character === "\\") index += 1; + else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === "(") parentheses += 1; + else if (character === ")") parentheses = Math.max(0, parentheses - 1); + else if (character === "{" && parentheses === 0) return index; + else if (character === ";" && parentheses === 0) return -index - 2; + } + return -1; +} +function findCssClosingBrace(css, openingBrace) { + let quote = null; + let depth = 1; + for (let index = openingBrace + 1; index < css.length; index += 1) { + const character = css[index]; + if (quote) { + if (character === "\\") index += 1; + else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === "{") depth += 1; + else if (character === "}") { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} +function walkCssTextRules(cssText, visit) { + const css = canonicalizeCssSyntax(cssText); + let cursor = 0; + while (cursor < css.length) { + while (cursor < css.length && /[\s;]/.test(css[cursor])) cursor += 1; + if (cursor >= css.length) break; + const openingResult = findCssOpeningBrace(css, cursor); + if (openingResult < 0) { + if (openingResult < -1) { + cursor = -openingResult - 1; + continue; + } + break; + } + const closingBrace = findCssClosingBrace(css, openingResult); + if (closingBrace < 0) break; + const prelude = css.slice(cursor, openingResult).trim(); + const block = css.slice(openingResult + 1, closingBrace); + if (/^@(media|supports|layer|container|document)\b/i.test(prelude)) { + walkCssTextRules(block, visit); + } else if (!prelude.startsWith("@")) { + visit(prelude, block); + } + cursor = closingBrace + 1; + } +} +function collectStylesheetDeclarations(parsed) { + const byElement = /* @__PURE__ */ new Map(); + let sourceOrder = 0; + for (const authoredStyle of parsed.querySelectorAll("style")) { + const cssDocument = parsed.implementation.createHTMLDocument(""); + const style = cssDocument.createElement("style"); + style.textContent = canonicalizeCssSyntax(authoredStyle.textContent || ""); + cssDocument.head.appendChild(style); + const mergeRule = (selectorText, declarationsToMerge) => { + let targets; + try { + targets = parsed.querySelectorAll(selectorText); + } catch { + return; + } + for (const target of targets) { + const specificity = matchingSelectorSpecificity(target, selectorText); + if (!specificity) continue; + const declarations = byElement.get(target) || /* @__PURE__ */ new Map(); + const entries = declarationsToMerge instanceof Map ? declarationsToMerge : styleDeclarations(declarationsToMerge); + for (const [property, declaration] of entries) { + applyCascadeDeclaration(declarations, property, { + ...declaration, + specificity, + sourceOrder + }); + } + byElement.set(target, declarations); + } + sourceOrder += 1; }; + if (style.sheet?.cssRules) { + walkCssRules(style.sheet.cssRules, (rule) => mergeRule(rule.selectorText, rule.style)); + } else { + walkCssTextRules(authoredStyle.textContent || "", (selectorText, declarationText) => { + mergeRule(selectorText, parsedDeclarations(parsed, declarationText)); + }); + } } - diagnostics.push(...localResult.failures.map((failure) => failureDiagnostic("local_raster_failed", failure, slideIndex))); - if (plan.pageVisualRequest) { - const pageResult = await renderRasterFallbackLayers( - [plan.pageVisualRequest], - renderRaster, - slideIndex - ); - if (pageResult.layers.length) { - const layer = pageResult.layers[0]; - diagnostics.push({ - severity: "fallback", - code: "page_visual_fallback", - message: `Slide ${slideIndex + 1} used a transparent page visual fallback.`, - sourceId: layer.sourceId, - slideNumber: slideIndex + 1, - phase: "page-visual", - reason: "One or more local visual layers could not be rendered." + for (const element2 of parsed.querySelectorAll("svg [fill], svg [stroke], svg[fill], svg[stroke]")) { + const declarations = byElement.get(element2) || /* @__PURE__ */ new Map(); + for (const property of ["fill", "stroke"]) { + if (!element2.hasAttribute(property)) continue; + applyCascadeDeclaration(declarations, property, { + value: canonicalizeCssSyntax(element2.getAttribute(property)), + important: false, + specificity: [0, 0, 0, 0], + sourceOrder: -1 }); - return { - layers: [layer], - fullPageFallback: null, - diagnostics, - blocking: false - }; } - diagnostics.push(...pageResult.failures.map((failure) => failureDiagnostic("page_visual_raster_failed", failure, slideIndex))); + byElement.set(element2, declarations); + } + for (const element2 of parsed.querySelectorAll("[style]")) { + const declarations = byElement.get(element2) || /* @__PURE__ */ new Map(); + for (const [property, declaration] of parsedDeclarations( + parsed, + element2.getAttribute("style") || "" + )) { + applyCascadeDeclaration(declarations, property, { + ...declaration, + specificity: [1, 0, 0, 0], + sourceOrder + }); + } + byElement.set(element2, declarations); + sourceOrder += 1; + } + return byElement; +} +function declarationsWithInheritedCustomProperties(element2, declarationsByElement) { + const declarations = /* @__PURE__ */ new Map(); + const ancestors = []; + let current = element2.parentElement; + while (current) { + ancestors.unshift(current); + current = current.parentElement; + } + for (const ancestor of ancestors) { + for (const [property, declaration] of declarationsByElement.get(ancestor) || []) { + if (property.startsWith("--") || ["fill", "stroke", "text-shadow"].includes(property)) { + declarations.set(property, declaration.value); + } + } + } + for (const [property, declaration] of declarationsByElement.get(element2) || []) { + declarations.set(property, declaration.value); + } + return declarations; +} +function auditAuthoredStyles(parsed, slideNumber) { + const declarationsByElement = collectStylesheetDeclarations(parsed); + for (const [element2] of declarationsByElement) { + const declarations = declarationsWithInheritedCustomProperties( + element2, + declarationsByElement + ); + if (element2.closest?.("svg")) { + for (const property of ["fill", "stroke"]) { + const value2 = declarations.get(property); + if (value2 && valueOrVariableHasUrl(value2, declarations)) { + fail2( + slideNumber, + element2, + "svg_paint_server_unsupported", + "SVG paint-server fills and strokes cannot be represented as editable PowerPoint paint." + ); + } + } + } + const textShadow = declarations.get("text-shadow"); + if (textShadow && textShadowIsVisible(textShadow, declarations)) { + fail2( + slideNumber, + element2, + "text_shadow_unsupported", + "CSS text-shadow cannot be represented as editable PowerPoint text." + ); + } } - if (plan.fullPageRequest) { - const fullResult = await renderRasterFallbackLayers( - [plan.fullPageRequest], - renderRaster, - slideIndex +} +function makeMountedStyleProjection(parsed) { + const ownerDocument = globalThis.document; + if (!ownerDocument?.body || typeof globalThis.getComputedStyle !== "function") return null; + const host = ownerDocument.createElement("div"); + host.setAttribute("aria-hidden", "true"); + host.style.cssText = [ + "position:fixed", + "left:-30000px", + "top:0", + "width:1280px", + "height:720px", + "overflow:hidden", + "opacity:0", + "pointer-events:none" + ].join(";"); + const projectionRoot = host.attachShadow?.({ mode: "open" }) || host; + const indexedElements = [...parsed.querySelectorAll("*")]; + indexedElements.forEach((element2, index) => { + element2.setAttribute("data-pptx-preflight-index", String(index)); + }); + for (const authoredStyle of parsed.querySelectorAll("style")) { + const style = ownerDocument.createElement("style"); + style.textContent = stripCssImports( + replaceCssUrlsForProjection(authoredStyle.textContent || "") ); - if (fullResult.layers.length) { - const fullPageFallback = fullResult.layers[0]; - diagnostics.push({ - severity: "fallback", - code: "full_page_fallback", - message: `Slide ${slideIndex + 1} was exported as a full-page PNG fallback.`, - sourceId: fullPageFallback.sourceId, - slideNumber: slideIndex + 1, - phase: "full-page", - reason: "Local and transparent page visual fallback rendering failed." - }); - return { - layers: [], - fullPageFallback, - diagnostics, - blocking: false - }; + projectionRoot.appendChild(style); + } + const controlStyle = ownerDocument.createElement("style"); + controlStyle.textContent = [ + '#pptx-cascade-control { fill:url("#__pptx_specificity_control__"); }', + '.pptx-cascade-control { fill:#fff;stroke:url("#__pptx_important_control__") !important; }', + "#pptx-cascade-control { stroke:#fff; }" + ].join(""); + projectionRoot.appendChild(controlStyle); + const body = ownerDocument.importNode(parsed.body, true); + body.querySelectorAll("style,script").forEach((element2) => element2.remove()); + for (const element2 of body.querySelectorAll("*")) { + if (element2.hasAttribute("style")) { + element2.setAttribute( + "style", + replaceCssUrlsForProjection(element2.getAttribute("style") || "") + ); + } + for (const property of ["fill", "stroke"]) { + if (element2.hasAttribute(property)) { + element2.setAttribute( + property, + replaceCssUrlsForProjection(element2.getAttribute(property) || "") + ); + } + } + for (const attribute of ["src", "srcset", "poster", "data"]) { + if (element2.hasAttribute(attribute)) element2.setAttribute(attribute, "data:,"); + } + for (const attribute of ["href", "xlink:href"]) { + if (element2.hasAttribute(attribute)) { + element2.setAttribute(attribute, "#__pptx_preflight_resource__"); + } + } + for (const attribute of [...element2.attributes]) { + if (attribute.name.toLowerCase().startsWith("on")) element2.removeAttribute(attribute.name); } - diagnostics.push(...fullResult.failures.map((failure) => failureDiagnostic("full_page_raster_failed", failure, slideIndex, "blocking"))); } + projectionRoot.appendChild(body); + const controlSvg = ownerDocument.createElementNS("http://www.w3.org/2000/svg", "svg"); + const control = ownerDocument.createElementNS("http://www.w3.org/2000/svg", "rect"); + control.id = "pptx-cascade-control"; + control.setAttribute("class", "pptx-cascade-control"); + controlSvg.appendChild(control); + projectionRoot.appendChild(controlSvg); + ownerDocument.body.appendChild(host); return { - layers: [], - fullPageFallback: null, - diagnostics, - blocking: true + host, + indexedElements, + projectedElement(element2) { + const index = element2.getAttribute("data-pptx-preflight-index"); + return body.querySelector(`[data-pptx-preflight-index="${index}"]`); + }, + supportsCascade() { + const computed = globalThis.getComputedStyle(control); + return cssValueHasUrlToken(computed.fill) && cssValueHasUrlToken(computed.stroke); + }, + cleanup() { + host.remove(); + indexedElements.forEach((element2) => { + element2.removeAttribute("data-pptx-preflight-index"); + }); + } }; } +function auditMountedComputedStyles(parsed, slideNumber) { + let projection; + try { + projection = makeMountedStyleProjection(parsed); + if (!projection || !projection.supportsCascade()) return false; + for (const element2 of projection.indexedElements) { + const projected = projection.projectedElement(element2); + if (!projected) continue; + const computed = globalThis.getComputedStyle(projected); + if (element2.closest?.("svg") && (cssValueHasUrlToken(computed.fill) || cssValueHasUrlToken(computed.stroke))) { + fail2( + slideNumber, + element2, + "svg_paint_server_unsupported", + "SVG paint-server fills and strokes cannot be represented as editable PowerPoint paint." + ); + } + if (textShadowIsVisible(computed.textShadow, /* @__PURE__ */ new Map())) { + fail2( + slideNumber, + element2, + "text_shadow_unsupported", + "CSS text-shadow cannot be represented as editable PowerPoint text." + ); + } + } + return true; + } catch (error2) { + if (error2 instanceof EditableExportError) throw error2; + return false; + } finally { + projection?.cleanup(); + } +} +function isActiveScript(script) { + const type = String(script.getAttribute("type") || "").trim().toLowerCase(); + return !type || type === "module" || type.includes("javascript") || type.includes("ecmascript"); +} +function auditRawSlideForEditableExport(html, slideNumber) { + const parsed = new DOMParser().parseFromString(String(html || ""), "text/html"); + const unsupported = parsed.querySelector(VISIBLE_UNSUPPORTED_SELECTOR); + if (unsupported) { + fail2( + slideNumber, + unsupported, + "unsupported_visible_html", + `Visible <${unsupported.localName}> content has no editable PowerPoint rewrite.` + ); + } + const activeScript = [...parsed.querySelectorAll("script")].find(isActiveScript); + if (activeScript) { + fail2( + slideNumber, + activeScript, + "active_content_unsupported", + "Active script content is not allowed in editable PowerPoint export." + ); + } + if (!auditMountedComputedStyles(parsed, slideNumber)) { + auditAuthoredStyles(parsed, slideNumber); + } +} -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-slide-browser.js +// src/export-slide-browser.js var EXPORT_VIEWPORT = { width: 1280, height: 720 }; -var RASTER_TEXT_TYPES = /* @__PURE__ */ new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6", "text", "list", "merged-text"]); -var EDITABLE_TEXT_TYPES = /* @__PURE__ */ new Set([...RASTER_TEXT_TYPES, "svg-text"]); var exportSessionHost = null; function getExportSessionHost() { if (!exportSessionHost?.isConnected) { @@ -13217,189 +15889,41 @@ async function loadHtmlInExportRoot(html) { await waitForExportPaint(); return wrapExportDocument(root, body); } -function analyzeMountedSlideForPptx(doc, source = "") { - if (!doc?.body) { - return { - valid: false, - issues: [{ - severity: "blocking", - kind: "blocking", - code: "unreadable_document", - message: "The slide document could not be read.", - sourceId: "slide-document" - }] - }; - } - const issues = [...doc._pptxSecurityDiagnostics || []]; - const seen = new Set(issues.map((item) => `${item.code}:${item.sourceId || ""}`)); - const add = (code, message, element2 = null, severity = "fallback") => { - const sourceId = element2?.dataset?.pptxSourceId || element2?.id || null; - const key = `${code}:${sourceId || ""}`; - if (seen.has(key)) return; - seen.add(key); - issues.push({ - severity, - kind: severity === "blocking" ? "blocking" : void 0, - code, - message, - sourceId, - tag: element2?.tagName?.toLowerCase?.() || null - }); - }; - const body = doc.body; - if (body.querySelector('script,iframe,object,embed,base,meta[http-equiv="refresh" i],foreignObject,maction')) { - add("active_content_residual", "Active content remained after sanitization.", body, "blocking"); - } - if (!String(source || "").trim() || !/<\/html>\s*$/i.test(String(source || "").trim())) { - add("incomplete_html", "The slide document is incomplete.", body, "blocking"); - } - let bodyRect; - try { - bodyRect = body.getBoundingClientRect(); - if (!(bodyRect.width > 0) || !(bodyRect.height > 0)) { - add("unmeasurable_canvas", "The slide canvas could not be measured.", body, "blocking"); - } - } catch { - add("unmeasurable_canvas", "The slide canvas could not be measured.", body, "blocking"); - } - if (bodyRect) { - if (Math.abs(bodyRect.width - EXPORT_VIEWPORT.width) > 2 || Math.abs(bodyRect.height - EXPORT_VIEWPORT.height) > 2) { - add("canvas_size", "The slide canvas size requires page visual fallback.", body); - } - const dimensions = measureBodyDimensions(doc); - if (dimensions.errors?.length) { - add("canvas_overflow", "Slide content exceeds the canvas.", body); - } - const view = doc.defaultView || window; - body.querySelectorAll("p,h1,h2,h3,h4,h5,h6,li").forEach((element2) => { - const rect = element2.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return; - if (rect.left < bodyRect.left - 1 || rect.top < bodyRect.top - 1 || rect.right > bodyRect.right + 1 || rect.bottom > bodyRect.bottom + 1) { - add("text_out_of_bounds", "Text extends outside the slide canvas.", element2); - } - const computed = view.getComputedStyle(element2); - if (parseFloat(computed.fontSize || 0) > 12 && rect.bottom > bodyRect.bottom - 48) { - add("bottom_safety_margin", "Text enters the bottom safety margin.", element2); - } - }); - } - return { valid: issues.length === 0, issues: issues.slice(0, 32) }; -} -async function prepareSlideOnce(html, aggressive, options = {}) { +async function prepareHtmlSlide(html, slideNumber) { let exportRoot = null; try { - const doc = await loadHtmlInExportRoot(html); - exportRoot = doc._exportRoot; - const repairResult = sanitizeSlideDocumentRoot(doc, aggressive); + auditRawSlideForEditableExport(html, slideNumber); + const mountedDoc = await loadHtmlInExportRoot(html); + exportRoot = mountedDoc._exportRoot; + sanitizeSlideDocumentRoot(mountedDoc); await waitForExportPaint(); - const bodyDimensions = measureBodyDimensions(doc); - const slideData = extractSlideDataFromDocument(doc); - const analysis = analyzeMountedSlideForPptx(doc, html); - const mergedDiagnostics = [ - ...analysis.issues || [], - ...repairResult?.diagnostics || [], - ...slideData.diagnostics || [] - ]; - const diagnosticKeys = /* @__PURE__ */ new Set(); - const diagnostics = mergedDiagnostics.filter((diagnostic) => { - const key = `${diagnostic.severity}:${diagnostic.code}:${diagnostic.sourceId || ""}`; - if (diagnosticKeys.has(key)) return false; - diagnosticKeys.add(key); - return true; + extractSlideDataFromDocument(mountedDoc); + return normalizeDocumentToEditableScene(mountedDoc, { + slideNumber, + width: EXPORT_VIEWPORT.width / 96, + height: EXPORT_VIEWPORT.height / 96 }); - slideData.diagnostics = diagnostics; - const rasterRequests = buildRasterFallbackRequests(doc, diagnostics); - const pageFallbackCodes = /* @__PURE__ */ new Set([ - "canvas_size", - "canvas_overflow", - "text_out_of_bounds", - "bottom_safety_margin" - ]); - const pageFallbackDiagnostics = diagnostics.filter((item) => pageFallbackCodes.has(item.code)); - const nativeVisualSourceIds = [...new Set( - (slideData.elements || []).filter((element2) => !EDITABLE_TEXT_TYPES.has(element2.type)).map((element2) => element2.sourceId).filter(Boolean) - )]; - const pageVisualRequest = pageFallbackDiagnostics.length ? buildWholePageVisualFallbackRequest( - doc, - pageFallbackDiagnostics, - nativeVisualSourceIds - ) : buildPageVisualFallbackRequest(doc, rasterRequests); - const overflowWarnings = bodyDimensions.errors || []; - const safeBodyDimensions = { ...bodyDimensions, errors: [] }; - const blocking = diagnostics.filter((diagnostic) => diagnostic.severity === "blocking"); - if (!blocking.length || options.allowValidationErrors) { - return { - slideData, - bodyDimensions: safeBodyDimensions, - diagnostics, - rasterRequests, - pageVisualRequest, - aggressive, - warnings: overflowWarnings - }; - } - const error2 = new Error(blocking.map((diagnostic) => diagnostic.message).join("\n")); - error2.diagnostics = blocking; - return { error: error2 }; } finally { if (exportRoot) removeExportRoot(exportRoot); } } -async function prepareSlideForPptxExport(html, options = {}) { - const first = await prepareSlideOnce(html, false, options); - if (first?.slideData) return first; - const second = await prepareSlideOnce(html, true, options); - if (second?.slideData) return second; - throw second?.error || first?.error || new Error("PPT Live slide preparation failed"); -} -async function prepareSlidesForPptxExport(slides, options = {}) { - const prepared = []; +async function prepareEditableSlides(slides, options = {}) { + const scenes = []; try { for (const [index, slide] of slides.entries()) { - if (!slide?.html) continue; - const item = await prepareSlideForPptxExport(slide.html, options); - if (item.rasterRequests?.length && typeof options.onRasterProgress === "function") { - options.onRasterProgress(index, slide); - } - const rasterResult = await renderRasterFallbackPlan({ - localRequests: item.rasterRequests || [], - pageVisualRequest: item.pageVisualRequest, - fullPageRequest: { - sourceId: `slide-${index + 1}`, - zIndex: 0, - paintOrder: 0, - kind: "raster", - phase: "full-page", - bbox: { x: 0, y: 0, w: 13.333, h: 7.5 }, - buildHtml: () => slideExportHtml(slide), - diagnostics: [] - } - }, options.renderRaster, index); - if (rasterResult.blocking) { - const error2 = new Error( - rasterResult.diagnostics.map((diagnostic) => diagnostic.message).join("\n") || `Slide ${index + 1} fallback rendering failed` - ); - error2.diagnostics = rasterResult.diagnostics; - throw error2; + if (typeof options.onSlideProgress === "function") { + options.onSlideProgress(index + 1, slide); } - item.slideData.fallbackLayers = [ - ...item.slideData.fallbackLayers || [], - ...rasterResult.layers - ]; - item.slideData.fullPageFallback = rasterResult.fullPageFallback; - item.slideData.diagnostics = [ - ...item.slideData.diagnostics || [], - ...rasterResult.diagnostics - ]; - prepared.push({ - index, - slideId: slide.id, - notes: slide, - ...item, - fallbackDiagnostics: rasterResult.diagnostics - }); + scenes.push(slide?.html ? await prepareHtmlSlide(slide.html, index + 1) : normalizeElementSlideToEditableScene(slide, { slideNumber: index + 1 })); + } + return scenes; + } catch (error2) { + if (error2?.diagnostic && !error2.diagnostics) { + error2.diagnostic.severity = "blocking"; + error2.diagnostic.kind = "blocking"; + error2.diagnostics = [error2.diagnostic]; } - return prepared; + throw error2; } finally { clearExportSessionHost(); } @@ -13409,7 +15933,7 @@ function slideExportHtml(slide) { return sanitizeSlideMarkup(buildElementSlideHtml(slide)); } -// node_modules/.pnpm/tslib@1.14.1/node_modules/tslib/tslib.es6.js +// ../../../../../../../../../node_modules/.pnpm/tslib@1.14.1/node_modules/tslib/tslib.es6.js var extendStatics = function(d, b) { extendStatics = Object.setPrototypeOf || { __proto__: [] } instanceof Array && function(d2, b2) { d2.__proto__ = b2; @@ -13550,7 +16074,7 @@ function __spreadArrays() { return r; } -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/base64.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/base64.js var chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; var lookup = new Uint8Array(256); for (i = 0; i < chars.length; i++) { @@ -13612,7 +16136,7 @@ var decodeFromBase64DataUri = function(dataUri) { return decodeFromBase64(data); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/strings.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/strings.js var toCharCode = function(character) { return character.charCodeAt(0); }; @@ -13750,7 +16274,7 @@ var findLastMatch = function(value2, regex) { return { match: lastMatch, pos: position }; }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/arrays.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/arrays.js var last = function(array) { return array[array.length - 1]; }; @@ -13871,7 +16395,7 @@ var toUint8Array = function(input) { } }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/async.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/async.js var waitForTick = function() { return new Promise(function(resolve) { setTimeout(function() { @@ -13880,7 +16404,7 @@ var waitForTick = function() { }); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/unicode.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/unicode.js var utf16Encode = function(input, byteOrderMark) { if (byteOrderMark === void 0) { byteOrderMark = true; @@ -13978,7 +16502,7 @@ var hasUtf16BOM = function(bytes) { return hasUtf16BigEndianBOM(bytes) || hasUtf16LittleEndianBOM(bytes); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/numbers.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/numbers.js var numberToString = function(num) { var numStr = String(num); if (Math.abs(num) < 1) { @@ -14013,12 +16537,12 @@ var bytesFor = function(n) { return bytes; }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/errors.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/errors.js var error = function(msg) { throw new Error(msg); }; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/utils.js +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/utils.js var import_pako = __toESM(require_pako()); var chars2 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; var lookup2 = new Uint8Array(256); @@ -14071,49 +16595,49 @@ var padStart2 = function(value2, length, padChar) { return padding + value2; }; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Courier-Bold.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Courier-Bold.compressed.json var Courier_Bold_compressed_default = "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"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Courier-BoldOblique.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Courier-BoldOblique.compressed.json var Courier_BoldOblique_compressed_default = "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"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Courier-Oblique.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Courier-Oblique.compressed.json var Courier_Oblique_compressed_default = "eJyFWVtT2zgU/isZP+3OhE5Iy/UtDaHNFhI2IdDS4UGxFUeLbKW6AKHT/77Hhnbb1fnUFw98x9K5fzpyvmZDU1Wy9tlxdnUenChlZ3e//+awc7B32D/Kutmpqf1EVJJeGJpglbQ706VWX4JshEHrX4Wdn4SiUnr7q5jga6nKdaPvXBYqVISMvdAqH9Slpjd3dvuEuFP1KIsL5fN1duxtkN1suBZW5F7auWxWjx69rAtZzEwl6hc73741j9nx553+QXenv9frHr456h729m672YJetVrV8sI45ZWpG0W93k+Cy7XK72rpXHZMK7MraV37WtbrvX7V6/VIxcR4lT87s9naxovOH/mfnd2jw6MuPY967XO3ffbb5+v2edAZFGYpO/Ot87JynXGdG7sxVnhZvOp0Blp3Zs1urjOTTtp7QknbiN4qVF3O87VsQ9huMveiLoQtvkvpxaHYvH+J6d4+Be/j9//e9Pe72cDlTZxsdrzfP+pmJ/LH/zu7ewfdbO6L99e0crf98+rlzybY59JblVM8Pn/Nrj/S+iZeEzLEbQSF4Vv3f7B8zLWoYvxLMOToUseSOlTLJs5lHcsKo7WwMb6RNm/qNRKIikSOogMsaBPG7CesrLVcIRFYJlyzo7tjVungYjSnNhMxvN5u1pLxnlxRhvHEaeHWMfwkrYlRUzNZ8g/Mm35tJfPuipqWQdU9865Tjwwo7znvJB/TWnEG50YbZg8nKwVEuuniWOmXIJgaLK2kPmTcJBJzLVPEuWdsH8TQ2xgaxtBJDI1i6DSG3sXQ+xgax9BfMfQhhs5i6DyGJjE0jaGLGPo7hmYxNI+hyxhaxNBVDF3H0McY+hRDNzG0pJPoTnqeNpYkA336sg5ySq5UrmweGBYNDWk7OjiYFmn5jTeG4Zwl02MM/zIdxHAB01AMy8WQiqF/YoihV6aFmWgxXcvQ3oYJZQwx3MDQCxP7EEP3MfQQQwyTbhnS5+sLFMKSO0zb91PV6JUu4FFfmAcmzvzp3ByXuplX4hJqpjqWZ7fc2bHSxir2PAC75MHSMZ4zAWzbxql27oRTCx/NMiiSVAZQQCNuN/6NGIR+xXTHiil8GuRYmilEWXJH2jPOjmLPA0eTO2kl0/s0C4nSig1HanQJkIwX/4V3KVwiwmkpCNGPBAC51FptnGIalTz1axPQpPh86POlTQHgRh+RB88NLi2Tr7Rk2hn0VSVyy9Xw0kpOcWG8yPl+K+iyJVn/LduFOV3GaOBmuDvUpbCh0iIwakxJQybD0rlg/ZAVX7ADZuQxtljRjMcPhWfggJ0inFdQEckFzWoQfLyb2ZLpTg30GoQzu1Nr0lWWSp5J2hjnU4LyE6BoQjqjEqTuE7agUPeKq+ZPwLJBqjMEWLRILdqCRa69dqEekaktF3yfLHAYBGqGBbAuoAUjrOSECIK7fyQdzb9/r2BUIcrJQR0IPi6TpAEa1Shg1MvkbkO0G2DVUYInHXDTQUJUQLs2T7IuGcEMqHiXdDIkmyQlHKCUWmBIDn29SUTucm0ss9kUaZ+BuM0BXgBrF0hB4Cuz/bbhQjvgMDPRFJTgAOGAVqugvdpoZswMwMFL4CCNWl4JXagVc7vaYmqYAD0qVSyjZJklTh0syoEdNaJBlNAJCNAYbNR8eaOBgfv8trTmTtbsHcjKUjkw9b4DyR6nGCVQV/NXkRGoKQscMigyN2DBDYjYy0cu8Als5JkJZQhJQSd5y6PRD56OSDBA40OiKkGAn1BrIN1TlBF09KBMOaQZOzFNjiXAwxOpPZMrz5O0fzAJRsSmVcwnDQUsMuk5c4RCX4AJp+VxKmBLhcyYNk/UK1+RH5J1fAYS560oZCUsY7lMsMIYRGmMCucMWE1BWwvNnAzNzQQ0uElVVA2mpsVv8gfiI5FDJeBScuglq1xS+SDMk6mbpi0viCW4XzsMSNEQGBEgmxcq59JwAjaySW8mID8G4LN0WSelA7DnAHI0P3NNwT5PiQ4ZodMe6b5LugIGgXGyFYAJPn25MWWT79pw30cwlwYsoq3Qr1XpCy13kD8Bp+rkVhRyfEIo1OOj0PwOedvNPkhbXwhlm1+Pb7/9C/NFF2U="; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Courier.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Courier.compressed.json var Courier_compressed_default = "eJyFWdtSGzkQ/RXXPO1WmZSBEAJvjnESb8AmGENCKg+ypj3Wohk5ugAmlX9fzUCyW6s+ysuUfVqXvh61Zr4XI1PX1PjiuLg6C05U1Ns/Ojx42TsYHB4eFf3irWn8VNQUB4xMsIpsCwatU1DUSm8T+JpUtW7XP6NShToiEy+0ksOm0nHkIP53b9UDlefKy3Vx7G2gfjFaCyukJzundu74wVNTUnlhatE8a/XmjXkojr/s7O33d/YOBv3D3YP+68HB136xiEOtVg2dG6e8Mk1xvLM7GPxHcLlW8rYh54rjOLO4Iuu6YcVgsP9iMBjELabGK/lkymZrWxt6f8g/e7tHr4/68Xk06J673XOve+53z8PesDRL6s23zlPtepNGGrsxVngqX/R6Q617F+1qrndBjuxdRONu4ziqVE01l2vqHNgtMveiKYUtf0rjwJHYvH/26MGrvX7x6ee/l3uv+sXQydZPtjh+tXfUL07o1/+d3YPDfjH35fvrOHO3+3n1/LN19hl5q2T0x5fvxfWnOL/11zQq4jYiuuFH/38wPUgt6hT/Fkw0dKlTSRPqZevnqkllpdFa2BTfkJVtdiYCUUeRi94BGnQBY9YTlhpNKyQC04RrV3S3zCwdXIrKWFQihdfbzZoY66MpyjCWOC3cOoUfyZoUNQ0TJX/PjPRrS8zYVSxZBlV3zFinHhiQ7jjriPdpoziFpdGGWcNRrYBIt1WcbvotCCYHK0uxDhkzvwVyHVOksWd0H6bQmxQapdBJCo1T6G0KvUuh9yk0SaG/UuhDCp2m0FkKTVNolkLnKfQxhS5SaJ5Clym0SKGrFLpOoU8p9DmFblJoGU+iW/I8bSyjDNTp8zzIKVIpqawMDIuGlrRdPDiYEun4jVeG4ZwlU2MM/zIVxHABU1AMy6WQSqG/U4ihV6aEGW8xVcvQ3oZxZQox3MDQC+P7kEJ3KXSfQgyTbhnS5/MLJMKSO0y78bls9EqX8KgvzT3jZ/50bo9L3fYraQq1XR3Ls1vu7FhpYxV7HoBVZLDxGJeMA7uycarrOmHXwnuzCipKagMooBV3C/9GDFy/YqpjxSR+bORYmilFVXFH2hPOtmJPDUcbO7LE1H7shURlxYYjtdj6E2PFv+5dCpfxcF4KXPQrAEBOWquNU0yhRkv92gTUKT4d+nxqRwdwrY+QwXONS8fkK01MOYO6qoW0XA4vLXEbl8YLyddbGa9axNpv2SqU8SoWG26Gu0NTCRtqLQKzjalik8mwtBSsHVTzCTtkWh5jy1Xs8fim8BQcsDOE8xvUkeSCZncQvL/b3pKpTg32NQhnVo+lGa+yMeWZoE1wPAmknwBJE/IRJRC6z1iDUt0pLps/A82GucoQYNIiN2kLJrnu2oVqhHJLLvg6WWA3CFQMC6BdQBPGeJOTSBDc/SNrqPz5voLZClGOBHkgeL9MswpolKOAUS+zq43QaoBVxxmedMBMBwlRgd21eaSmYgQXYIt3WSNDtkhywiEKqQWKSGjrTcZzl2tjmcVmaPcL4Lc5wEug7QJtEPjM7N5tuNA1OExPNAMpOEQ4oNU6aK82mmkzAzDwEhgYWy2vhC7VirldbTE1TME+Kpcs42yaZU4dLJJAjwbRIAroFDhoAhZq37zFhoF7/ba05pYa9g5kqVIOdL3vQLAnOUYJsar5q8gY5JQFBhnkmRsw4QZ47PklF3gFNvZMhzKCpKCzvOVR6wdPRyQYovYhk5XAwY+oNNDeMxQRdPSgSDm0MzZilm1LgIUnpD0TK8+TtL83GUbEqtXMKw0FNDL5PnOMXF+CDqfj8ZjANiYyo9o8k698Rn7I5vEpCJy3oqRaWEZzyrDCBHhpghLnFGgdnbYWmjkZ2psJKHCTy6gGdE2L38QP+IeQQRXg0mjQc1S5oPJOmGdDN8trXkaW4L52GBCiEVAiQDYvleTCcAIWsllrpiA+BuAX+bTOSodgzSHkaL7nmoF1HjMVMkanPdr7NmsKaAQm2VIAKvj85cZUbbwbw70fwVwasCguhb5W5S+03EH+CIxqsktFl+MTQqEaH4f2O+TXfvGBbHMulG2/Hn/98Q/b2xEO"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Helvetica-Bold.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Helvetica-Bold.compressed.json var Helvetica_Bold_compressed_default = "eJyNnVtzG0eyrf8KA0/7RMhzJJK6+U2+zMX2mJYsEuJMzANEtihsgYQMEITaO/Z/P41CV+bKlaug86JQf6uArsrKXNVX8H8m3y9vb7u7+8m3k4t/btazm+7o5PmTZy+PTl88eXk6eTT56/Lu/tfZbTc0+Hu3eOju51ezb75bLq532maxYO2oarPb+aJndRCm3fzm425/Y8N/3M8W86tXdzeLoeXjYXv91/mX7vq3+f3Vx8m396tN92jy/cfZanZ1361+73af/PHLfXd33V2/Wd7O7sY+fvfd8svk239/8+T540ffHB+/ePTk8eOTRy+fHf/n0eR8aLxazO+635br+f18eTf59ptBBuHtx/nVp7tuvZ58+3TgF91qXZpNHj8+/svjx4+Hnfy6HAawG8z3y8/9ajeGo/+6+j9HT16+ePpo9+/z8u/L3b8vH5d/nx+9ul6+745+79f33e366B93V8vV5+Vqdt9d/+Xo6NVicfRm9z3rozfduls9DNTDOF8fzY7uV7Pr7na2+nS0/HD0y/xued9/7r4ZGi2OXv3taHZ3/X+Xq6P58AXrzfv1/Ho+W8279V+Gzv447Op6fnfz+9XHrsxA6cnv98NHZqvrqg4Nv599/vs4Ic+fvHg0eVe3np4cP5q8Wl/tAr0axR862/7m+PHzR5Pf76//Pp18+2QnDv+/2P3/9PF+vv7Z3a/mV0NA//0/k+m7ybfHz4dGvw5dWX+eDXH830d7fHJyssfdl6vF7Nb46fPTPf9jsxzi9X5hytOnz/bK3eb2/W6ibu6ydr1cLGYr4y+GiSn8c7e62qV7FZ4fH++F2e0grYf4mGQdLj0oM557/Xm26u4W3YeWRB+r3Zitd9+4/uQdfzEO9/Nis85duBqqdJZ38bH//LG7y82HocyXYiTrxWz9MQfrz261zHR512V4vxUt7z+uOtH2w3KzEnT+INqu518E7B46MbddiKmnw/xOpNXVcrG8y3jd3c6jZDOw2NlAot0fm9ki45tVN5SzD/PZkyc1abp1sZqqvHz+dJx7kX2vMvouo+8z+sH3/Oz5Hv2YO/NX/2BNhb/l7/p7Tph/5DD/lD/4c97jL156NeT/zB/8NffrLA/ot9zqdf6uN/mDv+d+vc0fPM8fvPBZOx0neppbvcvoMu/xXzn53g+L2afuPtiGhfz9oMU65c9FT7FUnK2v5vOr+epqc5tnbbOz7fWw/nR5j8XfQmfsY7M8nve51VVudZ1bieL8kD94k9HH3OV5Rv+d9/gpt/IStiXhNu/xLqNlRp9F1WerFxa4zpG4z9+1yR98yJWwza2Ek/aOdsc9xfRzV3f5FRPh+MXjmpWrRvtD2Xg/X1w3l/rr5VaYe1idPWL35TjNk+NJrbgPuwND9Fkfs1o7PiyWq7ng667xLVeb1bCMX3kAj0+wbNbzcuCaoluPWnRZ3Wzmg3K7vNdHDju5fPFX5Bh6S5wPc8HE8dNwKCcPB65nNzedSNs9x0MxOuDYzV236kTtD8dCs5vV7DOY2tOaWcNJRCd80MP7frY+EOHD6kofK9gERH04KRg/Pxxizz+v52shDWO9/7jchGPFtOyH5PaZW80eRD3Mrjb36tClePmHRfcla43Kup1drdThzvtVp3Z8vbyfXYWKc2k+zCQGwJQV1qF3trseQqqOUTd3N7PV5nYx24jdLG+Gw8xP4utmOA6Yl9uQsy688sOek+cjW66uPwzHeeHA0I9Q4iLrByCR+x7OYA/Pntoebgen2yxwF7ayzMRie70r+vVaLGCLuGNfeSK3I5KlGNRQn8Mp8ZD34hziH2lK3QliBvryH/PGlyY5qf51cfb86Cj3oC4X1/OHOSS0fyT2zA+YRXF4txsfOj/0ob4Rg3U596IygaHmr/T9hVJx3J6IGdWDfyb2zmeCPuBnAWknfs4weASchBxXJ1YDfX7yvIrjVQ+xK3IdXztjHvgodVx+VR3w8mjlaDRVP9KXw7FTqda3RWOFcCarhAzRw1yzJ/rha9z76ct66rn8s7u7EZn7Ju7Cz+LUID05DhbJocx9xQuJHc02xnrFY/Xznxw5i+rbj8uVGNUZ7d3DQFVgJ3pU8Kd1EaOwWTXRDjxienErFzjWm3KUsxL9jSnoUWzxaKtmgrebxf3886IX/WqU/9s4QEuk4Xjrfj5bXM8/fMhz1bet4de4H09YkSxeGwfT7MCq05auGuO9a9lgK2N+jQHyxZDqHy+/DUcMeA3OToFWy0/dHZ4ImTmuupv5Oh76eonGyYblONdFPdRYb4aqDucjHmw6hrTCbERm2Ur1fzU+8C+q8NOX9di1XOmK18Eszj/ef8zw+6YBLpRv2VjuGybTNVfHlvCqdfhwICtjgP18uVUavG9zhdaMtJae1jK6bu0517Ht++BhCa+Y9bigW9wLA78PJu2euF0ecMTUNfu6240YSWMNX8rjTK8FPvixq0/xCOfFySn4+JDAqyGR1/n7fud8Pa2Tv2gsJD8fXH9/iRPnpxJ2X0eZYrIFt4wYJuetGv8ldtviMETt42wBS0Mt8t2pSaxwnwu1BJgvx8MmT7WvTGCjFLrWgG6imeKAxmlVs6rPRn6XB4iWwbLnlhDXg010KmMbS/731AlbuMhtTs3Or+dXymh/iF8EB2aHDnd/pcNa625j3t4czuuD+3rV+M5XTZOOpwM2A/F73IgPHFD+2Fruad9+iVie3dkBWTwSsG87WAo0QeaXB/e0WN7s5vtuKcK9bJvpJq9jNYOGr2pU8s3Bye1gJfeYN9L3Tq7jdnHnLh80u+e3lrsfN7u7kf95NPm5W939NpuvdveQ/z15tbtbPXn0zenj/zwat/buEdC+nxGNpo7wb8PWU9/au0pAODAUzsL3nOUu4NIbuE1VoPv6Dyg4T1DGkAW2vzoU0L5wEL0OW2+HrZe+VWOGKIzehfMQi/M6ekBh9MBh9EDr6AHR6EGx0QMb6zqwYidILoatF7Y1Hbae2dblsPXkiW/WISGDvgPeDJsnvlU/CCjEAjh8H9AaC0AUC1AsFsAsFsDGWDh5CJmwDVoft/KI+tzzsRGWpiEqDuNUpM65UqsC5WqIata4LNyqnuXv5hI2rurYxFzMJlFFG9dlbTLXtglU4Mapyit/nRHUuyEqeueq8qt6niPKHmBcGYGJ2Q1MIkswrn3BZDYHE9ghTIg2UTF4RUVgGBWhaxhj6zBB+EfVwEQMUd0ZV3ZiYrsy2ViMa3cxmS3GBPYZE6LZVPyQE3KbW/UCNQIhXGg0A3QhQ1TfxsmFnLMLVQVcyBC5kHHpQlU9y9/NLmRcuZCJ2YVMIhcyrl3IZHYhE8iFjJMLVf46I3AhQ+RCzpULVfU8R5RdyLhyIROzC5lELmRcu5DJ7EImsAuZEF2oYnChisCFKkIXMsYuZIJwoaqBCxmi4jOuXMjEdmWyCxnXLmQyu5AJ7EImRBeq+CEn5Da36gVqBEK4EIYGrShyqvQokimRyM4UZLCnyMmjoiiNKjQ5a+yPLSuKyrdii2xeUScHi6K2sdiGvSyqZGhRJFcL4usGB3+LnEyOROV0ocl5Y17Y86KojC+2yO4XdbLAKGofjG3YDKPKjhjVaItBA28MHAwycHTJKLBVRlX4ZWgAphk5GUYUlX3GFl/xFTbSKGo3jW3YUqPKvhrVaK5Be2jUxbbRvm/xQ/ETrusEPRcpGRVK5LdBYrcFEbwWKTktStJnocGZ3A97LErKYVHP/ooquStK2luxBTsrauSrKJGrgvRaUnBUpOSnQVJuCg3OZezZSVFSPop6dlFUyUNR0g6KLdg/UWP3RC16JyjgnEDBN4GiayJmz0RNOCbI4JdIqdpRUl6J+kEvYJ9ESbsktmCPRI0dErXoj6A8yAzfyra9pu1ICVccR4+WaIhMxTiZoXN2wqqADRoiDzQuDbCqZ/m72fqMK98zMZueSeR4xrXdmcxeZwIZnXFyucpfZwT+ZojMzblytqqe54iypxlXhmZidjOTyMqMax8zmU3MBHYwE6J9VQzeVREYV0XoWsbYskwQflU1MCtDVH/GlU2Z2K5MNijj2p1MZmsygX3JhGhKFT/khNzmVr1AjUAIF6p9RRtyRhXuAhkRCOxEJoEVOSMvckGakcln4vvZjlxQfuRqNiTXyJFc0JbkOnuSK2RKLpArmfBaMPAlZ2RMIChnMvlcxJe9yQVlTq5md3KN7MkF7U+us0G5wg7lSrQo4+BRxsCkjKFLOWSbckX4lIlgVM6oQF1QVuXqgfpls3JBu5XrbFeusF+5Eg3L+IPI1a1o1yvWiolwrdoxdC1nZAQukGuBwK5lEriWM3ItF6RrmXwmvp9dywXlWq5m13KNXMsF7Vqus2u5Qq7lArmWCa8FA9dyRq4FgnItk89FfNm1XFCu5Wp2LdfItVzQruU6u5Yr7FquRNcyDq5lDFzLGLqWQ3YtV4RrmQiu5Ywq1AXlWq4eqF92LRe0a7nOruUKu5Yr0bWMP4hc3Yp2vWKtmAjXWo2/6OG7q4RMoGLyK8PsVqMAXlUJOVXF0qdG8Sx9L3tUxcqhqpb9qSrkThVrb6oqO1Pl5EsVkyuN+HUi4EiVkB8ZVm40iucphuxEFSsfqlp2oaqQB1WsHaiq7D+Vs/tUHr1npOA8IwHfGQm6TkXsOZULxxkl8JtKqLIqVl5TtWbNsc9UrF2mquwxlbPDVB79ZaQPKeu2qU2fiR69cJUx19FWDFHhGidjcc7OUhWwFkPkLcaluVT1LH8324tx5S8mZoMxiRzGuLYYk9ljTCCTMU4uU/nrjMBnDJHROFdOU9XzHFH2GuPKbEzMbmMS2Y1x7Tcms+GYwI5jQrScisFzKgLTqQhdxxjbjgnCd6oGxmOIas+4sh4T25XJ5mNcu4/JbD8msP+YEA2o4oeckNvcqheoEYjsQt8N9FXcip8tqDoGIBHSwvUeYiALoiAVRvEpLISmkFq+jnbV9cS3LJ0che4CxwRzWrsLiKYcFBsIMBsIsHEge/LDGPdT34pu+gPGHZDw1h8o7kCjo/4Q4g7Mugts7C6QaJs/jCXvW9OwtSv0575VRwcIuux0/3tsdXJ3ZPzJNUOj/2L4DFEMjVMgjatomphDahLF1TgH1wSOsAkxzIYp1pVfZDTNCEJviOJvPE9ClWgmKk7TUV4IjNNREU9H5TwdlcvpqKKYjirxdFSepqMKaTqqQNNRMU/HyC8ymmaE01ERT0flYjpGiadjxDQdfx1n4oVv1V0BqvEHFEIPHDoEtAYckMUamIUZ2BhhIDW4jnbjPPatOgJAdQSAwgiAwwiA1hEAshEAsxEAG0cApI7AUZ2tJ48N2UyN7Kdxqo59Kw70J5wqQGKgP9FUAY0D/SlMFTAa6E8wVUDiQH+CgTqxcTraxK08zE1jTBs5pk0eEx+SgSJGuxGj3YTR/jzZn/Kc+FY8LipIHAQVng6CCo0HQQXJA8mi0OFRYfV8BlA8Ftqhctzy1LbsWMhRPYFBFA6PnOPhEVB7TTRgO2py5MdGzvzYyNhyNwLfskg7ipF2jpF2apF2xJF2xSPtzCLtyCJtaBPivsn5oc47fp6oU46fJ+ls42eR1aCI/ODTi58nfGaxI70tUGUrLtEFpYU2vIsf6oIECgGpKhrUJAeGGlCMSNXhokYcOZKpyEileosqJD8JVIWkUkGyKmqTmuQy5Qa5YqkFFS+pXMckc0lHGaqbBCp0UlXNU5Nc/tSAnIBUbQrUiP2BZLIKUsk1orppJRJ7CalfLyThMNTgYCE1fIcaHS6k5EYkR2OKIngUCWRXpCbn+mWC1/DKVrx8t0fiyt1O2B3ej5eddptTO0bdbZULWce+aSUODOvScfwFzUE6jZLgfo3nl0m6vPPLRF3Z+SW/o+qIgnDwHVVTMRz4BueLiDAw+Q1OFkSIqtaKU9BbYp8DwWFrv/X4S8wriCAJFEdWVTRjG4xpVCCyUcD4ksJRJlnEOrZoRVy0Otykb4WS56BdwGOD0V5xDgxR9J2ruFcVI14ZxLoijLIxjq8JIrJVa8U06C2xz4HgCBpPsRuO08oJ5lPfirccCop3gwoSNyAKT/ceCo23HQqiWwqF0d2EwsKNhELqeunorZn5Gc45ojDdLlyE75mGrXdhy6/QnE3SxZmzibous6P13Nd3aee+I6oWA9NgiObCOE2IcTUrJuapMYnmxzhPkgk8UybE6TJMc4brDoWBZ6+x7pB6kb97mtG7jGBa00LEPE9wlWiWK+apDi9TwXxHTpMeRZr5KKrpjy1yDkSdEiGKnA1R5ZSIasyLqFFypPc6VfQ4TQ6916maXDT2N23wdw0O+aNfb5RizqSgUzoFjXMKXkSBjEJK+YQSZRNKKpdQz5mEKuURSpxFqHEOoRYzCBXKH3qHLceJc6f9DltucCH3M5X0naSQMerVLiHlbAGVcgUUzpT6pgCkiSHKEeOUIMZVdpiYU8MkygvjnBQmcEaYENPBMOUCvuxDYeAsaLzsQ+pF/u5pRu8ygmlP78YwzxNeJZrtinmq47k5zjgrNPEs0/yzrNKA2+Rs4BaUFCxzbrDOKcJ6zBRWKWFIftuMKadPklUWUaOL5n6nTeVdU4EMY4USjeWcb9SC0o5Uzj57uh/yzhllnAuUay6oLHM155drlFkucE65wtnkSswj55RB4UUejghnTetFHpYvxPdPBXsnGORFft8lCTkXTKMsMM7zX083YfoN0ewbp8k3rubexDz1JtHMG+eJN4Hn3YQ47YZp1vEaBIWB57xxDYLUi/zd04zeZQTTnS5KMM+TXSWa64p5qutTYzDVhmiqjdNUG1dTbWKeapNoqo3zVJvAU21CnGrDNNX44CeFgae68eAnqRf5u6cZvcsIpjo9J8k8T3WVaKorpqn+bZzl8cmE33CGkdXZRUZP1rkQHq1z7M/WOYNH6BzCM3QO7SE6R3UGgflzMmUrXjErKD7RWJC4q1J4uq5WaLx/UhDdDymMboIUFu58FBLvKv4G8zZeTdyh2KDLg7L7iIj0oDo5qHCbEHAeayfG2omxLkOK2f0+QOKRr8LTrZxC44NeBcmHw4tCT38VFh8JLyg+2/UbVscY/dcTfMS0bMVHTAsSj5gWnh4xLTQ+YlqQfMS0KPSIaWH0iGlh4RHT155GPow6tD15M9nfzYet+GxOQeLZnMLTszmFxmdzCpLP5hSFns0prE4RoPjY0ZvRn2GrZj6i4MounMetPN7zxnjP5XjP83h5IkER4z2nZ5HewEQ68WXkzQQfMnwzrhSuXcal+Q2tDyOtVzFh9g1RSIyruJiYg2MSRci4DpPJHCsTKEGMU5bgdWhGlC+N69CkngvUiJXMIRPbseJsMn44VimvTODkMiFmWL7UbghyDa+rUyvOOnVdfZTqg8SQeoYonMZVOE3M4TSJwmlch9NkDqcJlHrGKfUqfysQpZ5zlXpVPReoESuZeia2Y8WpZ/xwrFLqmcCpZ0JMPXy0nTIEUg8fbadWnHrq0fYqpefYjqXAoT3wHJtuIsKsn2PTaiPkjefYtMypqp9jk+rbpsDJe+h5B9nmvCkcjLlO6tjkazFPCR7V/5+Y52SPckr5KFPipwdBZJZiEaTnQOQnUkE0nwLZNximu5z9vfSt+g2A6hkToDApwGEPQGv4AVk4gVkMgY2BA1Lz15G/oPoWSxiQONV4S8UKNJ5qvBVlCQqdarzFAgQUTzV2aHeO98K34rsaBcV3NQoS72oUnt7VKDS+q1EQvatRGL2rUVh4V6OQ+K7GDl0tFzTyeu7qbXafeOZbdZSAqrEgwlECh1EihVNXwHXwgGzwwGzwzj72nz925Zzr2NgyjGqZZ2vZmJqlnJplnho+nQVFTJqdzgLKM2Sns45WcSsPZBW93IV1dzvPU74JpbjJ9rFpeMVGesUmewU/kgqKcJGNcJFNcpFtmPA+buUk7XPm4buILwlRENK7iMxVhNS7iCxRrPK7iCxwbPhdRMbktXj8fkqIXFcfv7OY/TcdvzPXTpyP31kgT07H78TBxQxRrRgnnzauHMHEbAsmkTcYZxswgQ3chOjihsko/LXPhQodmXrFXa4Ftnfj5PHOhdGb2K45Zfmmke8bZ/M3gVeAKqRloArLHAxeEIwfygGxNJjUyIHGImFyK0V4uTDeSAVeOCpfCdQYul5HqioWkyrBimKo4ahybTGx7Zy8yhjXS43JLWNNi44J2li3Odt6gRrlpFajcKCPa1IUOI5R5fUpqjLWsYmIeGzAcY9qCm+UU5CjTKGOIq9k6XLAqRR4VTtwOUA3ESucvhyg1cZq17gcoGVe+fTlAKmi7UeBiz6qvCJGVXpibCKcMTZgf4xqssEop/UyyrRqRpENM6jsaCTGdTS+SNeq5bSmRpVXVlLV+hqbfM1L5FobW/CKG9W07kY5rb5BzmtwfMmuFc60Hkf16xmo1ubY4GAGttbp2OhwmqY1O6oHEzGt30FdNYWDYWus6KGNWtdDA1zdo3BwbdIrfWzytdUnrfpRbaz9sdHhJSofB0T50BK1bdVA3xQOWkM+Sjif4BM953g8ACg+x3OeVn7g6XriOa7xgOiZnfOwmgMLT+qc47rtqNroiRH6IZR6PRnH2nj1xjmN+tCrNy7m8TdevXHOkWi9euNCjEnj1RvjFJ30ysrIG6+sEKdgHXplhUQVtq+8skI6BfDgKyukcigPvLJCGgVVvr2hIsjhlW9vBEqhbb+9ESQV1oNvbwSVQnrg7Y2gcTibb28EhUIpXm3IseIw5lcbHFEAG682OFeha7/a4BIFrfVqgwscLv1qg2MKFL8SQKHgEDVfCUgKBezwKwFJVuH76isBqQUF8yuvBCSdQ3vwlYCkUqAbz8LruHLYxbPwwCjUrWfhQVDhPfAsPGgU0uaz8KBwGBvPwgOn0KVHxzkqHC77iW0IlzMKlwsULhdUuFzN4XKNwuUCh8sVDpcrMVzOKVwmULiMc7jGXw6GYFVCoaqYAlWxClPVcpCqQiGqmANUOYen8hicSik0I6bAjJTCcjGG5IVvxdOVCwwFIHG2d0EhABrP6y7C0IHRNYQLGDKQeJK2Q/6zzGUrzlxB8SzLhbO4FVOhIDHfhae5LjTOc0Hy94KLQrNfWD0/BRSnd4d20/rMt+IpS0E1BIDEdYvC0ylNofH6Q0F00aEwutJQ2DhjQOoIHMXT2YtJekR7h+Kguzw5dqUGkZ6vTs5XuBADOE9jJyarozLdMbu44tm5u6Dy0rfiKXlB4jy88HTyXWg84y5InmYXhc6tC6s5Biheyr2Y5Ke2dyxfiNjRTZjZTc7GTSP1NjL1Njn1+DICKCIpNyIpNyEpp6PrwVbs9RRdD5AYyJRcD2gcyDS4HjDq7hRcD0isoekEH7iboncBEo95Tcm7gMYHuqbCu0ChR7em6F2A4oNx09G7Tn0r3gyYoncBEjcFpuRdQOPl/2nwLmD0q7VT8C4g8Vr+FLzrCRC8Cj0drWv/I2VTtC5A9nYJoPwLbVOyLqT4donj+BNt02BdwPztEmNmXT7UZUi4ZS6SZaMilrIilrki2LpAEbVi1gUoFwZdqJ2Sc/m87Zzr1MZvzgUoJp5zTDynlniO+GaTK56SzjwlndWUNNKHeupz3fepvi9Hwxt/qekSHQ+ZvZEGLL6IAwK+iQPYXsUB5m/cAPRXbgDWd24A2RtpznbW99y34ot8l8n6gKd3+y7R+gDRxIFigwFW8xJQ7bajmS2wl2h9gOLN4stkfcDTscElWh8gOgK4DNYHLFxHv0Trc1RL6CmQW/xl5svR+174VjyfuETvQ5TPJy7J+5CC9wGOpxmXwfuA0WnG5Wh0MARzOmTq1cxL8jrE9GrmpXA7lPitzUv0O2T0hublJP8Y9iVZns/XJjbaiIFuWgPd6IFuxEDZ91BSA3XnQxhfT7206/RgBukmRBLY0/RtiKQKd0s3IpKQfC7fikgKOV66GcECeF96x4y5ckH1jhlL5Ietd8xYZmdM75gxJ4+sHIzSELmlcbJM48o3TczmaRI5qHG2URPYS02IhmqYXNVvMoVS5XtPXANgc4bIaY2T3ToXnmtiNl6XsvuaRhZsnH3YBDbjKizFoJMtmyAty1ThW6axeZnQcDDTk42ZwqZtAjt3upPIgvDwKm1E8+TmJhyMj/J101rxaTm86c34ZK83hQyfbvlVJ1T3/JTGzt+866caCP9X9/2UllYBeedPibQWqHt/QoMVASktCiipdQH1vDSgSqsDSnqBwBa8RqBGywRKtFKABIsFUlovUKIlAyW1aqCeFw5Uae1AiZcP1HgFQS0uIqjQOhJuBgfHELeJRYGBaSOlNQUlWlaCJFYW1PPiEtS8vqBMSwxKvMqgxgsNaEsdkrTcoCYdFRsIU0WZfRW1hrVik+SuKPIChBqvQepRAaGJlQjUjf5QWo9Q+1oA1aqE8oEAttYmbHIogHmFQjEuUkM5TfxXQsqW/66PoXj/yYXd3yTc/5WH3dY2bPl1nrIVr/MUlK7zVNfDHhmibhmXfasqdLCibUZ97gH313ju9Ngx7LQh6rRx2emqQqcr2mbU5x5wp43nTodnlaDnkVP3oyjHEJrAQALfNnjf6B+PK4p5cJDuMDSkNDCU5LCgAQwK6FbSXvaJh4NSHkx9zAdGYoiGYVyOoaowgIq2GfW5B9xv47nT9tgH9NoZddsF2W+ToePGtoL1oh/cdxdy5+0hDOi8M+q8C7Lz4c/Tjx0Nf56eWS/6wZ2Xf55+1MYHJaDrlVDHK5bdhr96PXYQ/up1JH3aN3dX/NXrUam/QAe9NUTdNS77i38kd+we/pFcQn3uAfdZ/ZHcvfR+oAvbc9ny4wRDqpdF8IObijbhq+nv4b1PxxrAZd/o7+G9FwcUoNCN0Pfh8AFY+LWK92OkfauPW3kMOY5XA/VA7LY+Be2T+gGRqzH4sBX3dZWDD0K8xXs1dtx70MeZvKKOj7QeC3zMCIZgSPamqguBaETGD38RjQ2PbaiTPEp1bDNK9uJrRjBUQ7KHVV0IREM1fviLaKj4viR1koeq3pes0nBat1jMaLAGcbgOdT9NX0jIg3bla1/HAzelV11Og3clD39/cjRZf55d7T5yOtJywp3/bM1xlhta/MLh9GxybTstW1f7v10LyE38Ovj3dR2ob9kIHeHQ9nTcA+7YEO298of86W1GvUDUI+OpW7uKG4O03zleSj028hA+sA1bX8JWH7diR1J97yldpx87whd2jyN+yJ/fZvQlo14g6qb0or1EPz4w9pVfTz+O+CF/fpvRl4x6gaiv0kxGSbwmUjus3hI5FtpD4+u2Df6lwfsW5+G0zqpGPV+IG0ckrsEcJ+VBftFW0i+S9prSKBonU1X1a3M8CFB4FCA96O/aavxF476BeSio5bHQayHjOPitkOOIH/Lntxl9yagXiPqrzgdHiV8PGDub3g44Jv4gvmIr2BfBesWoy/I0cNT4Gf2xz+kR/WPiD+IrtoJ9EaxXjPosz/722ocJXiSvpItb8aigoHotHFH+AePC05HDnuKflHUcf9e4IPr14sLo14t3bGlHOWUrHjIVJE6KCk8nGoXGk6KC5ElRUeikqLB46FVQfDr0wyRcgq6IDp1OohDozX6unvjGOGwg40whgTgA9jAg9GkCOsYGSA0AoDpHjvykXVxeaF5aqO1gpEbicA3HMTvOAzctjd6VFAKTYhwMUzCMU0TyZeCbxmXgm4OXgSOEMOkfgdBiDNmBn4DQLVL42j8AoRvEUDZ+/kGrFNao3rTCxCEmVQW6/knNY9+KNsN/SHNPP43utHfcT+hOgKJ9Ok+W/QndCRDfA3LFHdSZXVVyZHfK9ij/SoYWaCyHfiVDN8kjbPxKhlb1uFu/kqFlikbjVzL26iKszouwBi/y6ruQ6+4inwct8knPonHSs2if9MQrAvj1+QchtEC7av8gxNig/v2XbUa9QPT16u/P7qXbCV7pLFux2goSi3rhqQoLjYt6QXJRLwot6oXRlc7CwpXO2wn+2d1bHDEg6N2e3k3qTWXbikddd2mwwNMh1t0k3DA2JP9GxN0k3h42RkdZdxO8GVzJ7uD11LbcHsU9FH335C4+4RURBaH1fFcUczjE012R68CoZ7uiwCHKT3YFDMHKt5LvUrUzz7HD37t7Qohip3/vjsUcu/R7d8x17PLv3bHAsePfuyMMscNLLhQIjp265FKl9JtCT6TAcTzwm0K6iYip/k0hrTbi2/hNIS2nWMvfFJIixj0tITKUaQ6aS8jYoN47gzkwRNE3ruJuYo64SRRr4zrKJnN8TeDImhBjivcbTyPqcyA4gu2bi8sJ3llbhnV4t+V/uGkZdrXMe1nqHaB3EYJd4UXck9iqzx/kPbcdbpmucCoOHUlXOE9E+77xPdyvrzw3Aoeu2DV5uRIpdEs++xEodengsx9LvGpHCLqCV+1OYqs+f5B70H6Kg47FsRekQGdIgT6R0je/jXvIcu5ouF7IDDoXrheeULtefJa7cuCxkXrWgX3IB9OGoAd4fE0f5P2r4+tRQksiBLuvCHafjWvZMK5l27g+T/D84DN+FlA6K6gXzFp3GKPeEuM9RvoqU1+4uug+3Ncv3f//m9NnptYPXscPGa73DIXmN3wjjnGMmrrpG1vEa49BC3ERY1jFsBiuHVJavRostdBZ0WI3t88ErjtUWvzFUtLqTWuthu6oFnnyq+SFMgRp96wHbsUJK6j2EpF1DuB4/f2ZkeugW/o4urF6KFt2KcsRXb8ywV569y9bxq08EHXlvPBU1IXGk+yC5El2Uegku7CYvQXFK+c7ZFfOPWx/hAbrMO51NJcVZhEimx+EjVje11s5ZSO0cv5QL0yu9oYHG+GC7Cra3QjtdrsPzRBNlHFKO+ece3Qvv0ay4uvcklPRnqn2uBiipDQuo2lPSFF6Vr4UqDF+ma0m5pQ1ifLWuE5ekzmDTaA0Nk65zM9O8DT8kZuuc+A4v41TkjvnTHfl0AR5bhtRiQ8nDZTJfSaxDsS5wKjY8xweEUOUDMapGJxzMfBfqngW8XVuycVQORSDISoG4zLW6Y9H0A6WAjXGL4tB/e0IlqgYWn87gmUuhvS3I5hTMaS/HUHT8Eduus6B42IwTsXgnIvBlUMT5PluRBUDXMGiTO4zicUgLl9VJVxUwZKIAidGVLk8SE1FEnUqlSBetz6Vyibfr3uqBC6hg/frVJtUTukGlxYORlAXWPMGl27AxXbwBpdulApP3+DSKhdhUFMpBvWP1sfWrWlIxRlVLlFSU6GS/vU0gLqMXJYuXwqV1de3OBVz6zroXo/Xi2qYEOUHEj0gATbuAcJLjXQKPG6Vv905vuhnyJ/1IU63yIN6YadQlUwT2f0JyvHM3JAlB3G8EBClevY+npa/yOKo7PN3mMOJO1rZigVeUDUbQKLQC0/VXWgs6YKoRAuj+4mFhfuJhcT6fADrfWFk518nvhVvOj4kpwKebkY+oCcBIiMCxX9xzVm1HEB1HI7op8u2MLRTI27N2+zH24YJb6XzbrPdbpseuxXGus1uus0WusWh7Qeyu4Ls9x3KVry1UVB8rm6P8o2OwtM9jj1Nz9UVHO96FER3NAqjmxn9WCsnvhXzqsdaASRSradaARpTrQ+1Asx/ws/ZWCtAYo71qVb6MA99noc+z0PfmIdezkOv56HP89CLeegb81CK4KltWRE4ikXgHIvAqRWBIy4CV7wInFkROLIiMET1XRdEzCpDlFrGKb+MqyQzMWeaSZRuxjnnTODEMyFmn2FKQb7MQqGAdDBEGWmc0tK5yE0Tc4K6lLPUNEpV45yvJnDShms3TyOi9G1cuyExJ3K+dkNcp7S4dkMCJXe+dhM5pzncpINMR0rJjhLlO0oq5VHPWY8qJT5KnPuocfqjFisAFSqC/C6IiBWkG1KqBpSoIIIkagL1XBZBzZWBMhUHSlwfqHGJgAZVgpQKBSVVK6jnckGVKgYlXTTYgusGNSodlKh6xGtAY1L8OYHnmP+EHAASnlj+k2ccMJ9n/UnzCzQ8hfwnziag+Lzxn+DjTGKn2cUTzt0XHp6UNBB2cMY0pOTfI68nm10mcVyG47gc53GZlsblShqXSXFchmlcxmlc+JJUp2kcX5DiGKOUxxn0NNaopvEGOY45SDTuoMHY//O//w/7Vd1G"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Helvetica-BoldOblique.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Helvetica-BoldOblique.compressed.json var Helvetica_BoldOblique_compressed_default = "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"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Helvetica-Oblique.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Helvetica-Oblique.compressed.json var Helvetica_Oblique_compressed_default = "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"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Helvetica.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Helvetica.compressed.json var Helvetica_compressed_default = "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"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Times-Bold.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Times-Bold.compressed.json var Times_Bold_compressed_default = "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"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Times-BoldItalic.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Times-BoldItalic.compressed.json var Times_BoldItalic_compressed_default = "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"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Times-Italic.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Times-Italic.compressed.json var Times_Italic_compressed_default = "eJyNnV1320aWtf+KF6/mXcvpsWTJsnPnTtLdsdNx7ESGMb36gpZgmSNKcEhRCjNr/vsLgqhz9tlnFz03XsaziwDqVNWuDxSg/5l919/cdLd3s29n7/+5Wc+vukcnZ2fHZ49On5+dHs8ez/7W3979PL/phgS/LW669Tc/3s2Xi4udslkuUXnkyvxmsdyiNsCmW1x93l3nn93lYnMzkH36l7dXyyHdN0enfzkd2Ppviz+6y18WdxefZ9/erTbd49l3n+er+cVdt/q12/3+hz/uutvL7vJdfzO/ne7wr3/t/5h9+69vjp69ePzN8dHZ46MnR08eP3/+9N+PZ+dD4tVycdv90q8Xd4v+dnexJ09A+O3z4uL6tluvZ9+eDvx9t1qPyWZPnhz/5cmTJ8NFfu7vFhe77HzXf9mudjl59B8X/+/R0Yvnp493/56N/77Y/fviyfjv0/Hfs0cvL/uP3aNft+u77maI0e1Fv/rSr+Z33eVfHj16uVw+erc72/rRu27dre4Hug/mYv1o/uhuNb/sbuar60f9p0c/LW77u+2X7pt/dMOvXv790fz28j/71aPF8OP15uN6cbmYrxbd+i/D7f4wXOZycXv168XnbiyF8S5+vRt+Ml9dFnVI+N38yz+mgnl2+vTx7EM5Ojk5ejx7ub7YhXo1iM8H8fvOjscgz369u/xHM/v26fH43/fDf8+e7cvrn93danExBPRf/zNrPsy+Pd4F9ufhRtZf5kMc//fxHj99+nSPuz8ulvMb4yfHU/LfN/0QqY9LU06fTMrt5ubjrqCubrN22S+X85Xx5+UqX7rVxa6yF+Hs7PlemN8M0nqITr6z8Q7GEs/al/mqu112n2pS/Jnd3ny9O+P62pRnZ6fTr5abtVGL2cXQRuf5Ep+3Xz53tzn5kJVF7zk5LplcL+frz/lu/uxWfab9bZfh3YNIefd51Ym0n/rNStDFvUi7XvwhYHffibLtdExvF7eiWl30y/4243V3s4iSlcByZwOJdr9v5suMr1bd0JBFNn/fdOvRaoryolToud/7s6OjPXuZ0V8dPTvbo++82h4f79H3+Yc/ZPS3/MO/Z/SPHKYfvT2enOzRq3xfrz37p8/26Kfc9P6Zf/hzvok3+e5/yane5lTvchn8mu/rt3yu83yu9/num5zqQz59m9F/eVSH3mFEH4fO7Lq7C7ZhbfTjoMV2yr+LnnJS8jFfXywWF4vVxeYmh2KzM+310POIJjL6W7gZ96mMPuYqcSH8N6fqcl4/5R9eZfQ5/3CR0X/nK17nVMtc/iJawnSE7X0RrT4X2iqjdb4vEftNztB9bkIPOdUfGW3zTfzpqaxoh/rVUa08LbVyVUlPPdzJEdTGu8XyssuX3nf1l/2DiHPonb0nuBvHaV45jkr+P+0Ghuiz9put6js+LfvVQvB1VznLxWY1dOMXHsDjoxNoNuvFOHhNrb6MWnSzutosBuWmv9Mjh508nvgrcmVw8Wmh8i360WEoqIYDl/OrK9Wl7TkOxWjAsSu7btV52z899rHQ/Go1/wKmVn76cZhEdCKXHt6P8/WBCB9WKyGyAoj6c6uhy+Xiy3rhDXWYLnhW7z73mzBUTL1+qNtecKv5vfDf+cXmTo1cRiv/tOz+yBo1rIJv5hcrNdr5uOrUhS/7u/lFaHAuLYaCxACYssJm6Dc7TOmGEbcYom5ur+arzc1yvhGX6a+GUea1ON0c8+HFchNqrPGXPuY5PptqQL+6/DQM8sKo0IcnsYf10UfkL4p/vvELPD16Yhe4GVxus8QrmC/PRXd3uWvw67XovJaVkXkfuZ29F0PooW0O0+GhzotC+zGVp3fLsfp51x8rjXdLskT9dLHofGSU7sDG0JeL+8WlKKQ23pkPlkXL8NuOP/JRnviRd4/UBK2jHudd1EYgq/mUfr3QThynMPidU2Pw31RKaEM/8BlAuojPFwaDgAlInGBSRs+emTiteIhLkeX4mJDqgeUyxMVnAuoGvHnU6mh0VB/lq7P5NKp2tuiqEM7sk15DQjaBkyH60DVe/eRsusqy/7O7vRKXfxcv4TM4lUmvHAcbiRC9eXEvYiPZeCNQ1JRXn/vkyNllfvvcr0Su3tDVPQyVUvuVeLmry0rYzukCHrHYs4XFjfVmHOGsxP3GKuhRrPFoq2aCN5vl3eLLcivuizLolTwWR+n4hrHW3WK+vFx8+pTLaptt2JpgvI5X2EOV5YeD1exAr1OXLioFfVuzQa4x7ilzORr6kfoVXHobBgy4/mbTn1V/3d3iJMjMcdVdLdZx2OtNtDLw+lG0C5uJbIZWHeYiHmwaQFrDrESm56pu7bJSpf6LTPvkRRm4jqtccQ3McvnDnRihfFc1wKXyLW9uFZPpqr1jrRd8WRs+HKiVlQD/WWsatZt6UyuRWtdT89x17cr1Lv7NwWEJ21IZF3TLO7HYcxdM2gvpoT/giPUhzs1G5IT6cAuVHGd6W6DQ+yw1jnDOTtHHhwq8GiqyuLVf0wymKMtYI33VU/a/NsOIBffiebmN8kBHeWJ9PvZjZe74Y627/Im6vxKGIWif50tYeCttfDcziQ3ci+KQyd/GUZPXtK+UHw2DLAi17vkqeilmaCpVVah6EPqrHO5aBdYzHKtgg0uoxx09NS13Qn0Tm5j+5LRMsIdu80L57PeVsebq4Gj351g+fruV0e67w9VaXsustXLOl1WP1rOkN5WFwz8PjCd/qPX2dG1fHZZZsfFYGAj42Q42hXgLvrh78ErL/mpX3re9GMX3dS/dZKk05eFUlZZ8dXDO0N2Jhw5/Vqrv7cFufAh56iHc8mtt/IfN7kHkvx/PXner21/mi9Xu8fG/Zi93j6lnj795+uTfj6ejvXsEtL/PiCZPR/j33dGpHe1dJSDMGApvhqMTO8+bcguAoHIEbkUV6L79BxScJyhTyALbLw4FtG84iN6Go992OTqzI4sZoJh7E86Ho1M7z3nJPaCQe+CQe6Al94Ao96BY7oFN7Tqw0U6QvB+Ojp5YETbD4Qs7andJ/ciy5Ahv3SjsB8AAbYajY7vwppwNUAgQcLgK0BIgQBQgUCxAwCxAwKYAObkPWXsIR9t4lOOzzfGZEmF7NUSN1ji1XOfcfIsCbdgQNWTjsjUXFZq0IWrXxlXjNjG3cJOomRvXbd1kbvAmUKs3Tk2/8LcZgQkYIidwruygqOAJhsgYjCt3MDFbhEnkE8a1WZjMjmEC24YJ0TsKRgMpDFykoDa3APYT4/VGo5ylaGAvhshjjCujMTG7jUlkOca175jM5mMCO5AJ0YYKvs8RechoK1Al1MKfJptAfzJE/mSc/Mk5+1NRwJ8MkT8Zl/5UVPAnQ+RPxpU/mZj9ySTyJ+Pan0xmfzKB/Mk4+VPhbzMCfzJE/uRc+VNRwZ8MkT8ZV/5kYvYnk8ifjGt/Mpn9yQT2JxOiPxWM/lQY+FNBbW4B7E/G641G+VPRwJ8MkT8ZV/5kYvYnk8ifjGt/Mpn9yQT2JxOiPxV8nyPykNFWoEqohT9haNCkIieniiLZFYnsWUEG44qc3CuK0sJCEvCxyMnMoqgcLabIthZ18rYoaoOLadjlokpWF0XyuyC+rXBwvsjJ/khUHhiSgBFGTm4YRWWJMUX2xaiTOUZRO2RMwzYZVfbKqEbDDBq6ZhDAOgNvKy2UTTSKX2neyk5DAvDUyMlYo6jcNabIFht18tkoarONadhxo8q2G9XovUG7rwTyocK3NX6o1IQpO0FLRkqGjBLZcZDYjEEEK0ZKRoyStGFIACaMlCwYJWXAqGf7RZXMFyVtvZiCjRc1sl2UyHRBeispGC5SstsgKbOFBGC1SMloUVI2i3o2WVTJYlHSBosp2F5RY3NFLVorKGisgMFWgbayhbGlonSwaSo7BRnMFClZKUrKSFHPNooqmShK2kIxBRsoamyfqEXzBOVehuxB0q2m9XIRljnlHv3SEJmlcXJK52yTRQGPNEQGaVy6Y1HBGg2RLxpXpmhidkSTyA6Nay80mY3QBHJB42SBhb/NCMzPEDmfc2V7RQXPM0SGZ1y5nYnZ6kwinzOuTc5kdjgT2N5MiN5WMBpbYeBqBbW5BbCfGa83GuVkRQMbM0QeZlwZmInZvUwi6zKufctkNi0T2LFMiHZV8H2OyENGW4EqoRb+VO4VDcoZOZQLZFEgsEeZBCbljFzKBWlTJoNPOSOjckE5lavZqlwjr3JBm5Xr7FaukF25QH5lwlvBwLGckWWBoDzLZDAtZ+RaLijbcjX7lmtkXC5o53KdrcsV9i5XonkZR/cyCPZlrBUthA3MhQPNSlmYieBhzsjEXFAu5mq2MdfIx1zQRuY6O5krbGWuRC8zfi+C8yDYVrFa5IWhlRtDQ3NGhuYCGRoIbGgmgaE5I0NzQRqayWBozsjQXFCG5mo2NNfI0FzQhuY6G5orZGgukKGZ8FYwMDRnZGggKEMzGQzNGRmaC8rQXM2G5hoZmgva0FxnQ3OFDc2VaGjG0dAMgqEZa0ULYUNz4UCzUoZmIhiaMzI0F5ShuZoNzTUyNBe0obnOhuYKG5or0dCM34vgPAi2VawWeWFoq+n7JO5AhZCZFUxWZpiNbBLAxgohEytYWtgkgoEVQvZVsDKvomXrKgoZV8HatorKplU4WVbBZFgTfpsImFUhZFWGlVFNIthUIWRSBSuLKlo2qKKQPRWszamobE2FszEVHm1pomhKEwJLmkibajjbUcHVJqGsaJLAiAohGypYmVDRsgUVhQyoYG0/RWXzKZytp/BoPBO9T2F4SGSbiY6tsJupEaDfGCLDMU6O45wtpyjgOYbIdIxL1ykq2I4h8h3jynhMzM5jElmPce09JrP5mEDuY5zsp/C3GYEBGSIHcq4sqKjgQYbIhIwrFzIx25BJ5EPGtRGZzE5kAluRCdGLCkYzKgzcqKA2twD2I+P1RqMcqWhgSYbIk4wrUzIxu5JJZEvGtS+ZzMZkAjuTCdGaCr7PEXnIaCtQJdTZn/460Je7K/uRBdFR8RJAMaTOMZpOLZCOPEjOPD7OSmiclIbt6HyslHZUcgAo3C5wuF2g5XYBUZGDYhkBZhkBNmVkT76f4r733+8x7oCih3+f4g4cMgK0ZASQ3S4wu11g0+0CKXF39N689PvJBvyojUexF/me2v1EJ9PFyBii8BinGBlXgTIxR8skCplxjpsJHDwTYgQNUxgLf5/D0GTUCkShNS7iO77DGONbEMe3cI5v4TK+RRTxLRLHt/AU3yKk+BaB4lswx3fi73MYmoxagTi+haf4/m0K7dHRqR2aFwErIUUWDQoEdCjAZlHA3IkAuhUBLF4EqIQN2G6keeZHJSuASk4AhYwAh3wALdkAZLkAZpkANuUBSMmCo/0HLodMPTUUE3Q5U10Z+iHSmepkpuCF24BzXjuR107kdbGrYn5kFdJRHIw7xzrq1Ibgjnx47czuxFnvw7/x0LtaZ9TXuhA6W8fe2zpL3a1L0N86LJMAZFajnU1fMA0VYmWDofEoDp1GVCoEojAN2Auvpua/N4NX2PoBlSYDSMykXlHTBxrnT69CwwfmhedsajJA4iTp1dTon1p+5rFbeIWNHpDoDF5Rowcau4BXodEDI+N/BY0eSLT7V9Doj4108SiOcF9hm0eUR7ivqM0jhTYPOA58X4U2D4wGvq+mlgZH+Z77yg328gb7fCfcyEAR92hNDFAcib/CBuZoEwpnkyvUplJ7NrL2bHLt4fkYKKJebUS92oR69Xq2XwnZT33HoziLH5GYwI88zd1HGqftI5Iz9lGhyfrISvgBlfA76kIeuhjr11jREeXwv6aKjhQqOuBYKq9DRQdGsX89VfQTy0EfLfN1qujAkz++xooOSC4tvQ4VHVhcUHqNFd3RJh7lu95U7noj73qT75prNSjirjfk96+hVjvZxqN819t8d6Grw3ZBAjURUlVroSS54VACakOk6uZEibhlkUyNjFRqb1GFyk8CtUJSqUGyKtomJcnNlBPkFkspqPGSyu2YZG7SUe5rFYkbOqmq9VCSr1VVdgJSdfOiRNzSSCarIJVcI6qbqnAwMNJWKMnXAsNmQ+r/JTDJgkhmNyI5GlMUt1XhYGCyc/002y/tH/uRDfMAhZG8C7v1gv24fnfUhKM2pGzjsvOI0qLyjorl7J+mDD+1RJZLQNjE9xTfuT8mRJmsvHNPKmQX30cn1OYfcu7V++gkqTjga9iUR46Ieg17kmKVgOCQQCFiVQUqpoFwRaGpCW3tVBxAUnMYYwIVzNygZHw4sPUGNSWY7A4Da4hC6lwFs6gQxoKajNr8Qw6a8RyuIqlAFW2b88jBMZ7C8vNseoZyZkd2d47sGYqjOIFzjnlwahM4Rz5Nc+ZTSWflGYoTm7ntUWlSLwWivBinDBlXuTIxZ80kyp9xzqQJnFMTYnYNU57xYQMjynN62MBc5Vk9bGCJ8pwfNrDAeeaHDYw5z6GFv6wKnP+ochSiKmMRk4iIxAQcl6im6EQ5xSjKFKkoUrzKg9OXAlGMjFN0jKu4mJgjYhLFwjhHwQTOvwkx54Zjnt9M2d178BvMKaCSSUBxhuc8PXN+g7kC5HMzZ747wVnZmODEJmaGfrNR4BvsnBCFfsmFsUuyoyYcfQgp26D59gZHaUb7Bo12uttktMwp1tpoWcxRT0bLnOOfjZaFWBLJaIlDmaSxauKqdMJYNaImow/5h21OxcWmhq+TFF7nhgKMnEoxilSUUVTlGVPkQo06lWwUuXijymUc1VjQUaPSTh+eOBHR43I/9OEJleR9pVSaCv9QOU9bSc+1ov79hb0OL61CxUBK1QIlqhQoqSqBeq4QqFJ1QIkrA2pcFVCLFQEVqgb0MvxJihNXgfrL8DnBexn5RtIP8gytTMvFXntHfK+W1wChxA1RcRunsjauCtrEXMomUREb5/I1gQvXhFiyhqlY8R3fkxgGLtDKO76kvs/xbDL6kH/Y5lRcfPKV2L0U17iwCFmhkmSZCpRlVa6cJhcvp6BSZpkLm3Uuc9Zj0bNKNYBkqAisUH1IsqoWlOh9tcSaqvKhera2+huuOSznCmTvzEHVcUaVxgWqLi6oiuJqriKuUeVwgauFK1whXIlVwTlVgvDm7AlFhAu+9uYsy+9FdBvBPojftiIdF6p+wXSvldUdKE1DVJjGqSyNq6I0MZekSVSQxrkcTeBiNCGWomEqRFzNO4lh4CKsrOaR+j7Hs8noQ/5hm1Nx4akFvknCSfqUtTRJZ05lpyfpLOayS5N05lx2eZLOQiy7NEknDmWXXl1IXJUd7uuneDYZfcg/bHMqLju503+UfpmK7YUfld8CKoUFKJQTcLgC0FI6gKxggFmZAJuKA0gpCUe7zUbP/ajkAFDJAaCQA+CQA6AlB4AsB8AsB8CmHAApOXBE+yR3KCbocqbsyTUinalOZio8mAac89qJvHYir308yvfcV26wlzfY5zvhp8agiHu058OAcvB5U+LbGb7RMB7FNxpGJN5oGHl6o2Gk8Y2GEck3GkaF3mgYGb3RMLLwRsO7Gb4+Nh7F57UjEk+vR54e3o40PqcekXw4PSr0RHpk8fn8iOJD+XdTrOEo3/V55a7P5V2f57vmWIMi7vqcHp6/g1g7GV/Eel6OmnDUxiOrPY6wluxpWfiCMjREITGu4mJiDo5JFCHjOkwmc6xMoGI2TmVd+LlAlSzKojexnkWuBMYPZzFVBxO4TpgQKwYukVLBNhm1AlFlUeuhk1QeMkGNMUThNK7CaWIOp0kUTuM6nCZzOE2gGmOcakzh5wJVsihrjIn1LHKNMX44i6nGmMA1xoRYY/D9IyrYJqNWIKox6v2jIqWthOUm9FZCrcoAV7cS6gQc7INbCXWiFHi9lVCrXM+Cel4VDgZG17yY5GuBSbUwqv+XwOQaGeVUL6NMtTPtupFVqakJbVXgWlvddbNPMEy09hPMJ3YUZzkjsmmlI7HxdeRpLjTSuMV1RLRldWT00vbIwvvaI4n7VX+bmpzn502MwW+pcQGXAbFmBIiHla74sNKZvbfjyF7bMbSbmbw4tiObITqyGaKjOEN0jjNEpzZDdOQzRGc+Q3RWZohObIZo6KJfwirAnuxnXGcnhcRfdDmXNuFCFGqXc6xdQGHCBSexSufIK50zkfnP2y+fu9uQjUXIpr2rBoiWPnasD2ftc977SnH2sjj7XJw8cQNFFLRN3ADlUrWJm+d+FbK1yrmnl8n2SLxMthPW3c2i1JxnRjchzSZfYiMWsUae1q9GGpeuRsRb6V2h9ayRifLchFWsHXkIYdrGo5IHQLjLbk9xv9bkaGm/FnPyY71fi8XszGm/FnP26Lxfi4Xo1mm/FnHw7TTEZq4cXA2xWSIvrw2xWWZXT0Ns5uTvhYPJGyIfME52b1yZhInZKUwiuzDOzmACW6EJsTMwTN5ROHULjkPfULA4AfcSxqmrcC76CxNzp+FS7jlMo+7DOPchJtSix71J4YscIu5XjLMZFaHPl+NuxvihaiQ6HJMq1ajS9Zhcq2XcCRmv1Cbujgpf5Whwx2SceifnqosqquinirTJqbnHMq66LRNz32USdWDGdS9mMndlJtSqEHVqBT/kiG8Foj7OuOjo0ibd0hvoTbpa5a7vwCZdnUR0g3qTrlZTl1jZpKtl6h71Jl2pYlepVxW0KrvN6qqCTsBd6MFVBZ0odad6VUGr3LUGFTvYKLAPRpU726hKr4xJhGPGBOybUU32GOXUmUSZOuQospEGlTtnEmMXnV4FladM3bV+FbSiqq67+ipoJYHoxvWroPr3qUuvvAoqz52696AuaqFOXX1Uk1vHdzBrN5M6/6h+vVqrgUBMcLBa1wYFMdHhup8GCFE9WLvTYCGoq1o808Ahqjx8IFUOIkIaNZSIr47WfpmGFVGVg4uYRAwxYgIeaES1MtyIidKgI8qHKzMPQIL4UCvLbVXgIUn99b8xwfk0GtkvzZ7jEARQ/L7NeRpsAE+L0ec4rABEK8rnYQABLKwdn+NQwVFx7v0HSs5n6ZslZZEd85re0WBOudbvaLCY85/e0WDOkcjvaLAQY5Le0SBO0SmYQ5RehZhOo1+FkCJF7MCrEDJFjp1+FUKKHMXKqxBSjfHUr0IokSIbNA4vvU4wnU69TiAkCmz1dQKh56Cq1wmExAGVrxMILQZTvU6QJQokKBxG3KA/nSdt0GdO0dMb9FnMcUsb9JlzxPIGfRZirNIGfeIUpYI5RGIf/HSi2j74ikxxO7gPvpImR7G2D74ic0yr++AreoxwbR+8linepHLYw+7x6YR593gSKMiV3eNJzYHNu8eTwMEUu8eTEgOYd4+zQEEzzuGyv+cA4XJG4XKBwuWCCperOVyuUbhc4HC5wuFyJYbLOYXLBAqXcQ7X9DV6CFYhFKqCKVAFqzAVLQepKBSigjlAhXN4Co/BKZRCM2EKzEQpLO+nkDx7YkclHIBKKACFMACHEAAt2QdkWQdm2QY2ZRlIya6j3fLWUz8qOQAUPxnlPH23YqT26SdH/DU9V/xLUM7KHBSQfZLR0Li3+OjIDm0pDph/FdcZfRXXBVyKA+xfxXUGX8V1CF/FdWhfxXXkX8U1Fqen76H6HR2/KIh+04kM23JPYJUMhy/NAoX1HExtn5p15J+adaaiYKs0p5a/3dLMfo44HsVp44hinXOe5pAjtTrnyGuWM/8QrrE+3msvwtrXQtjrOtOLOpM+PwuSqk7++Vlgour4Tm+vKbji4RndxKMc8rigARwrilOrEI4oj6B4VXEmCqMsR+xJE+y1yfbaZHttKvbaSHttsr02wl4bYa9Nstcm22sz2eu+u2jQXgGJr642ZK9A41dXG2GvoNBXVxu0V0Dxq6vNDJf2m1laz29maRG/Sd4KPK1rNrO0Rt/M8sJ8M8ur8c2Ml+CbWVp3b5KpNmCqnib+osu5pAX0Jhkq8LRU3rCfQuK4KN7M8kp4M8vL3w266f6DU80MF7qbWVrdbmZ5SbuZ4Tp2M0uL102yPeCyOPtcnHpBupnlVehmlpaem1lab27Q7xzlBd5mhqu6zSwt5TbJ7oCnRdtmllZqG2F3oNCabDPLC7HNjFdfd2RcWTXr8OVUR2jGI21n+ES3RZcEFJ/dtsklgaentC26JCB6HtsGlwQWnry26JKOxmesp3ZkvbCj2Ak7xz7YqXXBjrgHdsU7YGfW/zqy7teQu0mbXbLNLtlWXLKVLtlml2yFS7bCJdvkkm12yTa5ZJtcsg0u2WaXbLNLthWXbKVLttol2+ySrXDJVrhkO0tPBtsZjjnbWRpzjkiMOUeexpwjjWPOEdGYs53lMWcbrLfN1ttWrLeV1ttm622r1tsK622z9bbZettsva203nayXk+zydnbVLK3kdnb5Oyx9YIisrcR9WMTGwc+oJlMKT2gYU6Wqh/QsJjNNT2gYc42mx/QsBANNz2gIQ7Wm17PY65MWL2exxLZce31PJbZmNPreczJoguf55JmszZOjm1c1VkTc8U1iWqvca6oJnBtNUFXWTZ1f+4W2iU/jqPU4gRs9MbJ7Z0fiJDwfZey+ZtGPYBx7gZMqEWPO4TCFwJR12Bc9Q8m5k7CJOopjHN3YQL3GUXoc7649zB+qDREP2JSpb5WehSTa9WZ+xbjlWrLvUzhoqsp0ian5k7H+KGoiO7HpEpUKh2RybWopC7JhNjI+StwTxKl3kl+BS5Lqo+qfQUuq9RT6a/AZY37K/UVuKxQrwUSdFxIqe9CSXVfqOceDFXqxFDS/Rim4K4MNerNUKIODaS5rCXcraFEPRtKqlmgnlsGqtQ4UOIGgBq3AdSqzYC7u/AYP9iDeMCff6PPxF0fStT7BelwFEUfGNTcDaJMPSFK3BmidiDI3CWCtNCUOkaUVN+Ieu4eUaUeEiXuJFHjfhK0XmaZe0uUvlJ6os9Etd4GKj0npjjQSrj/RKneFLgXBUl0pKBu5G+4O0XpK2ETnSqq9bBVulZMcSBsqYNFLZjL4Asz/+bMeGTPDR3FjaaTUDrtK4HoHMbliabEeCJDdCLj8kRhD9hVjdMpoyjPC9G70pTOiZI8Y9k+dCUQncu4PJFt8bhSjE7lgjyX7X+4UozO5YI817Rl4CoTOk/B8izlQ2dXAtF5jKsTfURTODHkf/L8IzZzQPHhlHN8OOXUHk45kn/Z/GNovsDo75l/hOa6Jxe7jssGRLuj66Bdx9xPgs0C/ZcFXedU+hz2TqGfo6DrnKpyjmEMsFzO6SwGr1VKfab9iGb/J0guPy7LXyE5OskyabgKcGTEd8aEugUo3oYL/gj6tKD7cPQQjrwe7Y78z6SMR3HzyYjSJpMyOONMoBufEKLsVNyYVM5Y4fcZPWQE+Sxom/PAOTaes83v8h5FDNk2RNk2LrOdXvqcMlT4fUYPGUG28d1FygNnW767OElqy/OR0DAAsruTog6F3EpdcorifYU/VDiGB/m2kuEUqCDmaIlJz1FSIFKqCxeSjJIab055Bule0gdJITpAtzJ7HBmURFx8cpUCAxJGBjGHBjUdG0iRggPavcYPGmN8AG91PlOEUMsh4n3eRxFDaNJAjbkMSdowPmWw8PuMHjKCEBS0zXngrBvP2U5bh4+IQ8bzuDIJMut5G/KUKxPuBXsQDLJvbCsywwFwIUcg7QY+Ig4RyKPhJMgI5J3FU85MuBfsQTCIgLGtyAxHwIUUgU8p7zsyNJdlt17vlkKeGfw0K+9C744Wdi/jEQ1eP+XsfqIx2X4KepWuvyNdPLJlTUe23RNQ/obryHFlEyhu9nQcP+06IvqA68joA65xtiNmOtVZzlUOVPkpx6XgTiCKkHEKk3MRKxNzwFzKUTONQmec42cCBzEvBVxVlgKuDi4FmMqB1W+dTz/Kb51rgUJdeeu8ooqw1986ryTIRVB561yrXBy1t86lfFUVqIBIlcVUeYd6X1jXoRCuc+Svc7ivKzG+loG91tG8ziG8FnG7FsHasT4e5XvuKzfYyxvs852k/dSuiHv03dSO7MmKoW08yne9zXdXazAs0MkONpikilh9rcGkBLmIDzYYVjmohxsMyX1VOBgWWUnqn0zQCQ5mq1KLap9M0DLVrconE6S6rQoHA5PrYRlC7kdbt7hSMSGcxRcUTgpCWUl01Afb67PX9TWD68vQbn+Ul8z7tEjDXJ42LMbsUWXxuz+0+N1/ffG7zxP+PZeL4r2aUQtJXomnzXual8r7ylJ5f3CpvA8zrT2it0qv6gpdiWV5QUoE1xWr9n1t1b4/vGrfx0nUnpU/7nIlEJ3duDx5UeHceU2+r6zJ9wfX5HtsZ3tU+v/aum7USRzZsvt0V/T9/8vrQviTmb/EGPEQyfmd1uIlxTlX+nf2gRellZ5PanHdO6dYmz9FXC6otHJBqZU1d62KeW1M8WV+0VVis/vJ0/yTu3hSkcLrxhDe/VuPp3YUt7qMyCqgI7HrZeRpt8tI4y6XEdHelZF5j++svO3oJG5f2aGLWXlzZTyySbqjUkKIrGAAlpnLPtqrqVJ7AqvLjuKVunzxLl88Dr+A4zICUBhoAbYNDo58Y4Mzi6qzq3hUyhcQ1SETbH/HsdWf3UjsxMrChl+A4hvaziG3QO3NbEf8QXdX/H1tZ/ZNe0f2QrYhnxV5Wf8esuojoRUaAKA4xF7F5o5QGHVxMGx+aR8xc2qIeh8xi7lJpn3EzLlx5n3ELMRmmvYRE4cGa4gajnFqPc65/aZHeFPBFn6Zk3Jzxp3LjCr3x61b71xmMbdzuXOZNWrxeecyC9z2cajMiFygMlQmlf0AdxWfxEJnZ9C7ilnMHpF2FTPXbpF3FbNAvpF2FRNPDlKE33OYwEsMkaEYJ1dxztbiivIX/GL11PzSF6uZk7/oL1azmP0lfbGaOftL/mI1C9Ff0heriYO/GKL2a5zar3P2l/SsfCr2wi9zUvYX/EY2o8r9sb/ob2SzmP1FfiObNfKX/I1sFthfcOMAI/KXysYBUtlf8EPZJ7HQ2V/0h7JZzP6SPpTNXPtL/lA2C+Qv6UPZxJO/FOH3HCbwF0PkL8bJX5yzv7gi/SWs9KDLRIG9JqrsOFGVvhOTCPeJCdiDopqcKMrJj6JMrhRF9qb4jATKMArsA1FlNyA1eZZ+MFMqVFAvaz9LLpbWp7VwMCfJ1w6sT+skwuPq69M6BftdZX1ay8n70gMdLbAPHnqgI9MkT0wL4yeqyiV/PLAwrpMIr9QL41qt+GZlYVzL7KF6YVyq2U+D/Hst3OitUWCHjSr7LKnJbUkXnjstBo2vbe03DBixW4nY7DVi8RV509BQoxK/G2+YvgVv3L0z8mKakcaPwhf8WyYWVsIxXkHc/UG2/R+tLWT3l9hOQkx3f4LtLKSxv71GGAK0V+7BWvcvjdxjddujh5ToISfaQqL9Bzy2mGhCPNElzMnF9r2s4I/+/b//H63X5Vs="; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Times-Roman.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Times-Roman.compressed.json var Times_Roman_compressed_default = "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"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Symbol.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Symbol.compressed.json var Symbol_compressed_default = "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"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/ZapfDingbats.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/ZapfDingbats.compressed.json var ZapfDingbats_compressed_default = "eJxtmNtu20YQhl+F4FULyMGeD7pz3AY1ChtG7NpFA18w1NomIlECSRcxgrx7SVk7+wOdG8H5OJydf2Z2d5gf9cV+t0v9VK/r+6vXsXlOlbHe28paq229qj/t++m62aXZ4J/m8PRb1z9/baZxefK63Z6eXN5dVMvTCh83u277xr/6kLrnl2XNq7TpXnczuZyabdee98/b2VzM/x4/dd/T5qab2pd6PQ2vaVVfvDRD005puE3Lu7eH1HbN9hTjx4/77/X6y5lcnUmjVzHIVVDicVX/1W/SsO36dLMfu6nb9/X6TAoBD+5euvZbn8axXtuZ36dhPJrVQqgPQoh5hev91LWLkIv94W1Ygq9+aX+tZAx2tfz64284/sblN/rqfLP/mqrbt3FKu7G67Nv9cNgPzZQ2H6rz7bb6vLgZq89pTMO/M/xfEqturJpqSM/d7GJIm2oamk3aNcO3av80O5xh3yyKmm1193ZIT02bqovTKjP+MAf++7zsZvZ3276kYyWWXB0z99S18/PbafPHQ71W4fjn/fxnFO+ZvkrT0LVzTr78qB/+nk38bHM9exgP8zr1z9U7jt6840YW5uSJKcZOCaBBnKgm5mU8MVNYyMwWFvO7Ukagkmgg6sDWQ5yFFqjzUrLEaQ3BEmiwNsMSaZS0vgWfOkPHWQowNeTUc0kumnxZvsgPxlGai6VTGUqAVCTQ6QkWnc77DKEiLktSUBJKqHIQZ86d8gCpHYoiEzMsb1ubYy8vW50DChB5ZhGqrijD0EqUIeiaEHIfCg5Kpuu0ApiToaGPSY0uaQsyr65L2oKi1yFt1PLaQ3lzfXTgXodGoJYzglndSLDMPg1sTPJpQJHJigw0QrGERqD9YhyTOgONQDUyuF1zaxuokc/BW2ztXCMrGZ9WMW1oQZHIXWNBkSCfRZEL5BMUiZw6CzVSFCfUSGZFNjIldoKDkonTKQiJIGzWmFd3BizJJ9SINoLDriOfUCOZS+zg+KGD1qGiLNMLxtJD1/ns00ON6EzyUCM6vbxhoBKaqbG3DFQCNiL1iHccBPV0DHhQH/JW8EW90dkyFKGywCJU0WkVSvSGeiSUODWFFD0HYdPQVoiRgfPMA+/nnRgiAyNYSjpWNQcNSMrtFCUH4ZIRpSCWocFCSuhCEY6hoUClc0WC52BJlCYYLQdhN+hygRRRlo5BKRRLS6oihSqh+ZzzRGG1Mo4Iz1LoP0qsxDGFzk0JE42ji0jCPejomJKCuwil4m5CiRMEUMVSzVLDUstSx1Juc0oVWMpqY295qVltmtWmWW2a1aZZbZrVplltmtWmWW2G1WZYbYbVZlhthtVmWG2G1WZYbYbVZlhtltVmWW2W1WZZbZbVZlltltVmWW2W1QYjQCh7E2aAQHeGhCFgPoNoy8KNb2wxBhmGKBxoUZXlLGsLI6AsftEDHV0wIURVbANLcTKlGGBIKPOAxCmhePCKUwFzAmpDFRQvjA9R06Hq8TONvshgKDCuRAZTXigUxjxNFfKRo3CLhnIJBMFRvMZpqpNBMlQJzGT5WFQMVQI/AikPMIhEU1aDjqJvQwmjSHB05cC9jbYwc5UtAHNLhDw41ha+lEqF4JaH3gmB61SYcqInxTDmQK8v08vjqv4zDf1N0w3Lf4A8/vwPpfK11w=="; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Font.js +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Font.js var compressedJsonForFontName = { "Courier": Courier_compressed_default, "Courier-Bold": Courier_Bold_compressed_default, @@ -14184,10 +16708,10 @@ var Font = ( })() ); -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/all-encodings.compressed.json +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/all-encodings.compressed.json var all_encodings_compressed_default = "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"; -// node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Encoding.js +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+standard-fonts@1.0.0/node_modules/@pdf-lib/standard-fonts/es/Encoding.js var decompressedEncodings = decompressJson(all_encodings_compressed_default); var allUnicodeMappings = JSON.parse(decompressedEncodings); var Encoding = ( @@ -14223,7 +16747,7 @@ var Encodings = { WinAnsi: new Encoding("WinAnsi", allUnicodeMappings.win1252) }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/objects.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/objects.js var values = function(obj) { return Object.keys(obj).map(function(k) { return obj[k]; @@ -14237,7 +16761,7 @@ var rectanglesAreEqual = function(a, b) { return a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/validators.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/validators.js var backtick = function(val2) { return "`" + val2 + "`"; }; @@ -14414,7 +16938,7 @@ var assertPositive = function(value2, valueName) { } }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/pdfDocEncoding.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/pdfDocEncoding.js var pdfDocEncodingToUnicode = new Uint16Array(256); for (idx = 0; idx < 256; idx++) { pdfDocEncodingToUnicode[idx] = idx; @@ -14472,7 +16996,7 @@ var pdfDocEncodingDecode = function(bytes) { return String.fromCodePoint.apply(String, codePoints); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/Cache.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/Cache.js var Cache = ( /** @class */ (function() { @@ -14499,7 +17023,7 @@ var Cache = ( ); var Cache_default = Cache; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/errors.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/errors.js var MethodNotImplementedError = ( /** @class */ (function(_super) { @@ -14872,7 +17396,7 @@ var MissingKeywordError = ( })(PDFParsingError) ); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/CharCodes.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/CharCodes.js var CharCodes; (function(CharCodes2) { CharCodes2[CharCodes2["Null"] = 0] = "Null"; @@ -14936,10 +17460,10 @@ var CharCodes; })(CharCodes || (CharCodes = {})); var CharCodes_default = CharCodes; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/PDFContext.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/PDFContext.js var import_pako3 = __toESM(require_pako()); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/document/PDFHeader.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/document/PDFHeader.js var PDFHeader = ( /** @class */ (function() { @@ -14980,7 +17504,7 @@ var PDFHeader = ( ); var PDFHeader_default = PDFHeader; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFObject.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFObject.js var PDFObject = ( /** @class */ (function() { @@ -15003,7 +17527,7 @@ var PDFObject = ( ); var PDFObject_default = PDFObject; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFNumber.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFNumber.js var PDFNumber = ( /** @class */ (function(_super) { @@ -15041,7 +17565,7 @@ var PDFNumber = ( ); var PDFNumber_default = PDFNumber; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFArray.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFArray.js var PDFArray = ( /** @class */ (function(_super) { @@ -15157,7 +17681,7 @@ var PDFArray = ( ); var PDFArray_default = PDFArray; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFBool.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFBool.js var ENFORCER = {}; var PDFBool = ( /** @class */ @@ -15206,7 +17730,7 @@ var PDFBool = ( ); var PDFBool_default = PDFBool; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/Delimiters.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/Delimiters.js var IsDelimiter = new Uint8Array(256); IsDelimiter[CharCodes_default.LeftParen] = 1; IsDelimiter[CharCodes_default.RightParen] = 1; @@ -15219,7 +17743,7 @@ IsDelimiter[CharCodes_default.RightCurly] = 1; IsDelimiter[CharCodes_default.ForwardSlash] = 1; IsDelimiter[CharCodes_default.Percent] = 1; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/Whitespace.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/Whitespace.js var IsWhitespace = new Uint8Array(256); IsWhitespace[CharCodes_default.Null] = 1; IsWhitespace[CharCodes_default.Tab] = 1; @@ -15228,7 +17752,7 @@ IsWhitespace[CharCodes_default.FormFeed] = 1; IsWhitespace[CharCodes_default.CarriageReturn] = 1; IsWhitespace[CharCodes_default.Space] = 1; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/Irregular.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/Irregular.js var IsIrregular = new Uint8Array(256); for (idx = 0, len = 256; idx < len; idx++) { IsIrregular[idx] = IsWhitespace[idx] || IsDelimiter[idx] ? 1 : 0; @@ -15237,7 +17761,7 @@ var idx; var len; IsIrregular[CharCodes_default.Hash] = 1; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFName.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFName.js var decodeName = function(name) { return name.replace(/#([\dABCDEF]{2})/g, function(_, hex2) { return charFromHexCode(hex2); @@ -15360,7 +17884,7 @@ var PDFName = ( ); var PDFName_default = PDFName; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFNull.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFNull.js var PDFNull = ( /** @class */ (function(_super) { @@ -15392,7 +17916,7 @@ var PDFNull = ( ); var PDFNull_default = new PDFNull(); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFDict.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFDict.js var PDFDict = ( /** @class */ (function(_super) { @@ -15525,7 +18049,7 @@ var PDFDict = ( ); var PDFDict_default = PDFDict; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFStream.js var PDFStream = ( /** @class */ (function(_super) { @@ -15596,7 +18120,7 @@ var PDFStream = ( ); var PDFStream_default = PDFStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFRawStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFRawStream.js var PDFRawStream = ( /** @class */ (function(_super) { @@ -15629,7 +18153,7 @@ var PDFRawStream = ( ); var PDFRawStream_default = PDFRawStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFRef.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFRef.js var ENFORCER3 = {}; var pool2 = /* @__PURE__ */ new Map(); var PDFRef = ( @@ -15676,7 +18200,7 @@ var PDFRef = ( ); var PDFRef_default = PDFRef; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/operators/PDFOperator.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/operators/PDFOperator.js var PDFOperator = ( /** @class */ (function() { @@ -15731,7 +18255,7 @@ var PDFOperator = ( ); var PDFOperator_default = PDFOperator; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/operators/PDFOperatorNames.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/operators/PDFOperatorNames.js var PDFOperatorNames; (function(PDFOperatorNames2) { PDFOperatorNames2["NonStrokingColor"] = "sc"; @@ -15810,7 +18334,7 @@ var PDFOperatorNames; })(PDFOperatorNames || (PDFOperatorNames = {})); var PDFOperatorNames_default = PDFOperatorNames; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFFlateStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFFlateStream.js var import_pako2 = __toESM(require_pako()); var PDFFlateStream = ( /** @class */ @@ -15842,7 +18366,7 @@ var PDFFlateStream = ( ); var PDFFlateStream_default = PDFFlateStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFContentStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFContentStream.js var PDFContentStream = ( /** @class */ (function(_super) { @@ -15905,7 +18429,7 @@ var PDFContentStream = ( ); var PDFContentStream_default = PDFContentStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/rng.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/rng.js var SimpleRNG = ( /** @class */ (function() { @@ -15923,7 +18447,7 @@ var SimpleRNG = ( })() ); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/PDFContext.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/PDFContext.js var byAscendingObjectNumber = function(_a, _b) { var a = _a[0]; var b = _b[0]; @@ -16098,7 +18622,7 @@ var PDFContext = ( ); var PDFContext_default = PDFContext; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFPageLeaf.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFPageLeaf.js var PDFPageLeaf = ( /** @class */ (function(_super) { @@ -16300,7 +18824,7 @@ var PDFPageLeaf = ( ); var PDFPageLeaf_default = PDFPageLeaf; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/PDFObjectCopier.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/PDFObjectCopier.js var PDFObjectCopier = ( /** @class */ (function() { @@ -16384,7 +18908,7 @@ var PDFObjectCopier = ( ); var PDFObjectCopier_default = PDFObjectCopier; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/document/PDFCrossRefSection.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/document/PDFCrossRefSection.js var PDFCrossRefSection = ( /** @class */ (function() { @@ -16503,7 +19027,7 @@ var PDFCrossRefSection = ( ); var PDFCrossRefSection_default = PDFCrossRefSection; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/document/PDFTrailer.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/document/PDFTrailer.js var PDFTrailer = ( /** @class */ (function() { @@ -16545,7 +19069,7 @@ var PDFTrailer = ( ); var PDFTrailer_default = PDFTrailer; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/document/PDFTrailerDict.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/document/PDFTrailerDict.js var PDFTrailerDict = ( /** @class */ (function() { @@ -16579,7 +19103,7 @@ var PDFTrailerDict = ( ); var PDFTrailerDict_default = PDFTrailerDict; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFObjectStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFObjectStream.js var PDFObjectStream = ( /** @class */ (function(_super) { @@ -16653,7 +19177,7 @@ var PDFObjectStream = ( ); var PDFObjectStream_default = PDFObjectStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/writers/PDFWriter.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/writers/PDFWriter.js var PDFWriter = ( /** @class */ (function() { @@ -16792,7 +19316,7 @@ var PDFWriter = ( ); var PDFWriter_default = PDFWriter; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFInvalidObject.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFInvalidObject.js var PDFInvalidObject = ( /** @class */ (function(_super) { @@ -16826,7 +19350,7 @@ var PDFInvalidObject = ( ); var PDFInvalidObject_default = PDFInvalidObject; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFCrossRefStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFCrossRefStream.js var EntryType; (function(EntryType2) { EntryType2[EntryType2["Deleted"] = 0] = "Deleted"; @@ -17006,7 +19530,7 @@ var PDFCrossRefStream = ( ); var PDFCrossRefStream_default = PDFCrossRefStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/writers/PDFStreamWriter.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/writers/PDFStreamWriter.js var PDFStreamWriter = ( /** @class */ (function(_super) { @@ -17111,7 +19635,7 @@ var PDFStreamWriter = ( ); var PDFStreamWriter_default = PDFStreamWriter; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFHexString.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFHexString.js var PDFHexString = ( /** @class */ (function(_super) { @@ -17182,7 +19706,7 @@ var PDFHexString = ( ); var PDFHexString_default = PDFHexString; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/StandardFontEmbedder.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/StandardFontEmbedder.js var StandardFontEmbedder = ( /** @class */ (function() { @@ -17265,7 +19789,7 @@ var StandardFontEmbedder = ( ); var StandardFontEmbedder_default = StandardFontEmbedder; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/CMap.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/CMap.js var createCmap = function(glyphs, glyphId) { var bfChars = new Array(glyphs.length); for (var idx = 0, len = glyphs.length; idx < len; idx++) { @@ -17305,7 +19829,7 @@ var cmapCodePointFormat = function(codePoint) { throw new Error(msg); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/FontFlags.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/FontFlags.js var makeFontFlags = function(options) { var flags = 0; var flipBit = function(bit) { @@ -17343,7 +19867,7 @@ var deriveFontFlags = function(font) { return flags; }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFString.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/objects/PDFString.js var PDFString = ( /** @class */ (function(_super) { @@ -17453,7 +19977,7 @@ var PDFString = ( ); var PDFString_default = PDFString; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/CustomFontEmbedder.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/CustomFontEmbedder.js var CustomFontEmbedder = ( /** @class */ (function() { @@ -17684,7 +20208,7 @@ var CustomFontEmbedder = ( ); var CustomFontEmbedder_default = CustomFontEmbedder; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/CustomFontSubsetEmbedder.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/CustomFontSubsetEmbedder.js var CustomFontSubsetEmbedder = ( /** @class */ (function(_super) { @@ -17750,7 +20274,7 @@ var CustomFontSubsetEmbedder = ( ); var CustomFontSubsetEmbedder_default = CustomFontSubsetEmbedder; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/FileEmbedder.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/FileEmbedder.js var AFRelationship; (function(AFRelationship2) { AFRelationship2["Source"] = "Source"; @@ -17820,7 +20344,7 @@ var FileEmbedder = ( ); var FileEmbedder_default = FileEmbedder; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/JpegEmbedder.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/JpegEmbedder.js var MARKERS = [ 65472, 65473, @@ -17932,7 +20456,7 @@ var JpegEmbedder = ( ); var JpegEmbedder_default = JpegEmbedder; -// node_modules/.pnpm/@pdf-lib+upng@1.0.1/node_modules/@pdf-lib/upng/UPNG.js +// ../../../../../../../../../node_modules/.pnpm/@pdf-lib+upng@1.0.1/node_modules/@pdf-lib/upng/UPNG.js var import_pako4 = __toESM(require_pako()); var UPNG = {}; UPNG.toRGBA8 = function(out) { @@ -19288,7 +21812,7 @@ UPNG.encode.concatRGBA = function(bufs) { }; var UPNG_default = UPNG; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/png.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/utils/png.js var getImageType = function(ctype) { if (ctype === 0) return PngType.Greyscale; @@ -19353,7 +21877,7 @@ var PNG = ( })() ); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/PngEmbedder.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/PngEmbedder.js var PngEmbedder = ( /** @class */ (function() { @@ -19419,7 +21943,7 @@ var PngEmbedder = ( ); var PngEmbedder_default = PngEmbedder; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/Stream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/Stream.js var Stream = ( /** @class */ (function() { @@ -19520,7 +22044,7 @@ var Stream = ( ); var Stream_default = Stream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/DecodeStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/DecodeStream.js var emptyBuffer = new Uint8Array(0); var DecodeStream = ( /** @class */ @@ -19658,7 +22182,7 @@ var DecodeStream = ( ); var DecodeStream_default = DecodeStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/Ascii85Stream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/Ascii85Stream.js var isSpace = function(ch) { return ch === 32 || ch === 9 || ch === 13 || ch === 10; }; @@ -19733,7 +22257,7 @@ var Ascii85Stream = ( ); var Ascii85Stream_default = Ascii85Stream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/AsciiHexStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/AsciiHexStream.js var AsciiHexStream = ( /** @class */ (function(_super) { @@ -19790,7 +22314,7 @@ var AsciiHexStream = ( ); var AsciiHexStream_default = AsciiHexStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/FlateStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/FlateStream.js var codeLenCodeMap = new Int32Array([ 16, 17, @@ -20669,7 +23193,7 @@ var FlateStream = ( ); var FlateStream_default = FlateStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/LZWStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/LZWStream.js var LZWStream = ( /** @class */ (function(_super) { @@ -20792,7 +23316,7 @@ var LZWStream = ( ); var LZWStream_default = LZWStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/RunLengthStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/RunLengthStream.js var RunLengthStream = ( /** @class */ (function(_super) { @@ -20834,7 +23358,7 @@ var RunLengthStream = ( ); var RunLengthStream_default = RunLengthStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/decode.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/streams/decode.js var decodeStream = function(stream2, encoding, params) { if (encoding === PDFName_default.of("FlateDecode")) { return new FlateStream_default(stream2); @@ -20877,7 +23401,7 @@ var decodePDFRawStream = function(_a) { return stream2; }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/PDFPageEmbedder.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/PDFPageEmbedder.js var fullPageBoundingBox = function(page) { var mediaBox = page.MediaBox(); var width = mediaBox.lookup(2, PDFNumber_default).asNumber() - mediaBox.lookup(0, PDFNumber_default).asNumber(); @@ -20957,7 +23481,7 @@ var PDFPageEmbedder = ( ); var PDFPageEmbedder_default = PDFPageEmbedder; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/interactive/ViewerPreferences.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/interactive/ViewerPreferences.js var asEnum = function(rawValue, enumType) { if (rawValue === void 0) return void 0; @@ -21191,7 +23715,7 @@ var ViewerPreferences = ( ); var ViewerPreferences_default = ViewerPreferences; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroField.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroField.js var tfRegex = /\/([^\0\t\n\f\r\ ]+)[\0\t\n\f\r\ ]*(\d*\.\d+|\d+)?[\0\t\n\f\r\ ]+Tf/; var PDFAcroField = ( /** @class */ @@ -21322,7 +23846,7 @@ var PDFAcroField = ( ); var PDFAcroField_default = PDFAcroField; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/annotation/BorderStyle.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/annotation/BorderStyle.js var BorderStyle = ( /** @class */ (function() { @@ -21351,7 +23875,7 @@ var BorderStyle = ( ); var BorderStyle_default = BorderStyle; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/annotation/PDFAnnotation.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/annotation/PDFAnnotation.js var PDFAnnotation = ( /** @class */ (function() { @@ -21467,7 +23991,7 @@ var PDFAnnotation = ( ); var PDFAnnotation_default = PDFAnnotation; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/annotation/AppearanceCharacteristics.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/annotation/AppearanceCharacteristics.js var AppearanceCharacteristics = ( /** @class */ (function() { @@ -21584,7 +24108,7 @@ var AppearanceCharacteristics = ( ); var AppearanceCharacteristics_default = AppearanceCharacteristics; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/annotation/PDFWidgetAnnotation.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/annotation/PDFWidgetAnnotation.js var PDFWidgetAnnotation = ( /** @class */ (function(_super) { @@ -21687,7 +24211,7 @@ var PDFWidgetAnnotation = ( ); var PDFWidgetAnnotation_default = PDFWidgetAnnotation; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroTerminal.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroTerminal.js var PDFAcroTerminal = ( /** @class */ (function(_super) { @@ -21743,7 +24267,7 @@ var PDFAcroTerminal = ( ); var PDFAcroTerminal_default = PDFAcroTerminal; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroButton.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroButton.js var PDFAcroButton = ( /** @class */ (function(_super) { @@ -21828,7 +24352,7 @@ var PDFAcroButton = ( ); var PDFAcroButton_default = PDFAcroButton; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroCheckBox.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroCheckBox.js var PDFAcroCheckBox = ( /** @class */ (function(_super) { @@ -21876,7 +24400,7 @@ var PDFAcroCheckBox = ( ); var PDFAcroCheckBox_default = PDFAcroCheckBox; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/flags.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/flags.js var flag = function(bitIndex) { return 1 << bitIndex; }; @@ -21913,7 +24437,7 @@ var AcroChoiceFlags; AcroChoiceFlags2[AcroChoiceFlags2["CommitOnSelChange"] = flag(27 - 1)] = "CommitOnSelChange"; })(AcroChoiceFlags || (AcroChoiceFlags = {})); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroChoice.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroChoice.js var PDFAcroChoice = ( /** @class */ (function(_super) { @@ -22030,7 +24554,7 @@ var PDFAcroChoice = ( ); var PDFAcroChoice_default = PDFAcroChoice; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroComboBox.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroComboBox.js var PDFAcroComboBox = ( /** @class */ (function(_super) { @@ -22055,7 +24579,7 @@ var PDFAcroComboBox = ( ); var PDFAcroComboBox_default = PDFAcroComboBox; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroNonTerminal.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroNonTerminal.js var PDFAcroNonTerminal = ( /** @class */ (function(_super) { @@ -22088,7 +24612,7 @@ var PDFAcroNonTerminal = ( ); var PDFAcroNonTerminal_default = PDFAcroNonTerminal; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroSignature.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroSignature.js var PDFAcroSignature = ( /** @class */ (function(_super) { @@ -22104,7 +24628,7 @@ var PDFAcroSignature = ( ); var PDFAcroSignature_default = PDFAcroSignature; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroText.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroText.js var PDFAcroText = ( /** @class */ (function(_super) { @@ -22169,7 +24693,7 @@ var PDFAcroText = ( ); var PDFAcroText_default = PDFAcroText; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroPushButton.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroPushButton.js var PDFAcroPushButton = ( /** @class */ (function(_super) { @@ -22194,7 +24718,7 @@ var PDFAcroPushButton = ( ); var PDFAcroPushButton_default = PDFAcroPushButton; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroRadioButton.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroRadioButton.js var PDFAcroRadioButton = ( /** @class */ (function(_super) { @@ -22248,7 +24772,7 @@ var PDFAcroRadioButton = ( ); var PDFAcroRadioButton_default = PDFAcroRadioButton; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroListBox.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroListBox.js var PDFAcroListBox = ( /** @class */ (function(_super) { @@ -22272,7 +24796,7 @@ var PDFAcroListBox = ( ); var PDFAcroListBox_default = PDFAcroListBox; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/utils.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/utils.js var createPDFAcroFields = function(kidDicts) { if (!kidDicts) return []; @@ -22359,7 +24883,7 @@ var ascend = function(startNode, visitor) { ascend(Parent, visitor); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroForm.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/acroform/PDFAcroForm.js var PDFAcroForm = ( /** @class */ (function() { @@ -22435,7 +24959,7 @@ var PDFAcroForm = ( ); var PDFAcroForm_default = PDFAcroForm; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFCatalog.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFCatalog.js var PDFCatalog = ( /** @class */ (function(_super) { @@ -22504,7 +25028,7 @@ var PDFCatalog = ( ); var PDFCatalog_default = PDFCatalog; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFPageTree.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/structures/PDFPageTree.js var PDFPageTree = ( /** @class */ (function(_super) { @@ -22646,7 +25170,7 @@ var PDFPageTree = ( ); var PDFPageTree_default = PDFPageTree; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/Numeric.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/Numeric.js var IsDigit = new Uint8Array(256); IsDigit[CharCodes_default.Zero] = 1; IsDigit[CharCodes_default.One] = 1; @@ -22669,7 +25193,7 @@ for (idx = 0, len = 256; idx < len; idx++) { var idx; var len; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/BaseParser.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/BaseParser.js var Newline = CharCodes_default.Newline; var CarriageReturn = CharCodes_default.CarriageReturn; var BaseParser = ( @@ -22772,7 +25296,7 @@ var BaseParser = ( ); var BaseParser_default = BaseParser; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/ByteStream.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/ByteStream.js var ByteStream = ( /** @class */ (function() { @@ -22834,7 +25358,7 @@ var ByteStream = ( ); var ByteStream_default = ByteStream; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/Keywords.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/syntax/Keywords.js var Space = CharCodes_default.Space; var CarriageReturn2 = CharCodes_default.CarriageReturn; var Newline2 = CharCodes_default.Newline; @@ -22916,7 +25440,7 @@ var Keywords = { EOF3endstream: __spreadArrays([Newline2], endstream) }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/PDFObjectParser.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/PDFObjectParser.js var PDFObjectParser = ( /** @class */ (function(_super) { @@ -23107,7 +25631,7 @@ var PDFObjectParser = ( ); var PDFObjectParser_default = PDFObjectParser; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/PDFObjectStreamParser.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/PDFObjectStreamParser.js var PDFObjectStreamParser = ( /** @class */ (function(_super) { @@ -23179,7 +25703,7 @@ var PDFObjectStreamParser = ( ); var PDFObjectStreamParser_default = PDFObjectStreamParser; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/PDFXRefStreamParser.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/PDFXRefStreamParser.js var PDFXRefStreamParser = ( /** @class */ (function() { @@ -23260,7 +25784,7 @@ var PDFXRefStreamParser = ( ); var PDFXRefStreamParser_default = PDFXRefStreamParser; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/PDFParser.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/parser/PDFParser.js var PDFParser = ( /** @class */ (function(_super) { @@ -23576,7 +26100,7 @@ var PDFParser = ( ); var PDFParser_default = PDFParser; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/annotation/flags.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/annotation/flags.js var flag2 = function(bitIndex) { return 1 << bitIndex; }; @@ -23594,7 +26118,7 @@ var AnnotationFlags; AnnotationFlags2[AnnotationFlags2["LockedContents"] = flag2(10 - 1)] = "LockedContents"; })(AnnotationFlags || (AnnotationFlags = {})); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/objects.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/objects.js var asPDFName = function(name) { return name instanceof PDFName_default ? name : PDFName_default.of(name); }; @@ -23605,7 +26129,7 @@ var asNumber = function(num) { return num instanceof PDFNumber_default ? num.asNumber() : num; }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/rotations.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/rotations.js var RotationTypes; (function(RotationTypes2) { RotationTypes2["Degrees"] = "degrees"; @@ -23673,7 +26197,7 @@ var rotateRectangle = function(rectangle, borderWidth, degreeAngle) { return { x: x - b, y: y - b, width: w, height: h }; }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/operators.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/operators.js var clip = function() { return PDFOperator_default.of(PDFOperatorNames_default.ClipNonZero); }; @@ -23865,7 +26389,7 @@ var endMarkedContent = function() { return PDFOperator_default.of(PDFOperatorNames_default.EndMarkedContent); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/colors.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/colors.js var ColorTypes; (function(ColorTypes2) { ColorTypes2["Grayscale"] = "Grayscale"; @@ -23908,7 +26432,7 @@ var colorToComponents = function(color) { return color.type === Grayscale ? [color.gray] : color.type === RGB ? [color.red, color.green, color.blue] : color.type === CMYK ? [color.cyan, color.magenta, color.yellow, color.key] : error("Invalid color: " + JSON.stringify(color)); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/svgPath.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/svgPath.js var cx = 0; var cy = 0; var px = 0; @@ -24262,7 +26786,7 @@ var svgPathToOperators = function(path) { return apply(parse(path)); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/operations.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/operations.js var drawLinesOfText = function(lines, options) { var operators = [ pushGraphicsState(), @@ -24688,7 +27212,7 @@ var drawOptionList = function(options) { ]); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/errors.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/errors.js var EncryptedPDFError = ( /** @class */ (function(_super) { @@ -24875,7 +27399,7 @@ var InvalidMaxLengthError = ( })(Error) ); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/text/alignment.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/text/alignment.js var TextAlignment; (function(TextAlignment2) { TextAlignment2[TextAlignment2["Left"] = 0] = "Left"; @@ -24883,7 +27407,7 @@ var TextAlignment; TextAlignment2[TextAlignment2["Right"] = 2] = "Right"; })(TextAlignment || (TextAlignment = {})); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/text/layout.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/text/layout.js var MIN_FONT_SIZE = 4; var MAX_FONT_SIZE = 500; var computeFontSize = function(lines, font, bounds, multiline) { @@ -25076,7 +27600,7 @@ var layoutSinglelineText = function(text2, _a) { }; }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/appearances.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/appearances.js var normalizeAppearance = function(appearance) { if ("normal" in appearance) return appearance; @@ -25461,7 +27985,7 @@ var defaultOptionListAppearanceProvider = function(optionList, widget, font) { })); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFEmbeddedPage.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFEmbeddedPage.js var PDFEmbeddedPage = ( /** @class */ (function() { @@ -25511,7 +28035,7 @@ var PDFEmbeddedPage = ( ); var PDFEmbeddedPage_default = PDFEmbeddedPage; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFFont.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFFont.js var PDFFont = ( /** @class */ (function() { @@ -25585,7 +28109,7 @@ var PDFFont = ( ); var PDFFont_default = PDFFont; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFImage.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFImage.js var PDFImage = ( /** @class */ (function() { @@ -25652,7 +28176,7 @@ var PDFImage = ( ); var PDFImage_default = PDFImage; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/image/alignment.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/image/alignment.js var ImageAlignment; (function(ImageAlignment2) { ImageAlignment2[ImageAlignment2["Left"] = 0] = "Left"; @@ -25660,7 +28184,7 @@ var ImageAlignment; ImageAlignment2[ImageAlignment2["Right"] = 2] = "Right"; })(ImageAlignment || (ImageAlignment = {})); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFField.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFField.js var assertFieldAppearanceOptions = function(options) { assertOrUndefined(options === null || options === void 0 ? void 0 : options.x, "options.x", ["number"]); assertOrUndefined(options === null || options === void 0 ? void 0 : options.y, "options.y", ["number"]); @@ -25873,7 +28397,7 @@ var PDFField = ( ); var PDFField_default = PDFField; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFCheckBox.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFCheckBox.js var PDFCheckBox = ( /** @class */ (function(_super) { @@ -25976,7 +28500,7 @@ var PDFCheckBox = ( ); var PDFCheckBox_default = PDFCheckBox; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFDropdown.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFDropdown.js var PDFDropdown = ( /** @class */ (function(_super) { @@ -26179,7 +28703,7 @@ var PDFDropdown = ( ); var PDFDropdown_default = PDFDropdown; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFOptionList.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFOptionList.js var PDFOptionList = ( /** @class */ (function(_super) { @@ -26360,7 +28884,7 @@ var PDFOptionList = ( ); var PDFOptionList_default = PDFOptionList; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFRadioGroup.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFRadioGroup.js var PDFRadioGroup = ( /** @class */ (function(_super) { @@ -26511,7 +29035,7 @@ var PDFRadioGroup = ( ); var PDFRadioGroup_default = PDFRadioGroup; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFSignature.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFSignature.js var PDFSignature = ( /** @class */ (function(_super) { @@ -26535,7 +29059,7 @@ var PDFSignature = ( ); var PDFSignature_default = PDFSignature; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFTextField.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFTextField.js var PDFTextField = ( /** @class */ (function(_super) { @@ -26759,7 +29283,7 @@ var PDFTextField = ( ); var PDFTextField_default = PDFTextField; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/StandardFonts.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/StandardFonts.js var StandardFonts; (function(StandardFonts2) { StandardFonts2["Courier"] = "Courier"; @@ -26778,7 +29302,7 @@ var StandardFonts; StandardFonts2["ZapfDingbats"] = "ZapfDingbats"; })(StandardFonts || (StandardFonts = {})); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFForm.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFForm.js var PDFForm = ( /** @class */ (function() { @@ -27135,7 +29659,7 @@ var addFieldToParent = function(_a, _b, partialName) { field.setParent(parentRef); }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/sizes.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/sizes.js var PageSizes = { "4A0": [4767.87, 6740.79], "2A0": [3370.39, 4767.87], @@ -27189,7 +29713,7 @@ var PageSizes = { Tabloid: [792, 1224] }; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFDocumentOptions.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFDocumentOptions.js var ParseSpeeds; (function(ParseSpeeds2) { ParseSpeeds2[ParseSpeeds2["Fastest"] = Infinity] = "Fastest"; @@ -27198,7 +29722,7 @@ var ParseSpeeds; ParseSpeeds2[ParseSpeeds2["Slow"] = 100] = "Slow"; })(ParseSpeeds || (ParseSpeeds = {})); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFEmbeddedFile.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFEmbeddedFile.js var PDFEmbeddedFile = ( /** @class */ (function() { @@ -27256,7 +29780,7 @@ var PDFEmbeddedFile = ( ); var PDFEmbeddedFile_default = PDFEmbeddedFile; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFJavaScript.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFJavaScript.js var PDFJavaScript = ( /** @class */ (function() { @@ -27310,7 +29834,7 @@ var PDFJavaScript = ( ); var PDFJavaScript_default = PDFJavaScript; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/JavaScriptEmbedder.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/core/embedders/JavaScriptEmbedder.js var JavaScriptEmbedder = ( /** @class */ (function() { @@ -27348,7 +29872,7 @@ var JavaScriptEmbedder = ( ); var JavaScriptEmbedder_default = JavaScriptEmbedder; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFDocument.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFDocument.js var PDFDocument = ( /** @class */ (function() { @@ -28034,7 +30558,7 @@ function assertIsLiteralOrHexString(pdfObject) { } } -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFPageOptions.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFPageOptions.js var BlendMode; (function(BlendMode2) { BlendMode2["Normal"] = "Normal"; @@ -28051,7 +30575,7 @@ var BlendMode; BlendMode2["Exclusion"] = "Exclusion"; })(BlendMode || (BlendMode = {})); -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFPage.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/PDFPage.js var PDFPage = ( /** @class */ (function() { @@ -28703,7 +31227,7 @@ var PDFPage = ( ); var PDFPage_default = PDFPage; -// node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFButton.js +// ../../../../../../../../../node_modules/.pnpm/pdf-lib@1.17.1/node_modules/pdf-lib/es/api/form/PDFButton.js var PDFButton = ( /** @class */ (function(_super) { @@ -28797,10 +31321,10 @@ var PDFButton = ( ); var PDFButton_default = PDFButton; -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-deck-browser.js -var import_jszip2 = __toESM(require_jszip_min(), 1); +// src/export-deck-browser.js +var import_jszip3 = __toESM(require_jszip_min(), 1); -// node_modules/.pnpm/pptxgenjs@4.0.1/node_modules/pptxgenjs/dist/pptxgen.es.js +// ../../../../../../../../../node_modules/.pnpm/pptxgenjs@4.0.1/node_modules/pptxgenjs/dist/pptxgen.es.js var import_jszip = __toESM(require_jszip_min()); function __awaiter2(thisArg, _arguments, P, generator) { function adopt(value2) { @@ -34120,24 +36644,11 @@ var PptxGenJS = class { } }; -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js -var PX_PER_IN = 96; -var EMU_PER_IN = 914400; +// src/pptx-html-build.js +var import_jszip2 = __toESM(require_jszip_min(), 1); var SLIDE_W_IN = 13.333; var SLIDE_H_IN = 7.5; -var EDITABLE_TEXT_TYPES2 = /* @__PURE__ */ new Set([ - "p", - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "text", - "svg-text", - "list", - "merged-text" -]); +var OOXML_ROUND_RECT_ADJUSTMENTS = /* @__PURE__ */ Symbol("ppt-live-round-rect-adjustments"); var WIDTH_SAFETY_IN = 0.15; function capTextBoxWidth(x, w) { return Math.min(w, Math.max(0.15, SLIDE_W_IN - x - 0.02)); @@ -34160,195 +36671,213 @@ function safeTextBoxGeometry(origX, origW, align, isVerticalText) { } function toImagePayload(src) { const raw = String(src || "").trim(); - if (!raw) return null; - if (raw.startsWith("data:")) return { data: raw }; - if (raw.startsWith("file://")) return { path: raw.replace("file://", "") }; - return { path: raw }; -} -function validateDimensions(bodyDimensions, pres) { - const errors = []; - const widthInches = bodyDimensions.width / PX_PER_IN; - const heightInches = bodyDimensions.height / PX_PER_IN; - if (pres.presLayout) { - const layoutWidth = pres.presLayout.width / EMU_PER_IN; - const layoutHeight = pres.presLayout.height / EMU_PER_IN; - if (Math.abs(layoutWidth - widthInches) > 0.1 || Math.abs(layoutHeight - heightInches) > 0.1) { - errors.push( - `HTML dimensions (${widthInches.toFixed(1)}" \xD7 ${heightInches.toFixed(1)}") don't match presentation layout (${layoutWidth.toFixed(1)}" \xD7 ${layoutHeight.toFixed(1)}")` - ); - } + if (!/^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/i.test(raw)) { + throw new Error("Intentional images must use an inline base64 PNG, JPEG, or WebP data URL"); } - return errors; + return { data: raw }; } -function validateTextBoxPosition(slideData, bodyDimensions) { - const errors = []; - const slideHeightInches = bodyDimensions.height / PX_PER_IN; - const minBottomMargin = 0.5; - for (const el of slideData.elements) { - if (!["p", "h1", "h2", "h3", "h4", "h5", "h6", "text", "list", "merged-text"].includes(el.type)) continue; - const fontSize = el.style?.fontSize || 0; - const bottomEdge = el.position.y + el.position.h; - const distanceFromBottom = slideHeightInches - bottomEdge; - const textValue = (() => { - if (typeof el.text === "string") return el.text; - if (Array.isArray(el.text)) return el.text.find((t) => t.text)?.text || ""; - if (Array.isArray(el.items)) return el.items.find((item) => item.text)?.text || ""; - return ""; - })(); - const isFooter = /^\d{1,2}\s*\/\s*\d{1,2}$/.test(textValue.trim()) || /^(Arknights|数据来源|source:)/i.test(textValue.trim()); - if (isFooter || fontSize <= 11) continue; - if (fontSize > 12 && distanceFromBottom < minBottomMargin) { - const textPrefix = `${textValue.substring(0, 50)}${textValue.length > 50 ? "..." : ""}`; - errors.push( - `Text box "${textPrefix}" ends too close to bottom edge (${distanceFromBottom.toFixed(2)}" from bottom, minimum ${minBottomMargin}" required)` - ); - } - } - return errors; +function tableCellBorders(border = {}) { + const uniform = border.color ? { color: border.color, width: border.width || 0 } : null; + return ["top", "right", "bottom", "left"].map((side) => { + const value2 = border[side] || uniform || { color: "000000", width: 0 }; + return { + type: value2.width > 0 ? "solid" : "none", + color: value2.color, + pt: value2.width + }; + }); } -async function addBackground(slideData, targetSlide) { - if (slideData.background?.type === "image" && slideData.background.path) { - const payload = toImagePayload(slideData.background.path); - if (payload) targetSlide.background = payload; - } else if (slideData.background?.type === "color" && slideData.background.value) { - targetSlide.background = { color: slideData.background.value }; +var CSS_SYSTEM_FONT_FACES = /* @__PURE__ */ new Set([ + "system-ui", + "-apple-system", + "blinkmacsystemfont", + "ui-sans-serif", + "ui-serif", + "ui-monospace", + "sans-serif", + "serif", + "monospace", + "emoji", + "math", + "fangsong", + ".applesystemuifont", + ".sf ns text", + ".sf ns display" +]); +function textHintContainsCjk(textHint) { + return /[\u4e00-\u9fff]/.test(String(textHint || "")); +} +function resolvePptxFontFace(fontFace, textHint = "") { + const face = String(fontFace || "").replace(/['"]/g, "").trim(); + if (!face) return "PingFang SC"; + const lower = face.toLowerCase(); + if (CSS_SYSTEM_FONT_FACES.has(lower) || lower.startsWith(".")) { + return "PingFang SC"; + } + if (textHintContainsCjk(textHint) && (lower === "arial" || lower === "helvetica" || lower === "times new roman")) { + return "PingFang SC"; } + return face; } -function addElements(slideData, targetSlide, pres) { - if (slideData.fullPageFallback) { - const payload = toImagePayload( - slideData.fullPageFallback.data || slideData.fullPageFallback.src - ); - if (!payload) throw new Error("Full-page fallback has no image payload"); - targetSlide.addImage({ - ...payload, - x: 0, - y: 0, - w: SLIDE_W_IN, - h: SLIDE_H_IN - }); - return; +function withResolvedFontFace(options = {}, textHint = "") { + if (!options || typeof options !== "object") return options; + if (options.fontFace == null && options.fontFamily == null) { + return textHintContainsCjk(textHint) ? { ...options, fontFace: "PingFang SC" } : options; } - const suppressedNativeVisualIds = /* @__PURE__ */ new Set([ - ...slideData.suppressedNativeVisualIds || [], - ...(slideData.fallbackLayers || []).flatMap((layer) => layer.suppressedNativeVisualIds || []) - ]); - const paintItems = [ - ...(slideData.elements || []).map((element2, order) => ({ - type: "element", - item: element2, - zIndex: element2.zIndex ?? 0, - order: element2.paintOrder ?? order, - subOrder: element2.subOrder ?? 0, - stableOrder: order - })), - ...(slideData.fallbackLayers || []).map((layer, order) => ({ - type: "fallback", - item: layer, - zIndex: layer.zIndex ?? 0, - order: layer.paintOrder ?? (slideData.elements || []).length + order, - subOrder: layer.subOrder ?? 0, - stableOrder: (slideData.elements || []).length + order - })) - ].sort((a, b) => a.zIndex - b.zIndex || a.order - b.order || a.subOrder - b.subOrder || a.stableOrder - b.stableOrder); - for (const paintItem of paintItems) { - if (paintItem.type === "fallback") { - const layer = paintItem.item; - const payload = toImagePayload(layer.data || layer.src); - const bbox = layer.bbox || {}; - if (!payload) continue; - const fullPageCanvas = layer.canvas === "full-page"; - targetSlide.addImage({ - ...payload, - x: fullPageCanvas ? 0 : bbox.x ?? 0, - y: fullPageCanvas ? 0 : bbox.y ?? 0, - w: fullPageCanvas ? SLIDE_W_IN : bbox.w ?? SLIDE_W_IN, - h: fullPageCanvas ? SLIDE_H_IN : bbox.h ?? SLIDE_H_IN - }); - continue; + return { + ...options, + fontFace: resolvePptxFontFace(options.fontFace || options.fontFamily, textHint) + }; +} +function resolveTableText(text2) { + if (!Array.isArray(text2)) return text2; + return text2.map((run) => { + if (!run || typeof run !== "object") return run; + return { + ...run, + options: withResolvedFontFace(run.options || {}, run.text) + }; + }); +} +function addEditableTable(table, targetSlide) { + const rows = table.rows.map((row) => row.cells.map((cell) => { + const style = cell.style || {}; + const cellTextHint = Array.isArray(cell.text) ? cell.text.map((run) => run?.text || "").join("") : cell.text; + const options = { + fill: style.fill == null ? { color: "FFFFFF", transparency: 100 } : style.fill, + border: tableCellBorders(style.border), + align: style.align, + valign: style.valign, + fontFace: resolvePptxFontFace(style.fontFamily, cellTextHint), + fontSize: style.fontSize, + color: style.fontColor, + bold: style.bold, + margin: Array.isArray(style.padding) ? style.padding.map((inches) => inches * 72) : 0, + ...cell.colspan > 1 ? { colspan: cell.colspan } : {}, + ...cell.rowspan > 1 ? { rowspan: cell.rowspan } : {} + }; + return { text: resolveTableText(cell.text), options }; + })); + targetSlide.addTable(rows, { + x: table.x, + y: table.y, + w: table.w, + h: table.h, + colW: table.columnWidths, + rowH: table.rows.map((row) => row.height), + autoFit: false, + autoPage: false + }); +} +function pptxLineStyle(style = {}) { + const { dash, ...line } = style; + return { + ...line, + ...dash ? { dashType: dash } : {} + }; +} +function pptxTextMargin(margin) { + if (!Array.isArray(margin)) return Number.isFinite(margin) ? margin * 72 : 0; + const [top, right, bottom, left] = margin; + return [left, right, bottom, top].map((inches) => inches * 72); +} +function pptxTextValue(value2) { + if (!Array.isArray(value2)) return value2; + return value2.map((run) => { + const options = withResolvedFontFace({ ...run.options || {} }, run.text); + if (options.bullet?.type === "bullet") { + const bullet = { ...options.bullet }; + delete bullet.type; + options.bullet = bullet; } + return { + ...run, + options + }; + }); +} +function addSceneNodes(slideData, targetSlide, pres) { + const paintItems = slideData.nodes.map((item, stableOrder) => ({ + type: "element", + item, + zIndex: item.zIndex ?? 0, + order: item.paintOrder ?? stableOrder, + subOrder: item.subOrder ?? 0, + stableOrder + })).sort((left, right) => left.zIndex - right.zIndex || left.order - right.order || left.subOrder - right.subOrder || left.stableOrder - right.stableOrder); + for (const paintItem of paintItems) { const el = paintItem.item; - if (suppressedNativeVisualIds.has(el.sourceId) && !EDITABLE_TEXT_TYPES2.has(el.type)) { - continue; - } - if (el.type === "image") { - const payload = toImagePayload(el.src); - if (!payload) continue; - targetSlide.addImage({ - ...payload, - x: el.position.x, - y: el.position.y, - w: el.position.w, - h: el.position.h - }); + if (el.type === "table") { + addEditableTable(el, targetSlide); + } else if (el.type === "image") { + try { + const payload = toImagePayload(el.src || el.path || el.data); + if (!payload) throw new Error(`Intentional image "${el.sourceId}" has no payload`); + targetSlide.addImage({ + ...payload, + x: el.x, + y: el.y, + w: el.w, + h: el.h + }); + } catch (cause) { + throw new EditableExportError({ + slideNumber: slideData.slideNumber, + sourceId: el.sourceId, + code: "pptx_image_serialization", + message: `Intentional image "${el.sourceId}" could not be serialized.`, + cause + }); + } } else if (el.type === "line") { targetSlide.addShape(pres.ShapeType.line, { x: el.x1, y: el.y1, w: el.x2 - el.x1, h: el.y2 - el.y1, - line: { color: el.color, width: el.width } + line: pptxLineStyle(el.style) }); - } else if (el.type === "shape" || el.type === "svg-shape") { + } else if (el.type === "shape") { const shapeOptions = { - x: el.position.x, - y: el.position.y, - w: el.position.w, - h: el.position.h + x: el.x, + y: el.y, + w: el.w, + h: el.h }; - const nativeShapeType = { - circle: pres.ShapeType.ellipse, - ellipse: pres.ShapeType.ellipse, - triangle: pres.ShapeType.triangle, - diamond: pres.ShapeType.diamond, - rect: pres.ShapeType.rect - }[el.svgType]; - shapeOptions.shape = nativeShapeType || (el.shape.rectRadius > 0 ? pres.ShapeType.roundRect : pres.ShapeType.rect); - if (el.shape.fill) { - shapeOptions.fill = { color: el.shape.fill }; - if (el.shape.transparency != null) shapeOptions.fill.transparency = el.shape.transparency; - } - if (el.shape.line) shapeOptions.line = el.shape.line; - if (el.shape.rectRadius > 0) shapeOptions.rectRadius = el.shape.rectRadius; - if (el.shape.shadow) shapeOptions.shadow = el.shape.shadow; - if (el.shape.rotate != null) shapeOptions.rotate = el.shape.rotate; - if (el.type === "svg-shape") { - targetSlide.addShape(shapeOptions.shape, shapeOptions); - } else { - targetSlide.addText(el.text || "", shapeOptions); + shapeOptions.shape = pres.ShapeType[el.shapeType]; + if (!shapeOptions.shape) { + throw new Error(`Unsupported native shape type "${el.shapeType}"`); } - } else if (el.type === "list" || el.type === "merged-text") { - const { x: boxX, w: boxW } = safeTextBoxGeometry(el.position.x, el.position.w, el.style.align, false); - const listOptions = { - x: boxX, - y: el.position.y, - w: boxW, - h: Math.min(el.position.h, Math.max(0.15, SLIDE_H_IN - el.position.y - 0.04)), - fontSize: el.style.fontSize, - fontFace: el.style.fontFace, - color: el.style.color, - align: el.style.align, - valign: "top", - lineSpacing: el.style.lineSpacing, - paraSpaceBefore: el.style.paraSpaceBefore, - paraSpaceAfter: el.style.paraSpaceAfter, - margin: el.style.margin || 0, - shrinkText: false, - autoFit: false - }; - if (el.style.transparency != null) listOptions.transparency = el.style.transparency; - targetSlide.addText(el.items || el.text, listOptions); - } else { - const lineHeight = el.style.lineSpacing || el.style.fontSize * 1.2; + if (el.style.fill) { + shapeOptions.fill = { color: el.style.fill }; + if (el.style.transparency != null) { + shapeOptions.fill.transparency = el.style.transparency; + } + } + if (el.style.line) shapeOptions.line = pptxLineStyle(el.style.line); + if (el.style.shadow) shapeOptions.shadow = el.style.shadow; + if (el.style.rotate != null) shapeOptions.rotate = el.style.rotate; + targetSlide.addShape(shapeOptions.shape, shapeOptions); + if (el.shapeType === "roundRect") { + const adjustment = Number.isFinite(el.style.radius) ? Math.round(el.style.radius / Math.min(el.w, el.h) * 1e5) : 16667; + if (!Array.isArray(pres[OOXML_ROUND_RECT_ADJUSTMENTS])) { + pres[OOXML_ROUND_RECT_ADJUSTMENTS] = []; + } + pres[OOXML_ROUND_RECT_ADJUSTMENTS].push(Math.max(0, Math.min(5e4, adjustment))); + } + } else if (el.type === "text") { const isVerticalText = el.style.vert && el.style.vert !== "horz"; - const { x: boxX, w: boxW } = safeTextBoxGeometry(el.position.x, el.position.w, el.style.align, isVerticalText); + const { x: boxX, w: boxW } = safeTextBoxGeometry(el.x, el.w, el.style.align, isVerticalText); const textOptions = { x: boxX, - y: el.position.y, + y: el.y, w: boxW, - h: Math.min(el.position.h, Math.max(0.15, SLIDE_H_IN - el.position.y - 0.04)), + h: Math.min(el.h, Math.max(0.15, SLIDE_H_IN - el.y - 0.04)), fontSize: el.style.fontSize, - fontFace: el.style.fontFace, + fontFace: resolvePptxFontFace( + el.style.fontFace, + Array.isArray(el.text) ? el.text.map((run) => run?.text || "").join("") : el.text + ), color: el.style.color, bold: el.style.bold, italic: el.style.italic, @@ -34359,7 +36888,7 @@ function addElements(slideData, targetSlide, pres) { paraSpaceAfter: el.style.paraSpaceAfter, // margin reproduces the element's CSS padding as PPTX internal inset, // preventing text from shifting toward the frame's top-left corner. - margin: el.style.margin || 0, + margin: pptxTextMargin(el.style.margin), shrinkText: false, autoFit: false }; @@ -34369,52 +36898,70 @@ function addElements(slideData, targetSlide, pres) { if (el.style.transparency != null && el.style.transparency !== void 0) { textOptions.transparency = el.style.transparency; } - targetSlide.addText(el.text, textOptions); + targetSlide.addText(pptxTextValue(el.text), textOptions); } } } -async function buildSlideFromExtracted(slideData, bodyDimensions, pres, options = {}) { - const validationWarnings = []; - if (bodyDimensions?.errors?.length) validationWarnings.push(...bodyDimensions.errors); - validationWarnings.push(...validateDimensions(bodyDimensions, pres)); - validationWarnings.push(...validateTextBoxPosition(slideData, bodyDimensions)); - if (slideData?.errors?.length) validationWarnings.push(...slideData.errors); - if (validationWarnings.length) { - console.warn("[ppt-live-export] slide validation warnings (export continues):", validationWarnings.join("; ")); - } - const diagnostics = [ - ...slideData?.diagnostics || [], - ...validationWarnings.map((message) => ({ - severity: "fallback", - code: "pptx_layout_warning", - message, - sourceId: null, - tag: null - })) - ]; +async function buildSlideFromScene(slideData, pres, options = {}) { + validateEditableSlideScene(slideData); const targetSlide = options.slide || pres.addSlide(); try { - await addBackground(slideData, targetSlide); - addElements(slideData, targetSlide, pres); + addSceneNodes(slideData, targetSlide, pres); } catch (error2) { + if (error2 instanceof EditableExportError) throw error2; const diagnostic = { severity: "blocking", kind: "blocking", code: "pptx_serialization", message: String(error2?.message || error2 || "PPTX serialization failed."), + slideNumber: slideData.slideNumber, sourceId: null, - tag: null + tag: null, + cause: error2 }; error2.diagnostic = diagnostic; - error2.diagnostics = [...diagnostics, diagnostic]; + error2.diagnostics = [diagnostic]; throw error2; } return { slide: targetSlide, - placeholders: slideData.placeholders || [], - diagnostics + diagnostics: [] }; } +function uniquifySlideObjectIds(xml) { + let nextId = 1; + return xml.replace(/ ` 0; + const zip = await import_jszip2.default.loadAsync(output, { base64: outputType === "base64" }); + const slidePaths = Object.keys(zip.files).filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path)).sort((left, right) => Number(left.match(/\d+/)?.[0]) - Number(right.match(/\d+/)?.[0])); + let adjustmentIndex = 0; + for (const path of slidePaths) { + let xml = await zip.file(path).async("string"); + if (needsRoundRect) { + xml = xml.replace( + /|>[\s\S]*?<\/a:avLst>)<\/a:prstGeom>/g, + (match) => { + const adjustment = adjustments[adjustmentIndex]; + adjustmentIndex += 1; + if (adjustment == null) return match; + return ``; + } + ); + } + xml = uniquifySlideObjectIds(xml); + zip.file(path, xml); + } + if (needsRoundRect && adjustmentIndex !== adjustments.length) { + throw new Error("Round rectangle OOXML adjustment count did not match serialized shapes"); + } + return zip.generateAsync({ + type: outputType, + compression: "DEFLATE" + }); +} function createPptxDeck(deck = {}) { const pptx = new PptxGenJS(); pptx.layout = "LAYOUT_WIDE"; @@ -34428,6 +36975,16 @@ function createPptxDeck(deck = {}) { bodyFontFace: "PingFang SC", lang: "zh-CN" }; + pptx[OOXML_ROUND_RECT_ADJUSTMENTS] = []; + const write = pptx.write.bind(pptx); + pptx.write = async (options = {}) => { + const output = await write(options); + return postProcessPptxOutput( + output, + options.outputType, + pptx[OOXML_ROUND_RECT_ADJUSTMENTS] + ); + }; return pptx; } function buildSpeakerNotes(sourceSlide = {}) { @@ -34440,301 +36997,8 @@ function buildSpeakerNotes(sourceSlide = {}) { ].filter(Boolean).join("\n\n"); } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-element-export.js -var SLIDE_W = 13.333; -var SLIDE_H = 7.5; -function drawSlideBackdrop(pptx, slide, theme, index) { - slide.addShape(pptx.ShapeType.rect, { - x: 0, - y: 0, - w: SLIDE_W, - h: SLIDE_H, - fill: { color: hex(theme.background) }, - line: { color: hex(theme.background), transparency: 100 } - }); - slide.addShape(pptx.ShapeType.ellipse, { - x: 10.3, - y: -0.45, - w: 3.8, - h: 3.8, - fill: { color: hex(index % 2 ? theme.accent : theme.primary), transparency: 84 }, - line: { color: hex(theme.background), transparency: 100 } - }); - slide.addShape(pptx.ShapeType.rect, { - x: 0, - y: 0, - w: 0.12, - h: SLIDE_H, - fill: { color: hex(theme.primary), transparency: 0 }, - line: { color: hex(theme.primary), transparency: 100 } - }); -} -function drawSlideMethodology(pptx, slide, sourceSlide, theme) { - if (sourceSlide.kicker) { - slide.addShape(pptx.ShapeType.roundRect, { - x: 0.96, - y: 0.48, - w: 0.22, - h: 0.07, - fill: { color: hex(theme.primary) }, - line: { color: hex(theme.primary), transparency: 100 } - }); - slide.addText(String(sourceSlide.kicker).toUpperCase(), { - x: 1.24, - y: 0.36, - w: 2.4, - h: 0.28, - margin: 0, - fontFace: "Aptos", - fontSize: 7, - bold: true, - color: hex(theme.primary), - fit: "shrink" - }); - } - if (sourceSlide.proofObject) { - slide.addText(String(sourceSlide.proofObject), { - x: 9.26, - y: 0.34, - w: 2.95, - h: 0.32, - margin: 0.04, - fontFace: "Aptos", - fontSize: 7, - bold: true, - color: hex(theme.muted), - align: "right", - fit: "shrink", - fill: { color: hex(theme.panel), transparency: 8 }, - line: { color: hex(theme.primary), transparency: 78 } - }); - } - if (sourceSlide.sourceNote) { - slide.addText(String(sourceSlide.sourceNote), { - x: 0.96, - y: 7.05, - w: 10.7, - h: 0.2, - margin: 0, - fontFace: "Aptos", - fontSize: 6, - color: hex(theme.muted), - fit: "shrink" - }); - } -} -function drawElement(pptx, slide, element2, theme) { - const box = toInches(element2); - const style = element2.style || {}; - const common = { - x: box.x, - y: box.y, - w: box.w, - h: box.h, - margin: 0.08, - fit: "shrink", - color: hex(resolveColor(style.color, theme)), - fontFace: "Aptos", - fontSize: pxToPt(style.fontSize || 22), - bold: Number(style.fontWeight || 500) >= 700, - align: style.align || "left", - valign: "mid", - breakLine: false - }; - if (element2.type === "shape") { - slide.addShape(pptx.ShapeType.roundRect, { - ...box, - rectRadius: 0.08, - fill: { color: hex(resolveColor(style.background, theme)), transparency: transparency(style.opacity) }, - line: { color: hex(resolveColor(style.background, theme)), transparency: 100 } - }); - if (element2.text) slide.addText(element2.text, common); - return; - } - if (element2.type === "list") { - const runs = (element2.items || []).map((item) => ({ text: item, options: { bullet: { type: "bullet" }, breakLine: true } })); - slide.addText(runs.length ? runs : [{ text: "" }], { - ...common, - valign: "top", - paraSpaceAfterPt: 6, - fit: "shrink" - }); - return; - } - if (element2.type === "metric") { - drawPanel(pptx, slide, box, style, theme); - slide.addText(String(element2.text || ""), { - ...common, - y: box.y + 0.08, - h: box.h * 0.48, - color: hex(resolveColor(style.color || "primary", theme)), - fontSize: pxToPt(style.fontSize || 42), - bold: true - }); - slide.addText(String(element2.label || ""), { - ...common, - y: box.y + box.h * 0.56, - h: box.h * 0.34, - color: hex(theme.muted), - fontSize: 10, - bold: false, - valign: "top" - }); - return; - } - if (element2.type === "chart") { - drawPanel(pptx, slide, box, style, theme); - slide.addText(String(element2.text || ""), { - ...common, - y: box.y + 0.1, - h: 0.32, - fontSize: 11, - bold: true - }); - drawBars(pptx, slide, element2, box, theme); - return; - } - if (element2.type === "media") { - slide.addShape(pptx.ShapeType.roundRect, { - ...box, - fill: { color: hex(resolveColor(style.background || "soft", theme)), transparency: 10 }, - line: { color: hex(theme.primary), transparency: 55, dash: "dash" } - }); - slide.addText(String(element2.text || "Image placeholder"), { - ...common, - align: "center", - color: hex(theme.muted), - fontSize: 12 - }); - return; - } - drawTextBackground(pptx, slide, box, style, theme); - slide.addText(String(element2.text || ""), common); -} -function drawPanel(pptx, slide, box, style, theme) { - slide.addShape(pptx.ShapeType.roundRect, { - ...box, - fill: { color: hex(resolveColor(style.background || "panel", theme)), transparency: transparency(style.opacity) }, - line: { color: hex(theme.primary), transparency: 82 }, - shadow: { type: "outer", color: "111827", opacity: 0.12, blur: 1, angle: 45, distance: 1 } - }); -} -function drawTextBackground(pptx, slide, box, style, theme) { - const bg = style.background || "transparent"; - if (bg === "transparent") return; - slide.addShape(pptx.ShapeType.roundRect, { - ...box, - fill: { color: hex(resolveColor(bg, theme)), transparency: transparency(style.opacity) }, - line: { color: hex(resolveColor(bg, theme)), transparency: 100 } - }); -} -function drawBars(pptx, slide, element2, box, theme) { - const data = Array.isArray(element2.data) && element2.data.length ? element2.data : [{ label: "A", value: 40 }, { label: "B", value: 70 }]; - const max = Math.max(1, ...data.map((point) => Number(point.value) || 0)); - const gap = 0.1; - const chartX = box.x + 0.18; - const chartY = box.y + 0.68; - const chartW = box.w - 0.36; - const chartH = box.h - 0.95; - const barW = Math.max(0.08, (chartW - gap * (data.length - 1)) / data.length); - data.forEach((point, index) => { - const value2 = Number(point.value) || 0; - const h = Math.max(0.15, value2 / max * chartH); - const x = chartX + index * (barW + gap); - const y = chartY + chartH - h; - slide.addShape(pptx.ShapeType.rect, { - x, - y, - w: barW, - h, - fill: { color: hex(index % 2 ? theme.accent : theme.primary) }, - line: { color: hex(index % 2 ? theme.accent : theme.primary), transparency: 100 } - }); - slide.addText(String(point.label || ""), { - x: x - 0.03, - y: chartY + chartH + 0.04, - w: barW + 0.06, - h: 0.2, - fontSize: 7, - color: hex(theme.muted), - align: "center", - margin: 0, - fit: "shrink" - }); - }); -} -function normalizeTheme(theme = {}) { - return { - background: theme.background || "#fbfcff", - ink: theme.ink || "#111827", - muted: theme.muted || "#5b6575", - primary: theme.primary || "#0f766e", - accent: theme.accent || "#f97316", - panel: theme.panel || "#ffffff" - }; -} -function toInches(element2) { - return { - x: pct(element2.x) * SLIDE_W, - y: pct(element2.y) * SLIDE_H, - w: pct(element2.w) * SLIDE_W, - h: pct(element2.h) * SLIDE_H - }; -} -function pct(value2) { - return Math.max(0, Math.min(100, Number(value2) || 0)) / 100; -} -function pxToPt(value2) { - return Math.max(6, Math.min(66, Math.round((Number(value2) || 22) * 0.58))); -} -function resolveColor(value2, theme) { - if (!value2 || value2 === "transparent") return theme.background; - if (value2 === "ink") return theme.ink; - if (value2 === "muted") return theme.muted; - if (value2 === "primary") return theme.primary; - if (value2 === "accent") return theme.accent; - if (value2 === "panel") return theme.panel; - if (value2 === "soft") return theme.primary; - if (value2 === "background") return theme.background; - return value2; -} -function hex(value2) { - const raw = String(value2 || "#111827").trim(); - if (/^#[0-9a-f]{6}$/i.test(raw)) return raw.slice(1).toUpperCase(); - if (/^[0-9a-f]{6}$/i.test(raw)) return raw.toUpperCase(); - if (/^#[0-9a-f]{3}$/i.test(raw)) { - return raw.slice(1).split("").map((part) => part + part).join("").toUpperCase(); - } - return "111827"; -} -function transparency(opacity) { - return Math.round((1 - Math.max(0, Math.min(1, Number(opacity ?? 1)))) * 100); -} -async function exportElementDeckToPptx(deck) { - const slides = Array.isArray(deck.slides) && deck.slides.length > 0 ? deck.slides : []; - const pptx = createPptxDeck(deck); - slides.forEach((sourceSlide, index) => { - const slide = pptx.addSlide(); - const theme = normalizeTheme(sourceSlide.theme); - slide.background = { color: hex(theme.background) }; - drawSlideBackdrop(pptx, slide, theme, index); - drawSlideMethodology(pptx, slide, sourceSlide, theme); - (sourceSlide.elements || []).forEach((element2) => drawElement(pptx, slide, element2, theme)); - const notes = buildSpeakerNotes(sourceSlide); - if (notes && typeof slide.addNotes === "function") slide.addNotes(notes); - }); - return pptx; -} - -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-deck-browser.js +// src/export-deck-browser.js var MIME_PPTX = "application/vnd.openxmlformats-officedocument.presentationml.presentation"; -var RASTER_TEXT_TYPES2 = /* @__PURE__ */ new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6", "text", "list", "merged-text"]); -function filterSlideDataForRasterBackdrop(slideData) { - return { - ...slideData, - elements: (slideData.elements || []).filter((el) => RASTER_TEXT_TYPES2.has(el.type)) - }; -} function uint8ToBase64(bytes) { const chunk = 32768; let binary = ""; @@ -34754,43 +37018,14 @@ async function pptxToExportResult(pptx, deck) { base64: String(base64 || "").replace(/^data:.*;base64,/, "") }; } -async function exportPptxFromDeck(deck) { - const slides = Array.isArray(deck?.slides) ? deck.slides : []; - if (slides.some((slide) => slide?.html)) { - throw new Error("HTML slides must use the WebView prepare export path."); - } - const pptx = await exportElementDeckToPptx(deck); - return pptxToExportResult(pptx, deck); -} -async function exportPptxPrepared(deck, preparedSlides) { - const prepared = Array.isArray(preparedSlides) ? preparedSlides : []; - if (!prepared.length) throw new Error("No prepared slides to export"); +async function exportEditablePptx(deck, scenes) { + const prepared = Array.isArray(scenes) ? scenes : []; + if (!prepared.length) throw new Error("No editable slide scenes to export"); const pptx = createPptxDeck(deck); const slides = Array.isArray(deck?.slides) ? deck.slides : []; - for (const item of prepared) { - const sourceSlide = slides[item.index] || item.notes || {}; - let slideData = item.slideData; - if (item.rasterBase64) { - const raw = String(item.rasterBase64).replace(/^data:.*;base64,/, ""); - if (item.rasterOnly) { - slideData = { - ...slideData, - elements: (slideData.elements || []).filter((el) => !RASTER_TEXT_TYPES2.has(el.type)), - background: { type: "image", path: `data:image/png;base64,${raw}` } - }; - } else { - slideData = filterSlideDataForRasterBackdrop(slideData); - slideData = { - ...slideData, - background: { type: "image", path: `data:image/png;base64,${raw}` } - }; - } - } - const result = await buildSlideFromExtracted( - slideData, - item.bodyDimensions, - pptx - ); + for (const [index, scene] of prepared.entries()) { + const sourceSlide = slides[index] || {}; + const result = await buildSlideFromScene(scene, pptx); const notes = buildSpeakerNotes(sourceSlide); if (notes && result?.slide && typeof result.slide.addNotes === "function") { result.slide.addNotes(notes); @@ -34819,7 +37054,7 @@ async function exportPdfFromBase64Pages(deck, pages) { async function exportPngZipFromPages(deck, pages) { const list = Array.isArray(pages) ? pages : []; if (!list.length) throw new Error("No rendered PNG pages to export"); - const zip = new import_jszip2.default(); + const zip = new import_jszip3.default(); list.forEach((item, index) => { const raw = typeof item === "string" ? item : String(item?.base64 || "").replace(/^data:.*;base64,/, ""); const slideIndex = (item?.index ?? index) + 1; @@ -34833,7 +37068,7 @@ async function exportPngZipFromPages(deck, pages) { }; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-html.js +// src/export-html.js function buildHtmlDeck(state2) { if ((state2.slides || []).some((slide) => slide.html)) { return buildSourceHtmlDeck(state2); @@ -34922,7 +37157,7 @@ body{margin:0;background:#111827;font-family:-apple-system,BlinkMacSystemFont,"S `; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-format-icons.js +// src/export-format-icons.js var SVG_ATTRS = 'xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" aria-hidden="true" focusable="false"'; var ICONS = { pptx: ``, @@ -34943,7 +37178,7 @@ function exportFormatTone(formatId) { return tones[formatId] || "#475569"; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/agent-prompt.js +// src/agent-prompt.js var PPT_DESIGN_SKILL_KEY = "user::bitfun-system::ppt-design"; function serializeInput(input) { try { @@ -34998,7 +37233,34 @@ ${prompt}`; - \u7528\u6237\u53EA\u80FD\u770B\u5230 PPT Live UI\uFF0C\u65E0\u6CD5\u56DE\u7B54\u63D0\u95EE\u3002\u5982\u6709\u6B67\u4E49\u81EA\u884C\u5224\u65AD\u6700\u4F18\u65B9\u6848\u5E76\u8BB0\u5F55\u5047\u8BBE\u3002 - \u4E0D\u8981\u8C03\u7528 AskUserQuestion\u3001ControlHub\u3001GenerativeUI\u3001ComputerUse \u7B49\u4EA4\u4E92\u5DE5\u5177\u3002 - \u7814\u7A76\u7528 WebSearch / WebFetch \u5373\u53EF\u3002 -- **\u4E00\u6B21\u5199\u5BF9\uFF0C\u7981\u6B62\u4E8B\u540E\u5BA1\u8BA1**\uFF1A\u6BCF\u9875 HTML \u5728\u5199\u5165\u65F6\u5C31\u8981\u6EE1\u8DB3\u6240\u6709\u7EA6\u675F\uFF08\u753B\u5E03\u5C3A\u5BF8\u3001\u56DB\u6761 OOXML \u786C\u7EA6\u675F\u3001\u9632\u6EA2\u51FA\u9884\u7B97\uFF09\u3002\u5B8C\u6210\u68C0\u67E5\u53EA\u6838\u5BF9\u751F\u6210\u6587\u4EF6\u534F\u8BAE\uFF0C\u4E0D\u9010\u9875 Read\u2192Edit \u8FD4\u5DE5\u6216 Grep \u6279\u91CF\u5BA1\u8BA1\u9875\u9762\u5185\u5BB9\u3002 + +## Authoring subset\uFF08\u751F\u6210\u89C4\u5219\uFF09 + +- **\u552F\u4E00\u5BFC\u51FA\u94FE\u8DEF**\uFF1Aeditable HTML \u2192 EditableSlideScene \u2192 OOXML\u3002\u6BCF\u9875\u4E25\u683C\u4E3A **1280px \xD7 720px**\u3002 +- \u53EA\u4F7F\u7528 solid color\uFF1B\u4E0D\u5F97\u751F\u6210 CSS gradient \u6216 \`background-image\`\u3002\u80CC\u666F\u3001border\u3001\u5706\u89D2\u53EA\u653E\u5728 \`div\` \u7B49\u51E0\u4F55\u5BB9\u5668\u3002 +- \`box-shadow\` \u53EA\u652F\u6301\u5355\u5C42 outer\u3001\u975E inset\u3001zero spread\uFF1B\u591A\u5C42\u3001inset\u3001blur+spread \u7B49\u4E0D\u652F\u6301\u5F62\u6001\u5FC5\u987B blocking\u3002\`text-shadow\` \u4EFB\u4F55\u975E \`none\` \u5F62\u6001\u5747\u5FC5\u987B blocking\u3002 +- HTML \u6587\u5B57\u53EA\u53EF\u653E\u5728 \`

        \`\u3001\`

        \`\u2013\`

        \`\u3001\`
      1. \` \u4E2D\uFF1B\`span\` \u53EA\u4F5C\u6587\u672C run\uFF0C\u4E0D\u5F97\u751F\u6210 \`div\` \u88F8\u6587\u5B57\u3002 +- \u7981\u6B62 CSS \`filter\`\u3001\`mask\`\u3001generated content\u3001animation\u3001\u5916\u90E8\u8D44\u6E90\u548C\u590D\u6742/filled SVG path\uFF1B\u7981\u6B62\u4EFB\u610F\u9876\u70B9/\u975E\u4E25\u683C\u5BF9\u79F0 polygon\uFF0C\u4EC5\u5141\u8BB8\u4E25\u683C\u5BF9\u79F0 triangle/diamond\u3002 +- \u7EBF\u4E0E\u66F2\u7EBF\u4F18\u5148\u76F4\u63A5\u751F\u6210 \`line\` \u6216 \`polyline\`\uFF1B\u786E\u6709\u5FC5\u8981\u65F6\u624D\u4F7F\u7528\u4E0B\u9762\u7684\u517C\u5BB9 path \u5B50\u96C6\u3002 +- Authoring \u6D41\u7A0B\u7BAD\u5934\u53EA\u7531 editable line + CSS border triangle\uFF0C\u6216 SVG line + strict symmetric triangle polygon \u6784\u6210\u3002 +- \u8868\u683C\u5FC5\u987B\u5199\u771F\u5B9E\u7684 \`
    \` \u5E76\u5BFC\u51FA\u4E3A native \`a:tbl\`\uFF1B\u56FE\u8868\u3001\u6D41\u7A0B\u7BAD\u5934\u3001\u865A\u7EBF\u548C\u66F2\u7EBF\u5FC5\u987B\u4F7F\u7528\u652F\u6301\u7684\u53EF\u7F16\u8F91\u539F\u8BED\u3002 +- intentional \u56FE\u7247\u53EA\u5141\u8BB8\u5185\u8054 base64 PNG\u3001JPEG\u3001WebP\uFF0C\u4E14\u4E0D\u5F97\u627F\u8F7D\u6587\u5B57\u3001\u56FE\u8868\u6216\u51E0\u4F55\uFF1B\u7981\u6B62 GIF\uFF0C\u56E0\u4E3A\u65E0\u6CD5\u8BC1\u660E\u5176\u4E3A\u9759\u6001\u5185\u5BB9\u3002 +- \u7981\u6B62\u4EFB\u4F55\u6B63\u5411 rasterize\u3001screenshot \u6216 fallback \u5EFA\u8BAE\uFF1B\u65E0\u6CD5\u8868\u793A\u65F6\u505C\u6B62\u751F\u6210\u5E76\u62A5\u544A\u5177\u4F53\u5143\u7D20\u3002 + +## Converter legacy rewrite boundary\uFF08\u517C\u5BB9\u8FB9\u754C\uFF0C\u4E0D\u662F\u751F\u6210\u5EFA\u8BAE\uFF09 + +- \u672C\u8FB9\u754C\u53EA\u7528\u4E8E\u517C\u5BB9\u65E2\u6709\u8F93\u5165\uFF0C\u4E0D\u662F\u751F\u6210\u8BB8\u53EF\uFF1Bauthoring agent \u4ECD\u5FC5\u987B\u9075\u5B88\u4E0A\u9762\u7684\u66F4\u4E25\u683C subset\u3002 +- SVG \`text\` \u662F converter \u652F\u6301\u7684 SVG \u539F\u8BED\uFF1B\`div\` \u88F8\u6587\u5B57\u4EC5\u5C5E repair \u517C\u5BB9\uFF0Cauthoring \u4E0D\u5E94\u751F\u6210\u3002 +- path \u4EC5\u652F\u6301 \`M/L/H/V/C/S/Q/T/Z\`\uFF0C\u5FC5\u987B \`fill:none\`\uFF1B\`Z\` \u53EF\u4EE5\u95ED\u5408 path\uFF0C\u4F46\u62D2\u7EDD \`A\` \u548C\u4EFB\u4F55 path/ancestor \`transform\`\u3002 +- \`C/S/Q/T\` \u66F2\u7EBF\u88AB\u91C7\u6837\u4E3A\u591A\u6BB5 editable line\uFF0C\u4E0D\u662F PowerPoint curve\uFF1Bauthoring \u4F18\u5148 \`line\`/\`polyline\`\uFF0C\u786E\u9700 path \u65F6\u624D\u4F7F\u7528\u4E0A\u8FF0\u5B50\u96C6\u3002 +- SVG polygon \u53EA\u8BC6\u522B\u4E25\u683C\u5BF9\u79F0\u7684 triangle \u548C diamond\uFF1B\u4EFB\u610F\u9876\u70B9\u6216\u975E\u4E25\u683C\u5BF9\u79F0 polygon \u90FD\u4F1A\u88AB\u62D2\u7EDD\u3002 +- legacy CSS \u4EC5\u517C\u5BB9\u53D7\u9650 \`linear-gradient\`\uFF1A\u89D2\u5EA6\u63A5\u53D7 \`deg\`\u3001\`turn\`\u3001\`rad\`\u3001\`grad\` \u4E0E\u65B9\u5411\u5173\u952E\u5B57\uFF1B\u4F4D\u7F6E\u53EA\u63A5\u53D7 percentage stop\uFF0C\u7F3A\u7701 stop \u5747\u5300\u5206\u914D\u3002 +- converter \u62D2\u7EDD \`radial-gradient\`\u3001px/em stop\u3001double-position stop\u3001color hint\u3001\u4E0D\u652F\u6301\u989C\u8272\u548C\u975E\u6CD5 alpha\uFF1B\u5408\u6CD5 gradient \u88AB\u91C7\u6837\u4E3A editable solid strips\uFF0C\u8FD9\u4E0D\u662F\u751F\u6210\u5EFA\u8BAE\u3002 +- legacy \u5355\u5C42 hard ring \`box-shadow\`\uFF08\`0 0 0 Npx\`\u3001\u975E inset\u3001blur=0\uFF09\u4F1A\u88AB\u91CD\u5199\u4E3A\u540C\u5FC3\u53EF\u7F16\u8F91 shape\uFF1Bauthoring \u4ECD\u5E94\u4F18\u5148 zero-spread outer shadow\uFF0C\u4E0D\u5F97\u4F9D\u8D56 ring rewrite\u3002 + + + +- **\u4E00\u6B21\u5199\u5BF9\uFF0C\u7981\u6B62\u4E8B\u540E\u5BA1\u8BA1**\uFF1A\u6BCF\u9875 HTML \u5728\u5199\u5165\u65F6\u5C31\u8981\u6EE1\u8DB3\u4E0A\u8FF0 authoring contract \u548C\u9632\u6EA2\u51FA\u9884\u7B97\u3002\u5B8C\u6210\u68C0\u67E5\u53EA\u6838\u5BF9\u751F\u6210\u6587\u4EF6\u534F\u8BAE\uFF0C\u4E0D\u9010\u9875 Read\u2192Edit \u8FD4\u5DE5\u6216 Grep \u6279\u91CF\u5BA1\u8BA1\u9875\u9762\u5185\u5BB9\u3002 `; if (hasDeck) { prompt += ` @@ -35027,7 +37289,7 @@ ${prompt}`; return prompt; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/bitfun-backend-adapter.js +// src/bitfun-backend-adapter.js var EVENT_LISTENERS = /* @__PURE__ */ new Set(); function emitEvent(event) { EVENT_LISTENERS.forEach((listener) => { @@ -35092,7 +37354,7 @@ function installBitFunBackendAdapter(app = window.app) { if (app.agent?.run) installAgentBackend(app); } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/deck-project-contract.js +// src/deck-project-contract.js var DeckProjectContractError = class extends Error { constructor(diagnostic) { super(`[${diagnostic.code}] ${diagnostic.summary}`); @@ -35397,39 +37659,37 @@ async function readDeckProjectContract(readFile, options = {}) { return { plan, slides }; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-diagnostics.js +// src/export-diagnostics.js var DIAGNOSTIC_REASONS = { "en-US": { active_content_removed: "Unsafe active content was removed.", - canvas_overflow: "Slide content exceeded the canvas; visual fallback was used.", - canvas_size: "Slide dimensions could not preserve native fidelity; visual fallback was used.", - text_out_of_bounds: "Text exceeded the slide boundary; visual fallback was used.", - bottom_safety_margin: "Text entered the bottom safety margin; visual fallback was used.", - css_gradient: "A CSS gradient required visual fallback.", - css_filter: "A CSS filter required visual fallback.", - generated_content: "Generated CSS content required visual fallback.", - page_visual_fallback: "A page visual fallback preserved the slide appearance.", - full_page_fallback: "A full-page visual fallback preserved the slide appearance.", + canvas_overflow: "Slide content exceeds the editable canvas.", + canvas_size: "Slide dimensions do not match the editable canvas.", + text_out_of_bounds: "Text exceeds the slide boundary.", + bottom_safety_margin: "Text enters the bottom safety margin.", + css_gradient: "A CSS gradient was rewritten as editable solid strips.", + svg_path_rewrite: "An SVG path was rewritten as editable line segments.", + css_box_shadow_ring: "A CSS ring box-shadow was rewritten as a concentric editable shape.", + box_shadow_unsupported: "This CSS box-shadow cannot be represented as editable PowerPoint geometry.", + manual_bullet_list: "Manual bullets were rewritten as an editable list.", unreadable_document: "The slide document could not be read.", unmeasurable_canvas: "The slide canvas could not be measured.", - pptx_serialization: "The slide could not be serialized to PPTX.", - full_page_raster_failed: "The final visual fallback could not be rendered." + pptx_serialization: "The slide could not be serialized to PPTX." }, "zh-CN": { active_content_removed: "\u5DF2\u79FB\u9664\u4E0D\u5B89\u5168\u7684\u6D3B\u52A8\u5185\u5BB9\u3002", - canvas_overflow: "\u9875\u9762\u5185\u5BB9\u8D85\u51FA\u5E7B\u706F\u7247\u8FB9\u754C\uFF0C\u5DF2\u5207\u6362\u89C6\u89C9\u515C\u5E95\u3002", - canvas_size: "\u9875\u9762\u5C3A\u5BF8\u65E0\u6CD5\u4FDD\u8BC1\u539F\u751F\u4FDD\u771F\uFF0C\u5DF2\u5207\u6362\u89C6\u89C9\u515C\u5E95\u3002", - text_out_of_bounds: "\u6587\u5B57\u8D85\u51FA\u5E7B\u706F\u7247\u8FB9\u754C\uFF0C\u5DF2\u5207\u6362\u89C6\u89C9\u515C\u5E95\u3002", - bottom_safety_margin: "\u6587\u5B57\u8FDB\u5165\u5E95\u90E8\u5B89\u5168\u8FB9\u8DDD\uFF0C\u5DF2\u5207\u6362\u89C6\u89C9\u515C\u5E95\u3002", - css_gradient: "CSS \u6E10\u53D8\u5DF2\u4F7F\u7528\u89C6\u89C9\u515C\u5E95\u3002", - css_filter: "CSS \u6EE4\u955C\u5DF2\u4F7F\u7528\u89C6\u89C9\u515C\u5E95\u3002", - generated_content: "CSS \u751F\u6210\u5185\u5BB9\u5DF2\u4F7F\u7528\u89C6\u89C9\u515C\u5E95\u3002", - page_visual_fallback: "\u5DF2\u4F7F\u7528\u9875\u9762\u89C6\u89C9\u515C\u5E95\u4FDD\u7559\u5916\u89C2\u3002", - full_page_fallback: "\u5DF2\u4F7F\u7528\u6574\u9875\u89C6\u89C9\u515C\u5E95\u4FDD\u7559\u5916\u89C2\u3002", + canvas_overflow: "\u9875\u9762\u5185\u5BB9\u8D85\u51FA\u53EF\u7F16\u8F91\u5E7B\u706F\u7247\u8FB9\u754C\u3002", + canvas_size: "\u9875\u9762\u5C3A\u5BF8\u4E0E\u53EF\u7F16\u8F91\u5E7B\u706F\u7247\u753B\u5E03\u4E0D\u4E00\u81F4\u3002", + text_out_of_bounds: "\u6587\u5B57\u8D85\u51FA\u5E7B\u706F\u7247\u8FB9\u754C\u3002", + bottom_safety_margin: "\u6587\u5B57\u8FDB\u5165\u5E95\u90E8\u5B89\u5168\u8FB9\u8DDD\u3002", + css_gradient: "CSS \u6E10\u53D8\u5DF2\u91CD\u5199\u4E3A\u53EF\u7F16\u8F91\u7EAF\u8272\u6761\u5E26\u3002", + svg_path_rewrite: "SVG \u8DEF\u5F84\u5DF2\u91CD\u5199\u4E3A\u53EF\u7F16\u8F91\u7EBF\u6BB5\u3002", + css_box_shadow_ring: "CSS \u73AF\u5F62 box-shadow \u5DF2\u91CD\u5199\u4E3A\u540C\u5FC3\u53EF\u7F16\u8F91\u5F62\u72B6\u3002", + box_shadow_unsupported: "\u8BE5 CSS box-shadow \u65E0\u6CD5\u8868\u793A\u4E3A\u53EF\u7F16\u8F91\u7684 PowerPoint \u51E0\u4F55\u3002", + manual_bullet_list: "\u624B\u5DE5\u9879\u76EE\u7B26\u53F7\u5DF2\u91CD\u5199\u4E3A\u53EF\u7F16\u8F91\u5217\u8868\u3002", unreadable_document: "\u65E0\u6CD5\u8BFB\u53D6\u5E7B\u706F\u7247\u6587\u6863\u3002", unmeasurable_canvas: "\u65E0\u6CD5\u6D4B\u91CF\u5E7B\u706F\u7247\u753B\u5E03\u3002", - pptx_serialization: "\u65E0\u6CD5\u5C06\u5E7B\u706F\u7247\u5E8F\u5217\u5316\u4E3A PPTX\u3002", - full_page_raster_failed: "\u6700\u7EC8\u89C6\u89C9\u515C\u5E95\u6E32\u67D3\u5931\u8D25\u3002" + pptx_serialization: "\u65E0\u6CD5\u5C06\u5E7B\u706F\u7247\u5E8F\u5217\u5316\u4E3A PPTX\u3002" } }; var UNKNOWN_REASON = { @@ -35444,10 +37704,9 @@ function formatLocalizedExportDiagnostic(diagnostic = {}, locale = "en-US") { const resolvedLocale = locale === "zh-CN" ? locale : "en-US"; const reason = DIAGNOSTIC_REASONS[resolvedLocale][diagnostic.code] || UNKNOWN_REASON[resolvedLocale]; return { - slideNumber: Number.isFinite(diagnostic.slideNumber) ? diagnostic.slideNumber : diagnostic.slideNumber, + slideNumber: diagnostic.slideNumber, sourceId: sanitizeDiagnosticSourceId(diagnostic.sourceId), - phase: String(diagnostic.phase || "").replace(/[^a-z-]/g, "").slice(0, 32) || null, - severity: diagnostic.severity === "blocking" ? "blocking" : diagnostic.severity, + severity: diagnostic.severity === "blocking" ? "blocking" : "rewrite", code: String(diagnostic.code || "unknown").replace(/[^a-z0-9_-]/gi, "").slice(0, 64), reason: reason.slice(0, 120) }; @@ -35455,74 +37714,43 @@ function formatLocalizedExportDiagnostic(diagnostic = {}, locale = "en-US") { function localizeExportDiagnosticLocations(locations = [], locale = "en-US") { return locations.map((location) => formatLocalizedExportDiagnostic(location, locale)); } -function summarizePptxExportDiagnostics(preparedSlides = []) { - const counts = { - repaired: 0, - svgImage: 0, - localPng: 0, - pageVisual: 0, - fullPage: 0, - blocking: 0 - }; +function summarizePptxExportDiagnostics(scenes = []) { + const counts = { rewritten: 0, blocking: 0 }; const locations = []; const seen = /* @__PURE__ */ new Set(); - const addLocation = (slideNumber, item, defaults = {}) => { + const add = (slideNumber, diagnostic) => { const location = { slideNumber, - sourceId: item?.sourceId || defaults.sourceId || null, - phase: item?.phase || defaults.phase || null, - severity: item?.severity || defaults.severity || "fallback", - code: item?.code || defaults.code || null, - reason: item?.reason || item?.message || defaults.reason || null - }; - const key = [ - location.slideNumber, - location.sourceId, - location.phase, - location.severity, - location.code - ].join(":"); + sourceId: diagnostic.sourceId || null, + severity: diagnostic.severity === "blocking" ? "blocking" : "rewrite", + code: diagnostic.code || diagnostic.rewrite || null + }; + const key = `${location.slideNumber}:${location.sourceId}:${location.severity}:${location.code}`; if (seen.has(key)) return; seen.add(key); locations.push(location); }; - preparedSlides.forEach((prepared, arrayIndex) => { - const slideNumber = (prepared?.index ?? arrayIndex) + 1; - const slideData = prepared?.slideData || {}; - (slideData.diagnostics || []).forEach((diagnostic) => { - if (diagnostic.severity === "repaired") counts.repaired += 1; - if (diagnostic.severity === "blocking") counts.blocking += 1; - if (diagnostic.severity === "repaired" || diagnostic.severity === "blocking" || diagnostic.phase || diagnostic.sourceId) { - addLocation(slideNumber, diagnostic); - } - }); - (slideData.fallbackLayers || []).forEach((layer) => { - if (layer.kind === "svg-image") counts.svgImage += 1; - if (layer.kind === "raster" && layer.phase === "local-visual") counts.localPng += 1; - if (layer.kind === "raster" && layer.phase === "page-visual") counts.pageVisual += 1; - addLocation(slideNumber, layer, { - severity: "fallback", - code: layer.kind === "svg-image" ? "svg_image_fallback" : "png_visual_fallback" + scenes.forEach((scene, index) => { + const slideNumber = scene?.slideNumber || index + 1; + (scene?.nodes || []).forEach((node) => { + if (!node.rewrite) return; + counts.rewritten += 1; + add(slideNumber, { + sourceId: node.sourceId, + severity: "rewrite", + code: node.rewrite }); }); - if (slideData.fullPageFallback) { - counts.fullPage += 1; - addLocation(slideNumber, slideData.fullPageFallback, { - severity: "fallback", - code: "full_page_fallback", - phase: "full-page" - }); - } }); return { counts, locations, - hasWarnings: counts.repaired + counts.svgImage + counts.localPng + counts.pageVisual + counts.fullPage > 0, + hasWarnings: counts.rewritten > 0, hasBlocking: counts.blocking > 0 }; } -// src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js +// ui.js var state = createInitialState(); var busy = false; var dragState = null; @@ -37432,24 +39660,14 @@ function getExportLabels(format) { function formatExportDiagnostics(summary) { if (!summary?.hasWarnings && !summary?.hasBlocking) return ""; const countLabels = [ - ["repaired", "exportDiagnosticsRepaired"], - ["svgImage", "exportDiagnosticsSvg"], - ["localPng", "exportDiagnosticsLocalPng"], - ["pageVisual", "exportDiagnosticsPageVisual"], - ["fullPage", "exportDiagnosticsFullPage"], + ["rewritten", "exportDiagnosticsRepaired"], ["blocking", "exportDiagnosticsBlocking"] ].filter(([countKey]) => summary.counts?.[countKey] > 0).map(([countKey, labelKey]) => translate(labelKey, { count: summary.counts[countKey] })); - const phaseKeys = { - "local-svg": "exportDiagnosticsPhaseSvg", - "local-visual": "exportDiagnosticsPhaseLocalPng", - "page-visual": "exportDiagnosticsPhasePageVisual", - "full-page": "exportDiagnosticsPhaseFullPage" - }; const locations = localizeExportDiagnosticLocations(summary.locations || [], getLocale()).filter((location) => location.sourceId).slice(0, 3).map((location) => translate("exportDiagnosticsLocation", { slide: location.slideNumber, source: location.sourceId, phase: translate( - phaseKeys[location.phase] || (location.severity === "blocking" ? "exportDiagnosticsPhaseBlocking" : "exportDiagnosticsPhaseRepair") + location.severity === "blocking" ? "exportDiagnosticsPhaseBlocking" : "exportDiagnosticsPhaseRepair" ), reason: location.reason })); @@ -37463,17 +39681,12 @@ function formatBlockingExportDiagnostics(diagnostics) { if (!blocking.length) return ""; return formatExportDiagnostics({ counts: { - repaired: 0, - svgImage: 0, - localPng: 0, - pageVisual: 0, - fullPage: 0, + rewritten: 0, blocking: blocking.length }, locations: blocking.map((diagnostic) => ({ slideNumber: diagnostic.slideNumber || "?", sourceId: diagnostic.sourceId || "?", - phase: diagnostic.phase || null, severity: "blocking", code: diagnostic.code })), @@ -37526,27 +39739,11 @@ async function executeExport(format) { let result; const deckPayload = clone(state); if (format === "pptx") { - if (slides.some((slide) => slide?.html)) { - const hostDeck = runtime(); - const renderRaster = typeof hostDeck?.deck?.renderPage === "function" ? async (html, index) => { - setExportRenderProgress(index, slides.length, "pptx"); - const base642 = await hostDeck.deck.renderPage({ - html, - format: "png", - width: EXPORT_VIEWPORT.width, - height: EXPORT_VIEWPORT.height - }); - return String(base642 || "").replace(/^data:.*;base64,/, ""); - } : null; - const preparedSlides = await prepareSlidesForPptxExport(slides, { - renderRaster, - onRasterProgress: (index) => setExportRenderProgress(index, slides.length, "pptx") - }); - result = await exportPptxPrepared(deckPayload, preparedSlides); - result.exportSummary = summarizePptxExportDiagnostics(preparedSlides); - } else { - result = await exportPptxFromDeck(deckPayload); - } + const scenes = await prepareEditableSlides(slides, { + onSlideProgress: (pageNumber) => setExportRenderProgress(pageNumber - 1, slides.length, "pptx") + }); + result = await exportEditablePptx(deckPayload, scenes); + result.exportSummary = summarizePptxExportDiagnostics(scenes); } else if (format === "pdf") { const pages = await renderSlidesInHostWebView(slides, "pdf"); result = await exportPdfFromBase64Pages(deckPayload, pages.map((page) => page.base64)); @@ -38193,7 +40390,13 @@ async function confirmExportFromModal() { } catch (error2) { const localizedDiagnostics = formatBlockingExportDiagnostics(error2?.diagnostics); const message = localizedDiagnostics || (error2 instanceof Error ? error2.message : String(error2)); - runtime().log?.error?.(`PPT Live ${format} export failed`, { error: message }); + runtime().log?.error?.(`PPT Live ${format} export failed`, { + error: message, + code: error2?.code || error2?.diagnostic?.code || null, + sourceId: error2?.sourceId || error2?.diagnostic?.sourceId || null, + slideNumber: error2?.slideNumber || error2?.diagnostic?.slideNumber || null, + detail: error2?.message || null + }); $("exportOverlay")?.classList.remove("is-exporting"); setExportModalFeedback("error", `${labels.failed} ${message}`); setExportStatus(`${labels.failed} ${message}`); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json index 157246aec8..c8164f5138 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json @@ -10,7 +10,7 @@ "ppt", "ai" ], - "version": 217, + "version": 231, "created_at": 0, "updated_at": 0, "permissions": { diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/agent-prompt.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/agent-prompt.js index b95ebb753a..15a69cb3ef 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/agent-prompt.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/agent-prompt.js @@ -61,7 +61,34 @@ export function buildAgentPrompt(input) { - 用户只能看到 PPT Live UI,无法回答提问。如有歧义自行判断最优方案并记录假设。 - 不要调用 AskUserQuestion、ControlHub、GenerativeUI、ComputerUse 等交互工具。 - 研究用 WebSearch / WebFetch 即可。 -- **一次写对,禁止事后审计**:每页 HTML 在写入时就要满足所有约束(画布尺寸、四条 OOXML 硬约束、防溢出预算)。完成检查只核对生成文件协议,不逐页 Read→Edit 返工或 Grep 批量审计页面内容。 + +## Authoring subset(生成规则) + +- **唯一导出链路**:editable HTML → EditableSlideScene → OOXML。每页严格为 **1280px × 720px**。 +- 只使用 solid color;不得生成 CSS gradient 或 \`background-image\`。背景、border、圆角只放在 \`div\` 等几何容器。 +- \`box-shadow\` 只支持单层 outer、非 inset、zero spread;多层、inset、blur+spread 等不支持形态必须 blocking。\`text-shadow\` 任何非 \`none\` 形态均必须 blocking。 +- HTML 文字只可放在 \`

    \`、\`

    \`–\`

    \`、\`
  • \` 中;\`span\` 只作文本 run,不得生成 \`div\` 裸文字。 +- 禁止 CSS \`filter\`、\`mask\`、generated content、animation、外部资源和复杂/filled SVG path;禁止任意顶点/非严格对称 polygon,仅允许严格对称 triangle/diamond。 +- 线与曲线优先直接生成 \`line\` 或 \`polyline\`;确有必要时才使用下面的兼容 path 子集。 +- Authoring 流程箭头只由 editable line + CSS border triangle,或 SVG line + strict symmetric triangle polygon 构成。 +- 表格必须写真实的 \`
  • \` 并导出为 native \`a:tbl\`;图表、流程箭头、虚线和曲线必须使用支持的可编辑原语。 +- intentional 图片只允许内联 base64 PNG、JPEG、WebP,且不得承载文字、图表或几何;禁止 GIF,因为无法证明其为静态内容。 +- 禁止任何正向 rasterize、screenshot 或 fallback 建议;无法表示时停止生成并报告具体元素。 + +## Converter legacy rewrite boundary(兼容边界,不是生成建议) + +- 本边界只用于兼容既有输入,不是生成许可;authoring agent 仍必须遵守上面的更严格 subset。 +- SVG \`text\` 是 converter 支持的 SVG 原语;\`div\` 裸文字仅属 repair 兼容,authoring 不应生成。 +- path 仅支持 \`M/L/H/V/C/S/Q/T/Z\`,必须 \`fill:none\`;\`Z\` 可以闭合 path,但拒绝 \`A\` 和任何 path/ancestor \`transform\`。 +- \`C/S/Q/T\` 曲线被采样为多段 editable line,不是 PowerPoint curve;authoring 优先 \`line\`/\`polyline\`,确需 path 时才使用上述子集。 +- SVG polygon 只识别严格对称的 triangle 和 diamond;任意顶点或非严格对称 polygon 都会被拒绝。 +- legacy CSS 仅兼容受限 \`linear-gradient\`:角度接受 \`deg\`、\`turn\`、\`rad\`、\`grad\` 与方向关键字;位置只接受 percentage stop,缺省 stop 均匀分配。 +- converter 拒绝 \`radial-gradient\`、px/em stop、double-position stop、color hint、不支持颜色和非法 alpha;合法 gradient 被采样为 editable solid strips,这不是生成建议。 +- legacy 单层 hard ring \`box-shadow\`(\`0 0 0 Npx\`、非 inset、blur=0)会被重写为同心可编辑 shape;authoring 仍应优先 zero-spread outer shadow,不得依赖 ring rewrite。 + + + +- **一次写对,禁止事后审计**:每页 HTML 在写入时就要满足上述 authoring contract 和防溢出预算。完成检查只核对生成文件协议,不逐页 Read→Edit 返工或 Grep 批量审计页面内容。 `; if (hasDeck) { diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-normalize.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-normalize.js new file mode 100644 index 0000000000..20ba5da78e --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-normalize.js @@ -0,0 +1,1298 @@ +import { EditableExportError, validateEditableSlideScene } from './editable-slide-scene.js'; +import { + classifySvgPresetPolygon, + createSvgViewportMapper, + extractSlideDataFromDocument, + measureBodyDimensions, + PX_PER_IN, +} from './html2pptx-dom-core.js'; + +const GRADIENT_GRID_SIZE = 16; +const CURVE_MAX_SEGMENTS = 64; + +function sourceIdOf(element, fallback = 'slide-document') { + return element?.dataset?.pptxSourceId || element?.id || fallback; +} + +function fail(slideNumber, element, code, message) { + throw new EditableExportError({ + slideNumber, + sourceId: sourceIdOf(element, `slide-${slideNumber}`), + code, + message, + }); +} + +function failSource(slideNumber, sourceId, code, message) { + throw new EditableExportError({ + slideNumber, + sourceId: sourceId || `slide-${slideNumber}`, + code, + message, + }); +} + +function isTransparent(value) { + return !value + || value === 'none' + || value === 'transparent' + || /^rgba\([^)]*,\s*0(?:\.0+)?\s*\)$/i.test(value); +} + +function parseColor(value) { + const raw = String(value || '').trim().toLowerCase(); + const named = { + black: '000000', white: 'FFFFFF', red: 'FF0000', green: '008000', + blue: '0000FF', yellow: 'FFFF00', gray: '808080', grey: '808080', + }; + if (raw === 'transparent') return { hex: '000000', alpha: 0 }; + if (named[raw]) return { hex: named[raw], alpha: 1 }; + const hex = raw.match(/^#([\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i)?.[1]; + if (hex) { + const expanded = hex.length <= 4 + ? hex.split('').map((channel) => channel.repeat(2)).join('') + : hex; + return { + hex: expanded.slice(0, 6).toUpperCase(), + alpha: expanded.length === 8 ? parseInt(expanded.slice(6, 8), 16) / 255 : 1, + }; + } + const rgb = raw.match( + /^rgba?\(\s*([\d.]+)[,\s]+([\d.]+)[,\s]+([\d.]+)(?:\s*[,/]\s*([\d.]+%?))?\s*\)$/i, + ); + if (rgb) { + const channels = rgb.slice(1, 4).map(Number); + if (channels.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 255)) { + return null; + } + let alpha = 1; + if (rgb[4] != null) { + alpha = rgb[4].endsWith('%') ? Number.parseFloat(rgb[4]) / 100 : Number(rgb[4]); + if (!Number.isFinite(alpha) || alpha < 0 || alpha > 1) return null; + } + return { + hex: channels.map((channel) => Math.round(channel).toString(16).padStart(2, '0')) + .join('').toUpperCase(), + alpha, + }; + } + return null; +} + +function colorToHex(value, fallback = null) { + return parseColor(value)?.hex || fallback; +} + +function elementBox(element, bodyRect) { + const rect = element.getBoundingClientRect(); + return { + x: Math.max(0, rect.left - bodyRect.left) / PX_PER_IN, + y: Math.max(0, rect.top - bodyRect.top) / PX_PER_IN, + w: Math.max(1, rect.width) / PX_PER_IN, + h: Math.max(1, rect.height) / PX_PER_IN, + }; +} + +function generatedContentOwner(doc, selector) { + const raw = String(selector || '').trim(); + const candidates = [raw]; + const simpleSelectors = raw.match(/(?:[.#][\w-]+|\[[^\]]+\]|[a-z][\w-]*)/gi); + if (simpleSelectors?.length) candidates.push(simpleSelectors.at(-1)); + for (const candidate of candidates) { + try { + const owner = doc.querySelector(candidate); + if (owner) return owner; + } catch { + // Try the next selector candidate. + } + } + return null; +} + +function parsePolygonPoints(element) { + const values = String(element.getAttribute('points') || '').trim() + .split(/[\s,]+/).map(Number).filter(Number.isFinite); + const points = []; + for (let index = 0; index + 1 < values.length; index += 2) { + points.push({ x: values[index], y: values[index + 1] }); + } + return points; +} + +function validateSvgPresetGeometry(doc, slideNumber) { + for (const polygon of doc.querySelectorAll('svg polygon')) { + if (!classifySvgPresetPolygon(parsePolygonPoints(polygon))) { + fail(slideNumber, polygon, 'svg_polygon_unsupported', + 'SVG polygon geometry does not exactly match an editable PowerPoint triangle or diamond preset.'); + } + } + for (const rect of doc.querySelectorAll('svg rect[rx], svg rect[ry]')) { + const width = Number(rect.getAttribute('width')); + const height = Number(rect.getAttribute('height')); + const rawRx = rect.hasAttribute('rx') ? Number(rect.getAttribute('rx')) : null; + const rawRy = rect.hasAttribute('ry') ? Number(rect.getAttribute('ry')) : null; + const rx = rawRx ?? rawRy; + const ry = rawRy ?? rawRx; + const maxRadius = Math.min(width, height) / 2; + const tolerance = Math.max(1, Math.min(width, height)) * 1e-6; + const svg = rect.closest('svg'); + const viewportMapper = createSvgViewportMapper(svg, svg.getBoundingClientRect()); + const hasNonuniformRadiusScale = Math.abs(viewportMapper.xScale - viewportMapper.yScale) + > Math.max(viewportMapper.xScale, viewportMapper.yScale, 1) * 1e-6; + if (![width, height, rx, ry, maxRadius].every(Number.isFinite) + || width <= 0 || height <= 0 || rx <= 0 || ry <= 0 + || Math.abs(rx - ry) > tolerance + || rx - maxRadius > tolerance + || hasNonuniformRadiusScale) { + fail(slideNumber, rect, 'svg_round_rect_radius_unsupported', + 'SVG rect radius must be equal on both axes and within half the shorter side.'); + } + } +} + +function editableErrorPreflight(doc, slideNumber) { + const view = doc.defaultView; + for (const element of [doc.body, ...doc.querySelectorAll('*')]) { + const computed = view.getComputedStyle(element); + const backgroundImage = String(computed.backgroundImage || '').trim(); + if (backgroundImage && backgroundImage !== 'none' && !backgroundImage.includes('gradient(')) { + fail(slideNumber, element, 'background_image_unsupported', + 'CSS background images cannot be represented as intentional editable picture nodes; use an img element.'); + } + const filter = String(computed.filter || element.style?.filter || '').trim(); + if (filter && filter !== 'none') { + fail(slideNumber, element, 'css_filter', 'CSS filters cannot be represented by editable PowerPoint objects.'); + } + const animationName = String(computed.animationName || element.style?.animationName || '').trim(); + const animation = String(element.style?.animation || '').trim(); + if ((animationName && animationName !== 'none') || animation) { + fail(slideNumber, element, 'animation_unsupported', 'CSS animation cannot be represented in a static editable slide.'); + } + const mask = [ + computed.mask, + computed.maskImage, + element.style?.getPropertyValue?.('mask'), + element.style?.getPropertyValue?.('mask-image'), + element.getAttribute?.('style')?.match(/(?:^|;)\s*(?:-webkit-)?mask(?:-image)?\s*:\s*([^;]+)/i)?.[1], + ].find((value) => value && value !== 'none'); + if (mask) { + fail(slideNumber, element, 'css_mask', 'CSS masks cannot be represented by editable PowerPoint objects.'); + } + for (const attribute of ['src', 'href', 'xlink:href']) { + const value = element.getAttribute?.(attribute); + if (value && /^(?:https?:|\/\/|file:|blob:)/i.test(value.trim())) { + fail(slideNumber, element, 'external_resource', 'External resources are not allowed in editable slide export.'); + } + } + const cssText = element.getAttribute?.('style') || ''; + if (/url\(\s*["']?(?!#|data:image\/(?:png|jpeg|jpg|webp);base64,)/i.test(cssText)) { + fail(slideNumber, element, 'external_resource', 'External CSS resources are not allowed in editable slide export.'); + } + } + + const defsGeometry = doc.querySelector( + 'svg defs rect, svg defs circle, svg defs ellipse, svg defs line, ' + + 'svg defs polyline, svg defs polygon, svg defs path, svg defs text', + ); + if (defsGeometry) { + fail(slideNumber, defsGeometry, 'svg_defs_geometry_unsupported', + 'SVG geometry inside defs cannot be emitted as editable slide objects.'); + } + for (const element of doc.querySelectorAll('svg [fill], svg [stroke]')) { + const paint = ['fill', 'stroke'].find((attribute) => ( + /\burl\s*\(\s*["']?#[^)]+\)/i.test(element.getAttribute(attribute) || '') + )); + if (paint) { + fail(slideNumber, element, 'svg_paint_server_unsupported', + `SVG ${paint} paint servers cannot be represented as editable PowerPoint colors.`); + } + } + + const masked = doc.querySelector('svg [mask]') || doc.querySelector('svg mask'); + if (masked) { + fail(slideNumber, masked, 'svg_mask', 'SVG masks cannot be represented by editable PowerPoint objects.'); + } + const filtered = doc.querySelector('svg [filter], svg filter, svg foreignObject, svg pattern, svg use, svg image'); + if (filtered) { + const external = filtered.matches('image,use') + && /^(?!#)(?:https?:|\/\/|file:|blob:|data:)/i.test( + filtered.getAttribute('href') || filtered.getAttribute('xlink:href') || '', + ); + fail( + slideNumber, + filtered, + external ? 'external_resource' : 'svg_filter_unsupported', + 'This SVG resource construct cannot be represented by editable PowerPoint objects.', + ); + } + const animated = doc.querySelector('animate, animateMotion, animateTransform, set'); + if (animated) { + fail(slideNumber, animated.parentElement || animated, 'animation_unsupported', + 'SVG animation cannot be represented in a static editable slide.'); + } + + const inspectGeneratedContentRules = (rules) => { + for (const rule of rules || []) { + if (rule.cssRules) { + inspectGeneratedContentRules(rule.cssRules); + continue; + } + const selector = rule.selectorText || ''; + const content = rule.style?.content; + if (!/::?(?:before|after)\b/i.test(selector) + || !content || ['none', 'normal', '""', "''"].includes(content.trim())) continue; + const baseSelector = selector.replace(/::?(?:before|after)\b.*$/i, '').trim(); + const owner = generatedContentOwner(doc, baseSelector); + fail(slideNumber, owner || doc.body, 'generated_content', + 'Generated pseudo-element content cannot be represented as an editable object.'); + } + }; + for (const sheet of doc.styleSheets || []) { + try { + inspectGeneratedContentRules(sheet.cssRules); + } catch { + // Cross-origin stylesheets are unavailable to CSSOM and cannot occur after sanitization. + } + } + for (const styleElement of doc.querySelectorAll('style')) { + const cssText = styleElement.textContent || ''; + const pseudoRules = cssText.matchAll( + /([^{}]+)::?(?:before|after)\s*\{[^{}]*\bcontent\s*:\s*(?!none|normal)([^;}]+)/gi, + ); + for (const match of pseudoRules) { + const baseSelector = match[1].trim().replace(/^.*[;}]\s*/, ''); + const owner = generatedContentOwner(doc, baseSelector); + fail(slideNumber, owner || doc.body, 'generated_content', + 'Generated pseudo-element content cannot be represented as an editable object.'); + } + } +} + +function splitGradientArguments(value) { + const inner = value.slice(value.indexOf('(') + 1, value.lastIndexOf(')')); + const parts = []; + let depth = 0; + let start = 0; + for (let index = 0; index < inner.length; index += 1) { + if (inner[index] === '(') depth += 1; + else if (inner[index] === ')') depth -= 1; + else if (inner[index] === ',' && depth === 0) { + parts.push(inner.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(inner.slice(start).trim()); + return parts; +} + +function hexChannels(hex) { + return [0, 2, 4].map((offset) => parseInt(hex.slice(offset, offset + 2), 16)); +} + +function interpolateColor(left, right, amount) { + const alpha = left.alpha + (right.alpha - left.alpha) * amount; + const leftChannels = hexChannels(left.hex); + const rightChannels = hexChannels(right.hex); + const channels = leftChannels.map((channel, index) => { + if (alpha <= 0) return 0; + const premultiplied = channel * left.alpha + + (rightChannels[index] * right.alpha - channel * left.alpha) * amount; + return Math.round(premultiplied / alpha); + }); + return { + hex: channels.map((channel) => channel.toString(16).padStart(2, '0')).join('').toUpperCase(), + alpha, + }; +} + +function parseLinearGradient(value) { + const args = splitGradientArguments(value); + let angle = 180; + const angleMatch = args[0]?.match(/^(-?(?:\d*\.)?\d+)(deg|turn|rad|grad)$/i); + if (angleMatch) { + args.shift(); + const amount = Number(angleMatch[1]); + angle = { + deg: amount, + turn: amount * 360, + rad: amount * 180 / Math.PI, + grad: amount * 0.9, + }[angleMatch[2].toLowerCase()]; + } else if (/^to\s+/i.test(args[0])) { + const direction = args.shift().toLowerCase(); + const resolved = { + 'to top': 0, + 'to top right': 45, + 'to right top': 45, + 'to right': 90, + 'to bottom right': 135, + 'to right bottom': 135, + 'to bottom': 180, + 'to bottom left': 225, + 'to left bottom': 225, + 'to left': 270, + 'to top left': 315, + 'to left top': 315, + }[direction]; + if (resolved == null) return null; + angle = resolved; + } else if (/^-?(?:\d*\.)?\d/.test(args[0] || '')) { + return null; + } + const stops = args.map((stop) => { + const match = stop.match(/^(.*?)(?:\s+(-?(?:\d*\.)?\d+)%\s*)?$/); + const color = parseColor(match?.[1]); + return { + color, + offset: match?.[2] == null ? null : Number(match[2]) / 100, + }; + }); + if (stops.length < 2 || stops.some((stop) => !stop.color)) return null; + if (stops[0].offset == null) stops[0].offset = 0; + if (stops.at(-1).offset == null) stops.at(-1).offset = 1; + for (let index = 0; index < stops.length;) { + if (stops[index].offset != null) { + stops[index].offset = Math.max(index ? stops[index - 1].offset : 0, stops[index].offset); + index += 1; + continue; + } + const start = index - 1; + let end = index; + while (end < stops.length && stops[end].offset == null) end += 1; + const span = end - start; + for (let cursor = index; cursor < end; cursor += 1) { + stops[cursor].offset = stops[start].offset + + (stops[end].offset - stops[start].offset) * (cursor - start) / span; + } + index = end; + } + return { angle: ((angle % 360) + 360) % 360, stops }; +} + +function gradientValueForElement(element, view) { + const computed = String(view.getComputedStyle(element).backgroundImage || ''); + if (computed.includes('gradient(')) return computed; + const inline = element.getAttribute?.('style') || ''; + const start = inline.search(/(?:linear|radial)-gradient\(/i); + if (start < 0) return ''; + let depth = 0; + for (let index = start; index < inline.length; index += 1) { + if (inline[index] === '(') depth += 1; + else if (inline[index] === ')' && --depth === 0) return inline.slice(start, index + 1); + } + return inline.slice(start); +} + +function gradientColorAt(stops, offset) { + const rightIndex = stops.findIndex((stop) => stop.offset >= offset); + if (rightIndex <= 0) return stops[0].color; + if (rightIndex < 0) return stops.at(-1).color; + const left = stops[rightIndex - 1]; + const right = stops[rightIndex]; + const range = right.offset - left.offset; + return interpolateColor(left.color, right.color, range > 0 ? (offset - left.offset) / range : 0); +} + +function gradientNodes(doc, bodyRect, slideNumber, paintOrderBySource) { + const nodes = []; + for (const element of [doc.body, ...doc.querySelectorAll('*')]) { + if (element.closest?.('table')) continue; + const backgroundImage = gradientValueForElement(element, doc.defaultView); + if (!backgroundImage.includes('gradient(')) continue; + if (!backgroundImage.startsWith('linear-gradient(')) { + fail(slideNumber, element, 'css_gradient_unsupported', + 'Only linear gradients can be rewritten as editable solid strips.'); + } + const gradient = parseLinearGradient(backgroundImage); + if (!gradient) { + fail(slideNumber, element, 'css_gradient_unsupported', 'Linear gradient stops could not be parsed.'); + } + const box = elementBox(element, bodyRect); + const radians = gradient.angle * Math.PI / 180; + const direction = { x: Math.sin(radians), y: -Math.cos(radians) }; + const gradientLength = Math.abs(box.w * direction.x) + Math.abs(box.h * direction.y); + const isAxisAligned = gradient.angle % 90 === 0; + const columns = isAxisAligned && gradient.angle % 180 === 0 ? 1 : GRADIENT_GRID_SIZE; + const rows = isAxisAligned && gradient.angle % 180 !== 0 ? 1 : GRADIENT_GRID_SIZE; + const sourceId = sourceIdOf(element); + const paintOrder = paintOrderBySource.get(sourceId) ?? 0; + const parsedZIndex = Number.parseInt(doc.defaultView.getComputedStyle(element).zIndex, 10); + const zIndex = Number.isFinite(parsedZIndex) ? parsedZIndex : 0; + const total = columns * rows; + for (let row = 0; row < rows; row += 1) { + for (let column = 0; column < columns; column += 1) { + const order = row * columns + column; + const centerX = box.w * (column + 0.5) / columns; + const centerY = box.h * (row + 0.5) / rows; + const projection = (centerX - box.w / 2) * direction.x + + (centerY - box.h / 2) * direction.y; + const sample = gradientLength > 0 ? 0.5 + projection / gradientLength : 0.5; + const strip = { + x: box.x + box.w * column / columns, + y: box.y + box.h * row / rows, + w: box.w / columns, + h: box.h / rows, + }; + const color = gradientColorAt(gradient.stops, sample); + nodes.push({ + type: 'shape', + shapeType: 'rect', + rewrite: 'css_gradient', + sourceId, + ...strip, + order, + paintOrder, + subOrder: order - total, + zIndex, + style: { + fill: color.hex, + transparency: (1 - color.alpha) * 100, + line: null, + }, + }); + } + } + } + return nodes; +} + +function tableTextRuns(cell, view) { + const runs = []; + const blockTags = new Set(['P', 'DIV', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'LI']); + const textOptions = (element) => { + const computed = view.getComputedStyle(element || cell); + const cellComputed = view.getComputedStyle(cell); + const fontWeightValue = computed.fontWeight || cellComputed.fontWeight; + const fontWeight = Number.parseInt(fontWeightValue, 10); + return { + fontFace: String(computed.fontFamily || cellComputed.fontFamily || '') + .split(',')[0].replace(/['"]/g, '').trim() || 'Arial', + fontSize: (Number.parseFloat(computed.fontSize || cellComputed.fontSize) || 16) * 0.75, + color: colorToHex(computed.color || cellComputed.color, '000000'), + bold: fontWeightValue === 'bold' || fontWeight >= 600, + italic: computed.fontStyle === 'italic', + underline: String(computed.textDecoration || '').includes('underline'), + }; + }; + const appendLineBreak = (owner) => { + if (!runs.length || runs.at(-1).text.endsWith('\n')) return; + runs.push({ text: '\n', options: textOptions(owner) }); + }; + const significantSibling = (node, direction) => { + let sibling = node[direction]; + while (sibling) { + if (sibling.nodeType === view.Node.ELEMENT_NODE) return sibling; + if (sibling.nodeType === view.Node.TEXT_NODE && String(sibling.textContent || '').trim()) { + return sibling; + } + sibling = sibling[direction]; + } + return null; + }; + const isBlockNode = (node) => ( + node?.nodeType === view.Node.ELEMENT_NODE && blockTags.has(node.tagName) + ); + const visit = (node, owner) => { + if (node.nodeType === view.Node.TEXT_NODE) { + const raw = String(node.textContent || ''); + let text = raw.replace(/\s+/g, ' '); + if (!text.trim()) { + const previous = significantSibling(node, 'previousSibling'); + const next = significantSibling(node, 'nextSibling'); + if (previous && next && !isBlockNode(previous) && !isBlockNode(next)) { + runs.push({ text: ' ', options: textOptions(owner) }); + } + return; + } + if (runs.at(-1)?.text.endsWith('\n')) text = text.replace(/^ /, ''); + if (isBlockNode(significantSibling(node, 'nextSibling'))) text = text.replace(/ $/, ''); + if (text) runs.push({ text, options: textOptions(owner) }); + return; + } + if (node.nodeType !== view.Node.ELEMENT_NODE) return; + if (node.tagName === 'BR') { + appendLineBreak(owner); + return; + } + if (blockTags.has(node.tagName)) appendLineBreak(node); + [...node.childNodes].forEach((child) => visit(child, node)); + if (blockTags.has(node.tagName)) appendLineBreak(node); + }; + [...cell.childNodes].forEach((child) => visit(child, cell)); + if (!runs.length) return ''; + runs[0].text = runs[0].text.replace(/^\s+/, ''); + runs.at(-1).text = runs.at(-1).text.replace(/\s+$/, ''); + return runs.filter((run) => run.text); +} + +function authoredStyleValue(cssText, property) { + const escaped = property.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return String(cssText || '').match( + new RegExp(`(?:^|;)\\s*${escaped}\\s*:\\s*([^;]+)`, 'i'), + )?.[1]?.trim() || ''; +} + +function cssLengthToPoints(value) { + const match = String(value || '').trim().match(/^(-?(?:\d*\.)?\d+)(pt|px)$/i); + if (!match) return 0; + const amount = Number(match[1]); + return match[2].toLowerCase() === 'pt' ? amount : amount * 0.75; +} + +function cssLengthToInches(value) { + const match = String(value || '').trim().match(/^(-?(?:\d*\.)?\d+)(pt|px)$/i); + if (!match) return 0; + const amount = Number(match[1]); + return amount / (match[2].toLowerCase() === 'pt' ? 72 : PX_PER_IN); +} + +function authoredBoxValues(value) { + const values = String(value || '').trim().split(/\s+/).filter(Boolean); + if (!values.length || values.length > 4) return null; + if (values.length === 1) return [values[0], values[0], values[0], values[0]]; + if (values.length === 2) return [values[0], values[1], values[0], values[1]]; + if (values.length === 3) return [values[0], values[1], values[2], values[1]]; + return values; +} + +function tableBorderSide(computed, side, authoredStyle) { + const authoredBorder = authoredStyleValue(authoredStyle, `border-${side.toLowerCase()}`) + || authoredStyleValue(authoredStyle, 'border'); + const authoredWidth = authoredBorder.match(/-?(?:\d*\.)?\d+(?:pt|px)/i)?.[0]; + const authoredColor = authoredBorder.match(/#[\da-f]{3,8}|rgba?\([^)]+\)|[a-z]+$/i)?.[0]; + const width = authoredWidth + ? cssLengthToPoints(authoredWidth) + : Number.parseFloat(computed[`border${side}Width`]) * 0.75 || 0; + const style = authoredBorder + ? (/\bnone\b/i.test(authoredBorder) ? 'none' : 'solid') + : computed[`border${side}Style`]; + return { + color: colorToHex(authoredColor || computed[`border${side}Color`], '000000'), + width: style === 'none' ? 0 : width, + }; +} + +function tableCellStyle(cell, doc) { + const view = doc.defaultView; + const computed = view.getComputedStyle(cell); + const textOwner = cell.querySelector( + 'p,h1,h2,h3,h4,h5,h6,span,strong,b,em,i,u,small,label,a,code,mark,sub,sup', + ) || cell; + const textComputed = view.getComputedStyle(textOwner); + const fontWeightValue = textComputed.fontWeight || computed.fontWeight; + const fontWeight = Number.parseInt(fontWeightValue, 10); + const align = String(textComputed.textAlign || computed.textAlign || 'left'); + const verticalAlign = String(computed.verticalAlign || '').toLowerCase(); + const authoredStyle = cell.dataset.pptxAuthoredStyle || cell.getAttribute('style') || ''; + const authoredPadding = authoredBoxValues(authoredStyleValue(authoredStyle, 'padding')); + const shorthandColor = String(cell.style.background || '').trim().match( + /#[\da-f]{3,8}|rgba?\([^)]+\)|^[a-z]+/i, + )?.[0]; + const authoredBackground = cell.style.backgroundColor + || shorthandColor + || authoredStyleValue(authoredStyle, 'background-color') + || authoredStyleValue(authoredStyle, 'background').match( + /#[\da-f]{3,8}|rgba?\([^)]+\)|^[a-z]+/i, + )?.[0]; + const backgroundColor = !isTransparent(computed.backgroundColor) + ? computed.backgroundColor + : authoredBackground; + return { + fill: isTransparent(backgroundColor) + ? null + : colorToHex(backgroundColor, null), + border: { + top: tableBorderSide(computed, 'Top', authoredStyle), + right: tableBorderSide(computed, 'Right', authoredStyle), + bottom: tableBorderSide(computed, 'Bottom', authoredStyle), + left: tableBorderSide(computed, 'Left', authoredStyle), + }, + align: align === 'center' ? 'center' : align === 'right' || align === 'end' ? 'right' : 'left', + valign: verticalAlign === 'middle' + ? 'mid' + : verticalAlign === 'bottom' ? 'bottom' : 'top', + fontFamily: String(textComputed.fontFamily || '').split(',')[0].replace(/['"]/g, '').trim() + || 'Arial', + fontSize: (Number.parseFloat(textComputed.fontSize) || 16) * 0.75, + fontColor: colorToHex(textComputed.color, '000000'), + bold: fontWeightValue === 'bold' || fontWeight >= 600, + padding: authoredPadding + ? authoredPadding.map(cssLengthToInches) + : [ + Number.parseFloat(computed.paddingTop) || 0, + Number.parseFloat(computed.paddingRight) || 0, + Number.parseFloat(computed.paddingBottom) || 0, + Number.parseFloat(computed.paddingLeft) || 0, + ].map((value) => value / PX_PER_IN), + }; +} + +function tableZIndex(table, doc) { + let current = table; + while (current && current !== doc.body) { + const parsed = Number.parseInt(doc.defaultView.getComputedStyle(current).zIndex, 10); + if (Number.isFinite(parsed)) return parsed; + current = current.parentElement; + } + return 0; +} + +function effectiveTableRowspan(cell) { + const raw = cell.getAttribute('rowspan'); + if (raw != null && String(raw).trim() === '0') { + const row = cell.parentElement; + const rowGroup = row?.parentElement; + const groupRows = rowGroup && ['THEAD', 'TBODY', 'TFOOT'].includes(rowGroup.tagName) + ? [...rowGroup.rows] + : []; + const groupIndex = groupRows.indexOf(row); + return groupIndex >= 0 ? groupRows.length - groupIndex : 1; + } + return cell.rowSpan || 1; +} + +function tableNodes(doc, bodyRect, slideNumber, paintOrderBySource) { + const nodes = []; + for (const table of doc.querySelectorAll('table')) { + if (table.closest('td,th')) { + fail(slideNumber, table, 'table_nested_unsupported', + 'Nested tables cannot be represented as one editable PowerPoint table.'); + } + const rect = table.getBoundingClientRect(); + const rows = [...table.rows]; + if (!(rect.width > 0) || !(rect.height > 0) || !rows.length) { + fail(slideNumber, table, 'table_geometry_unmeasurable', + 'Editable table requires measurable table and row geometry.'); + } + const rowRects = rows.map((row) => row.getBoundingClientRect()); + if (rowRects.some((rowRect) => !(rowRect.height > 0))) { + fail(slideNumber, table, 'table_geometry_unmeasurable', + 'Editable table rows require measurable positive heights.'); + } + const cellsByRow = rows.map((row) => [...row.cells]); + const cells = cellsByRow.flat(); + if (!cells.length) { + fail(slideNumber, table, 'table_grid_non_rectangular', + 'Editable table must contain at least one cell.'); + } + let maximumHorizontalBorder = 0; + for (const cell of cells) { + const computed = doc.defaultView.getComputedStyle(cell); + maximumHorizontalBorder = Math.max( + maximumHorizontalBorder, + parseFloat(computed.borderLeftWidth) || 0, + parseFloat(computed.borderRightWidth) || 0, + ); + if (computed.backgroundImage && computed.backgroundImage !== 'none') { + fail(slideNumber, cell, 'table_cell_background_unsupported', + 'Table cell background images and gradients cannot be represented natively.'); + } + if (cell.querySelector('table,svg,img,canvas,video,audio')) { + fail(slideNumber, cell, 'table_cell_content_unsupported', + 'Table cells may only contain editable text content.'); + } + } + const tolerance = Math.max(0.75, maximumHorizontalBorder / 2 + 0.5); + + const rawBoundaries = [ + rect.left, + rect.right, + ...cells.flatMap((cell) => { + const cellRect = cell.getBoundingClientRect(); + return [cellRect.left, cellRect.right]; + }), + ].sort((left, right) => left - right); + const boundaries = []; + rawBoundaries.forEach((value) => { + const previous = boundaries.at(-1); + if (previous == null || Math.abs(value - previous) > tolerance) boundaries.push(value); + else boundaries[boundaries.length - 1] = (previous + value) / 2; + }); + if (boundaries.length < 2 + || Math.abs(boundaries[0] - rect.left) > tolerance + || Math.abs(boundaries.at(-1) - rect.right) > tolerance) { + fail(slideNumber, table, 'table_grid_non_rectangular', + 'Rendered table cell boundaries do not cover the complete table width.'); + } + boundaries[0] = rect.left; + boundaries[boundaries.length - 1] = rect.right; + const columnCount = boundaries.length - 1; + const grid = rows.map(() => Array(columnCount).fill(null)); + const nearestBoundary = (value) => ( + boundaries.findIndex((boundary) => Math.abs(boundary - value) <= tolerance) + ); + const normalizedRows = rows.map((row, rowIndex) => { + const normalizedCells = cellsByRow[rowIndex].map((cell) => { + const cellRect = cell.getBoundingClientRect(); + const startColumn = nearestBoundary(cellRect.left); + const endColumn = nearestBoundary(cellRect.right); + const colspan = cell.colSpan || 1; + const rowspan = effectiveTableRowspan(cell); + if (startColumn < 0 || endColumn <= startColumn + || endColumn - startColumn !== colspan + || rowIndex + rowspan > rows.length) { + fail(slideNumber, table, 'table_grid_non_rectangular', + 'Rendered cell boundaries do not match the declared colspan or rowspan.'); + } + const expectedTop = rowRects[rowIndex].top; + const expectedBottom = rowRects[rowIndex + rowspan - 1].bottom; + if (Math.abs(cellRect.top - expectedTop) > tolerance + || Math.abs(cellRect.bottom - expectedBottom) > tolerance) { + fail(slideNumber, table, 'table_grid_non_rectangular', + 'Rendered row boundaries do not match the declared rowspan.'); + } + for (let gridRow = rowIndex; gridRow < rowIndex + rowspan; gridRow += 1) { + for (let column = startColumn; column < endColumn; column += 1) { + if (grid[gridRow][column]) { + fail(slideNumber, table, 'table_grid_non_rectangular', + 'Rendered table spans overlap and cannot form a rectangular grid.'); + } + grid[gridRow][column] = cell; + } + } + return { + text: tableTextRuns(cell, doc.defaultView), + style: tableCellStyle(cell, doc), + ...(colspan > 1 ? { colspan } : {}), + ...(rowspan > 1 ? { rowspan } : {}), + }; + }); + return { + height: rowRects[rowIndex].height / PX_PER_IN, + cells: normalizedCells, + }; + }); + if (grid.some((row) => row.some((cell) => !cell))) { + fail(slideNumber, table, 'table_grid_non_rectangular', + 'Rendered table spans leave uncovered cells and cannot form a rectangular grid.'); + } + const sourceId = sourceIdOf(table); + const normalizedTableHeight = rowRects.reduce((sum, rowRect) => ( + sum + rowRect.height + ), 0) / PX_PER_IN; + nodes.push({ + type: 'table', + sourceId, + x: Math.max(0, rect.left - bodyRect.left) / PX_PER_IN, + y: Math.max(0, rowRects[0].top - bodyRect.top) / PX_PER_IN, + w: rect.width / PX_PER_IN, + h: normalizedTableHeight, + columnWidths: boundaries.slice(1).map((boundary, index) => ( + (boundary - boundaries[index]) / PX_PER_IN + )), + rows: normalizedRows, + zIndex: tableZIndex(table, doc), + paintOrder: paintOrderBySource.get(sourceId) ?? 0, + subOrder: 0, + }); + } + return nodes; +} + +function pathTokens(data) { + return String(data || '').match(/[a-zA-Z]|[-+]?(?:\d*\.\d+|\d+\.?)(?:e[-+]?\d+)?/gi) || []; +} + +function pointLineDistance(point, start, end) { + const dx = end.x - start.x; + const dy = end.y - start.y; + const lengthSquared = dx * dx + dy * dy; + if (!lengthSquared) return Math.hypot(point.x - start.x, point.y - start.y); + const area = Math.abs(dy * point.x - dx * point.y + end.x * start.y - end.y * start.x); + return area / Math.sqrt(lengthSquared); +} + +function midpoint(left, right) { + return { x: (left.x + right.x) / 2, y: (left.y + right.y) / 2 }; +} + +function sampleQuadratic(start, control, end, tolerance = 0.5) { + const output = []; + const visit = (a, b, c, depth) => { + if (depth >= Math.log2(CURVE_MAX_SEGMENTS) || pointLineDistance(b, a, c) <= tolerance) { + output.push(c); + return; + } + const ab = midpoint(a, b); + const bc = midpoint(b, c); + const center = midpoint(ab, bc); + visit(a, ab, center, depth + 1); + visit(center, bc, c, depth + 1); + }; + visit(start, control, end, 0); + return output; +} + +function sampleCubic(start, first, second, end, tolerance = 0.5) { + const output = []; + const visit = (a, b, c, d, depth) => { + const flatness = Math.max(pointLineDistance(b, a, d), pointLineDistance(c, a, d)); + if (depth >= Math.log2(CURVE_MAX_SEGMENTS) || flatness <= tolerance) { + output.push(d); + return; + } + const ab = midpoint(a, b); + const bc = midpoint(b, c); + const cd = midpoint(c, d); + const abc = midpoint(ab, bc); + const bcd = midpoint(bc, cd); + const center = midpoint(abc, bcd); + visit(a, ab, abc, center, depth + 1); + visit(center, bcd, cd, d, depth + 1); + }; + visit(start, first, second, end, 0); + return output; +} + +function parseEditablePath(data, slideNumber, element) { + const tokens = pathTokens(data); + const points = []; + let index = 0; + let command = ''; + let current = { x: 0, y: 0 }; + let subpathStart = null; + let lastCubicControl = null; + let lastQuadraticControl = null; + const number = () => Number(tokens[index++]); + const point = (relative) => { + const result = { x: number(), y: number() }; + return relative ? { x: current.x + result.x, y: current.y + result.y } : result; + }; + const append = (next) => { + points.push([current, next]); + current = next; + }; + + while (index < tokens.length) { + if (/^[a-z]$/i.test(tokens[index])) command = tokens[index++]; + if (!command) fail(slideNumber, element, 'svg_path_invalid', 'SVG path begins without a command.'); + const relative = command === command.toLowerCase(); + switch (command.toUpperCase()) { + case 'M': { + current = point(relative); + subpathStart = current; + command = relative ? 'l' : 'L'; + break; + } + case 'L': + append(point(relative)); + break; + case 'H': { + const x = number(); + append({ x: relative ? current.x + x : x, y: current.y }); + break; + } + case 'V': { + const y = number(); + append({ x: current.x, y: relative ? current.y + y : y }); + break; + } + case 'C': { + const start = current; + const first = point(relative); + const second = point(relative); + const end = point(relative); + sampleCubic(start, first, second, end).forEach(append); + lastCubicControl = second; + lastQuadraticControl = null; + break; + } + case 'S': { + const start = current; + const first = lastCubicControl + ? { x: 2 * current.x - lastCubicControl.x, y: 2 * current.y - lastCubicControl.y } + : current; + const second = point(relative); + const end = point(relative); + sampleCubic(start, first, second, end).forEach(append); + lastCubicControl = second; + lastQuadraticControl = null; + break; + } + case 'Q': { + const start = current; + const control = point(relative); + const end = point(relative); + sampleQuadratic(start, control, end).forEach(append); + lastQuadraticControl = control; + lastCubicControl = null; + break; + } + case 'T': { + const start = current; + const control = lastQuadraticControl + ? { x: 2 * current.x - lastQuadraticControl.x, y: 2 * current.y - lastQuadraticControl.y } + : current; + const end = point(relative); + sampleQuadratic(start, control, end).forEach(append); + lastQuadraticControl = control; + lastCubicControl = null; + break; + } + case 'Z': + if (subpathStart && (current.x !== subpathStart.x || current.y !== subpathStart.y)) append(subpathStart); + command = ''; + break; + default: + fail(slideNumber, element, 'svg_path_command_unsupported', + `SVG path command "${command}" cannot be represented as editable lines.`); + } + if (!['C', 'S'].includes(command.toUpperCase())) lastCubicControl = null; + if (!['Q', 'T'].includes(command.toUpperCase())) lastQuadraticControl = null; + } + return points; +} + +function dashStyle(value) { + const values = String(value || '').match(/[-+]?(?:\d*\.)?\d+/g)?.map(Number) + .filter((item) => Number.isFinite(item) && item >= 0) || []; + if (!values.length) return null; + if (values.length >= 4 && values[2] <= values[0] * 0.5) return 'dashDot'; + if (values.length >= 2 && values[0] <= values[1] * 0.5) return 'dot'; + return 'dash'; +} + +function hrNodes(doc, bodyRect, paintOrderBySource) { + return [...doc.querySelectorAll('hr')].map((element) => { + const rect = element.getBoundingClientRect(); + const computed = doc.defaultView.getComputedStyle(element); + const borderCandidates = [ + ['Top', computed.borderTopStyle, computed.borderTopWidth, computed.borderTopColor], + ['Bottom', computed.borderBottomStyle, computed.borderBottomWidth, computed.borderBottomColor], + ['Left', computed.borderLeftStyle, computed.borderLeftWidth, computed.borderLeftColor], + ['Right', computed.borderRightStyle, computed.borderRightWidth, computed.borderRightColor], + ]; + const border = borderCandidates.find(([, style, width]) => ( + style && style !== 'none' && (Number.parseFloat(width) || 0) > 0 + )); + const [, borderStyle = 'solid', borderWidth = '1px', borderColor = computed.color] = border || []; + const width = Number.parseFloat(borderWidth) || 1; + const color = colorToHex(borderColor || computed.color); + const sourceId = sourceIdOf(element); + const dash = borderStyle === 'dotted' ? 'dot' : borderStyle === 'dashed' ? 'dash' : null; + return { + type: 'line', + sourceId, + x1: Math.max(0, rect.left - bodyRect.left) / PX_PER_IN, + y1: Math.max(0, rect.top - bodyRect.top) / PX_PER_IN, + x2: Math.max(0, rect.right - bodyRect.left) / PX_PER_IN, + y2: Math.max(0, rect.top - bodyRect.top) / PX_PER_IN, + paintOrder: paintOrderBySource.get(sourceId) ?? 0, + subOrder: 0, + zIndex: Number.parseInt(computed.zIndex, 10) || 0, + style: { + color, + width: width * 0.75, + ...(dash ? { dash, dashType: dash } : {}), + }, + }; + }); +} + +function pathNodes(doc, bodyRect, slideNumber, paintOrderBySource) { + const nodes = []; + for (const path of doc.querySelectorAll('svg path')) { + let transformedAncestor = path; + while (transformedAncestor && transformedAncestor.localName !== 'svg') { + const computedTransform = doc.defaultView.getComputedStyle(transformedAncestor).transform; + if (transformedAncestor.hasAttribute('transform') + || (computedTransform && computedTransform !== 'none')) { + fail(slideNumber, path, 'svg_path_transform_unsupported', + 'Transformed SVG paths cannot yet be safely decomposed into editable slide lines.'); + } + transformedAncestor = transformedAncestor.parentElement; + } + const segments = parseEditablePath(path.getAttribute('d'), slideNumber, path); + const computed = doc.defaultView.getComputedStyle(path); + const fill = path.hasAttribute('fill') + ? path.getAttribute('fill') + : computed.fill || 'black'; + if (segments.length && !isTransparent(fill)) { + fail(slideNumber, path, 'svg_path_fill_unsupported', + 'A filled SVG path cannot be safely decomposed into editable line objects.'); + } + const svg = path.closest('svg'); + const svgRect = svg.getBoundingClientRect(); + const viewportMapper = createSvgViewportMapper(svg, svgRect); + const map = (point) => { + const mapped = viewportMapper.map(point); + return { + x: Math.max(0, mapped.x - bodyRect.left) / PX_PER_IN, + y: Math.max(0, mapped.y - bodyRect.top) / PX_PER_IN, + }; + }; + const stroke = path.getAttribute('stroke') || computed.stroke || fill; + const dashArray = path.getAttribute('stroke-dasharray') || computed.strokeDasharray; + const dash = dashStyle(dashArray); + const style = { + color: colorToHex(stroke), + width: (Number.parseFloat(path.getAttribute('stroke-width') || computed.strokeWidth) || 1) * 0.75, + ...(dash ? { dash, dashType: dash } : {}), + }; + const sourceId = sourceIdOf(path); + segments.forEach(([start, end], subOrder) => { + const first = map(start); + const second = map(end); + if (first.x === second.x && first.y === second.y) return; + nodes.push({ + type: 'line', + rewrite: 'svg_path_rewrite', + sourceId, + x1: first.x, y1: first.y, x2: second.x, y2: second.y, + paintOrder: paintOrderBySource.get(sourceId) ?? 0, + subOrder, + style: { ...style }, + }); + }); + } + return nodes; +} + +function mapExtractedElement(element, doc) { + const position = element.position || element.bbox; + const shapeRadius = element.type === 'shape' + ? element.shape?.rectRadius + : element.shape?.radius; + const source = element.sourceId + ? [...doc.querySelectorAll('[data-pptx-source-id]')] + .find((candidate) => candidate.dataset.pptxSourceId === element.sourceId) + : null; + if (element.type === 'line') { + const dashArray = source?.getAttribute('stroke-dasharray') + || (source ? doc.defaultView.getComputedStyle(source).strokeDasharray : ''); + const dash = element.style?.dash || dashStyle(dashArray); + return { + type: 'line', + sourceId: element.sourceId, + x1: element.x1, y1: element.y1, x2: element.x2, y2: element.y2, + paintOrder: element.paintOrder, + subOrder: element.subOrder, + zIndex: element.zIndex, + style: { + color: String(element.color || '000000').toUpperCase(), + width: element.width || 0.75, + ...(dash ? { dash, dashType: dash } : {}), + }, + }; + } + if (element.type === 'image') { + return { + type: 'image', + intent: 'user-image', + sourceId: element.sourceId, + x: position.x, y: position.y, w: position.w, h: position.h, + src: element.src, + paintOrder: element.paintOrder, + subOrder: element.subOrder, + zIndex: element.zIndex, + }; + } + if (element.type === 'shape' || element.type === 'svg-shape') { + const cssEllipse = element.type === 'shape' && element.shape?.rectRadius === 1; + const rounded = element.svgType === 'roundRect' + || (source?.localName === 'rect' + && (Number(source.getAttribute('rx')) > 0 || Number(source.getAttribute('ry')) > 0)); + return { + type: 'shape', + shapeType: cssEllipse ? 'ellipse' : (rounded ? 'roundRect' : ({ + rect: 'rect', circle: 'ellipse', ellipse: 'ellipse', + triangle: 'triangle', diamond: 'diamond', + }[element.svgType] || (element.shape?.rectRadius ? 'roundRect' : 'rect'))), + sourceId: element.sourceId, + x: position.x, y: position.y, w: position.w, h: position.h, + paintOrder: element.paintOrder, + subOrder: element.subOrder, + zIndex: element.zIndex, + ...(element.rewrite ? { rewrite: element.rewrite } : {}), + style: { + fill: String(element.shape?.fill || 'FFFFFF').toUpperCase(), + line: element.shape?.line, + transparency: element.shape?.transparency, + rotate: element.shape?.rotate ?? 0, + ...(element.shape?.shadow ? { shadow: element.shape.shadow } : {}), + ...(Number.isFinite(shapeRadius) && shapeRadius > 0 && !cssEllipse + ? { radius: shapeRadius } + : {}), + }, + }; + } + const text = element.text ?? element.items; + if (text != null) { + const anchor = source?.getAttribute('text-anchor'); + return { + type: 'text', + sourceId: element.sourceId, + x: position.x, y: position.y, w: position.w, h: position.h, + text, + paintOrder: element.paintOrder, + subOrder: element.subOrder, + zIndex: element.zIndex, + style: { + ...(element.style || {}), + ...(anchor ? { align: { start: 'left', middle: 'center', end: 'right' }[anchor] || 'left' } : {}), + }, + }; + } + return null; +} + +function isSupportedEditablePolygon(element) { + return Boolean(classifySvgPresetPolygon(parsePolygonPoints(element))); +} + +function auditExtractedDiagnostics(extracted, rewrittenSourceIds, slideNumber) { + for (const diagnostic of extracted.diagnostics || []) { + if (diagnostic.severity !== 'blocking' || rewrittenSourceIds.has(diagnostic.sourceId)) continue; + failSource( + slideNumber, + diagnostic.sourceId, + diagnostic.code || 'editable_input_unsupported', + diagnostic.message || 'The source cannot be represented as editable PowerPoint objects.', + ); + } +} + +export function normalizeDocumentToEditableScene(doc, { slideNumber, width, height } = {}) { + if (!doc?.body || !doc.defaultView) { + fail(slideNumber, null, 'unreadable_document', 'The slide document is not readable.'); + } + editableErrorPreflight(doc, slideNumber); + validateSvgPresetGeometry(doc, slideNumber); + const bodyRect = doc.body.getBoundingClientRect(); + if (!(bodyRect.width > 0) || !(bodyRect.height > 0)) { + fail(slideNumber, doc.body, 'unmeasurable_canvas', + 'The editable slide canvas must have measurable positive dimensions.'); + } + if (Math.abs(bodyRect.width - width * PX_PER_IN) > 2 + || Math.abs(bodyRect.height - height * PX_PER_IN) > 2) { + fail(slideNumber, doc.body, 'canvas_size', + 'The source canvas dimensions do not match the editable slide dimensions.'); + } + const bodyDimensions = measureBodyDimensions(doc); + if (bodyDimensions.errors?.length) { + fail(slideNumber, doc.body, 'canvas_overflow', bodyDimensions.errors.join(' ')); + } + const extracted = extractSlideDataFromDocument(doc); + for (const element of doc.querySelectorAll('[data-pptx-source-id]')) { + const computed = doc.defaultView.getComputedStyle(element); + const textShadow = [computed.textShadow, element.style?.textShadow, + authoredStyleValue(element.dataset?.pptxAuthoredStyle, 'text-shadow')] + .find((value) => value && value !== 'none'); + if (textShadow) { + fail(slideNumber, element, 'text_shadow_unsupported', + 'CSS text-shadow cannot be represented as editable PowerPoint text.'); + } + const boxShadow = [computed.boxShadow, element.style?.boxShadow, + authoredStyleValue(element.dataset?.pptxAuthoredStyle, 'box-shadow')] + .find((value) => value && value !== 'none'); + if (!boxShadow) continue; + const sourceId = sourceIdOf(element); + const nativeShadow = extracted.elements.some((candidate) => ( + candidate.sourceId === sourceId && candidate.shape?.shadow + )); + const ringRewrite = extracted.elements.some((candidate) => ( + candidate.sourceId === sourceId && candidate.rewrite === 'css_box_shadow_ring' + )); + if (!nativeShadow && !ringRewrite) { + fail(slideNumber, element, 'box_shadow_unsupported', + 'CSS box-shadow cannot be represented as one native editable PowerPoint outer shadow.'); + } + } + const paintOrderBySource = new Map(); + [doc.body, ...doc.body.querySelectorAll('[data-pptx-source-id]')].forEach((element, index) => { + paintOrderBySource.set(sourceIdOf(element), index); + }); + extracted.elements.forEach((element) => { + if (element.sourceId && Number.isFinite(element.paintOrder)) { + paintOrderBySource.set(element.sourceId, element.paintOrder); + } + }); + const tableOwnedSourceIds = new Set( + [...doc.querySelectorAll('table, table *')] + .map((element) => element.dataset?.pptxSourceId || element.id || null) + .filter(Boolean), + ); + const hrSourceIds = new Set([...doc.querySelectorAll('hr')].map(sourceIdOf)); + const pathSourceIds = new Set([...doc.querySelectorAll('svg path')].map(sourceIdOf)); + const gradientSourceIds = new Set( + [doc.body, ...doc.querySelectorAll('*')] + .filter((element) => ( + !element.closest?.('table') + && gradientValueForElement(element, doc.defaultView).includes('gradient(') + )) + .map(sourceIdOf), + ); + const rewrittenSourceIds = new Set([ + ...pathSourceIds, + ...gradientSourceIds, + ...tableOwnedSourceIds, + ...hrSourceIds, + ]); + doc.querySelectorAll('svg').forEach((svg) => { + const paths = [...svg.querySelectorAll('path')]; + if (paths.length && paths.every((path) => pathSourceIds.has(sourceIdOf(path)))) { + rewrittenSourceIds.add(sourceIdOf(svg)); + } + }); + doc.querySelectorAll('svg polygon').forEach((polygon) => { + const sourceId = sourceIdOf(polygon); + const nativePolygon = extracted.elements.some((element) => ( + element.sourceId === sourceId + && element.type === 'svg-shape' + && ['triangle', 'diamond'].includes(element.svgType) + )); + if (isSupportedEditablePolygon(polygon) && nativePolygon) rewrittenSourceIds.add(sourceId); + }); + const normalizedPaths = pathNodes(doc, bodyRect, slideNumber, paintOrderBySource); + const normalizedTables = tableNodes(doc, bodyRect, slideNumber, paintOrderBySource); + auditExtractedDiagnostics(extracted, rewrittenSourceIds, slideNumber); + if (extracted.background?.type === 'image') { + fail(slideNumber, doc.body, 'background_image_unsupported', + 'Slide background images must be authored as intentional img elements.'); + } + const backgroundNodes = extracted.background?.type === 'color' + ? [{ + type: 'shape', + shapeType: 'rect', + sourceId: `${sourceIdOf(doc.body)}-background`, + x: 0, + y: 0, + w: width, + h: height, + paintOrder: -1, + subOrder: 0, + zIndex: -1, + style: { + fill: String(extracted.background.value || 'FFFFFF').toUpperCase(), + line: null, + }, + }] + : []; + const nativeNodes = extracted.elements + .filter((element) => ( + !tableOwnedSourceIds.has(element.sourceId) + && !pathSourceIds.has(element.sourceId) + && !(gradientSourceIds.has(element.sourceId) + && ['shape', 'svg-shape'].includes(element.type)) + )) + .map((element) => mapExtractedElement(element, doc)) + .filter(Boolean); + const scene = { + slideNumber, + width, + height, + nodes: [ + ...backgroundNodes, + ...nativeNodes, + ...gradientNodes(doc, bodyRect, slideNumber, paintOrderBySource), + ...normalizedPaths, + ...normalizedTables, + ...hrNodes(doc, bodyRect, paintOrderBySource), + ].sort((left, right) => ( + (left.zIndex ?? 0) - (right.zIndex ?? 0) + || (left.paintOrder ?? 0) - (right.paintOrder ?? 0) + || (left.subOrder ?? 0) - (right.subOrder ?? 0) + )), + }; + return validateEditableSlideScene(scene); +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-scene.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-scene.js new file mode 100644 index 0000000000..e52685c77a --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-scene.js @@ -0,0 +1,781 @@ +const EDITABLE_NODE_TYPES = new Set([ + 'text', + 'shape', + 'line', + 'table', + 'image', +]); + +const EDITABLE_SHAPE_TYPES = new Set([ + 'rect', + 'roundRect', + 'ellipse', + 'triangle', + 'diamond', + 'rightArrow', + 'leftArrow', + 'upArrow', + 'downArrow', + 'chevron', + 'parallelogram', + 'trapezoid', + 'hexagon', +]); + +const FORBIDDEN_FALLBACK_FIELDS = [ + 'captureStrategy', + 'phase', + 'canvas', + 'sourceIds', + 'buildFailure', + 'diagnostics', + 'html', + 'buildHtml', + 'rasterHtml', + 'rasterBase64', + 'rasterOnly', + 'renderRaster', +]; + +const LEGACY_FALLBACK_KINDS = new Set([ + 'raster', + 'svg-image', + 'fallback', + 'page-visual', + 'full-page', +]); + +export class EditableExportError extends Error { + constructor(diagnostic) { + const blockingDiagnostic = { + severity: 'blocking', + kind: 'blocking', + ...diagnostic, + }; + super(`[${blockingDiagnostic.code}] ${blockingDiagnostic.message}`); + this.name = 'EditableExportError'; + this.diagnostic = blockingDiagnostic; + this.diagnostics = [blockingDiagnostic]; + this.slideNumber = blockingDiagnostic.slideNumber; + this.sourceId = blockingDiagnostic.sourceId; + this.code = blockingDiagnostic.code; + if (blockingDiagnostic.cause !== undefined) this.cause = blockingDiagnostic.cause; + } +} + +function sceneSourceId(slideNumber) { + return Number.isInteger(slideNumber) && slideNumber > 0 + ? `slide-${slideNumber}` + : 'slide'; +} + +function editableSceneError(scene, code, message, sourceId = null) { + const slideNumber = scene?.slideNumber ?? null; + return new EditableExportError({ + slideNumber, + sourceId: sourceId || sceneSourceId(slideNumber), + code, + message, + }); +} + +function isObject(value) { + return Boolean(value) && !Array.isArray(value) && typeof value === 'object'; +} + +function hasOnlyFields(value, fields) { + return isObject(value) && Object.keys(value).every((field) => fields.has(field)); +} + +function isColor(value) { + return typeof value === 'string' && /^[0-9A-F]{6}$/i.test(value); +} + +function isOptionalColor(value) { + return value === undefined || value === null || isColor(value); +} + +function isNonNegativeFinite(value) { + return Number.isFinite(value) && value >= 0; +} + +function isPercentage(value) { + return Number.isFinite(value) && value >= 0 && value <= 100; +} + +const TEXT_STYLE_FIELDS = new Set([ + 'fontSize', 'fontFace', 'color', 'bold', 'italic', 'underline', + 'align', 'valign', 'lineSpacing', 'paraSpaceBefore', 'paraSpaceAfter', + 'margin', 'rotate', 'vert', 'transparency', 'bulletColor', +]); +const TEXT_RUN_OPTION_FIELDS = new Set([ + 'fontSize', 'fontFace', 'color', 'bold', 'italic', 'underline', + 'breakLine', 'bullet', 'transparency', +]); +const BULLET_FIELDS = new Set(['type', 'indent']); +const LINE_STYLE_FIELDS = new Set([ + 'color', 'width', 'dash', 'dashType', 'transparency', +]); +const SHAPE_STYLE_FIELDS = new Set([ + 'fill', 'line', 'transparency', 'rotate', 'radius', 'shadow', +]); +const SHADOW_FIELDS = new Set(['type', 'angle', 'blur', 'color', 'offset', 'opacity']); +const TABLE_NODE_FIELDS = new Set([ + 'type', 'sourceId', 'x', 'y', 'w', 'h', 'columnWidths', 'rows', + 'paintOrder', 'subOrder', 'zIndex', +]); +const TABLE_ROW_FIELDS = new Set(['height', 'cells']); +const TABLE_CELL_FIELDS = new Set(['text', 'style', 'rowspan', 'colspan']); +const TABLE_CELL_STYLE_FIELDS = new Set([ + 'fill', 'border', 'align', 'valign', 'padding', + 'fontFamily', 'fontSize', 'fontColor', 'bold', +]); +const TABLE_BORDER_FIELDS = new Set(['color', 'width']); +const TABLE_BORDER_SIDES = new Set(['top', 'right', 'bottom', 'left']); +const IMAGE_NODE_FIELDS = new Set([ + 'type', 'intent', 'sourceId', 'x', 'y', 'w', 'h', 'src', 'data', + 'paintOrder', 'subOrder', 'zIndex', +]); +const SCENE_FIELDS = new Set(['slideNumber', 'width', 'height', 'nodes']); +const REWRITE_TYPES = new Set(['css_gradient', 'svg_path_rewrite', 'css_box_shadow_ring']); +const DASH_TYPES = new Set(['dash', 'dot', 'dashDot']); +const TEXT_ALIGNS = new Set(['left', 'center', 'right', 'justify']); +const TEXT_VALIGNS = new Set(['top', 'mid', 'bottom']); +const TEXT_VERTS = new Set(['horz', 'vert', 'vert270', 'wordArtVert', 'eaVert', 'mongolianVert']); + +function hasValidMargin(value) { + return value === undefined + || isNonNegativeFinite(value) + || (Array.isArray(value) + && value.length === 4 + && value.every(isNonNegativeFinite)); +} + +function hasValidTextStyle(style) { + return hasOnlyFields(style, TEXT_STYLE_FIELDS) + && (style.fontSize === undefined || (Number.isFinite(style.fontSize) && style.fontSize > 0)) + && (style.fontFace === undefined + || (typeof style.fontFace === 'string' && Boolean(style.fontFace.trim()))) + && isOptionalColor(style.color) + && isOptionalColor(style.bulletColor) + && ['bold', 'italic'].every((field) => ( + style[field] === undefined || typeof style[field] === 'boolean' + )) + && (style.underline === undefined + || typeof style.underline === 'boolean' + || typeof style.underline === 'string') + && (style.align === undefined || TEXT_ALIGNS.has(style.align)) + && (style.valign === undefined || TEXT_VALIGNS.has(style.valign)) + && ['lineSpacing', 'paraSpaceBefore', 'paraSpaceAfter'].every((field) => ( + style[field] === undefined || isNonNegativeFinite(style[field]) + )) + && hasValidMargin(style.margin) + && (style.rotate === undefined || Number.isFinite(style.rotate)) + && (style.vert === undefined || TEXT_VERTS.has(style.vert)) + && (style.transparency === undefined || style.transparency === null + || isPercentage(style.transparency)); +} + +function hasValidTextRunOptions(options) { + if (options === undefined) return true; + return hasOnlyFields(options, TEXT_RUN_OPTION_FIELDS) + && (options.fontSize === undefined || (Number.isFinite(options.fontSize) && options.fontSize > 0)) + && (options.fontFace === undefined + || (typeof options.fontFace === 'string' && Boolean(options.fontFace.trim()))) + && isOptionalColor(options.color) + && ['bold', 'italic', 'breakLine'].every((field) => ( + options[field] === undefined || typeof options[field] === 'boolean' + )) + && (options.underline === undefined + || typeof options.underline === 'boolean' + || typeof options.underline === 'string') + && (options.transparency === undefined || isPercentage(options.transparency)) + && (options.bullet === undefined || ( + hasOnlyFields(options.bullet, BULLET_FIELDS) + && (options.bullet.type === undefined || options.bullet.type === 'bullet') + && (options.bullet.indent === undefined || isNonNegativeFinite(options.bullet.indent)) + && (options.bullet.type === 'bullet' || options.bullet.indent !== undefined) + )); +} + +function hasValidTextValue(value, allowEmpty = false) { + if (!isTextValue(value, allowEmpty)) return false; + return !Array.isArray(value) || value.every((run) => hasValidTextRunOptions(run.options)); +} + +function hasValidLineStyle(style, { allowNull = false } = {}) { + if (allowNull && (style === null || style === undefined)) return true; + return hasOnlyFields(style, LINE_STYLE_FIELDS) + && isColor(style.color) + && (style.width === undefined || isNonNegativeFinite(style.width)) + && (style.dash === undefined || DASH_TYPES.has(style.dash)) + && (style.dashType === undefined || DASH_TYPES.has(style.dashType)) + && (style.dash === undefined || style.dashType === undefined || style.dash === style.dashType) + && (style.transparency === undefined || isPercentage(style.transparency)); +} + +function hasValidShapeStyle(style, shapeType) { + return hasOnlyFields(style, SHAPE_STYLE_FIELDS) + && isOptionalColor(style.fill) + && hasValidLineStyle(style.line, { allowNull: true }) + && (style.transparency === undefined || style.transparency === null + || isPercentage(style.transparency)) + && (style.rotate === undefined || Number.isFinite(style.rotate)) + && (style.radius === undefined || ( + shapeType === 'roundRect' + && Number.isFinite(style.radius) + && style.radius > 0 + )) + && (style.shadow === undefined || hasValidShadow(style.shadow)); +} + +function hasValidShadow(shadow) { + return hasOnlyFields(shadow, SHADOW_FIELDS) + && shadow.type === 'outer' + && Number.isFinite(shadow.angle) + && shadow.angle >= 0 + && shadow.angle < 360 + && isNonNegativeFinite(shadow.blur) + && isColor(shadow.color) + && isNonNegativeFinite(shadow.offset) + && Number.isFinite(shadow.opacity) + && shadow.opacity >= 0 + && shadow.opacity <= 1; +} + +function findForbiddenFallbackMetadata( + value, + { + allowDirectData = false, + skipKeys = new Set(), + seen = new Set(), + } = {}, +) { + if (!value || typeof value !== 'object' || seen.has(value)) return null; + seen.add(value); + + if (Array.isArray(value)) { + for (const item of value) { + const nestedField = findForbiddenFallbackMetadata(item, { seen }); + if (nestedField) return nestedField; + } + return null; + } + + for (const [field, nestedValue] of Object.entries(value)) { + if (skipKeys.has(field)) continue; + const normalizedField = field.toLowerCase(); + if (normalizedField.includes('fallback') + || normalizedField === ['suppressed', 'native', 'visual', 'ids'].join('') + || FORBIDDEN_FALLBACK_FIELDS.includes(field)) return field; + if (field === 'data' && !allowDirectData) return field; + if (field === 'kind' && LEGACY_FALLBACK_KINDS.has(nestedValue)) return field; + + const nestedField = findForbiddenFallbackMetadata(nestedValue, { seen }); + if (nestedField) return nestedField; + } + return null; +} + +function isTextValue(value, allowEmpty = false) { + if (typeof value === 'string') return allowEmpty || Boolean(value.trim()); + if (!Array.isArray(value) || !value.length) return false; + if (!value.every((run) => ( + hasOnlyFields(run, new Set(['text', 'options'])) + && typeof run.text === 'string' + && (run.options === undefined || isObject(run.options)) + ))) return false; + return allowEmpty || Boolean(value.map((run) => run.text).join('').trim()); +} + +function hasValidBoxGeometry(node, { allowNegativeOrigin = false } = {}) { + return Number.isFinite(node.x) + && Number.isFinite(node.y) + && Number.isFinite(node.w) + && Number.isFinite(node.h) + && (allowNegativeOrigin || node.x >= 0) + && (allowNegativeOrigin || node.y >= 0) + && node.w > 0 + && node.h > 0; +} + +function hasValidLineGeometry(node, { allowNegativeOrigin = false } = {}) { + return Number.isFinite(node.x1) + && Number.isFinite(node.y1) + && Number.isFinite(node.x2) + && Number.isFinite(node.y2) + && (allowNegativeOrigin || node.x1 >= 0) + && (allowNegativeOrigin || node.y1 >= 0) + && (allowNegativeOrigin || node.x2 >= 0) + && (allowNegativeOrigin || node.y2 >= 0) + && (node.x1 !== node.x2 || node.y1 !== node.y2); +} + +function isPositiveInteger(value) { + return Number.isInteger(value) && value > 0; +} + +function hasValidTableBorderSide(side) { + return hasOnlyFields(side, TABLE_BORDER_FIELDS) + && /^[0-9A-F]{6}$/i.test(side.color) + && Number.isFinite(side.width) + && side.width >= 0; +} + +function hasValidTableBorder(border) { + if (!isObject(border)) return false; + if (hasOnlyFields(border, TABLE_BORDER_SIDES) + && [...TABLE_BORDER_SIDES].every((side) => hasValidTableBorderSide(border[side]))) { + return true; + } + return hasOnlyFields(border, TABLE_BORDER_FIELDS) + && /^[0-9A-F]{6}$/i.test(border.color) + && Number.isFinite(border.width) + && border.width >= 0; +} + +function hasValidTableCellStyle(style) { + return hasOnlyFields(style, TABLE_CELL_STYLE_FIELDS) + && (style.fill === null || /^[0-9A-F]{6}$/i.test(style.fill)) + && hasValidTableBorder(style.border) + && ['left', 'center', 'right'].includes(style.align) + && (style.valign === undefined || ['top', 'mid', 'bottom'].includes(style.valign)) + && (style.padding === undefined || ( + Array.isArray(style.padding) + && style.padding.length === 4 + && style.padding.every((value) => Number.isFinite(value) && value >= 0) + )) + && (style.fontFamily === undefined || ( + typeof style.fontFamily === 'string' && Boolean(style.fontFamily.trim()) + )) + && (style.fontSize === undefined || (Number.isFinite(style.fontSize) && style.fontSize > 0)) + && (style.fontColor === undefined || /^[0-9A-F]{6}$/i.test(style.fontColor)) + && (style.bold === undefined || typeof style.bold === 'boolean'); +} + +function hasValidTableCell(cell) { + return hasOnlyFields(cell, TABLE_CELL_FIELDS) + && Object.prototype.hasOwnProperty.call(cell, 'text') + && hasValidTextValue(cell.text, true) + && hasValidTableCellStyle(cell.style) + && (cell.rowspan === undefined || isPositiveInteger(cell.rowspan)) + && (cell.colspan === undefined || isPositiveInteger(cell.colspan)); +} + +function hasValidTableGrid(rows, columnCount) { + let activeRowspans = Array(columnCount).fill(0); + + for (const row of rows) { + const occupied = activeRowspans.map((remaining) => remaining > 0); + const nextRowspans = activeRowspans.map((remaining) => Math.max(0, remaining - 1)); + + for (const cell of row.cells) { + const colspan = cell.colspan || 1; + const rowspan = cell.rowspan || 1; + const startColumn = occupied.indexOf(false); + if (startColumn < 0 || startColumn + colspan > columnCount) return false; + + for (let column = startColumn; column < startColumn + colspan; column += 1) { + if (occupied[column]) return false; + occupied[column] = true; + if (rowspan > 1) nextRowspans[column] = Math.max(nextRowspans[column], rowspan - 1); + } + } + + if (occupied.some((isOccupied) => !isOccupied)) return false; + activeRowspans = nextRowspans; + } + + return activeRowspans.every((remaining) => remaining === 0); +} + +function hasValidTablePayload(node) { + if (!Array.isArray(node.columnWidths) + || !node.columnWidths.length + || !node.columnWidths.every((width) => Number.isFinite(width) && width > 0) + || !Array.isArray(node.rows) + || !node.rows.length) { + return false; + } + + const hasValidRows = node.rows.every((row) => ( + hasOnlyFields(row, TABLE_ROW_FIELDS) + && Array.isArray(row.cells) + && row.cells.length > 0 + && row.cells.every(hasValidTableCell) + && Number.isFinite(row.height) + && row.height > 0 + )); + if (!hasValidRows || !hasValidTableGrid(node.rows, node.columnWidths.length)) return false; + const nearlyEqual = (left, right) => ( + Math.abs(left - right) <= Math.max(0.001, Math.abs(right) * 0.0001) + ); + const totalWidth = node.columnWidths.reduce((sum, width) => sum + width, 0); + const totalHeight = node.rows.reduce((sum, row) => sum + row.height, 0); + return nearlyEqual(totalWidth, node.w) && nearlyEqual(totalHeight, node.h); +} + +function readUint32BigEndian(bytes, offset) { + return (((bytes[offset] << 24) >>> 0) + + (bytes[offset + 1] << 16) + + (bytes[offset + 2] << 8) + + bytes[offset + 3]) >>> 0; +} + +function readUint32LittleEndian(bytes, offset) { + return (bytes[offset] + + (bytes[offset + 1] << 8) + + (bytes[offset + 2] << 16) + + ((bytes[offset + 3] << 24) >>> 0)) >>> 0; +} + +function bytesEqualAscii(bytes, offset, value) { + return [...value].every((character, index) => ( + bytes[offset + index] === character.charCodeAt(0) + )); +} + +function pngCrc32(bytes, start, end) { + let crc = 0xFFFFFFFF; + for (let index = start; index < end; index += 1) { + crc ^= bytes[index]; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ ((crc & 1) ? 0xEDB88320 : 0); + } + } + return (crc ^ 0xFFFFFFFF) >>> 0; +} + +function hasValidPngContainer(bytes) { + const signature = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + if (bytes.length < 45 || !signature.every((byte, index) => bytes[index] === byte)) { + return false; + } + let offset = 8; + let chunkIndex = 0; + let foundIdat = false; + let idatClosed = false; + let foundIend = false; + while (offset < bytes.length) { + if (offset + 12 > bytes.length) return false; + const length = readUint32BigEndian(bytes, offset); + const typeOffset = offset + 4; + const dataOffset = offset + 8; + const chunkEnd = dataOffset + length + 4; + if (!Number.isSafeInteger(chunkEnd) || chunkEnd > bytes.length) return false; + const type = String.fromCharCode(...bytes.slice(typeOffset, typeOffset + 4)); + if (pngCrc32(bytes, typeOffset, dataOffset + length) + !== readUint32BigEndian(bytes, dataOffset + length)) return false; + if (chunkIndex === 0) { + if (type !== 'IHDR' || length !== 13) return false; + const width = readUint32BigEndian(bytes, dataOffset); + const height = readUint32BigEndian(bytes, dataOffset + 4); + if (width === 0 || height === 0) return false; + const bitDepth = bytes[dataOffset + 8]; + const colorType = bytes[dataOffset + 9]; + const validDepths = { + 0: new Set([1, 2, 4, 8, 16]), + 2: new Set([8, 16]), + 3: new Set([1, 2, 4, 8]), + 4: new Set([8, 16]), + 6: new Set([8, 16]), + }; + if (!validDepths[colorType]?.has(bitDepth) + || bytes[dataOffset + 10] !== 0 + || bytes[dataOffset + 11] !== 0 + || ![0, 1].includes(bytes[dataOffset + 12])) return false; + } else if (type === 'IHDR') { + return false; + } + if (type === 'PLTE' && foundIdat) return false; + if (type === 'IDAT') { + if (length === 0 || idatClosed) return false; + foundIdat = true; + } else if (foundIdat && type !== 'IEND') { + idatClosed = true; + } + offset = chunkEnd; + chunkIndex += 1; + if (type === 'IEND') { + if (length !== 0 || !foundIdat || offset !== bytes.length) return false; + foundIend = true; + break; + } + } + return foundIend; +} + +const JPEG_SOF_MARKERS = new Set([ + 0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, + 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF, +]); + +function hasValidJpegContainer(bytes) { + if (bytes.length < 8 || bytes[0] !== 0xFF || bytes[1] !== 0xD8) return false; + let offset = 2; + let foundSof = false; + let foundScan = false; + while (offset < bytes.length) { + if (bytes[offset] !== 0xFF) return false; + while (offset < bytes.length && bytes[offset] === 0xFF) offset += 1; + if (offset >= bytes.length) return false; + const marker = bytes[offset]; + offset += 1; + if (marker === 0xD9) return foundSof && foundScan && offset === bytes.length; + if (marker === 0x00 || marker === 0xD8 || (marker >= 0xD0 && marker <= 0xD7)) return false; + if (offset + 2 > bytes.length) return false; + const segmentLength = (bytes[offset] << 8) + bytes[offset + 1]; + if (segmentLength < 2 || offset + segmentLength > bytes.length) return false; + if (JPEG_SOF_MARKERS.has(marker)) { + if (segmentLength < 8) return false; + foundSof = true; + } + offset += segmentLength; + if (marker !== 0xDA) continue; + foundScan = true; + while (offset < bytes.length) { + if (bytes[offset] !== 0xFF) { + offset += 1; + continue; + } + let markerOffset = offset + 1; + while (markerOffset < bytes.length && bytes[markerOffset] === 0xFF) markerOffset += 1; + if (markerOffset >= bytes.length) return false; + const scanMarker = bytes[markerOffset]; + if (scanMarker === 0x00 || (scanMarker >= 0xD0 && scanMarker <= 0xD7)) { + offset = markerOffset + 1; + continue; + } + break; + } + } + return false; +} + +function hasValidWebpContainer(bytes) { + if (bytes.length < 30 + || !bytesEqualAscii(bytes, 0, 'RIFF') + || !bytesEqualAscii(bytes, 8, 'WEBP') + || readUint32LittleEndian(bytes, 4) + 8 !== bytes.length) { + return false; + } + let offset = 12; + let foundImageDataChunk = false; + let foundExtendedHeader = false; + let chunkIndex = 0; + while (offset < bytes.length) { + if (offset + 8 > bytes.length) return false; + const chunkType = String.fromCharCode(...bytes.slice(offset, offset + 4)); + const chunkLength = readUint32LittleEndian(bytes, offset + 4); + const dataEnd = offset + 8 + chunkLength; + const paddedEnd = dataEnd + (chunkLength % 2); + if (!Number.isSafeInteger(paddedEnd) || paddedEnd > bytes.length) return false; + if (['VP8 ', 'VP8L', 'VP8X'].includes(chunkType)) { + const dataOffset = offset + 8; + const validImageChunk = ( + (chunkType === 'VP8 ' && chunkLength >= 10 + && bytes[dataOffset + 3] === 0x9D + && bytes[dataOffset + 4] === 0x01 + && bytes[dataOffset + 5] === 0x2A) + || (chunkType === 'VP8L' && chunkLength >= 5 && bytes[dataOffset] === 0x2F) + || (chunkType === 'VP8X' && chunkLength === 10) + ); + if (!validImageChunk) return false; + if (chunkType === 'VP8X') { + if (chunkIndex !== 0 || foundExtendedHeader) return false; + foundExtendedHeader = true; + } else { + foundImageDataChunk = true; + } + } + offset = paddedEnd; + chunkIndex += 1; + } + return offset === bytes.length && foundImageDataChunk; +} + +function hasValidImagePayload(node) { + if (!hasOnlyFields(node, IMAGE_NODE_FIELDS)) return false; + if (node.intent !== 'user-image') return false; + const imageSources = ['src', 'data'].filter((field) => ( + typeof node[field] === 'string' && Boolean(node[field].trim()) + )); + if (imageSources.length !== 1 || Object.hasOwn(node, 'path')) return false; + const source = node[imageSources[0]].trim(); + const match = source.match( + /^data:image\/(png|jpeg|webp);base64,([A-Za-z0-9+/]+={0,2})$/i, + ); + if (!match || match[2].length % 4 !== 0) return false; + let bytes; + try { + const binary = typeof atob === 'function' + ? atob(match[2]) + : Buffer.from(match[2], 'base64').toString('binary'); + bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + } catch { + return false; + } + const mime = match[1].toLowerCase(); + if (mime === 'png') return hasValidPngContainer(bytes); + if (mime === 'jpeg') return hasValidJpegContainer(bytes); + return hasValidWebpContainer(bytes); +} + +function hasValidCommonNodeFields(node) { + return (node.zIndex === undefined || Number.isSafeInteger(node.zIndex)) + && (node.paintOrder === undefined || Number.isSafeInteger(node.paintOrder)) + && (node.subOrder === undefined || Number.isSafeInteger(node.subOrder)) + && (node.rewrite === undefined || REWRITE_TYPES.has(node.rewrite)) + && (node.order === undefined || (Number.isSafeInteger(node.order) && node.order >= 0)); +} + +function hasValidNodePayload(node) { + if (!hasValidCommonNodeFields(node)) return false; + switch (node.type) { + case 'text': + return hasOnlyFields(node, new Set([ + 'type', 'sourceId', 'x', 'y', 'w', 'h', 'text', 'style', + 'paintOrder', 'subOrder', 'zIndex', 'rewrite', + ])) + && hasValidTextValue(node.text) + && hasValidTextStyle(node.style); + case 'shape': + return hasOnlyFields(node, new Set([ + 'type', 'sourceId', 'x', 'y', 'w', 'h', 'shapeType', 'style', + 'paintOrder', 'subOrder', 'zIndex', 'rewrite', 'order', + ])) + && EDITABLE_SHAPE_TYPES.has(node.shapeType) + && hasValidShapeStyle(node.style, node.shapeType); + case 'line': + return hasOnlyFields(node, new Set([ + 'type', 'sourceId', 'x1', 'y1', 'x2', 'y2', 'style', + 'paintOrder', 'subOrder', 'zIndex', 'rewrite', + ])) + && hasValidLineStyle(node.style); + case 'table': + return hasOnlyFields(node, TABLE_NODE_FIELDS) && hasValidTablePayload(node); + case 'image': + return hasValidImagePayload(node); + default: + return false; + } +} + +export function validateEditableSlideScene(scene) { + if (!scene || Array.isArray(scene) || typeof scene !== 'object') { + throw editableSceneError( + scene, + 'editable_scene_invalid', + 'Editable slide scene must be an object.', + ); + } + const fallbackField = findForbiddenFallbackMetadata(scene, { + skipKeys: new Set(['nodes']), + }); + if (fallbackField) { + throw editableSceneError( + scene, + 'editable_scene_fallback_forbidden', + `Editable slide scene must not contain ${fallbackField}.`, + ); + } + if (!hasOnlyFields(scene, SCENE_FIELDS)) { + throw editableSceneError( + scene, + 'editable_scene_invalid', + 'Editable slide scene contains unknown top-level fields.', + ); + } + + if (!Number.isInteger(scene.slideNumber) || scene.slideNumber <= 0) { + throw editableSceneError( + scene, + 'editable_scene_invalid', + 'Editable slide scene requires a positive integer slideNumber.', + ); + } + if (!Number.isFinite(scene.width) || scene.width <= 0 + || !Number.isFinite(scene.height) || scene.height <= 0) { + throw editableSceneError( + scene, + 'editable_scene_geometry_invalid', + 'Editable slide scene width and height must be finite positive numbers.', + ); + } + if (!Array.isArray(scene.nodes)) { + throw editableSceneError( + scene, + 'editable_scene_invalid', + 'Editable slide scene nodes must be an array.', + ); + } + + scene.nodes.forEach((node, index) => { + const defaultSourceId = `node-${index + 1}`; + if (!node || Array.isArray(node) || typeof node !== 'object') { + throw editableSceneError( + scene, + 'editable_scene_node_invalid', + 'Editable slide scene nodes must be objects.', + defaultSourceId, + ); + } + + const hasValidSourceId = typeof node.sourceId === 'string' && Boolean(node.sourceId.trim()); + const sourceId = hasValidSourceId ? node.sourceId : defaultSourceId; + const nodeFallbackField = findForbiddenFallbackMetadata(node, { + allowDirectData: node.type === 'image', + }); + if (nodeFallbackField) { + throw editableSceneError( + scene, + 'editable_scene_fallback_forbidden', + `Editable scene nodes must not contain ${nodeFallbackField}.`, + sourceId, + ); + } + if (!EDITABLE_NODE_TYPES.has(node.type)) { + throw editableSceneError( + scene, + 'editable_scene_node_type_unsupported', + `Editable scene node type "${String(node.type)}" is not supported.`, + sourceId, + ); + } + if (!hasValidSourceId) { + throw editableSceneError( + scene, + 'editable_scene_source_id_invalid', + 'Editable scene nodes require a non-empty sourceId.', + sourceId, + ); + } + + // Shapes/images/lines may intentionally bleed past the slide origin + // (decorative bands, full-bleed accents). Text/tables stay non-negative. + const allowNegativeOrigin = node.type === 'shape' + || node.type === 'image' + || node.type === 'line'; + const hasValidGeometry = node.type === 'line' + ? hasValidLineGeometry(node, { allowNegativeOrigin }) + : hasValidBoxGeometry(node, { allowNegativeOrigin }); + if (!hasValidGeometry) { + throw editableSceneError( + scene, + 'editable_scene_geometry_invalid', + `Editable scene node "${sourceId}" has invalid geometry.`, + sourceId, + ); + } + if (!hasValidNodePayload(node)) { + throw editableSceneError( + scene, + 'editable_scene_payload_invalid', + `Editable scene node "${sourceId}" has an invalid ${node.type} payload.`, + sourceId, + ); + } + }); + + return scene; +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-deck-browser.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-deck-browser.js index 2c7d6bfe07..d05077f9b2 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-deck-browser.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-deck-browser.js @@ -1,9 +1,7 @@ // ───────────────────────────────────────────────────────────────────────────── // PPTX / PDF / PNG Export Functions // -// exportPptxPrepared() — builds a PPTX deck from prepared slideData + raster -// backgrounds (the main export path for HTML-based slides). -// exportPptxFromDeck() — builds a PPTX from element-model slides (no HTML). +// exportEditablePptx() — the only EditableSlideScene → PPTX serializer entry. // exportPdfFromBase64Pages() — merges rendered PDF pages. // exportPngZipFromPages() — zips rendered PNG slides. // @@ -12,23 +10,13 @@ import { PDFDocument } from 'pdf-lib'; import JSZip from 'jszip'; import { - buildSlideFromExtracted, + buildSlideFromScene, buildSpeakerNotes, createPptxDeck, } from './pptx-html-build.js'; -import { exportElementDeckToPptx } from './pptx-element-export.js'; const MIME_PPTX = 'application/vnd.openxmlformats-officedocument.presentationml.presentation'; -const RASTER_TEXT_TYPES = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'text', 'list', 'merged-text']); - -function filterSlideDataForRasterBackdrop(slideData) { - return { - ...slideData, - elements: (slideData.elements || []).filter((el) => RASTER_TEXT_TYPES.has(el.type)), - }; -} - function uint8ToBase64(bytes) { const chunk = 0x8000; let binary = ''; @@ -51,44 +39,14 @@ async function pptxToExportResult(pptx, deck) { }; } -export async function exportPptxFromDeck(deck) { - const slides = Array.isArray(deck?.slides) ? deck.slides : []; - if (slides.some((slide) => slide?.html)) { - throw new Error('HTML slides must use the WebView prepare export path.'); - } - const pptx = await exportElementDeckToPptx(deck); - return pptxToExportResult(pptx, deck); -} - -export async function exportPptxPrepared(deck, preparedSlides) { - const prepared = Array.isArray(preparedSlides) ? preparedSlides : []; - if (!prepared.length) throw new Error('No prepared slides to export'); +export async function exportEditablePptx(deck, scenes) { + const prepared = Array.isArray(scenes) ? scenes : []; + if (!prepared.length) throw new Error('No editable slide scenes to export'); const pptx = createPptxDeck(deck); const slides = Array.isArray(deck?.slides) ? deck.slides : []; - for (const item of prepared) { - const sourceSlide = slides[item.index] || item.notes || {}; - let slideData = item.slideData; - if (item.rasterBase64) { - const raw = String(item.rasterBase64).replace(/^data:.*;base64,/, ''); - if (item.rasterOnly) { - slideData = { - ...slideData, - elements: (slideData.elements || []).filter((el) => !RASTER_TEXT_TYPES.has(el.type)), - background: { type: 'image', path: `data:image/png;base64,${raw}` }, - }; - } else { - slideData = filterSlideDataForRasterBackdrop(slideData); - slideData = { - ...slideData, - background: { type: 'image', path: `data:image/png;base64,${raw}` }, - }; - } - } - const result = await buildSlideFromExtracted( - slideData, - item.bodyDimensions, - pptx, - ); + for (const [index, scene] of prepared.entries()) { + const sourceSlide = slides[index] || {}; + const result = await buildSlideFromScene(scene, pptx); const notes = buildSpeakerNotes(sourceSlide); if (notes && result?.slide && typeof result.slide.addNotes === 'function') { result.slide.addNotes(notes); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-deck-host.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-deck-host.js index 669d25223d..9d0ec9f9e6 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-deck-host.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-deck-host.js @@ -2,8 +2,7 @@ // build-bitfun.mjs (esbuild, bundle:true) resolves all npm dependencies // (pptxgenjs, pdf-lib, jszip) at final bundle time. export { - exportPptxFromDeck, - exportPptxPrepared, + exportEditablePptx, exportPdfFromBase64Pages, exportPngZipFromPages, } from './export-deck-browser.js'; diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-diagnostics.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-diagnostics.js index 95c71005af..106d350d17 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-diagnostics.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-diagnostics.js @@ -1,35 +1,33 @@ const DIAGNOSTIC_REASONS = { 'en-US': { active_content_removed: 'Unsafe active content was removed.', - canvas_overflow: 'Slide content exceeded the canvas; visual fallback was used.', - canvas_size: 'Slide dimensions could not preserve native fidelity; visual fallback was used.', - text_out_of_bounds: 'Text exceeded the slide boundary; visual fallback was used.', - bottom_safety_margin: 'Text entered the bottom safety margin; visual fallback was used.', - css_gradient: 'A CSS gradient required visual fallback.', - css_filter: 'A CSS filter required visual fallback.', - generated_content: 'Generated CSS content required visual fallback.', - page_visual_fallback: 'A page visual fallback preserved the slide appearance.', - full_page_fallback: 'A full-page visual fallback preserved the slide appearance.', + canvas_overflow: 'Slide content exceeds the editable canvas.', + canvas_size: 'Slide dimensions do not match the editable canvas.', + text_out_of_bounds: 'Text exceeds the slide boundary.', + bottom_safety_margin: 'Text enters the bottom safety margin.', + css_gradient: 'A CSS gradient was rewritten as editable solid strips.', + svg_path_rewrite: 'An SVG path was rewritten as editable line segments.', + css_box_shadow_ring: 'A CSS ring box-shadow was rewritten as a concentric editable shape.', + box_shadow_unsupported: 'This CSS box-shadow cannot be represented as editable PowerPoint geometry.', + manual_bullet_list: 'Manual bullets were rewritten as an editable list.', unreadable_document: 'The slide document could not be read.', unmeasurable_canvas: 'The slide canvas could not be measured.', pptx_serialization: 'The slide could not be serialized to PPTX.', - full_page_raster_failed: 'The final visual fallback could not be rendered.', }, 'zh-CN': { active_content_removed: '已移除不安全的活动内容。', - canvas_overflow: '页面内容超出幻灯片边界,已切换视觉兜底。', - canvas_size: '页面尺寸无法保证原生保真,已切换视觉兜底。', - text_out_of_bounds: '文字超出幻灯片边界,已切换视觉兜底。', - bottom_safety_margin: '文字进入底部安全边距,已切换视觉兜底。', - css_gradient: 'CSS 渐变已使用视觉兜底。', - css_filter: 'CSS 滤镜已使用视觉兜底。', - generated_content: 'CSS 生成内容已使用视觉兜底。', - page_visual_fallback: '已使用页面视觉兜底保留外观。', - full_page_fallback: '已使用整页视觉兜底保留外观。', + canvas_overflow: '页面内容超出可编辑幻灯片边界。', + canvas_size: '页面尺寸与可编辑幻灯片画布不一致。', + text_out_of_bounds: '文字超出幻灯片边界。', + bottom_safety_margin: '文字进入底部安全边距。', + css_gradient: 'CSS 渐变已重写为可编辑纯色条带。', + svg_path_rewrite: 'SVG 路径已重写为可编辑线段。', + css_box_shadow_ring: 'CSS 环形 box-shadow 已重写为同心可编辑形状。', + box_shadow_unsupported: '该 CSS box-shadow 无法表示为可编辑的 PowerPoint 几何。', + manual_bullet_list: '手工项目符号已重写为可编辑列表。', unreadable_document: '无法读取幻灯片文档。', unmeasurable_canvas: '无法测量幻灯片画布。', pptx_serialization: '无法将幻灯片序列化为 PPTX。', - full_page_raster_failed: '最终视觉兜底渲染失败。', }, }; @@ -48,10 +46,9 @@ export function formatLocalizedExportDiagnostic(diagnostic = {}, locale = 'en-US const reason = DIAGNOSTIC_REASONS[resolvedLocale][diagnostic.code] || UNKNOWN_REASON[resolvedLocale]; return { - slideNumber: Number.isFinite(diagnostic.slideNumber) ? diagnostic.slideNumber : diagnostic.slideNumber, + slideNumber: diagnostic.slideNumber, sourceId: sanitizeDiagnosticSourceId(diagnostic.sourceId), - phase: String(diagnostic.phase || '').replace(/[^a-z-]/g, '').slice(0, 32) || null, - severity: diagnostic.severity === 'blocking' ? 'blocking' : diagnostic.severity, + severity: diagnostic.severity === 'blocking' ? 'blocking' : 'rewrite', code: String(diagnostic.code || 'unknown').replace(/[^a-z0-9_-]/gi, '').slice(0, 64), reason: reason.slice(0, 120), }; @@ -61,73 +58,38 @@ export function localizeExportDiagnosticLocations(locations = [], locale = 'en-U return locations.map((location) => formatLocalizedExportDiagnostic(location, locale)); } -export function summarizePptxExportDiagnostics(preparedSlides = []) { - const counts = { - repaired: 0, - svgImage: 0, - localPng: 0, - pageVisual: 0, - fullPage: 0, - blocking: 0, - }; +export function summarizePptxExportDiagnostics(scenes = []) { + const counts = { rewritten: 0, blocking: 0 }; const locations = []; const seen = new Set(); - const addLocation = (slideNumber, item, defaults = {}) => { + const add = (slideNumber, diagnostic) => { const location = { slideNumber, - sourceId: item?.sourceId || defaults.sourceId || null, - phase: item?.phase || defaults.phase || null, - severity: item?.severity || defaults.severity || 'fallback', - code: item?.code || defaults.code || null, - reason: item?.reason || item?.message || defaults.reason || null, + sourceId: diagnostic.sourceId || null, + severity: diagnostic.severity === 'blocking' ? 'blocking' : 'rewrite', + code: diagnostic.code || diagnostic.rewrite || null, }; - const key = [ - location.slideNumber, - location.sourceId, - location.phase, - location.severity, - location.code, - ].join(':'); + const key = `${location.slideNumber}:${location.sourceId}:${location.severity}:${location.code}`; if (seen.has(key)) return; seen.add(key); locations.push(location); }; - - preparedSlides.forEach((prepared, arrayIndex) => { - const slideNumber = (prepared?.index ?? arrayIndex) + 1; - const slideData = prepared?.slideData || {}; - (slideData.diagnostics || []).forEach((diagnostic) => { - if (diagnostic.severity === 'repaired') counts.repaired += 1; - if (diagnostic.severity === 'blocking') counts.blocking += 1; - if (diagnostic.severity === 'repaired' || diagnostic.severity === 'blocking' - || diagnostic.phase || diagnostic.sourceId) { - addLocation(slideNumber, diagnostic); - } - }); - (slideData.fallbackLayers || []).forEach((layer) => { - if (layer.kind === 'svg-image') counts.svgImage += 1; - if (layer.kind === 'raster' && layer.phase === 'local-visual') counts.localPng += 1; - if (layer.kind === 'raster' && layer.phase === 'page-visual') counts.pageVisual += 1; - addLocation(slideNumber, layer, { - severity: 'fallback', - code: layer.kind === 'svg-image' ? 'svg_image_fallback' : 'png_visual_fallback', + scenes.forEach((scene, index) => { + const slideNumber = scene?.slideNumber || index + 1; + (scene?.nodes || []).forEach((node) => { + if (!node.rewrite) return; + counts.rewritten += 1; + add(slideNumber, { + sourceId: node.sourceId, + severity: 'rewrite', + code: node.rewrite, }); }); - if (slideData.fullPageFallback) { - counts.fullPage += 1; - addLocation(slideNumber, slideData.fullPageFallback, { - severity: 'fallback', - code: 'full_page_fallback', - phase: 'full-page', - }); - } }); - return { counts, locations, - hasWarnings: counts.repaired + counts.svgImage + counts.localPng - + counts.pageVisual + counts.fullPage > 0, + hasWarnings: counts.rewritten > 0, hasBlocking: counts.blocking > 0, }; } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-slide-browser.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-slide-browser.js index fc6b5679e0..7340fa0ffd 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-slide-browser.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/export-slide-browser.js @@ -1,72 +1,27 @@ // ───────────────────────────────────────────────────────────────────────────── // Slide Preparation Orchestrator // -// prepareSlidesForPptxExport() is the entry point for the PPTX export pipeline: +// prepareEditableSlides() is the entry point for the PPTX export pipeline: // 1. Mount slide HTML in an off-screen shadow-DOM div (1280×720) // 2. sanitizeSlideDocumentRoot() — normalize/repair the HTML for export -// 3. extractSlideDataFromDocument() — walk DOM → structured slideData -// 4. Rasterize isolated local visual layers when native/SVG mapping is unsafe -// 5. Escalate failed local captures to page-visual, then full-page fallback +// 3. normalizeDocumentToEditableScene() — rewrite DOM into EditableSlideScene +// 4. Reject any input that cannot be represented as editable native objects // -// The prepared slideData is then passed to export-deck-browser.js → -// pptx-html-build.js for the actual PPTX generation. +// The scenes are passed to export-deck-browser.js → pptx-html-build.js. // ───────────────────────────────────────────────────────────────────────────── import { normalizeSlideDocument, scopeSlideAuthorStyles } from './render.js'; import { sanitizeSlideDocument, sanitizeSlideMarkup } from './sanitize-slide-markup.js'; import { sanitizeSlideDocumentRoot } from './sanitize-slide-html.js'; import { extractSlideDataFromDocument, measureBodyDimensions } from './html2pptx-dom-core.js'; import { buildElementSlideHtml } from './element-model-html.js'; -import { - buildPageVisualFallbackRequest, - buildRasterFallbackRequests, - buildWholePageVisualFallbackRequest, - renderRasterFallbackPlan, -} from './fallback-layer-render.js'; +import { normalizeDocumentToEditableScene } from './editable-slide-normalize.js'; +import { normalizeElementSlideToEditableScene } from './pptx-element-export.js'; +import { auditRawSlideForEditableExport } from './preflight-slide-audit.js'; export { buildElementSlideHtml }; export const EXPORT_VIEWPORT = { width: 1280, height: 720 }; -const RASTER_TEXT_TYPES = new Set(['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'text', 'list', 'merged-text']); -const EDITABLE_TEXT_TYPES = new Set([...RASTER_TEXT_TYPES, 'svg-text']); - -export function countVectorTextElements(slideData) { - return (slideData?.elements || []).filter((el) => RASTER_TEXT_TYPES.has(el.type)).length; -} - -/** - * HTML for host WebView raster capture. Hides ALL text via universal CSS so - * the raster contains only visual elements (backgrounds, borders, images). - * The vector layer overlays editable text extracted from the slide. - * - * Previously used tag-based selective hiding which caused text overlap - * (missed inline tags like b/strong/i/em) and text loss (over-broad - * div/td selectors hid unextracted text). Universal hiding eliminates - * both classes of bugs. - */ -export function slideHtmlForRasterBackdrop(html) { - const markup = normalizeSlideDocument(html); - if (markup.includes('data-pptx-raster="1"') && markup.includes('pptx-raster-hide-text')) { - return markup; - } - const hideCss = `body[data-pptx-raster="1"], body[data-pptx-raster="1"] * { - color: transparent !important; - -webkit-text-fill-color: transparent !important; - text-shadow: none !important; -} -body[data-pptx-raster="1"] ::marker { - color: transparent !important; - -webkit-text-fill-color: transparent !important; -}`; - const styleTag = ``; - if (/<\/head>/i.test(markup)) { - return markup - .replace(/<\/head>/i, `${styleTag}`) - .replace(/ `${item.code}:${item.sourceId || ''}`)); - const add = (code, message, element = null, severity = 'fallback') => { + const add = (code, message, element = null, severity = 'blocking') => { const sourceId = element?.dataset?.pptxSourceId || element?.id || null; const key = `${code}:${sourceId || ''}`; if (seen.has(key)) return; @@ -270,7 +224,7 @@ export function analyzeMountedSlideForPptx(doc, source = '') { if (bodyRect) { if (Math.abs(bodyRect.width - EXPORT_VIEWPORT.width) > 2 || Math.abs(bodyRect.height - EXPORT_VIEWPORT.height) > 2) { - add('canvas_size', 'The slide canvas size requires page visual fallback.', body); + add('canvas_size', 'The slide canvas size does not match the editable export canvas.', body); } const dimensions = measureBodyDimensions(doc); if (dimensions.errors?.length) { @@ -306,283 +260,44 @@ export async function validateSlideForPptxGeneration(html) { } } -async function validateSlideForPptxGenerationLegacy(html) { - const source = String(html || '').trim(); - const issues = []; - const seen = new Set(); - const add = (code, message, element = null, severity = 'fallback') => { - const suffix = element ? ` (${elementLabel(element)})` : ''; - const key = `${code}:${message}${suffix}`; - if (seen.has(key)) return; - seen.add(key); - issues.push({ - severity, - code, - message: `${message}${suffix}`, - sourceId: element?.dataset?.pptxSourceId || element?.id || null, - tag: element?.tagName?.toLowerCase?.() || null, - }); - }; - - if (!source || !/<\/html>\s*$/i.test(source)) { - add('incomplete_html', 'The slide must be a complete HTML document ending with .'); - } - if (/ element.dataset.pptxSourceId).filter(Boolean), - ); - let sourceSequence = 1; - sourceElements.forEach((element) => { - if (element.dataset.pptxSourceId) return; - while (usedSourceIds.has(`pptx-source-${sourceSequence}`)) sourceSequence += 1; - const sourceId = `pptx-source-${sourceSequence}`; - element.dataset.pptxSourceId = sourceId; - usedSourceIds.add(sourceId); - sourceSequence += 1; - }); - const bodyRect = body.getBoundingClientRect(); - const bodyDimensions = measureBodyDimensions(doc); - bodyDimensions.errors.forEach((message) => add('canvas_overflow', message)); - - const expectedWidth = EXPORT_VIEWPORT.width; - const expectedHeight = EXPORT_VIEWPORT.height; - if (Math.abs(bodyRect.width - expectedWidth) > 2 || Math.abs(bodyRect.height - expectedHeight) > 2) { - add( - 'canvas_size', - `Computed canvas must be 960pt x 540pt (${expectedWidth}px x ${expectedHeight}px); got ${bodyRect.width.toFixed(1)}px x ${bodyRect.height.toFixed(1)}px.`, - ); - } - - body.querySelectorAll('div').forEach((div) => { - const computed = view.getComputedStyle(div); - if (computed.backgroundImage && computed.backgroundImage !== 'none') { - add('div_background_image', 'DIV background-image is unsupported; use an img element.', div); - } - }); - - const textSelector = 'p,h1,h2,h3,h4,h5,h6,li'; - body.querySelectorAll(textSelector).forEach((element) => { - const computed = view.getComputedStyle(element); - if (!isTransparentColor(computed.backgroundColor) - || (computed.backgroundImage && computed.backgroundImage !== 'none') - || hasVisibleBorder(computed) - || (computed.boxShadow && computed.boxShadow !== 'none')) { - add( - 'decorated_text_element', - 'Background, border, image, and shadow styling must be on an enclosing DIV shape.', - element, - ); - } - - const rect = element.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return; - if (rect.left < bodyRect.left - 1 - || rect.top < bodyRect.top - 1 - || rect.right > bodyRect.right + 1 - || rect.bottom > bodyRect.bottom + 1) { - add('text_out_of_bounds', 'A text element extends outside the slide canvas.', element); - } - if (parseFloat(computed.fontSize || 0) > 12 && rect.bottom > bodyRect.bottom - 48) { - add('bottom_safety_margin', 'Text larger than 12px must keep a 36pt bottom safety margin.', element); - } - }); - - body.querySelectorAll('span,em,strong,b,i,u,a,small,mark,sub,sup,code').forEach((element) => { - const computed = view.getComputedStyle(element); - const hasBoxSpacing = [ - 'marginTop', 'marginRight', 'marginBottom', 'marginLeft', - 'paddingTop', 'paddingRight', 'paddingBottom', 'paddingLeft', - ].some((prop) => parseFloat(computed[prop] || 0) > 0); - if (hasBoxSpacing - || !isTransparentColor(computed.backgroundColor) - || (computed.backgroundImage && computed.backgroundImage !== 'none') - || hasVisibleBorder(computed) - || (computed.boxShadow && computed.boxShadow !== 'none')) { - add('unsafe_inline_style', 'Inline text elements cannot carry box spacing, fills, borders, or shadows.', element); - } - }); - - body.querySelectorAll('*').forEach((element) => { - const computed = view.getComputedStyle(element); - if (String(computed.backgroundImage || '').includes('gradient')) { - add('computed_gradient', 'Computed CSS contains an unsupported gradient.', element); - } - for (const pseudo of ['::before', '::after']) { - try { - const content = view.getComputedStyle(element, pseudo)?.content; - if (content && content !== 'none' && content !== 'normal' && content !== '""') { - add('generated_content', `${pseudo} generated text/content is unsupported for editable PPTX.`, element); - } - } catch { - // Some WebViews do not expose pseudo-element computed styles. - } - } - }); - - try { - const slideData = extractSlideDataFromDocument(doc); - (slideData.diagnostics || []).forEach((diagnostic) => { - const key = `${diagnostic.code}:${diagnostic.message}:${diagnostic.sourceId || ''}`; - if (seen.has(key)) return; - seen.add(key); - issues.push(diagnostic); - }); - } catch (error) { - add( - 'pptx_serialization', - String(error?.message || error || 'PPTX conversion validation failed.'), - null, - 'blocking', - ); - } - } finally { - if (exportRoot) removeExportRoot(exportRoot); - } - - return { - valid: issues.length === 0, - issues: issues.slice(0, 32), - }; -} - -async function prepareSlideOnce(html, aggressive, options = {}) { - let exportRoot = null; - try { - const doc = await loadHtmlInExportRoot(html); - exportRoot = doc._exportRoot; - const repairResult = sanitizeSlideDocumentRoot(doc, aggressive); + auditRawSlideForEditableExport(html, slideNumber); + const mountedDoc = await loadHtmlInExportRoot(html); + exportRoot = mountedDoc._exportRoot; + sanitizeSlideDocumentRoot(mountedDoc); await waitForExportPaint(); - - const bodyDimensions = measureBodyDimensions(doc); - const slideData = extractSlideDataFromDocument(doc); - const analysis = analyzeMountedSlideForPptx(doc, html); - const mergedDiagnostics = [ - ...(analysis.issues || []), - ...(repairResult?.diagnostics || []), - ...(slideData.diagnostics || []), - ]; - const diagnosticKeys = new Set(); - const diagnostics = mergedDiagnostics.filter((diagnostic) => { - const key = `${diagnostic.severity}:${diagnostic.code}:${diagnostic.sourceId || ''}`; - if (diagnosticKeys.has(key)) return false; - diagnosticKeys.add(key); - return true; + extractSlideDataFromDocument(mountedDoc); + return normalizeDocumentToEditableScene(mountedDoc, { + slideNumber, + width: EXPORT_VIEWPORT.width / 96, + height: EXPORT_VIEWPORT.height / 96, }); - slideData.diagnostics = diagnostics; - const rasterRequests = buildRasterFallbackRequests(doc, diagnostics); - const pageFallbackCodes = new Set([ - 'canvas_size', 'canvas_overflow', 'text_out_of_bounds', 'bottom_safety_margin', - ]); - const pageFallbackDiagnostics = diagnostics.filter((item) => pageFallbackCodes.has(item.code)); - const nativeVisualSourceIds = [...new Set( - (slideData.elements || []) - .filter((element) => !EDITABLE_TEXT_TYPES.has(element.type)) - .map((element) => element.sourceId) - .filter(Boolean), - )]; - const pageVisualRequest = pageFallbackDiagnostics.length - ? buildWholePageVisualFallbackRequest( - doc, - pageFallbackDiagnostics, - nativeVisualSourceIds, - ) - : buildPageVisualFallbackRequest(doc, rasterRequests); - const overflowWarnings = bodyDimensions.errors || []; - const safeBodyDimensions = { ...bodyDimensions, errors: [] }; - const blocking = diagnostics.filter((diagnostic) => diagnostic.severity === 'blocking'); - if (!blocking.length || options.allowValidationErrors) { - return { - slideData, - bodyDimensions: safeBodyDimensions, - diagnostics, - rasterRequests, - pageVisualRequest, - aggressive, - warnings: overflowWarnings, - }; - } - const error = new Error(blocking.map((diagnostic) => diagnostic.message).join('\n')); - error.diagnostics = blocking; - return { error }; } finally { if (exportRoot) removeExportRoot(exportRoot); } } -export async function prepareSlideForPptxExport(html, options = {}) { - const first = await prepareSlideOnce(html, false, options); - if (first?.slideData) return first; - - const second = await prepareSlideOnce(html, true, options); - if (second?.slideData) return second; - throw second?.error || first?.error || new Error('PPT Live slide preparation failed'); -} - -export async function prepareSlidesForPptxExport(slides, options = {}) { - const prepared = []; +export async function prepareEditableSlides(slides, options = {}) { + const scenes = []; try { for (const [index, slide] of slides.entries()) { - if (!slide?.html) continue; - const item = await prepareSlideForPptxExport(slide.html, options); - if (item.rasterRequests?.length && typeof options.onRasterProgress === 'function') { - options.onRasterProgress(index, slide); + if (typeof options.onSlideProgress === 'function') { + options.onSlideProgress(index + 1, slide); } - const rasterResult = await renderRasterFallbackPlan({ - localRequests: item.rasterRequests || [], - pageVisualRequest: item.pageVisualRequest, - fullPageRequest: { - sourceId: `slide-${index + 1}`, - zIndex: 0, - paintOrder: 0, - kind: 'raster', - phase: 'full-page', - bbox: { x: 0, y: 0, w: 13.333, h: 7.5 }, - buildHtml: () => slideExportHtml(slide), - diagnostics: [], - }, - }, options.renderRaster, index); - if (rasterResult.blocking) { - const error = new Error( - rasterResult.diagnostics.map((diagnostic) => diagnostic.message).join('\n') - || `Slide ${index + 1} fallback rendering failed`, - ); - error.diagnostics = rasterResult.diagnostics; - throw error; - } - item.slideData.fallbackLayers = [ - ...(item.slideData.fallbackLayers || []), - ...rasterResult.layers, - ]; - item.slideData.fullPageFallback = rasterResult.fullPageFallback; - item.slideData.diagnostics = [ - ...(item.slideData.diagnostics || []), - ...rasterResult.diagnostics, - ]; - prepared.push({ - index, - slideId: slide.id, - notes: slide, - ...item, - fallbackDiagnostics: rasterResult.diagnostics, - }); + scenes.push(slide?.html + ? await prepareHtmlSlide(slide.html, index + 1) + : normalizeElementSlideToEditableScene(slide, { slideNumber: index + 1 })); + } + return scenes; + } catch (error) { + if (error?.diagnostic && !error.diagnostics) { + error.diagnostic.severity = 'blocking'; + error.diagnostic.kind = 'blocking'; + error.diagnostics = [error.diagnostic]; } - return prepared; + throw error; } finally { clearExportSessionHost(); } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/fallback-layer-render.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/fallback-layer-render.js deleted file mode 100644 index a1697bf577..0000000000 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/fallback-layer-render.js +++ /dev/null @@ -1,516 +0,0 @@ -import { buildDomPaintOrderMap } from './paint-order.js'; - -const EXPORT_WIDTH = 1280; -const EXPORT_HEIGHT = 720; -const LOCAL_RASTER_CODES = new Set([ - 'css_gradient', - 'css_filter', - 'computed_gradient', - 'generated_content', - 'container_background_image', - 'merge_background_image', - 'complex_svg_raster', -]); - -function rasterLayerZIndex(element, view) { - let current = element; - while (current) { - const raw = view.getComputedStyle(current).zIndex; - if (raw && raw !== 'auto') { - const parsed = parseInt(raw, 10); - if (Number.isFinite(parsed)) return parsed; - } - current = current.parentElement; - } - return 0; -} - -function escapedAttributeValue(value) { - return String(value).replace(/["\\]/g, '\\$&'); -} - -function serializeRasterTargetDocument(doc, targetSpecs) { - const targets = (Array.isArray(targetSpecs) ? targetSpecs : [targetSpecs]).map((target) => ( - typeof target === 'string' - ? { sourceId: target, captureStrategy: 'visual-subtree' } - : target - )); - const sourceRoot = doc._exportRoot || doc.documentElement; - const sourceBody = doc.body; - const bodyClone = sourceBody.cloneNode(true); - let targetCount = 0; - targets.forEach(({ sourceId, captureStrategy }) => { - const target = bodyClone.querySelector( - `[data-pptx-source-id="${escapedAttributeValue(sourceId)}"]`, - ) || (bodyClone.dataset?.pptxSourceId === sourceId ? bodyClone : null); - if (!target) return; - target.setAttribute('data-pptx-raster-target', '1'); - target.setAttribute('data-pptx-capture-strategy', captureStrategy); - targetCount += 1; - }); - if (!targetCount) return null; - - const authorStyles = [...sourceRoot.querySelectorAll('style')] - .map((style) => style.textContent || '') - .join('\n'); - const bodyAttributes = [...bodyClone.attributes] - .filter((attribute) => !['class', 'style'].includes(attribute.name)) - .map((attribute) => ` ${attribute.name}="${String(attribute.value).replace(/"/g, '"')}"`) - .join(''); - const isolationCss = ` -html, body, .ppt-export-root, .ppt-export-body { - margin: 0 !important; - padding: 0 !important; - width: ${EXPORT_WIDTH}px !important; - height: ${EXPORT_HEIGHT}px !important; - overflow: hidden !important; - background: transparent !important; - background-color: transparent !important; -} -.ppt-export-body [data-pptx-source-id] { - visibility: hidden !important; -} -.ppt-export-body [data-pptx-raster-target="1"], -.ppt-export-body [data-pptx-capture-strategy="visual-subtree"] * { - visibility: visible !important; -} -.ppt-export-body [data-pptx-capture-strategy="self-decoration"] > *, -.ppt-export-body [data-pptx-capture-strategy="pseudo-only"] > * { - visibility: hidden !important; -} -.ppt-export-body [data-pptx-capture-strategy="pseudo-only"] { - background: none !important; - background-image: none !important; - border-color: transparent !important; - box-shadow: none !important; - filter: none !important; -} -.ppt-export-body [data-pptx-raster-target="1"] :is(p,h1,h2,h3,h4,h5,h6,li,span,a,small,label,code,b,strong,i,em,u,mark,sub,sup), -.ppt-export-body [data-pptx-raster-target="1"]:is(p,h1,h2,h3,h4,h5,h6,li,span,a,small,label,code,b,strong,i,em,u,mark,sub,sup) { - color: transparent !important; - -webkit-text-fill-color: transparent !important; - text-shadow: none !important; -} -.ppt-export-body [data-pptx-raster-target="1"] ::marker { - color: transparent !important; - -webkit-text-fill-color: transparent !important; -} -.ppt-export-body [data-pptx-raster-target="1"] svg text, -.ppt-export-body svg[data-pptx-raster-target="1"] text { - visibility: hidden !important; -} -`; - return `
    ${bodyClone.innerHTML}
    `; -} - -export function buildRasterFallbackRequests(doc, diagnostics = []) { - const bodyRect = doc.body.getBoundingClientRect(); - const view = doc.defaultView || globalThis.window; - const sourceOrder = buildDomPaintOrderMap(doc); - const grouped = new Map(); - diagnostics.forEach((diagnostic) => { - if (!LOCAL_RASTER_CODES.has(diagnostic.code) || !diagnostic.sourceId) return; - const captureStrategy = diagnostic.code === 'generated_content' - ? 'pseudo-only' - : (['css_filter', 'complex_svg_raster'].includes(diagnostic.code) - ? 'visual-subtree' - : 'self-decoration'); - const key = `${diagnostic.sourceId}:${captureStrategy}`; - if (!grouped.has(key)) { - grouped.set(key, { - sourceId: diagnostic.sourceId, - captureStrategy, - diagnostics: [], - }); - } - grouped.get(key).diagnostics.push(diagnostic); - }); - const requests = []; - grouped.forEach(({ sourceId, captureStrategy, diagnostics: sourceDiagnostics }) => { - const element = doc.body.dataset?.pptxSourceId === sourceId - ? doc.body - : doc.body.querySelector( - `[data-pptx-source-id="${escapedAttributeValue(sourceId)}"]`, - ); - const failureRequest = (code, reason, details = {}) => ({ - sourceId, - zIndex: details.zIndex ?? 0, - paintOrder: sourceOrder.get(sourceId) ?? requests.length, - subOrder: 0, - kind: 'raster', - phase: 'local-visual', - canvas: 'full-page', - captureStrategy, - suppressedNativeVisualIds: [], - bbox: details.bbox || { x: 0, y: 0, w: 0, h: 0 }, - diagnostics: sourceDiagnostics, - buildFailure: { - code, - stage: 'request-build', - reason, - }, - }); - if (!element) { - requests.push(failureRequest( - 'local_raster_target_missing', - `Raster fallback source "${sourceId}" is not present in the export document.`, - )); - return; - } - let rect; - try { - rect = element.getBoundingClientRect(); - } catch (error) { - requests.push(failureRequest( - 'local_raster_serialize_failed', - `Raster fallback source "${sourceId}" could not be measured: ${String(error?.message || error)}`, - { zIndex: rasterLayerZIndex(element, view) }, - )); - return; - } - const bbox = { - x: (rect.left - bodyRect.left) / 96, - y: (rect.top - bodyRect.top) / 96, - w: rect.width / 96, - h: rect.height / 96, - }; - if (!(rect.width > 0) || !(rect.height > 0)) { - requests.push(failureRequest( - 'local_raster_unmeasurable', - `Raster fallback source "${sourceId}" has no measurable width or height.`, - { zIndex: rasterLayerZIndex(element, view), bbox }, - )); - return; - } - try { - if (!serializeRasterTargetDocument(doc, { sourceId, captureStrategy })) { - throw new Error('Raster target was not retained in the export document.'); - } - } catch (error) { - requests.push(failureRequest( - 'local_raster_serialize_failed', - `Raster fallback source "${sourceId}" could not be serialized: ${String(error?.message || error)}`, - { zIndex: rasterLayerZIndex(element, view), bbox }, - )); - return; - } - const suppressedNativeVisualIds = captureStrategy === 'pseudo-only' - ? [] - : (captureStrategy === 'self-decoration' - ? [sourceId] - : [element, ...element.querySelectorAll('[data-pptx-source-id]')] - .map((item) => item.dataset?.pptxSourceId) - .filter(Boolean)); - requests.push({ - sourceId, - zIndex: rasterLayerZIndex(element, view), - paintOrder: sourceOrder.get(sourceId) ?? requests.length, - subOrder: 0, - kind: 'raster', - phase: 'local-visual', - canvas: 'full-page', - captureStrategy, - suppressedNativeVisualIds: [...new Set(suppressedNativeVisualIds)], - bbox, - buildHtml: () => serializeRasterTargetDocument(doc, { sourceId, captureStrategy }), - diagnostics: sourceDiagnostics, - }); - }); - return requests.sort((a, b) => a.zIndex - b.zIndex || a.paintOrder - b.paintOrder); -} - -export function buildPageVisualFallbackRequest(doc, localRequests = []) { - const sourceIds = [...new Set(localRequests.map((request) => request.sourceId).filter(Boolean))]; - if (!sourceIds.length) return null; - const targetSpecs = localRequests.map((request) => ({ - sourceId: request.sourceId, - captureStrategy: request.captureStrategy, - })); - const request = { - sourceId: 'slide-visuals', - sourceIds, - captureStrategy: 'page-visual', - suppressedNativeVisualIds: [...new Set( - localRequests.flatMap((request) => request.suppressedNativeVisualIds || []), - )], - zIndex: Math.min(...localRequests.map((request) => request.zIndex ?? 0)), - paintOrder: Math.min(...localRequests.map((request) => request.paintOrder ?? 0)), - subOrder: Math.min(...localRequests.map((request) => request.subOrder ?? 0)), - kind: 'raster', - phase: 'page-visual', - canvas: 'full-page', - bbox: { x: 0, y: 0, w: 13.333, h: 7.5 }, - diagnostics: localRequests.flatMap((request) => request.diagnostics || []), - }; - const missingSourceIds = localRequests - .filter((localRequest) => localRequest.buildFailure?.code === 'local_raster_target_missing') - .map((localRequest) => localRequest.sourceId); - if (missingSourceIds.length) { - return { - ...request, - buildFailure: { - code: 'page_visual_target_missing', - stage: 'request-build', - reason: `Page-visual fallback cannot cover missing sources: ${missingSourceIds.join(', ')}.`, - }, - }; - } - try { - if (!serializeRasterTargetDocument(doc, targetSpecs)) { - return { - ...request, - buildFailure: { - code: 'page_visual_target_missing', - stage: 'request-build', - reason: `Page-visual fallback could not locate any requested sources: ${sourceIds.join(', ')}.`, - }, - }; - } - return { - ...request, - buildHtml: () => serializeRasterTargetDocument(doc, targetSpecs), - }; - } catch (error) { - return { - ...request, - buildFailure: { - code: 'page_visual_serialize_failed', - stage: 'request-build', - reason: `Page-visual fallback could not be serialized: ${String(error?.message || error)}`, - }, - }; - } -} - -export function buildWholePageVisualFallbackRequest( - doc, - diagnostics = [], - suppressedNativeVisualIds = [], -) { - const bodySourceId = doc?.body?.dataset?.pptxSourceId; - if (!bodySourceId) { - return { - sourceId: 'slide-visuals', - phase: 'page-visual', - kind: 'raster', - bbox: { x: 0, y: 0, w: 13.333, h: 7.5 }, - diagnostics, - suppressedNativeVisualIds: [...new Set(suppressedNativeVisualIds)], - buildFailure: { - code: 'page_visual_target_missing', - stage: 'request-build', - reason: 'Whole-page visual source is unavailable.', - }, - }; - } - const request = buildPageVisualFallbackRequest(doc, [{ - sourceId: bodySourceId, - captureStrategy: 'visual-subtree', - suppressedNativeVisualIds: [...new Set(suppressedNativeVisualIds)], - zIndex: 0, - paintOrder: 0, - subOrder: 0, - diagnostics, - }]); - return { - ...request, - sourceId: 'slide-visuals', - sourceIds: [bodySourceId], - captureStrategy: 'whole-page-visual', - }; -} - -export async function renderRasterFallbackLayers(requests, renderRaster, slideIndex) { - const layers = []; - const failures = []; - if (typeof renderRaster !== 'function') { - return { layers, failures: [...requests] }; - } - for (const request of requests) { - if (request.buildFailure) { - failures.push({ - ...request, - error: request.buildFailure.reason, - }); - continue; - } - try { - const html = typeof request.buildHtml === 'function' ? request.buildHtml() : request.html; - if (!html) throw new Error('Raster fallback HTML could not be generated'); - const rendered = await renderRaster(html, slideIndex, { - sourceId: request.sourceId, - bbox: request.bbox, - zIndex: request.zIndex, - paintOrder: request.paintOrder, - subOrder: request.subOrder, - phase: request.phase, - captureStrategy: request.captureStrategy, - suppressedNativeVisualIds: request.suppressedNativeVisualIds || [], - }); - const raw = String(rendered || '').replace(/^data:.*;base64,/, ''); - if (!raw) throw new Error('Raster renderer returned no PNG data'); - layers.push({ - ...request, - buildHtml: undefined, - html: undefined, - data: `data:image/png;base64,${raw}`, - }); - } catch (error) { - failures.push({ ...request, error: String(error?.message || error) }); - } - } - return { layers, failures }; -} - -function failureDiagnostic(code, failure, slideIndex, severity = 'fallback') { - const reason = failure.buildFailure?.reason || failure.error; - return { - severity, - kind: severity === 'blocking' ? 'blocking' : undefined, - code: failure.buildFailure?.code || code, - message: `${failure.phase || 'raster'} fallback failed for ${failure.sourceId || 'slide visual'}: ${reason}`, - sourceId: failure.sourceId || null, - slideNumber: slideIndex + 1, - phase: failure.phase || null, - stage: failure.buildFailure?.stage || 'render', - reason, - }; -} - -export async function renderRasterFallbackPlan(plan, renderRaster, slideIndex) { - const diagnostics = []; - if (plan.pageVisualRequest?.captureStrategy === 'whole-page-visual') { - const pageResult = await renderRasterFallbackLayers( - [plan.pageVisualRequest], - renderRaster, - slideIndex, - ); - if (pageResult.layers.length) { - return { - layers: pageResult.layers, - fullPageFallback: null, - diagnostics: [{ - severity: 'fallback', - code: 'page_visual_fallback', - message: `Slide ${slideIndex + 1} used a page visual fallback.`, - sourceId: 'slide-visuals', - slideNumber: slideIndex + 1, - phase: 'page-visual', - }], - blocking: false, - }; - } - diagnostics.push(...pageResult.failures.map((failure) => ( - failureDiagnostic('page_visual_raster_failed', failure, slideIndex) - ))); - const fullResult = await renderRasterFallbackLayers( - plan.fullPageRequest ? [plan.fullPageRequest] : [], - renderRaster, - slideIndex, - ); - if (fullResult.layers.length) { - return { - layers: [], - fullPageFallback: fullResult.layers[0], - diagnostics: [ - ...diagnostics, - { - severity: 'fallback', - code: 'full_page_fallback', - message: `Slide ${slideIndex + 1} used a full-page fallback.`, - sourceId: fullResult.layers[0].sourceId, - slideNumber: slideIndex + 1, - phase: 'full-page', - }, - ], - blocking: false, - }; - } - diagnostics.push(...fullResult.failures.map((failure) => ( - failureDiagnostic('full_page_raster_failed', failure, slideIndex, 'blocking') - ))); - return { layers: [], fullPageFallback: null, diagnostics, blocking: true }; - } - const localResult = await renderRasterFallbackLayers( - plan.localRequests || [], - renderRaster, - slideIndex, - ); - if (!localResult.failures.length) { - return { - layers: localResult.layers, - fullPageFallback: null, - diagnostics, - blocking: false, - }; - } - - diagnostics.push(...localResult.failures.map((failure) => ( - failureDiagnostic('local_raster_failed', failure, slideIndex) - ))); - - if (plan.pageVisualRequest) { - const pageResult = await renderRasterFallbackLayers( - [plan.pageVisualRequest], - renderRaster, - slideIndex, - ); - if (pageResult.layers.length) { - const layer = pageResult.layers[0]; - diagnostics.push({ - severity: 'fallback', - code: 'page_visual_fallback', - message: `Slide ${slideIndex + 1} used a transparent page visual fallback.`, - sourceId: layer.sourceId, - slideNumber: slideIndex + 1, - phase: 'page-visual', - reason: 'One or more local visual layers could not be rendered.', - }); - return { - layers: [layer], - fullPageFallback: null, - diagnostics, - blocking: false, - }; - } - diagnostics.push(...pageResult.failures.map((failure) => ( - failureDiagnostic('page_visual_raster_failed', failure, slideIndex) - ))); - } - - if (plan.fullPageRequest) { - const fullResult = await renderRasterFallbackLayers( - [plan.fullPageRequest], - renderRaster, - slideIndex, - ); - if (fullResult.layers.length) { - const fullPageFallback = fullResult.layers[0]; - diagnostics.push({ - severity: 'fallback', - code: 'full_page_fallback', - message: `Slide ${slideIndex + 1} was exported as a full-page PNG fallback.`, - sourceId: fullPageFallback.sourceId, - slideNumber: slideIndex + 1, - phase: 'full-page', - reason: 'Local and transparent page visual fallback rendering failed.', - }); - return { - layers: [], - fullPageFallback, - diagnostics, - blocking: false, - }; - } - diagnostics.push(...fullResult.failures.map((failure) => ( - failureDiagnostic('full_page_raster_failed', failure, slideIndex, 'blocking') - ))); - } - - return { - layers: [], - fullPageFallback: null, - diagnostics, - blocking: true, - }; -} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/html2pptx-dom-core.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/html2pptx-dom-core.js index 369e58251e..16b7336309 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/html2pptx-dom-core.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/html2pptx-dom-core.js @@ -18,6 +18,94 @@ import { buildDomPaintOrderMap } from './paint-order.js'; export const PT_PER_PX = 0.75; export const PX_PER_IN = 96; +export function createSvgViewportMapper(svg, svgRect) { + const values = String(svg.getAttribute('viewBox') || `0 0 ${svgRect.width} ${svgRect.height}`) + .trim().split(/[\s,]+/).map(Number); + const [viewBoxX = 0, viewBoxY = 0, viewBoxWidth = svgRect.width, viewBoxHeight = svgRect.height] = values; + const raw = String(svg.getAttribute('preserveAspectRatio') || 'xMidYMid meet').trim(); + const parts = raw.split(/\s+/).filter(Boolean); + const align = parts.find((part) => /^(?:none|x(?:Min|Mid|Max)Y(?:Min|Mid|Max))$/.test(part)) + || 'xMidYMid'; + const mode = parts.includes('slice') ? 'slice' : 'meet'; + const naturalXScale = svgRect.width / (viewBoxWidth || svgRect.width); + const naturalYScale = svgRect.height / (viewBoxHeight || svgRect.height); + let xScale = naturalXScale; + let yScale = naturalYScale; + let offsetX = 0; + let offsetY = 0; + if (align !== 'none') { + const scale = mode === 'slice' + ? Math.max(naturalXScale, naturalYScale) + : Math.min(naturalXScale, naturalYScale); + xScale = scale; + yScale = scale; + const remainingX = svgRect.width - viewBoxWidth * scale; + const remainingY = svgRect.height - viewBoxHeight * scale; + offsetX = align.startsWith('xMid') ? remainingX / 2 : align.startsWith('xMax') ? remainingX : 0; + offsetY = align.includes('YMid') ? remainingY / 2 : align.includes('YMax') ? remainingY : 0; + } + return { + xScale, + yScale, + map(point) { + return { + x: svgRect.left + offsetX + (point.x - viewBoxX) * xScale, + y: svgRect.top + offsetY + (point.y - viewBoxY) * yScale, + }; + }, + }; +} + +export function classifySvgPresetPolygon(points) { + if (!Array.isArray(points)) return null; + const tolerance = 1e-6; + if (points.length === 3) { + for (let left = 0; left < points.length; left += 1) { + for (let right = left + 1; right < points.length; right += 1) { + const apex = points.find((_point, index) => index !== left && index !== right); + const baseMidpoint = { + x: (points[left].x + points[right].x) / 2, + y: (points[left].y + points[right].y) / 2, + }; + if (Math.abs(points[left].x - points[right].x) <= tolerance + && Math.abs(apex.y - baseMidpoint.y) <= tolerance + && Math.abs(apex.x - baseMidpoint.x) > tolerance) { + return { shapeType: 'triangle', rotate: apex.x > baseMidpoint.x ? 90 : 270 }; + } + if (Math.abs(points[left].y - points[right].y) <= tolerance + && Math.abs(apex.x - baseMidpoint.x) <= tolerance + && Math.abs(apex.y - baseMidpoint.y) > tolerance) { + return { shapeType: 'triangle', rotate: apex.y > baseMidpoint.y ? 180 : 0 }; + } + } + } + return null; + } + if (points.length !== 4) return null; + const xs = points.map((point) => point.x); + const ys = points.map((point) => point.y); + const minX = Math.min(...xs); + const maxX = Math.max(...xs); + const minY = Math.min(...ys); + const maxY = Math.max(...ys); + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const expected = [ + { x: centerX, y: minY }, + { x: maxX, y: centerY }, + { x: centerX, y: maxY }, + { x: minX, y: centerY }, + ]; + const matches = expected.every((target) => points.some((point) => ( + Math.abs(point.x - target.x) <= tolerance && Math.abs(point.y - target.y) <= tolerance + ))); + return matches ? { shapeType: 'diamond', rotate: 0 } : null; +} + +function clipsOverflowAxis(value) { + return value === 'hidden' || value === 'clip'; +} + export function measureBodyDimensions(doc = document) { const view = doc.defaultView || window; const body = doc.body; @@ -33,11 +121,21 @@ export function measureBodyDimensions(doc = document) { const heightOverflowPx = Math.max(0, bodyDimensions.scrollHeight - bodyDimensions.height - 1); const widthOverflowPt = widthOverflowPx * PT_PER_PX; const heightOverflowPt = heightOverflowPx * PT_PER_PX; - if (widthOverflowPt > 0 || heightOverflowPt > 0) { + const overflowX = style.overflowX || style.overflow || 'visible'; + const overflowY = style.overflowY || style.overflow || 'visible'; + const clipsX = clipsOverflowAxis(overflowX); + const clipsY = clipsOverflowAxis(overflowY); + // Slide bodies use overflow:hidden as the canvas clip frame (also enforced by + // ensureExportCanvas). Decorative bleed/rotation expands scrollWidth/Height + // while remaining visually clipped — same as PowerPoint off-slide shapes. + // Only unclipped overflow is a blocking canvas error; text bounds stay separate. + const reportWidth = widthOverflowPx > 0 && !clipsX; + const reportHeight = heightOverflowPx > 0 && !clipsY; + if (reportWidth || reportHeight) { const directions = []; - if (widthOverflowPt > 0) directions.push(`${widthOverflowPt.toFixed(1)}pt horizontally`); - if (heightOverflowPt > 0) directions.push(`${heightOverflowPt.toFixed(1)}pt vertically`); - const reminder = heightOverflowPt > 0 ? ' (Remember: leave 0.5" margin at bottom of slide)' : ''; + if (reportWidth) directions.push(`${widthOverflowPt.toFixed(1)}pt horizontally`); + if (reportHeight) directions.push(`${heightOverflowPt.toFixed(1)}pt vertically`); + const reminder = reportHeight ? ' (Remember: leave 0.5" margin at bottom of slide)' : ''; errors.push(`HTML content overflows body by ${directions.join(' and ')}${reminder}`); } return { ...bodyDimensions, errors }; @@ -118,6 +216,13 @@ export function extractSlideDataFromDocument(doc = document) { // Handle transparent backgrounds by defaulting to white if (rgbStr === 'rgba(0, 0, 0, 0)' || rgbStr === 'transparent') return 'FFFFFF'; + const hex = String(rgbStr || '').trim().match(/^#([\da-f]{3,8})$/i)?.[1]; + if (hex) { + if (hex.length === 3 || hex.length === 4) { + return hex.slice(0, 3).split('').map((channel) => channel.repeat(2)).join(''); + } + return hex.slice(0, 6); + } const match = rgbStr.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (!match) return 'FFFFFF'; return match.slice(1).map(n => parseInt(n).toString(16).padStart(2, '0')).join(''); @@ -243,9 +348,18 @@ export function extractSlideDataFromDocument(doc = document) { }; }; - // Parse CSS box-shadow into PptxGenJS shadow properties + // Parse CSS box-shadow into either: + // - { mode: 'outer', shadow } native PowerPoint outer shadow (zero spread) + // - { mode: 'ring', ring } zero-offset/zero-blur spread ring → concentric shape rewrite + // Returns null for none / unsupported (inset, multi-layer, soft+spread, etc.). const parseBoxShadow = (boxShadow) => { if (!boxShadow || boxShadow === 'none') return null; + let nesting = 0; + for (const character of boxShadow) { + if (character === '(') nesting += 1; + else if (character === ')') nesting = Math.max(0, nesting - 1); + else if (character === ',' && nesting === 0) return null; + } // Browser computed style format: "rgba(0, 0, 0, 0.3) 2px 2px 8px 0px [inset]" // CSS format: "[inset] 2px 2px 8px 0px rgba(0, 0, 0, 0.3)" @@ -256,17 +370,44 @@ export function extractSlideDataFromDocument(doc = document) { // Only process outer shadows to avoid file corruption if (insetMatch) return null; - // Extract color first (rgba or rgb at start) - const colorMatch = boxShadow.match(/rgba?\([^)]+\)/); - - // Extract numeric values (handles both px and pt units) - const parts = boxShadow.match(/([-\d.]+)(px|pt)/g); + // Extract color first (rgba/rgb/hex); length tokens may be unitless 0. + const colorMatch = boxShadow.match(/rgba?\([^)]+\)|#[0-9a-fA-F]{3,8}/); + const withoutColor = boxShadow + .replace(/rgba?\([^)]+\)/g, ' ') + .replace(/#[0-9a-fA-F]{3,8}/g, ' '); + // CSS allows unitless 0; browsers often keep "0 0 0 1px" in authored/computed forms. + const parts = withoutColor.match(/-?\d*\.?\d+(?:px|pt)?/g); if (!parts || parts.length < 2) return null; const offsetX = parseFloat(parts[0]); const offsetY = parseFloat(parts[1]); const blur = parts.length > 2 ? parseFloat(parts[2]) : 0; + const spread = parts.length > 3 ? parseFloat(parts[3]) : 0; + if (![offsetX, offsetY, blur, spread].every(Number.isFinite) || blur < 0) return null; + + // Extract opacity from rgba + let opacity = 0.5; + if (colorMatch) { + const opacityMatch = colorMatch[0].match(/[\d.]+\)$/); + if (opacityMatch) { + opacity = parseFloat(opacityMatch[0].replace(')', '')); + } + } + if (!Number.isFinite(opacity) || opacity < 0 || opacity > 1) return null; + const color = colorMatch ? rgbToHex(colorMatch[0]) : '000000'; + if (!color) return null; + + // Hard CSS ring: 0 offset, 0 blur, positive > 0 → concentric editable shape. + if (spread !== 0) { + if (offsetX === 0 && offsetY === 0 && blur === 0 && spread > 0) { + return { + mode: 'ring', + ring: { spreadPx: spread, color, opacity }, + }; + } + return null; + } // Calculate angle from offsets (in degrees, 0 = right, 90 = down) let angle = 0; @@ -278,25 +419,21 @@ export function extractSlideDataFromDocument(doc = document) { // Calculate offset distance (hypotenuse) const offset = Math.sqrt(offsetX * offsetX + offsetY * offsetY) * PT_PER_PX; - // Extract opacity from rgba - let opacity = 0.5; - if (colorMatch) { - const opacityMatch = colorMatch[0].match(/[\d.]+\)$/); - if (opacityMatch) { - opacity = parseFloat(opacityMatch[0].replace(')', '')); - } - } - return { - type: 'outer', - angle: Math.round(angle), - blur: blur * 0.75, // Convert to points - color: colorMatch ? rgbToHex(colorMatch[0]) : '000000', - offset: offset, - opacity + mode: 'outer', + shadow: { + type: 'outer', + angle: Math.round(angle), + blur: blur * 0.75, // Convert to points + color, + offset, + opacity, + }, }; }; + const outerShadowOf = (effect) => (effect?.mode === 'outer' ? effect.shadow : null); + // Parse inline formatting tags (, , , , , ) into text runs const parseInlineFormatting = (element, baseOptions = {}, runs = [], baseTextTransform = (x) => x) => { let prevNodeIsText = false; @@ -339,16 +476,16 @@ export function extractSlideDataFromDocument(doc = document) { // Validate: Check for margins on inline elements if (computed.marginLeft && parseFloat(computed.marginLeft) > 0) { - addDiagnostic('fallback', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has margin-left; fallback layout may be needed.`, node); + addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-left.`, node); } if (computed.marginRight && parseFloat(computed.marginRight) > 0) { - addDiagnostic('fallback', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has margin-right; fallback layout may be needed.`, node); + addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-right.`, node); } if (computed.marginTop && parseFloat(computed.marginTop) > 0) { - addDiagnostic('fallback', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has margin-top; fallback layout may be needed.`, node); + addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-top.`, node); } if (computed.marginBottom && parseFloat(computed.marginBottom) > 0) { - addDiagnostic('fallback', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has margin-bottom; fallback layout may be needed.`, node); + addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-bottom.`, node); } // Recursively process the child node. This will flatten nested spans into multiple runs. @@ -370,6 +507,26 @@ export function extractSlideDataFromDocument(doc = document) { const isTransparentBg = (color) => !color || color === 'transparent' || color === 'rgba(0, 0, 0, 0)'; + const authoredStyleValue = (element, property) => { + const authored = element?.dataset?.pptxAuthoredStyle || ''; + const match = authored.match(new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, 'i')); + return match?.[1]?.trim() || ''; + }; + + const elementBoxShadow = (element, computed) => ( + [computed?.boxShadow, element?.style?.boxShadow, authoredStyleValue(element, 'box-shadow')] + .find((value) => value && value !== 'none') || 'none' + ); + + const elementBackgroundColor = (element, computed) => { + if (!isTransparentBg(computed.backgroundColor)) return computed.backgroundColor; + return element.style.backgroundColor + || authoredStyleValue(element, 'background-color') + || element.style.background + || authoredStyleValue(element, 'background') + || computed.backgroundColor; + }; + const resolveSlideBackground = (rootBody) => { const candidates = [ rootBody, @@ -499,6 +656,40 @@ export function extractSlideDataFromDocument(doc = document) { elements.push(entry); }; + const pushCssRingBackdrop = (el, rectPx, rectRadius, ring) => { + if (!ring || !rectPx || !(rectPx.width > 0) || !(rectPx.height > 0)) return; + const spreadIn = pxToInch(ring.spreadPx); + if (!(spreadIn > 0)) return; + const ringRadius = rectRadius === 1 || !Number.isFinite(rectRadius) || rectRadius <= 0 + ? rectRadius + : rectRadius + spreadIn; + const transparency = Math.round((1 - ring.opacity) * 100); + pushElement({ + type: 'shape', + text: '', + rewrite: 'css_box_shadow_ring', + position: { + x: pxToInch(rectPx.left) - spreadIn, + y: pxToInch(rectPx.top) - spreadIn, + w: pxToInch(rectPx.width) + spreadIn * 2, + h: pxToInch(rectPx.height) + spreadIn * 2, + }, + shape: { + fill: ring.color, + transparency: Number.isFinite(transparency) ? transparency : null, + line: null, + rectRadius: ringRadius || 0, + shadow: null, + }, + }, el); + addDiagnostic( + 'rewrite', + 'css_box_shadow_ring', + 'CSS ring box-shadow was rewritten as a concentric editable shape.', + el, + ); + }; + const resolveListBulletColor = (ul, liElements, textHex) => { const ulComputed = view.getComputedStyle(ul); const listColor = ulComputed.listStyleColor; @@ -523,9 +714,9 @@ export function extractSlideDataFromDocument(doc = document) { const bgResolved = resolveSlideBackground(body); if (bgResolved.gradient) { addDiagnostic( - 'fallback', + 'rewrite', 'css_gradient', - 'CSS gradient requires fallback rendering; native gradient mapping is not available.', + 'CSS gradient will be rewritten as editable solid strips.', bgResolved.element || body, ); } @@ -537,7 +728,6 @@ export function extractSlideDataFromDocument(doc = document) { // Process all elements const elements = []; - const slideDataFallbackLayers = []; const placeholders = []; const textTags = ['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6', 'UL', 'OL', 'LI']; const genericInlineTextTags = new Set([ @@ -569,8 +759,9 @@ export function extractSlideDataFromDocument(doc = document) { }; const resolveTextAlign = (computed) => { - if (computed.textAlign && !['start', 'auto'].includes(computed.textAlign)) { - return computed.textAlign === 'end' ? 'right' : computed.textAlign; + if (computed.textAlign === 'end') return 'right'; + if (['left', 'center', 'right', 'justify'].includes(computed.textAlign)) { + return computed.textAlign; } if (computed.display.includes('flex')) { if (computed.justifyContent === 'center') return 'center'; @@ -604,12 +795,13 @@ export function extractSlideDataFromDocument(doc = document) { // visual line spacing of the HTML preview. const resolveLineSpacing = (computed) => { const lh = computed.lineHeight; + const fontSize = pxToPoints(computed.fontSize) || 12; if (!lh || lh === 'normal') { - return pxToPoints(computed.fontSize) * 1.2; + return fontSize * 1.2; } const parsed = parseFloat(lh); - if (Number.isNaN(parsed)) return pxToPoints(computed.fontSize) * 1.2; - return pxToPoints(lh); + if (Number.isNaN(parsed)) return fontSize * 1.2; + return pxToPoints(lh) || fontSize * 1.2; }; const emitTextElement = (el, type = el.tagName.toLowerCase(), exactFrame = false, rectOverride = null) => { @@ -619,7 +811,7 @@ export function extractSlideDataFromDocument(doc = document) { if (type !== 'text' && el.tagName !== 'LI' && /^[•●○▪‣·▸◆◇■□]\s/u.test(text.trimStart())) { addDiagnostic( - 'fallback', + 'blocking', 'manual_bullet_unrepaired', `Text element <${el.tagName.toLowerCase()}> still starts with a manual bullet; exporting as editable text.`, el, @@ -648,13 +840,13 @@ export function extractSlideDataFromDocument(doc = document) { const padR = parseFloat(computed.paddingRight) || 0; const padT = parseFloat(computed.paddingTop) || 0; const padB = parseFloat(computed.paddingBottom) || 0; - const textInset = [padL, padR, padT, padB].some((v) => v > 0) - ? [pxToInch(padL), pxToInch(padR), pxToInch(padB), pxToInch(padT)] + const textInset = [padT, padR, padB, padL].some((v) => v > 0) + ? [pxToInch(padT), pxToInch(padR), pxToInch(padB), pxToInch(padL)] : 0; const baseStyle = { - fontSize: pxToPoints(computed.fontSize), - fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(), + fontSize: pxToPoints(computed.fontSize) || 12, + fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim() || 'Arial', color: resolveTextColor(computed, el), align: resolveTextAlign(computed), valign: resolveVerticalAlign(computed, rect), @@ -736,6 +928,14 @@ export function extractSlideDataFromDocument(doc = document) { const parsed = parseFloat(String(value || '')); return Number.isFinite(parsed) ? parsed : fallback; }; + const svgDashStyle = (value) => { + const values = String(value || '').match(/[-+]?(?:\d*\.)?\d+/g)?.map(Number) + .filter((item) => Number.isFinite(item) && item >= 0) || []; + if (!values.length) return null; + if (values.length >= 4 && values[2] <= values[0] * 0.5) return 'dashDot'; + if (values.length >= 2 && values[0] <= values[1] * 0.5) return 'dot'; + return 'dash'; + }; const identityMatrix = () => [1, 0, 0, 1, 0, 0]; const multiplyMatrix = (left, right) => [ left[0] * right[0] + left[2] * right[1], @@ -898,67 +1098,6 @@ export function extractSlideDataFromDocument(doc = document) { } return points; }; - const svgStyleProperties = [ - 'fill', 'fill-opacity', 'fill-rule', - 'stroke', 'stroke-width', 'stroke-opacity', 'stroke-linecap', 'stroke-linejoin', - 'stroke-dasharray', 'stroke-dashoffset', - 'opacity', 'color', 'vector-effect', - ]; - const resolvedSvgStyle = (element, property) => { - let current = element; - while (current) { - const value = String( - view.getComputedStyle(current).getPropertyValue(property) || '', - ).trim(); - if (value && !['inherit', 'unset', 'initial'].includes(value)) return value; - if (['opacity', 'vector-effect'].includes(property)) break; - current = current.parentElement; - } - return ''; - }; - const inlineComputedSvgStyles = (original, clone) => { - svgStyleProperties.forEach((property) => { - const value = resolvedSvgStyle(original, property); - if (value) clone.style.setProperty(property, value); - }); - const originalChildren = [...original.children]; - const cloneChildren = [...clone.children]; - originalChildren.forEach((child, index) => { - if (cloneChildren[index]) inlineComputedSvgStyles(child, cloneChildren[index]); - }); - }; - const serializeSvgVisual = (svg, node) => { - const rootClone = svg.cloneNode(false); - rootClone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); - if (!rootClone.getAttribute('width')) rootClone.setAttribute('width', '100%'); - if (!rootClone.getAttribute('height')) rootClone.setAttribute('height', '100%'); - inlineComputedSvgStyles(svg, rootClone); - - [...svg.querySelectorAll('defs')].forEach((defs) => { - const defsClone = defs.cloneNode(true); - inlineComputedSvgStyles(defs, defsClone); - rootClone.appendChild(defsClone); - }); - - const ancestors = []; - let current = node.parentElement; - while (current && current !== svg) { - ancestors.unshift(current); - current = current.parentElement; - } - let targetParent = rootClone; - ancestors.forEach((ancestor) => { - const ancestorClone = ancestor.cloneNode(false); - inlineComputedSvgStyles(ancestor, ancestorClone); - targetParent.appendChild(ancestorClone); - targetParent = ancestorClone; - }); - const targetClone = node.cloneNode(true); - inlineComputedSvgStyles(node, targetClone); - targetClone.querySelectorAll?.('text').forEach((text) => text.remove()); - targetParent.appendChild(targetClone); - return `data:image/svg+xml,${encodeURIComponent(rootClone.outerHTML)}`; - }; const boundsOfPoints = (points) => { const xs = points.map((point) => point.x); const ys = points.map((point) => point.y); @@ -969,29 +1108,12 @@ export function extractSlideDataFromDocument(doc = document) { height: Math.max(...ys) - Math.min(...ys), }; }; - const isDiamondPoints = (points) => { - if (points.length !== 4) return false; - const bounds = boundsOfPoints(points); - const cx = bounds.left + bounds.width / 2; - const cy = bounds.top + bounds.height / 2; - const tolerance = Math.max(bounds.width, bounds.height) * 0.08 + 0.01; - return points.every((point) => ( - Math.abs(point.x - cx) <= tolerance || Math.abs(point.y - cy) <= tolerance - )); - }; const emitNativeSvg = (svg) => { const svgRect = rectFor(svg); if (svgRect.width <= 0 || svgRect.height <= 0) return; - const viewBox = String(svg.getAttribute('viewBox') || `0 0 ${svgRect.width} ${svgRect.height}`) - .trim().split(/[\s,]+/).map(Number); - const [vbX = 0, vbY = 0, vbW = svgRect.width, vbH = svgRect.height] = viewBox; - const xScale = svgRect.width / (vbW || svgRect.width); - const yScale = svgRect.height / (vbH || svgRect.height); - const fallbackLayers = slideDataFallbackLayers; - const toSlidePoint = (point) => ({ - x: svgRect.left + ((point.x - vbX) * xScale), - y: svgRect.top + ((point.y - vbY) * yScale), - }); + const viewportMapper = createSvgViewportMapper(svg, svgRect); + const { xScale, yScale } = viewportMapper; + const toSlidePoint = (point) => viewportMapper.map(point); const pushLine = (start, end, node, stroke, width, coordinateSpace = 'viewBox') => { const toLinePoint = (point) => ( coordinateSpace === 'viewport' @@ -1000,6 +1122,14 @@ export function extractSlideDataFromDocument(doc = document) { ); const first = toLinePoint(start); const second = toLinePoint(end); + const dashArray = node.getAttribute?.('stroke-dasharray') + || view.getComputedStyle(node).strokeDasharray; + const dash = svgDashStyle(dashArray); + const lineStyle = { + color: stroke || '000000', + width, + ...(dash ? { dash } : {}), + }; pushElement({ type: 'line', kind: 'native', @@ -1007,68 +1137,23 @@ export function extractSlideDataFromDocument(doc = document) { y1: pxToInch(first.y), x2: pxToInch(second.x), y2: pxToInch(second.y), - color: stroke || '000000', - width, + color: lineStyle.color, + width: lineStyle.width, + style: lineStyle, }, node); }; - const hasBrowserOnlySvg = Boolean( - svg.querySelector('filter,mask,foreignObject,use,pattern,textPath,clipPath,image'), - ); - const canSerializeSvgVisual = (node) => ( - !svg.querySelector('script') - && !/(?:href|src)\s*=\s*["']\s*(?:https?:)?\/\//i.test(node.outerHTML || '') - ); - const pushSvgImageLayer = (node, code, message) => { - if (!canSerializeSvgVisual(node)) { - addDiagnostic( - 'fallback', - 'complex_svg_raster', - 'Complex SVG cannot be safely serialized and requires local browser raster rendering.', - svg, - ); - return; - } - const paintMetadata = nextPaintMetadata(node); - fallbackLayers.push({ - sourceId: paintMetadata.sourceId, - zIndex: readZIndex(node), - paintOrder: paintMetadata.paintOrder, - subOrder: paintMetadata.subOrder, - kind: 'svg-image', - captureStrategy: 'local-svg', - bbox: { - x: pxToInch(svgRect.left), - y: pxToInch(svgRect.top), - w: pxToInch(svgRect.width), - h: pxToInch(svgRect.height), - }, - data: serializeSvgVisual(svg, node), - diagnostics: [{ - severity: 'fallback', - code, - message, - sourceId: node.dataset?.pptxSourceId || node.id || null, - }], - }); - }; svg.querySelectorAll('rect,circle,ellipse,line,polyline,polygon,text,path').forEach((node) => { const tag = node.tagName.toLowerCase(); if (tag === 'path') { - if (!hasBrowserOnlySvg) { - pushSvgImageLayer( - node, - 'complex_svg_vector', - 'Complex SVG geometry is preserved as a local movable vector image.', - ); - } return; } const transform = transformForSvgNode(node, svg); if (!transform.reliable) { - pushSvgImageLayer( + addDiagnostic( + 'blocking', + 'svg_transform_unsupported', + 'SVG transform cannot be represented reliably as an editable native shape.', node, - 'svg_transform_vector', - 'SVG transform cannot be represented reliably as a native shape; preserving it as SVG.', ); return; } @@ -1116,6 +1201,13 @@ export function extractSlideDataFromDocument(doc = document) { ]); common.position = { x: pxToInch(layoutBounds.left), y: pxToInch(layoutBounds.top), w: pxToInch(layoutBounds.width), h: pxToInch(layoutBounds.height) }; common.bbox = { x: pxToInch(bounds.left), y: pxToInch(bounds.top), w: pxToInch(bounds.width), h: pxToInch(bounds.height) }; + const rx = svgNumber(node.getAttribute('rx')); + const ry = svgNumber(node.getAttribute('ry')); + if (rx > 0 || ry > 0) { + common.svgType = 'roundRect'; + common.shape.rectRadius = pxToInch(Math.max(rx, ry)); + common.shape.radius = pxToInch(rx * xScale); + } if (transform.rotation) common.shape.rotate = transform.rotation; } else if (tag === 'circle' || tag === 'ellipse') { const rx = tag === 'circle' ? svgNumber(node.getAttribute('r')) : svgNumber(node.getAttribute('rx')); @@ -1147,16 +1239,73 @@ export function extractSlideDataFromDocument(doc = document) { common.text = node.textContent || ''; const fontSize = svgNumber(node.getAttribute('font-size'), 16); const origin = mapPoint(node.getAttribute('x'), svgNumber(node.getAttribute('y')) - fontSize); - common.position = { x: pxToInch(origin.x), y: pxToInch(origin.y), w: pxToInch(Math.max(1, svgRect.width)), h: pxToInch(Math.max(1, fontSize * yScale * 1.3)) }; - common.style = { fontSize: svgNumber(node.getAttribute('font-size'), 16) * 0.75, fontFace: 'Arial', color: fill || '000000', align: 'left' }; + const textAnchor = node.getAttribute('text-anchor') + || view.getComputedStyle(node).textAnchor + || 'start'; + let measuredBox = null; + try { + const bbox = node.getBBox?.(); + if (bbox && [bbox.x, bbox.y, bbox.width, bbox.height].every(Number.isFinite) + && bbox.width > 0 && bbox.height > 0) { + const bounds = boundsOfPoints([ + mapPoint(bbox.x, bbox.y), + mapPoint(bbox.x + bbox.width, bbox.y + bbox.height), + ]); + measuredBox = { + x: bounds.left, + y: bounds.top, + width: bounds.width, + height: bounds.height, + }; + } + } catch { + // Fall through to text length or deterministic character metrics. + } + let userWidth = 0; + if (!measuredBox) { + try { + userWidth = Number(node.getComputedTextLength?.()) || 0; + } catch { + userWidth = 0; + } + if (!(userWidth > 0)) { + userWidth = [...common.text].reduce((sum, character) => ( + sum + (/[\u1100-\u11ff\u2e80-\u9fff\uf900-\ufaff\uff01-\uff60\uffe0-\uffe6]/u.test(character) + ? fontSize + : fontSize * 0.6) + ), 0); + } + const textWidth = Math.max(1, userWidth * xScale); + const anchorOffset = textAnchor === 'middle' ? textWidth / 2 : textAnchor === 'end' ? textWidth : 0; + measuredBox = { + x: Math.max(0, origin.x - anchorOffset), + y: origin.y, + width: textWidth, + height: Math.max(1, fontSize * yScale * 1.3), + }; + } + common.position = { + x: pxToInch(measuredBox.x), + y: pxToInch(measuredBox.y), + w: pxToInch(measuredBox.width), + h: pxToInch(measuredBox.height), + }; + common.style = { + fontSize: svgNumber(node.getAttribute('font-size'), 16) * 0.75, + fontFace: 'Arial', + color: fill || '000000', + align: { start: 'left', middle: 'center', end: 'right' }[textAnchor] || 'left', + }; if (transform.rotation) common.style.rotate = transform.rotation; } else { - const points = parseSvgPoints(node.getAttribute('points')) + const rawPoints = parseSvgPoints(node.getAttribute('points')); + const points = rawPoints .map((point) => transformPoint(point, transform.matrix)); - if (tag === 'polygon' && (points.length === 3 || isDiamondPoints(points))) { + const preset = tag === 'polygon' ? classifySvgPresetPolygon(points) : null; + if (preset) { const slidePoints = points.map(transformedToSlidePoint); const bounds = boundsOfPoints(slidePoints); - common.svgType = points.length === 3 ? 'triangle' : 'diamond'; + common.svgType = preset.shapeType; common.position = { x: pxToInch(bounds.left), y: pxToInch(bounds.top), @@ -1164,7 +1313,7 @@ export function extractSlideDataFromDocument(doc = document) { h: pxToInch(bounds.height), }; common.bbox = { ...common.position }; - if (transform.rotation) common.shape.rotate = transform.rotation; + common.shape.rotate = preset.rotate; } else { const closed = tag === 'polygon' && points.length > 2 ? [...points, points[0]] : points; for (let index = 0; index + 1 < closed.length; index += 1) { @@ -1177,29 +1326,6 @@ export function extractSlideDataFromDocument(doc = document) { transform.coordinateSpace, ); } - if (tag === 'polygon' && fill && points.length > 2) { - const sourceId = node.dataset?.pptxSourceId || node.id || null; - fallbackLayers.push({ - sourceId, - zIndex: readZIndex(node), - paintOrder: domPaintOrder.get(sourceId) ?? unmappedPaintOrder++, - subOrder: -1, - kind: 'svg-image', - bbox: { - x: pxToInch(svgRect.left), - y: pxToInch(svgRect.top), - w: pxToInch(svgRect.width), - h: pxToInch(svgRect.height), - }, - data: serializeSvgVisual(svg, node), - diagnostics: [{ - severity: 'fallback', - code: 'svg_polygon_fill', - message: 'Polygon fill is preserved as a local SVG layer over an editable outline.', - sourceId: node.dataset?.pptxSourceId || node.id || null, - }], - }); - } return; } } @@ -1232,9 +1358,9 @@ export function extractSlideDataFromDocument(doc = document) { // Reject nested merge containers — undefined behavior. if (el.querySelector('[data-pptx-merge="true"]')) { addDiagnostic( - 'fallback', + 'blocking', 'nested_merge_container', - 'Nested data-pptx-merge containers require fallback handling.', + 'Nested data-pptx-merge containers cannot be represented as editable text.', el, ); processed.add(el); @@ -1246,9 +1372,9 @@ export function extractSlideDataFromDocument(doc = document) { // Container background image — same restriction as regular divs. if (mergeComputed.backgroundImage && mergeComputed.backgroundImage !== 'none') { addDiagnostic( - 'fallback', + 'blocking', 'merge_background_image', - 'Background image on data-pptx-merge requires fallback rendering.', + 'Background image on data-pptx-merge cannot be represented as editable objects.', el, ); } @@ -1265,6 +1391,22 @@ export function extractSlideDataFromDocument(doc = document) { const mHasUniformBorder = mHasBorder && mBorders.every(b => b === mBorders[0]); if (mHasBg || mHasUniformBorder) { + const mergeRectRadius = (() => { + const radius = mergeComputed.borderRadius; + const radiusValue = parseFloat(radius); + if (radiusValue === 0) return 0; + if (radius.includes('%')) { + if (radiusValue >= 50) return 1; + const minDim = Math.min(containerRect.width, containerRect.height); + return (radiusValue / 100) * pxToInch(minDim); + } + if (radius.includes('pt')) return radiusValue / 72; + return radiusValue / PX_PER_IN; + })(); + const mergeShadowEffect = parseBoxShadow(elementBoxShadow(el, mergeComputed)); + if (mergeShadowEffect?.mode === 'ring') { + pushCssRingBackdrop(el, containerRect, mergeRectRadius, mergeShadowEffect.ring); + } pushElement({ type: 'shape', text: '', @@ -1281,19 +1423,8 @@ export function extractSlideDataFromDocument(doc = document) { color: rgbToHex(mergeComputed.borderColor), width: pxToPoints(mergeComputed.borderWidth) } : null, - rectRadius: (() => { - const radius = mergeComputed.borderRadius; - const radiusValue = parseFloat(radius); - if (radiusValue === 0) return 0; - if (radius.includes('%')) { - if (radiusValue >= 50) return 1; - const minDim = Math.min(containerRect.width, containerRect.height); - return (radiusValue / 100) * pxToInch(minDim); - } - if (radius.includes('pt')) return radiusValue / 72; - return radiusValue / PX_PER_IN; - })(), - shadow: parseBoxShadow(mergeComputed.boxShadow) + rectRadius: mergeRectRadius, + shadow: outerShadowOf(mergeShadowEffect), } }, el); } @@ -1302,7 +1433,7 @@ export function extractSlideDataFromDocument(doc = document) { const textDescendants = Array.from(el.querySelectorAll('p, h1, h2, h3, h4, h5, h6')); if (textDescendants.length === 0) { addDiagnostic( - 'fallback', + 'blocking', 'empty_merge_container', 'data-pptx-merge container has no semantic text children.', el, @@ -1322,14 +1453,16 @@ export function extractSlideDataFromDocument(doc = document) { const mPadT = pxToInch(parseFloat(mergeComputed.paddingTop) || 0); const mPadB = pxToInch(parseFloat(mergeComputed.paddingBottom) || 0); const baseStyle = { - fontSize: pxToPoints(firstComputed.fontSize), - fontFace: firstComputed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(), + fontSize: pxToPoints(firstComputed.fontSize) || 12, + fontFace: firstComputed.fontFamily.split(',')[0].replace(/['"]/g, '').trim() || 'Arial', color: rgbToHex(firstComputed.color), - align: firstComputed.textAlign === 'start' ? 'left' : firstComputed.textAlign, + align: !firstComputed.textAlign || firstComputed.textAlign === 'start' + ? 'left' + : firstComputed.textAlign, lineSpacing: resolveLineSpacing(firstComputed), paraSpaceBefore: 0, paraSpaceAfter: 0, - margin: [mPadL, mPadR, mPadB, mPadT].some((v) => v > 0) ? [mPadL, mPadR, mPadB, mPadT] : 0, + margin: [mPadT, mPadR, mPadB, mPadL].some((v) => v > 0) ? [mPadT, mPadR, mPadB, mPadL] : 0, }; const baseTransparency = extractAlpha(firstComputed.color); if (baseTransparency !== null) baseStyle.transparency = baseTransparency; @@ -1342,8 +1475,9 @@ export function extractSlideDataFromDocument(doc = document) { const transformStr = tComputed.textTransform; // Per-paragraph style overrides — only include if they differ from base. - const elemFontSize = pxToPoints(tComputed.fontSize); - const elemFontFace = tComputed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(); + const elemFontSize = pxToPoints(tComputed.fontSize) || baseStyle.fontSize; + const elemFontFace = tComputed.fontFamily.split(',')[0].replace(/['"]/g, '').trim() + || baseStyle.fontFace; const elemColor = rgbToHex(tComputed.color); const elemBold = tComputed.fontWeight === 'bold' || parseInt(tComputed.fontWeight) >= 600; const elemItalic = tComputed.fontStyle === 'italic'; @@ -1430,6 +1564,20 @@ export function extractSlideDataFromDocument(doc = document) { if (solid.fill || hasUniformBorder) { const radius = computed.borderRadius; const radiusValue = parseFloat(radius); + const decoRectRadius = (() => { + if (!radiusValue) return 0; + if (radius.includes('%')) { + if (radiusValue >= 50) return 1; + const minDim = Math.min(decoRect.width, decoRect.height); + return (radiusValue / 100) * pxToInch(minDim); + } + if (radius.includes('pt')) return radiusValue / 72; + return radiusValue / PX_PER_IN; + })(); + const decoShadowEffect = parseBoxShadow(elementBoxShadow(el, computed)); + if (decoShadowEffect?.mode === 'ring') { + pushCssRingBackdrop(el, decoRect, decoRectRadius, decoShadowEffect.ring); + } pushElement({ type: 'shape', text: '', @@ -1446,17 +1594,8 @@ export function extractSlideDataFromDocument(doc = document) { color: rgbToHex(computed.borderColor), width: pxToPoints(computed.borderWidth), } : null, - rectRadius: (() => { - if (!radiusValue) return 0; - if (radius.includes('%')) { - if (radiusValue >= 50) return 1; - const minDim = Math.min(decoRect.width, decoRect.height); - return (radiusValue / 100) * pxToInch(minDim); - } - if (radius.includes('pt')) return radiusValue / 72; - return radiusValue / PX_PER_IN; - })(), - shadow: parseBoxShadow(computed.boxShadow), + rectRadius: decoRectRadius, + shadow: outerShadowOf(decoShadowEffect), }, }, el); } @@ -1472,7 +1611,7 @@ export function extractSlideDataFromDocument(doc = document) { const rect = rectFor(el); if (rect.width === 0 || rect.height === 0) { addDiagnostic( - 'fallback', + 'blocking', 'unmeasurable_placeholder', `Placeholder "${el.id || 'unnamed'}" has ${rect.width === 0 ? 'width: 0' : 'height: 0'}.`, el, @@ -1509,8 +1648,11 @@ export function extractSlideDataFromDocument(doc = document) { } } - // Common CSS arrow: a zero-sized box with one opaque border and - // transparent side borders maps cleanly to an editable PPT triangle. + // Common CSS arrow: an empty transparent box with one opaque border and + // the two perpendicular transparent side borders maps cleanly to an + // editable PPT triangle. WebKit may report the total border-box size as + // computed width/height for authored zero-content border boxes, so the + // classification must use border semantics rather than computed size. if (el.tagName === 'DIV') { const computed = view.getComputedStyle(el); const isZeroBox = svgNumber(computed.width) === 0 && svgNumber(computed.height) === 0; @@ -1522,13 +1664,31 @@ export function extractSlideDataFromDocument(doc = document) { const opaqueSides = sides.filter((side) => ( side.width > 0 && !isTransparentBg(side.color) )); - if (isZeroBox && opaqueSides.length === 1 && sides.filter((side) => side.width > 0).length >= 3) { - const active = opaqueSides[0]; + const transparentSides = sides.filter((side) => ( + side.width > 0 && isTransparentBg(side.color) + )); + const perpendicularSides = { + Bottom: ['Left', 'Right'], + Left: ['Top', 'Bottom'], + Top: ['Left', 'Right'], + Right: ['Top', 'Bottom'], + }; + const active = opaqueSides[0]; + const requiredTransparentSides = active ? perpendicularSides[active.side] : []; + const hasNoVisibleContent = !el.textContent.replace(/\s+/g, '').length + && el.children.length === 0; + const hasTransparentBackground = isTransparentBg(elementBackgroundColor(el, computed)); + const formsDirectionalTriangle = requiredTransparentSides.length === 2 + && requiredTransparentSides.every((side) => ( + transparentSides.some((candidate) => candidate.side === side) + )); + const isSemanticCssTriangle = hasNoVisibleContent + && hasTransparentBackground + && opaqueSides.length === 1 + && transparentSides.length >= 2 + && formsDirectionalTriangle; + if (isSemanticCssTriangle) { const rect = rectFor(el); - const horizontal = sides.find((side) => side.side === 'Left').width - + sides.find((side) => side.side === 'Right').width; - const vertical = sides.find((side) => side.side === 'Top').width - + sides.find((side) => side.side === 'Bottom').width; const rotations = { Bottom: 0, Left: 90, Top: 180, Right: 270 }; pushElement({ type: 'svg-shape', @@ -1536,10 +1696,10 @@ export function extractSlideDataFromDocument(doc = document) { kind: 'native', text: '', position: { - x: pxToInch(rect.left - sides.find((side) => side.side === 'Left').width), - y: pxToInch(rect.top - sides.find((side) => side.side === 'Top').width), - w: pxToInch(Math.max(1, horizontal)), - h: pxToInch(Math.max(1, vertical)), + x: pxToInch(rect.left), + y: pxToInch(rect.top), + w: pxToInch(Math.max(1, rect.width)), + h: pxToInch(Math.max(1, rect.height)), }, shape: { fill: rgbToHex(active.color), @@ -1558,15 +1718,16 @@ export function extractSlideDataFromDocument(doc = document) { const isContainer = containerTags.has(el.tagName); if (isContainer) { const computed = view.getComputedStyle(el); - const hasBg = computed.backgroundColor && computed.backgroundColor !== 'rgba(0, 0, 0, 0)'; + const backgroundColor = elementBackgroundColor(el, computed); + const hasBg = !isTransparentBg(backgroundColor); // Check for background images on shapes const bgImage = computed.backgroundImage; if (bgImage && bgImage !== 'none') { addDiagnostic( - 'fallback', + 'blocking', 'container_background_image', - 'Container background image requires fallback rendering.', + 'Container background image cannot be represented as editable objects.', el, ); } @@ -1640,12 +1801,16 @@ export function extractSlideDataFromDocument(doc = document) { return; } if (rect.width > 0 && rect.height > 0) { - const shadow = parseBoxShadow(computed.boxShadow); + const shadowEffect = parseBoxShadow(elementBoxShadow(el, computed)); + const rotation = getRotation(computed.transform) + || getRotation(el.style?.transform) + || getRotation(authoredStyleValue(el, 'transform')); + const placed = getPositionAndSize(el, rect, rotation); // Only add shape if there's background or uniform border if (hasBg || hasUniformBorder) { const solid = hasBg - ? resolveSolidFill(computed.backgroundColor, slideBackdropHex) + ? resolveSolidFill(backgroundColor, slideBackdropHex) : { fill: null, transparency: null }; let fillHex = solid.fill; let fillTransparency = solid.transparency; @@ -1653,14 +1818,44 @@ export function extractSlideDataFromDocument(doc = document) { fillHex = rgbToHex(computed.borderColor) || '2A2A30'; fillTransparency = fillTransparency ?? 88; } + // Convert border-radius to rectRadius (in inches) + // % values: 50%+ = circle (1), <50% = percentage of min dimension + // pt values: divide by 72 (72pt = 1 inch) + // px values: divide by 96 (96px = 1 inch) + const shapeRectRadius = (() => { + const radius = computed.borderRadius + || el.style.borderRadius + || authoredStyleValue(el, 'border-radius'); + const radiusValue = parseFloat(radius); + if (radiusValue === 0) return 0; + + if (radius.includes('%')) { + if (radiusValue >= 50) return 1; + // Calculate percentage of smaller dimension + const minDim = Math.min(placed.w, placed.h); + return (radiusValue / 100) * pxToInch(minDim); + } + + if (radius.includes('pt')) return radiusValue / 72; + return radiusValue / PX_PER_IN; + })(); + const placedRect = { + left: placed.x, + top: placed.y, + width: placed.w, + height: placed.h, + }; + if (shadowEffect?.mode === 'ring') { + pushCssRingBackdrop(el, placedRect, shapeRectRadius, shadowEffect.ring); + } pushElement({ type: 'shape', text: '', // Shape only - child text elements render on top position: { - x: pxToInch(rect.left), - y: pxToInch(rect.top), - w: pxToInch(rect.width), - h: pxToInch(rect.height) + x: pxToInch(placed.x), + y: pxToInch(placed.y), + w: pxToInch(placed.w), + h: pxToInch(placed.h) }, shape: { fill: fillHex, @@ -1669,26 +1864,9 @@ export function extractSlideDataFromDocument(doc = document) { color: rgbToHex(computed.borderColor), width: pxToPoints(computed.borderWidth) } : null, - // Convert border-radius to rectRadius (in inches) - // % values: 50%+ = circle (1), <50% = percentage of min dimension - // pt values: divide by 72 (72pt = 1 inch) - // px values: divide by 96 (96px = 1 inch) - rectRadius: (() => { - const radius = computed.borderRadius; - const radiusValue = parseFloat(radius); - if (radiusValue === 0) return 0; - - if (radius.includes('%')) { - if (radiusValue >= 50) return 1; - // Calculate percentage of smaller dimension - const minDim = Math.min(rect.width, rect.height); - return (radiusValue / 100) * pxToInch(minDim); - } - - if (radius.includes('pt')) return radiusValue / 72; - return radiusValue / PX_PER_IN; - })(), - shadow: shadow + rectRadius: shapeRectRadius, + shadow: outerShadowOf(shadowEffect), + ...(rotation != null ? { rotate: rotation } : {}), } }, el); } @@ -1768,17 +1946,17 @@ export function extractSlideDataFromDocument(doc = document) { h: pxToInch(listFrame.h) }, style: { - fontSize: pxToPoints(computed.fontSize), - fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim(), + fontSize: pxToPoints(computed.fontSize) || 12, + fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim() || 'Arial', color: textHex, bulletColor, transparency: extractAlpha(computed.color), - align: computed.textAlign === 'start' ? 'left' : computed.textAlign, + align: resolveTextAlign(computed), lineSpacing: resolveLineSpacing(computed), paraSpaceBefore: 0, - paraSpaceAfter: pxToPoints(computed.marginBottom), - // PptxGenJS margin array is [left, right, bottom, top] - margin: [marginLeft, ulPadR, ulPadB, ulPadT] + paraSpaceAfter: pxToPoints(computed.marginBottom) || 0, + // PptxGenJS margin array is [top, right, bottom, left]. + margin: [ulPadT, ulPadR, ulPadB, marginLeft / 72] } }, el); @@ -1807,21 +1985,21 @@ export function extractSlideDataFromDocument(doc = document) { const computed = view.getComputedStyle(element); const filter = String(computed.filter || element.style?.filter || ''); if (filter && filter !== 'none') { - addDiagnostic('fallback', 'css_filter', 'CSS filter requires fallback rendering.', element); + addDiagnostic('blocking', 'css_filter', 'CSS filter cannot be represented as editable objects.', element); } if (String(element.tagName).toUpperCase() === 'SVG') { if (element.querySelector('filter,mask,foreignObject,use,pattern,textPath,clipPath,image')) { addDiagnostic( - 'fallback', - 'complex_svg_raster', - 'SVG filter, mask, or foreignObject requires local browser raster rendering.', + 'blocking', + 'complex_svg_unsupported', + 'SVG filter, mask, or foreignObject cannot be represented as editable objects.', element, ); } else if (element.querySelector('path')) { addDiagnostic( - 'fallback', - 'complex_svg_vector', - 'Complex SVG geometry is preserved as a local SVG image.', + 'rewrite', + 'svg_path_rewrite', + 'SVG path geometry will be rewritten as editable line segments.', element, ); } @@ -1831,19 +2009,9 @@ export function extractSlideDataFromDocument(doc = document) { const blockingErrors = diagnostics .filter((diagnostic) => diagnostic.severity === 'blocking') .map((diagnostic) => diagnostic.message); - slideDataFallbackLayers.sort((a, b) => { - const z = (a.zIndex ?? 0) - (b.zIndex ?? 0); - if (z !== 0) return z; - const order = (a.paintOrder ?? 0) - (b.paintOrder ?? 0); - if (order !== 0) return order; - const sub = (a.subOrder ?? 0) - (b.subOrder ?? 0); - if (sub !== 0) return sub; - return (a.stableOrder ?? 0) - (b.stableOrder ?? 0); - }); return { background, elements, - fallbackLayers: slideDataFallbackLayers, placeholders, diagnostics, errors: blockingErrors, diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js index 9037409775..9733e12a48 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js @@ -260,17 +260,9 @@ export const STRINGS = { exportPptxFailed: 'PPTX export failed:', exportDiagnosticsSummary: 'Exported with visual adjustments: {{counts}}. {{locations}}', exportDiagnosticsRepaired: '{{count}} automatic repairs', - exportDiagnosticsSvg: '{{count}} local SVG layers', - exportDiagnosticsLocalPng: '{{count}} local PNG layers', - exportDiagnosticsPageVisual: '{{count}} page visual layers', - exportDiagnosticsFullPage: '{{count}} full-page fallbacks', exportDiagnosticsBlocking: '{{count}} blocking failures', exportDiagnosticsLocation: 'Slide {{slide}}, element {{source}} ({{phase}}): {{reason}}', exportDiagnosticsPhaseRepair: 'automatic repair', - exportDiagnosticsPhaseSvg: 'local SVG', - exportDiagnosticsPhaseLocalPng: 'local PNG', - exportDiagnosticsPhasePageVisual: 'page visual fallback', - exportDiagnosticsPhaseFullPage: 'full-page fallback', exportDiagnosticsPhaseBlocking: 'blocking failure', exportPdfWorking: 'Rendering PDF...', exportPdfDone: 'PDF downloaded.', @@ -650,17 +642,9 @@ export const STRINGS = { exportPptxFailed: 'PPTX 导出失败:', exportDiagnosticsSummary: '已完成导出,并进行了视觉调整:{{counts}}。{{locations}}', exportDiagnosticsRepaired: '自动修复 {{count}} 处', - exportDiagnosticsSvg: '局部 SVG 层 {{count}} 个', - exportDiagnosticsLocalPng: '局部 PNG 层 {{count}} 个', - exportDiagnosticsPageVisual: '页面视觉层 {{count}} 个', - exportDiagnosticsFullPage: '整页兜底 {{count}} 页', exportDiagnosticsBlocking: '阻断失败 {{count}} 处', exportDiagnosticsLocation: '第 {{slide}} 页,元素 {{source}}({{phase}}):{{reason}}', exportDiagnosticsPhaseRepair: '自动修复', - exportDiagnosticsPhaseSvg: '局部 SVG', - exportDiagnosticsPhaseLocalPng: '局部 PNG', - exportDiagnosticsPhasePageVisual: '页面视觉兜底', - exportDiagnosticsPhaseFullPage: '整页兜底', exportDiagnosticsPhaseBlocking: '阻断失败', exportPdfWorking: '正在渲染 PDF...', exportPdfDone: 'PDF 已下载。', diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-element-export.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-element-export.js index de527436a1..0b28878239 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-element-export.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-element-export.js @@ -1,302 +1,327 @@ -import { createPptxDeck, buildSpeakerNotes } from './pptx-html-build.js'; +import { + EditableExportError, + validateEditableSlideScene, +} from './editable-slide-scene.js'; const SLIDE_W = 13.333; const SLIDE_H = 7.5; -function drawSlideBackdrop(pptx, slide, theme, index) { - slide.addShape(pptx.ShapeType.rect, { - x: 0, - y: 0, - w: SLIDE_W, - h: SLIDE_H, - fill: { color: hex(theme.background) }, - line: { color: hex(theme.background), transparency: 100 }, - }); - slide.addShape(pptx.ShapeType.ellipse, { - x: 10.3, - y: -0.45, - w: 3.8, - h: 3.8, - fill: { color: hex(index % 2 ? theme.accent : theme.primary), transparency: 84 }, - line: { color: hex(theme.background), transparency: 100 }, - }); - slide.addShape(pptx.ShapeType.rect, { - x: 0, - y: 0, - w: 0.12, - h: SLIDE_H, - fill: { color: hex(theme.primary), transparency: 0 }, - line: { color: hex(theme.primary), transparency: 100 }, - }); +function pct(value) { + return Math.max(0, Math.min(100, Number(value) || 0)) / 100; } -function drawSlideMethodology(pptx, slide, sourceSlide, theme) { - if (sourceSlide.kicker) { - slide.addShape(pptx.ShapeType.roundRect, { - x: 0.96, - y: 0.48, - w: 0.22, - h: 0.07, - fill: { color: hex(theme.primary) }, - line: { color: hex(theme.primary), transparency: 100 }, - }); - slide.addText(String(sourceSlide.kicker).toUpperCase(), { - x: 1.24, - y: 0.36, - w: 2.4, - h: 0.28, - margin: 0, - fontFace: 'Aptos', - fontSize: 7, - bold: true, - color: hex(theme.primary), - fit: 'shrink', - }); - } - if (sourceSlide.proofObject) { - slide.addText(String(sourceSlide.proofObject), { - x: 9.26, - y: 0.34, - w: 2.95, - h: 0.32, - margin: 0.04, - fontFace: 'Aptos', - fontSize: 7, - bold: true, - color: hex(theme.muted), - align: 'right', - fit: 'shrink', - fill: { color: hex(theme.panel), transparency: 8 }, - line: { color: hex(theme.primary), transparency: 78 }, - }); - } - if (sourceSlide.sourceNote) { - slide.addText(String(sourceSlide.sourceNote), { - x: 0.96, - y: 7.05, - w: 10.7, - h: 0.2, - margin: 0, - fontFace: 'Aptos', - fontSize: 6, - color: hex(theme.muted), - fit: 'shrink', - }); +function pxToPt(value) { + return Math.max(6, Math.min(66, Math.round((Number(value) || 22) * 0.58))); +} + +function hex(value, fallback = '111827') { + const raw = String(value || '').trim(); + if (/^#[0-9a-f]{6}$/i.test(raw)) return raw.slice(1).toUpperCase(); + if (/^[0-9a-f]{6}$/i.test(raw)) return raw.toUpperCase(); + if (/^#[0-9a-f]{3}$/i.test(raw)) { + return raw.slice(1).split('').map((part) => part + part).join('').toUpperCase(); } + return fallback; } -function drawElement(pptx, slide, element, theme) { - const box = toInches(element); - const style = element.style || {}; - const common = { - x: box.x, - y: box.y, - w: box.w, - h: box.h, - margin: 0.08, - fit: 'shrink', - color: hex(resolveColor(style.color, theme)), - fontFace: 'Aptos', - fontSize: pxToPt(style.fontSize || 22), - bold: Number(style.fontWeight || 500) >= 700, - align: style.align || 'left', - valign: 'mid', - breakLine: false, +function themeFor(sourceSlide = {}) { + const theme = sourceSlide.theme || {}; + return { + background: hex(theme.background, 'FBFCFF'), + ink: hex(theme.ink, '111827'), + muted: hex(theme.muted, '5B6575'), + primary: hex(theme.primary, '0F766E'), + accent: hex(theme.accent, 'F97316'), + panel: hex(theme.panel, 'FFFFFF'), }; - if (element.type === 'shape') { - slide.addShape(pptx.ShapeType.roundRect, { - ...box, - rectRadius: 0.08, - fill: { color: hex(resolveColor(style.background, theme)), transparency: transparency(style.opacity) }, - line: { color: hex(resolveColor(style.background, theme)), transparency: 100 }, - }); - if (element.text) slide.addText(element.text, common); - return; - } - if (element.type === 'list') { - const runs = (element.items || []).map((item) => ({ text: item, options: { bullet: { type: 'bullet' }, breakLine: true } })); - slide.addText(runs.length ? runs : [{ text: '' }], { - ...common, - valign: 'top', - paraSpaceAfterPt: 6, - fit: 'shrink', - }); - return; - } - if (element.type === 'metric') { - drawPanel(pptx, slide, box, style, theme); - slide.addText(String(element.text || ''), { - ...common, - y: box.y + 0.08, - h: box.h * 0.48, - color: hex(resolveColor(style.color || 'primary', theme)), - fontSize: pxToPt(style.fontSize || 42), - bold: true, - }); - slide.addText(String(element.label || ''), { - ...common, - y: box.y + box.h * 0.56, - h: box.h * 0.34, - color: hex(theme.muted), - fontSize: 10, - bold: false, - valign: 'top', - }); - return; - } - if (element.type === 'chart') { - drawPanel(pptx, slide, box, style, theme); - slide.addText(String(element.text || ''), { - ...common, - y: box.y + 0.1, - h: 0.32, - fontSize: 11, - bold: true, - }); - drawBars(pptx, slide, element, box, theme); - return; - } - if (element.type === 'media') { - slide.addShape(pptx.ShapeType.roundRect, { - ...box, - fill: { color: hex(resolveColor(style.background || 'soft', theme)), transparency: 10 }, - line: { color: hex(theme.primary), transparency: 55, dash: 'dash' }, - }); - slide.addText(String(element.text || 'Image placeholder'), { - ...common, - align: 'center', - color: hex(theme.muted), - fontSize: 12, - }); - return; - } - drawTextBackground(pptx, slide, box, style, theme); - slide.addText(String(element.text || ''), common); } -function drawPanel(pptx, slide, box, style, theme) { - slide.addShape(pptx.ShapeType.roundRect, { - ...box, - fill: { color: hex(resolveColor(style.background || 'panel', theme)), transparency: transparency(style.opacity) }, - line: { color: hex(theme.primary), transparency: 82 }, - shadow: { type: 'outer', color: '111827', opacity: 0.12, blur: 1, angle: 45, distance: 1 }, - }); +function resolveColor(value, theme) { + const key = String(value || '').replace(/^#/, '').toLowerCase(); + if (theme[key]) return theme[key]; + return hex(value, theme.ink); } -function drawTextBackground(pptx, slide, box, style, theme) { - const bg = style.background || 'transparent'; - if (bg === 'transparent') return; - slide.addShape(pptx.ShapeType.roundRect, { - ...box, - fill: { color: hex(resolveColor(bg, theme)), transparency: transparency(style.opacity) }, - line: { color: hex(resolveColor(bg, theme)), transparency: 100 }, - }); +function elementBox(element) { + return { + x: pct(element.x) * SLIDE_W, + y: pct(element.y) * SLIDE_H, + w: Math.max(0.01, pct(element.w) * SLIDE_W), + h: Math.max(0.01, pct(element.h) * SLIDE_H), + }; } -function drawBars(pptx, slide, element, box, theme) { - const data = Array.isArray(element.data) && element.data.length - ? element.data - : [{ label: 'A', value: 40 }, { label: 'B', value: 70 }]; - const max = Math.max(1, ...data.map((point) => Number(point.value) || 0)); - const gap = 0.1; - const chartX = box.x + 0.18; - const chartY = box.y + 0.68; - const chartW = box.w - 0.36; - const chartH = box.h - 0.95; - const barW = Math.max(0.08, (chartW - gap * (data.length - 1)) / data.length); - data.forEach((point, index) => { - const value = Number(point.value) || 0; - const h = Math.max(0.15, (value / max) * chartH); - const x = chartX + index * (barW + gap); - const y = chartY + chartH - h; - slide.addShape(pptx.ShapeType.rect, { - x, - y, - w: barW, - h, - fill: { color: hex(index % 2 ? theme.accent : theme.primary) }, - line: { color: hex(index % 2 ? theme.accent : theme.primary), transparency: 100 }, - }); - slide.addText(String(point.label || ''), { - x: x - 0.03, - y: chartY + chartH + 0.04, - w: barW + 0.06, - h: 0.2, - fontSize: 7, - color: hex(theme.muted), - align: 'center', - margin: 0, - fit: 'shrink', - }); - }); +function nodeId(element, index, suffix = '') { + const base = String(element.id || element.sourceId || `element-${index + 1}`); + return suffix ? `${base}-${suffix}` : base; } -function normalizeTheme(theme = {}) { +function shapeNode(sourceId, box, shapeType, fill, paintOrder, extraStyle = {}) { return { - background: theme.background || '#fbfcff', - ink: theme.ink || '#111827', - muted: theme.muted || '#5b6575', - primary: theme.primary || '#0f766e', - accent: theme.accent || '#f97316', - panel: theme.panel || '#ffffff', + type: 'shape', + shapeType, + sourceId, + ...box, + zIndex: paintOrder, + paintOrder, + subOrder: 0, + style: { + fill, + line: null, + ...extraStyle, + }, }; } -function toInches(element) { +function textNode(sourceId, box, text, style, theme, paintOrder, subOrder = 1) { return { - x: pct(element.x) * SLIDE_W, - y: pct(element.y) * SLIDE_H, - w: pct(element.w) * SLIDE_W, - h: pct(element.h) * SLIDE_H, + type: 'text', + sourceId, + ...box, + zIndex: paintOrder, + text, + paintOrder, + subOrder, + style: { + margin: 0.08, + fontFace: 'Aptos', + fontSize: pxToPt(style.fontSize || 22), + bold: Number(style.fontWeight || 500) >= 700, + color: resolveColor(style.color || 'ink', theme), + align: style.align || 'left', + valign: 'mid', + }, }; } -function pct(value) { - return Math.max(0, Math.min(100, Number(value) || 0)) / 100; -} - -function pxToPt(value) { - return Math.max(6, Math.min(66, Math.round((Number(value) || 22) * 0.58))); +function elementModelError(slideNumber, sourceId, code, message) { + throw new EditableExportError({ + slideNumber, + sourceId, + code, + message, + }); } -function resolveColor(value, theme) { - if (!value || value === 'transparent') return theme.background; - if (value === 'ink') return theme.ink; - if (value === 'muted') return theme.muted; - if (value === 'primary') return theme.primary; - if (value === 'accent') return theme.accent; - if (value === 'panel') return theme.panel; - if (value === 'soft') return theme.primary; - if (value === 'background') return theme.background; - return value; +function hasText(value) { + return typeof value === 'string' && Boolean(value.trim()); } -function hex(value) { - const raw = String(value || '#111827').trim(); - if (/^#[0-9a-f]{6}$/i.test(raw)) return raw.slice(1).toUpperCase(); - if (/^[0-9a-f]{6}$/i.test(raw)) return raw.toUpperCase(); - if (/^#[0-9a-f]{3}$/i.test(raw)) { - return raw.slice(1).split('').map((part) => part + part).join('').toUpperCase(); +function normalizeElement(element, index, theme, paintOrder, slideNumber) { + const box = elementBox(element); + const style = element.style || {}; + const sourceId = nodeId(element, index); + const zIndex = Number.isFinite(element.zIndex) ? element.zIndex : paintOrder; + if ((element.type === 'image' || element.type === 'media') && (element.src || element.path || element.data)) { + const nodes = [{ + type: 'image', + intent: 'user-image', + sourceId, + ...box, + zIndex, + paintOrder, + subOrder: 0, + ...(element.src ? { src: element.src } : {}), + ...(element.path ? { path: element.path } : {}), + ...(element.data ? { data: element.data } : {}), + }]; + validateEditableSlideScene({ + slideNumber, + width: SLIDE_W, + height: SLIDE_H, + nodes, + }); + return nodes; } - return '111827'; -} - -function transparency(opacity) { - return Math.round((1 - Math.max(0, Math.min(1, Number(opacity ?? 1)))) * 100); + if (element.type === 'image' || element.type === 'media' || element.type === 'video') { + elementModelError( + slideNumber, + sourceId, + 'element_model_media_unsupported', + `Element "${sourceId}" requires an inline base64 user image; media placeholders and video are not editable.`, + ); + } + if (element.type === 'shape') { + const fill = resolveColor(style.background || 'panel', theme); + const nodes = [ + shapeNode(sourceId, box, 'roundRect', fill, paintOrder, { radius: 0.08 }), + ...(element.text + ? [textNode(`${sourceId}-text`, box, String(element.text), style, theme, paintOrder)] + : []), + ]; + nodes.forEach((node) => { node.zIndex = zIndex; }); + return nodes; + } + if (element.type === 'list') { + if (!Array.isArray(element.items) + || !element.items.length + || element.items.some((item) => !hasText(item))) { + elementModelError( + slideNumber, + sourceId, + 'element_model_payload_invalid', + `List element "${sourceId}" requires at least one non-empty item.`, + ); + } + const runs = (element.items || []).map((item) => ({ + text: String(item), + options: { bullet: { type: 'bullet' }, breakLine: true }, + })); + const nodes = [textNode(sourceId, box, runs, style, theme, paintOrder, 0)]; + nodes[0].zIndex = zIndex; + return nodes; + } + if (element.type === 'metric') { + if (!hasText(element.text) || !hasText(element.label)) { + elementModelError( + slideNumber, + sourceId, + 'element_model_payload_invalid', + `Metric element "${sourceId}" requires non-empty text and label values.`, + ); + } + const nodes = [ + shapeNode(`${sourceId}-panel`, box, 'roundRect', resolveColor(style.background || 'panel', theme), paintOrder, { + radius: 0.08, + }), + textNode(sourceId, { + ...box, + y: box.y + 0.08, + h: box.h * 0.48, + }, String(element.text || ''), { + ...style, + color: style.color || 'primary', + fontSize: style.fontSize || 42, + fontWeight: 700, + }, theme, paintOrder), + textNode(`${sourceId}-label`, { + ...box, + y: box.y + box.h * 0.56, + h: box.h * 0.34, + }, String(element.label || ''), { + color: 'muted', + fontSize: 17, + }, theme, paintOrder, 2), + ]; + nodes.forEach((node) => { node.zIndex = zIndex; }); + return nodes; + } + if (element.type === 'chart') { + if (!Array.isArray(element.data) + || !element.data.length + || element.data.some((point) => !hasText(point?.label) + || !Number.isFinite(Number(point?.value)))) { + elementModelError( + slideNumber, + sourceId, + 'element_model_payload_invalid', + `Chart element "${sourceId}" requires labeled finite data points.`, + ); + } + const data = element.data; + const max = Math.max(1, ...data.map((point) => Number(point.value) || 0)); + const chartX = box.x + 0.18; + const chartY = box.y + 0.68; + const chartW = box.w - 0.36; + const chartH = box.h - 0.95; + const gap = 0.1; + const barW = Math.max(0.08, (chartW - gap * (data.length - 1)) / data.length); + const nodes = [ + shapeNode(`${sourceId}-panel`, box, 'roundRect', resolveColor(style.background || 'panel', theme), paintOrder, { + radius: 0.08, + }), + textNode(`${sourceId}-title`, { ...box, y: box.y + 0.1, h: 0.32 }, + String(element.text || ''), { ...style, fontSize: 19, fontWeight: 700 }, theme, paintOrder, 1), + ]; + data.forEach((point, dataIndex) => { + const height = Math.max(0.15, ((Number(point.value) || 0) / max) * chartH); + const x = chartX + dataIndex * (barW + gap); + nodes.push(shapeNode(`${sourceId}-bar-${dataIndex + 1}`, { + x, + y: chartY + chartH - height, + w: barW, + h: height, + }, 'rect', dataIndex % 2 ? theme.accent : theme.primary, paintOrder, { + line: null, + })); + nodes.at(-1).subOrder = 2 + dataIndex * 2; + nodes.push(textNode(`${sourceId}-label-${dataIndex + 1}`, { + x: x - 0.03, + y: chartY + chartH + 0.04, + w: barW + 0.06, + h: 0.2, + }, String(point.label || ''), { + color: 'muted', + fontSize: 12, + align: 'center', + }, theme, paintOrder, 3 + dataIndex * 2)); + }); + nodes.forEach((node) => { node.zIndex = zIndex; }); + return nodes; + } + if (element.type === 'text') { + if (!hasText(element.text)) { + elementModelError( + slideNumber, + sourceId, + 'element_model_payload_invalid', + `Text element "${sourceId}" requires non-empty text.`, + ); + } + const nodes = []; + if (style.background && style.background !== 'transparent') { + nodes.push(shapeNode(`${sourceId}-background`, box, 'roundRect', + resolveColor(style.background, theme), paintOrder, { radius: 0.08 })); + } + nodes.push(textNode(sourceId, box, element.text, style, theme, paintOrder, nodes.length)); + nodes.forEach((node) => { node.zIndex = zIndex; }); + return nodes; + } + elementModelError( + slideNumber, + sourceId, + 'element_model_type_unsupported', + `Element type "${String(element.type)}" is not supported by editable PPTX export.`, + ); } -export async function exportElementDeckToPptx(deck) { - const slides = Array.isArray(deck.slides) && deck.slides.length > 0 ? deck.slides : []; - const pptx = createPptxDeck(deck); - slides.forEach((sourceSlide, index) => { - const slide = pptx.addSlide(); - const theme = normalizeTheme(sourceSlide.theme); - slide.background = { color: hex(theme.background) }; - drawSlideBackdrop(pptx, slide, theme, index); - drawSlideMethodology(pptx, slide, sourceSlide, theme); - (sourceSlide.elements || []).forEach((element) => drawElement(pptx, slide, element, theme)); - const notes = buildSpeakerNotes(sourceSlide); - if (notes && typeof slide.addNotes === 'function') slide.addNotes(notes); +export function normalizeElementSlideToEditableScene(sourceSlide = {}, { slideNumber = 1 } = {}) { + const theme = themeFor(sourceSlide); + const nodes = [ + shapeNode('legacy-slide-background', { + x: 0, y: 0, w: SLIDE_W, h: SLIDE_H, + }, 'rect', theme.background, 0), + shapeNode('legacy-slide-accent', { + x: 0, y: 0, w: 0.12, h: SLIDE_H, + }, 'rect', theme.primary, 1), + ]; + let paintOrder = 2; + if (sourceSlide.kicker) { + nodes.push(shapeNode('legacy-kicker-mark', { + x: 0.96, y: 0.48, w: 0.22, h: 0.07, + }, 'roundRect', theme.primary, paintOrder, { radius: 0.035 })); + nodes.push(textNode('legacy-kicker', { + x: 1.24, y: 0.36, w: 2.4, h: 0.28, + }, String(sourceSlide.kicker).toUpperCase(), { + color: 'primary', fontSize: 12, fontWeight: 700, + }, theme, paintOrder, 1)); + paintOrder += 1; + } + (sourceSlide.elements || []).forEach((element, index) => { + nodes.push(...normalizeElement(element, index, theme, paintOrder, slideNumber)); + paintOrder += 1; + }); + if (sourceSlide.sourceNote) { + nodes.push(textNode('legacy-source-note', { + x: 0.96, y: 7.05, w: 10.7, h: 0.2, + }, String(sourceSlide.sourceNote), { + color: 'muted', fontSize: 10, + }, theme, paintOrder)); + } + return validateEditableSlideScene({ + slideNumber, + width: SLIDE_W, + height: SLIDE_H, + nodes, }); - return pptx; } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js index 6f55d7b20f..0af0895090 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js @@ -1,8 +1,7 @@ // ───────────────────────────────────────────────────────────────────────────── -// Slide Data → PPTX Build (Stage 2 of 2) +// EditableSlideScene → PPTX Build // -// buildSlideFromExtracted() takes the slideData from html2pptx-dom-core.js and -// maps each element to pptxgenjs API calls (addText, addImage, addShape, etc.). +// buildSlideFromScene() validates and serializes only scene.nodes. // // Key design decisions: // WIDTH_SAFETY_IN — text boxes are widened by 0.15" to absorb cross-renderer @@ -14,14 +13,12 @@ // valign — resolved from CSS flex/grid align-items or line-height ratio. // ───────────────────────────────────────────────────────────────────────────── import pptxgen from 'pptxgenjs'; +import JSZip from 'jszip'; +import { EditableExportError, validateEditableSlideScene } from './editable-slide-scene.js'; -const PX_PER_IN = 96; -const EMU_PER_IN = 914400; const SLIDE_W_IN = 13.333; const SLIDE_H_IN = 7.5; -const EDITABLE_TEXT_TYPES = new Set([ - 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'text', 'svg-text', 'list', 'merged-text', -]); +const OOXML_ROUND_RECT_ADJUSTMENTS = Symbol('ppt-live-round-rect-adjustments'); // PowerPoint and browsers render the same font at the same point size with // measurably different glyph widths (different font metric tables / hinting). @@ -70,209 +67,251 @@ function safeTextBoxGeometry(origX, origW, align, isVerticalText) { function toImagePayload(src) { const raw = String(src || '').trim(); - if (!raw) return null; - if (raw.startsWith('data:')) return { data: raw }; - if (raw.startsWith('file://')) return { path: raw.replace('file://', '') }; - return { path: raw }; + if (!/^data:image\/(?:png|jpeg|webp);base64,[A-Za-z0-9+/]+={0,2}$/i.test(raw)) { + throw new Error('Intentional images must use an inline base64 PNG, JPEG, or WebP data URL'); + } + return { data: raw }; } -function validateDimensions(bodyDimensions, pres) { - const errors = []; - const widthInches = bodyDimensions.width / PX_PER_IN; - const heightInches = bodyDimensions.height / PX_PER_IN; - if (pres.presLayout) { - const layoutWidth = pres.presLayout.width / EMU_PER_IN; - const layoutHeight = pres.presLayout.height / EMU_PER_IN; - if (Math.abs(layoutWidth - widthInches) > 0.1 || Math.abs(layoutHeight - heightInches) > 0.1) { - errors.push( - `HTML dimensions (${widthInches.toFixed(1)}" × ${heightInches.toFixed(1)}") ` - + `don't match presentation layout (${layoutWidth.toFixed(1)}" × ${layoutHeight.toFixed(1)}")`, - ); - } - } - return errors; +function tableCellBorders(border = {}) { + const uniform = border.color + ? { color: border.color, width: border.width || 0 } + : null; + return ['top', 'right', 'bottom', 'left'].map((side) => { + const value = border[side] || uniform || { color: '000000', width: 0 }; + return { + type: value.width > 0 ? 'solid' : 'none', + color: value.color, + pt: value.width, + }; + }); } -function validateTextBoxPosition(slideData, bodyDimensions) { - const errors = []; - const slideHeightInches = bodyDimensions.height / PX_PER_IN; - const minBottomMargin = 0.5; - for (const el of slideData.elements) { - if (!['p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'text', 'list', 'merged-text'].includes(el.type)) continue; - const fontSize = el.style?.fontSize || 0; - const bottomEdge = el.position.y + el.position.h; - const distanceFromBottom = slideHeightInches - bottomEdge; - const textValue = (() => { - if (typeof el.text === 'string') return el.text; - if (Array.isArray(el.text)) return el.text.find((t) => t.text)?.text || ''; - if (Array.isArray(el.items)) return el.items.find((item) => item.text)?.text || ''; - return ''; - })(); - const isFooter = /^\d{1,2}\s*\/\s*\d{1,2}$/.test(textValue.trim()) - || /^(Arknights|数据来源|source:)/i.test(textValue.trim()); - if (isFooter || fontSize <= 11) continue; - if (fontSize > 12 && distanceFromBottom < minBottomMargin) { - const textPrefix = `${textValue.substring(0, 50)}${textValue.length > 50 ? '...' : ''}`; - errors.push( - `Text box "${textPrefix}" ends too close to bottom edge ` - + `(${distanceFromBottom.toFixed(2)}" from bottom, minimum ${minBottomMargin}" required)`, - ); - } - } - return errors; +// CSS system / private font names are not installable PowerPoint typefaces. +// Mapping them to the deck CJK body font keeps usable after export. +const CSS_SYSTEM_FONT_FACES = new Set([ + 'system-ui', + '-apple-system', + 'blinkmacsystemfont', + 'ui-sans-serif', + 'ui-serif', + 'ui-monospace', + 'sans-serif', + 'serif', + 'monospace', + 'emoji', + 'math', + 'fangsong', + '.applesystemuifont', + '.sf ns text', + '.sf ns display', +]); + +function textHintContainsCjk(textHint) { + return /[\u4e00-\u9fff]/.test(String(textHint || '')); } -async function addBackground(slideData, targetSlide) { - if (slideData.background?.type === 'image' && slideData.background.path) { - const payload = toImagePayload(slideData.background.path); - if (payload) targetSlide.background = payload; - } else if (slideData.background?.type === 'color' && slideData.background.value) { - targetSlide.background = { color: slideData.background.value }; +function resolvePptxFontFace(fontFace, textHint = '') { + const face = String(fontFace || '').replace(/['"]/g, '').trim(); + if (!face) return 'PingFang SC'; + const lower = face.toLowerCase(); + if (CSS_SYSTEM_FONT_FACES.has(lower) || lower.startsWith('.')) { + return 'PingFang SC'; + } + // PptxGenJS writes the same typeface into latin/ea/cs. Latin-only faces such + // as Arial therefore lock East-Asian glyphs to tofu after PowerPoint opens + // (or repairs) the table. Prefer the deck CJK body font for CJK runs. + if ( + textHintContainsCjk(textHint) + && (lower === 'arial' || lower === 'helvetica' || lower === 'times new roman') + ) { + return 'PingFang SC'; } + return face; } -function addElements(slideData, targetSlide, pres) { - if (slideData.fullPageFallback) { - const payload = toImagePayload( - slideData.fullPageFallback.data || slideData.fullPageFallback.src, - ); - if (!payload) throw new Error('Full-page fallback has no image payload'); - targetSlide.addImage({ - ...payload, - x: 0, - y: 0, - w: SLIDE_W_IN, - h: SLIDE_H_IN, - }); - return; +function withResolvedFontFace(options = {}, textHint = '') { + if (!options || typeof options !== 'object') return options; + if (options.fontFace == null && options.fontFamily == null) { + return textHintContainsCjk(textHint) + ? { ...options, fontFace: 'PingFang SC' } + : options; } - const suppressedNativeVisualIds = new Set([ - ...(slideData.suppressedNativeVisualIds || []), - ...(slideData.fallbackLayers || []) - .flatMap((layer) => layer.suppressedNativeVisualIds || []), - ]); - const paintItems = [ - ...(slideData.elements || []).map((element, order) => ({ - type: 'element', - item: element, - zIndex: element.zIndex ?? 0, - order: element.paintOrder ?? order, - subOrder: element.subOrder ?? 0, - stableOrder: order, - })), - ...(slideData.fallbackLayers || []).map((layer, order) => ({ - type: 'fallback', - item: layer, - zIndex: layer.zIndex ?? 0, - order: layer.paintOrder ?? ((slideData.elements || []).length + order), - subOrder: layer.subOrder ?? 0, - stableOrder: (slideData.elements || []).length + order, - })), - ].sort((a, b) => ( - a.zIndex - b.zIndex - || a.order - b.order - || a.subOrder - b.subOrder - || a.stableOrder - b.stableOrder + return { + ...options, + fontFace: resolvePptxFontFace(options.fontFace || options.fontFamily, textHint), + }; +} + +function resolveTableText(text) { + if (!Array.isArray(text)) return text; + return text.map((run) => { + if (!run || typeof run !== 'object') return run; + return { + ...run, + options: withResolvedFontFace(run.options || {}, run.text), + }; + }); +} + +function addEditableTable(table, targetSlide) { + const rows = table.rows.map((row) => row.cells.map((cell) => { + const style = cell.style || {}; + const cellTextHint = Array.isArray(cell.text) + ? cell.text.map((run) => run?.text || '').join('') + : cell.text; + const options = { + fill: style.fill == null + ? { color: 'FFFFFF', transparency: 100 } + : style.fill, + border: tableCellBorders(style.border), + align: style.align, + valign: style.valign, + fontFace: resolvePptxFontFace(style.fontFamily, cellTextHint), + fontSize: style.fontSize, + color: style.fontColor, + bold: style.bold, + margin: Array.isArray(style.padding) + ? style.padding.map((inches) => inches * 72) + : 0, + ...(cell.colspan > 1 ? { colspan: cell.colspan } : {}), + ...(cell.rowspan > 1 ? { rowspan: cell.rowspan } : {}), + }; + return { text: resolveTableText(cell.text), options }; + })); + targetSlide.addTable(rows, { + x: table.x, + y: table.y, + w: table.w, + h: table.h, + colW: table.columnWidths, + rowH: table.rows.map((row) => row.height), + autoFit: false, + autoPage: false, + }); +} + +function pptxLineStyle(style = {}) { + const { dash, ...line } = style; + return { + ...line, + ...(dash ? { dashType: dash } : {}), + }; +} + +function pptxTextMargin(margin) { + if (!Array.isArray(margin)) return Number.isFinite(margin) ? margin * 72 : 0; + const [top, right, bottom, left] = margin; + // EditableSlideScene uses CSS order. PptxGenJS 4.0.1's text serializer reads + // its four-value array as left/right/bottom/top and expects point values. + return [left, right, bottom, top].map((inches) => inches * 72); +} + +function pptxTextValue(value) { + if (!Array.isArray(value)) return value; + return value.map((run) => { + const options = withResolvedFontFace({ ...(run.options || {}) }, run.text); + if (options.bullet?.type === 'bullet') { + const bullet = { ...options.bullet }; + delete bullet.type; + options.bullet = bullet; + } + return { + ...run, + options, + }; + }); +} + +function addSceneNodes(slideData, targetSlide, pres) { + const paintItems = slideData.nodes.map((item, stableOrder) => ({ + type: 'element', + item, + zIndex: item.zIndex ?? 0, + order: item.paintOrder ?? stableOrder, + subOrder: item.subOrder ?? 0, + stableOrder, + })).sort((left, right) => ( + left.zIndex - right.zIndex + || left.order - right.order + || left.subOrder - right.subOrder + || left.stableOrder - right.stableOrder )); for (const paintItem of paintItems) { - if (paintItem.type === 'fallback') { - const layer = paintItem.item; - const payload = toImagePayload(layer.data || layer.src); - const bbox = layer.bbox || {}; - if (!payload) continue; - const fullPageCanvas = layer.canvas === 'full-page'; - targetSlide.addImage({ - ...payload, - x: fullPageCanvas ? 0 : (bbox.x ?? 0), - y: fullPageCanvas ? 0 : (bbox.y ?? 0), - w: fullPageCanvas ? SLIDE_W_IN : (bbox.w ?? SLIDE_W_IN), - h: fullPageCanvas ? SLIDE_H_IN : (bbox.h ?? SLIDE_H_IN), - }); - continue; - } const el = paintItem.item; - if (suppressedNativeVisualIds.has(el.sourceId) && !EDITABLE_TEXT_TYPES.has(el.type)) { - continue; - } - if (el.type === 'image') { - const payload = toImagePayload(el.src); - if (!payload) continue; - targetSlide.addImage({ - ...payload, - x: el.position.x, - y: el.position.y, - w: el.position.w, - h: el.position.h, - }); + if (el.type === 'table') { + addEditableTable(el, targetSlide); + } else if (el.type === 'image') { + try { + const payload = toImagePayload(el.src || el.path || el.data); + if (!payload) throw new Error(`Intentional image "${el.sourceId}" has no payload`); + targetSlide.addImage({ + ...payload, + x: el.x, + y: el.y, + w: el.w, + h: el.h, + }); + } catch (cause) { + throw new EditableExportError({ + slideNumber: slideData.slideNumber, + sourceId: el.sourceId, + code: 'pptx_image_serialization', + message: `Intentional image "${el.sourceId}" could not be serialized.`, + cause, + }); + } } else if (el.type === 'line') { targetSlide.addShape(pres.ShapeType.line, { x: el.x1, y: el.y1, w: el.x2 - el.x1, h: el.y2 - el.y1, - line: { color: el.color, width: el.width }, + line: pptxLineStyle(el.style), }); - } else if (el.type === 'shape' || el.type === 'svg-shape') { + } else if (el.type === 'shape') { const shapeOptions = { - x: el.position.x, - y: el.position.y, - w: el.position.w, - h: el.position.h, + x: el.x, + y: el.y, + w: el.w, + h: el.h, }; - const nativeShapeType = { - circle: pres.ShapeType.ellipse, - ellipse: pres.ShapeType.ellipse, - triangle: pres.ShapeType.triangle, - diamond: pres.ShapeType.diamond, - rect: pres.ShapeType.rect, - }[el.svgType]; - shapeOptions.shape = nativeShapeType - || (el.shape.rectRadius > 0 ? pres.ShapeType.roundRect : pres.ShapeType.rect); - if (el.shape.fill) { - shapeOptions.fill = { color: el.shape.fill }; - if (el.shape.transparency != null) shapeOptions.fill.transparency = el.shape.transparency; + shapeOptions.shape = pres.ShapeType[el.shapeType]; + if (!shapeOptions.shape) { + throw new Error(`Unsupported native shape type "${el.shapeType}"`); } - if (el.shape.line) shapeOptions.line = el.shape.line; - if (el.shape.rectRadius > 0) shapeOptions.rectRadius = el.shape.rectRadius; - if (el.shape.shadow) shapeOptions.shadow = el.shape.shadow; - if (el.shape.rotate != null) shapeOptions.rotate = el.shape.rotate; - if (el.type === 'svg-shape') { - targetSlide.addShape(shapeOptions.shape, shapeOptions); - } else { - targetSlide.addText(el.text || '', shapeOptions); + if (el.style.fill) { + shapeOptions.fill = { color: el.style.fill }; + if (el.style.transparency != null) { + shapeOptions.fill.transparency = el.style.transparency; + } } - } else if (el.type === 'list' || el.type === 'merged-text') { - const { x: boxX, w: boxW } = safeTextBoxGeometry(el.position.x, el.position.w, el.style.align, false); - const listOptions = { - x: boxX, - y: el.position.y, - w: boxW, - h: Math.min(el.position.h, Math.max(0.15, SLIDE_H_IN - el.position.y - 0.04)), - fontSize: el.style.fontSize, - fontFace: el.style.fontFace, - color: el.style.color, - align: el.style.align, - valign: 'top', - lineSpacing: el.style.lineSpacing, - paraSpaceBefore: el.style.paraSpaceBefore, - paraSpaceAfter: el.style.paraSpaceAfter, - margin: el.style.margin || 0, - shrinkText: false, - autoFit: false, - }; - if (el.style.transparency != null) listOptions.transparency = el.style.transparency; - targetSlide.addText(el.items || el.text, listOptions); - } else { - const lineHeight = el.style.lineSpacing || el.style.fontSize * 1.2; + if (el.style.line) shapeOptions.line = pptxLineStyle(el.style.line); + if (el.style.shadow) shapeOptions.shadow = el.style.shadow; + if (el.style.rotate != null) shapeOptions.rotate = el.style.rotate; + targetSlide.addShape(shapeOptions.shape, shapeOptions); + if (el.shapeType === 'roundRect') { + const adjustment = Number.isFinite(el.style.radius) + ? Math.round((el.style.radius / Math.min(el.w, el.h)) * 100000) + : 16667; + if (!Array.isArray(pres[OOXML_ROUND_RECT_ADJUSTMENTS])) { + pres[OOXML_ROUND_RECT_ADJUSTMENTS] = []; + } + pres[OOXML_ROUND_RECT_ADJUSTMENTS].push(Math.max(0, Math.min(50000, adjustment))); + } + } else if (el.type === 'text') { const isVerticalText = el.style.vert && el.style.vert !== 'horz'; - const { x: boxX, w: boxW } = safeTextBoxGeometry(el.position.x, el.position.w, el.style.align, isVerticalText); + const { x: boxX, w: boxW } = safeTextBoxGeometry(el.x, el.w, el.style.align, isVerticalText); const textOptions = { x: boxX, - y: el.position.y, + y: el.y, w: boxW, - h: Math.min(el.position.h, Math.max(0.15, SLIDE_H_IN - el.position.y - 0.04)), + h: Math.min(el.h, Math.max(0.15, SLIDE_H_IN - el.y - 0.04)), fontSize: el.style.fontSize, - fontFace: el.style.fontFace, + fontFace: resolvePptxFontFace( + el.style.fontFace, + Array.isArray(el.text) ? el.text.map((run) => run?.text || '').join('') : el.text, + ), color: el.style.color, bold: el.style.bold, italic: el.style.italic, @@ -283,7 +322,7 @@ function addElements(slideData, targetSlide, pres) { paraSpaceAfter: el.style.paraSpaceAfter, // margin reproduces the element's CSS padding as PPTX internal inset, // preventing text from shifting toward the frame's top-left corner. - margin: el.style.margin || 0, + margin: pptxTextMargin(el.style.margin), shrinkText: false, autoFit: false, }; @@ -293,57 +332,85 @@ function addElements(slideData, targetSlide, pres) { if (el.style.transparency != null && el.style.transparency !== undefined) { textOptions.transparency = el.style.transparency; } - targetSlide.addText(el.text, textOptions); + targetSlide.addText(pptxTextValue(el.text), textOptions); } } } -export async function buildSlideFromExtracted(slideData, bodyDimensions, pres, options = {}) { - // Validation findings (overflow, bottom-margin, dimension mismatch) are - // logged as warnings instead of aborting the export: a clipped slide in the - // PPTX is always better than a failed export run. - const validationWarnings = []; - if (bodyDimensions?.errors?.length) validationWarnings.push(...bodyDimensions.errors); - validationWarnings.push(...validateDimensions(bodyDimensions, pres)); - validationWarnings.push(...validateTextBoxPosition(slideData, bodyDimensions)); - if (slideData?.errors?.length) validationWarnings.push(...slideData.errors); - if (validationWarnings.length) { - console.warn('[ppt-live-export] slide validation warnings (export continues):', validationWarnings.join('; ')); - } - const diagnostics = [ - ...(slideData?.diagnostics || []), - ...validationWarnings.map((message) => ({ - severity: 'fallback', - code: 'pptx_layout_warning', - message, - sourceId: null, - tag: null, - })), - ]; +export async function buildSlideFromScene(slideData, pres, options = {}) { + validateEditableSlideScene(slideData); const targetSlide = options.slide || pres.addSlide(); try { - await addBackground(slideData, targetSlide); - addElements(slideData, targetSlide, pres); + addSceneNodes(slideData, targetSlide, pres); } catch (error) { + if (error instanceof EditableExportError) throw error; const diagnostic = { severity: 'blocking', kind: 'blocking', code: 'pptx_serialization', message: String(error?.message || error || 'PPTX serialization failed.'), + slideNumber: slideData.slideNumber, sourceId: null, tag: null, + cause: error, }; error.diagnostic = diagnostic; - error.diagnostics = [...diagnostics, diagnostic]; + error.diagnostics = [diagnostic]; throw error; } return { slide: targetSlide, - placeholders: slideData.placeholders || [], - diagnostics, + diagnostics: [], }; } +// PptxGenJS 4.0.1 assigns table cNvPr ids with `tableIndex * slideNum + 1`, +// while shapes use `objectIndex + 2`. On a slide that already has a background +// shape at id=2, the first table also gets id=2. Duplicate cNvPr ids make +// Microsoft PowerPoint open the file as "needs repair"; the repair rewrite +// commonly destroys CJK runs inside a:tbl while leaving non-table text intact. +function uniquifySlideObjectIds(xml) { + let nextId = 1; + return xml.replace(/ ` 0; + const zip = await JSZip.loadAsync(output, { base64: outputType === 'base64' }); + const slidePaths = Object.keys(zip.files) + .filter((path) => /^ppt\/slides\/slide\d+\.xml$/.test(path)) + .sort((left, right) => ( + Number(left.match(/\d+/)?.[0]) - Number(right.match(/\d+/)?.[0]) + )); + let adjustmentIndex = 0; + for (const path of slidePaths) { + let xml = await zip.file(path).async('string'); + if (needsRoundRect) { + xml = xml.replace( + /|>[\s\S]*?<\/a:avLst>)<\/a:prstGeom>/g, + (match) => { + const adjustment = adjustments[adjustmentIndex]; + adjustmentIndex += 1; + if (adjustment == null) return match; + return '' + + `` + + ''; + }, + ); + } + xml = uniquifySlideObjectIds(xml); + zip.file(path, xml); + } + if (needsRoundRect && adjustmentIndex !== adjustments.length) { + throw new Error('Round rectangle OOXML adjustment count did not match serialized shapes'); + } + return zip.generateAsync({ + type: outputType, + compression: 'DEFLATE', + }); +} + export function createPptxDeck(deck = {}) { const pptx = new pptxgen(); pptx.layout = 'LAYOUT_WIDE'; @@ -357,6 +424,16 @@ export function createPptxDeck(deck = {}) { bodyFontFace: 'PingFang SC', lang: 'zh-CN', }; + pptx[OOXML_ROUND_RECT_ADJUSTMENTS] = []; + const write = pptx.write.bind(pptx); + pptx.write = async (options = {}) => { + const output = await write(options); + return postProcessPptxOutput( + output, + options.outputType, + pptx[OOXML_ROUND_RECT_ADJUSTMENTS], + ); + }; return pptx; } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/preflight-slide-audit.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/preflight-slide-audit.js new file mode 100644 index 0000000000..4565e71f6c --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/preflight-slide-audit.js @@ -0,0 +1,820 @@ +import { EditableExportError } from './editable-slide-scene.js'; + +const VISIBLE_UNSUPPORTED_SELECTOR = [ + 'canvas', 'video', 'audio', 'iframe', 'object', 'embed', + 'frame', 'frameset', 'portal', 'applet', + 'input', 'button', 'select', 'textarea', 'progress', 'meter', 'dialog', + 'link[rel~="stylesheet" i]', +].join(','); + +function sourceIdOf(element, slideNumber) { + return element?.getAttribute?.('data-pptx-source-id') + || element?.id + || `slide-${slideNumber}`; +} + +function fail(slideNumber, element, code, message) { + throw new EditableExportError({ + slideNumber, + sourceId: sourceIdOf(element, slideNumber), + code, + message, + }); +} + +function canonicalizeCssSyntax(value) { + const input = String(value || ''); + let output = ''; + let quote = null; + for (let index = 0; index < input.length;) { + const character = input[index]; + if (quote) { + output += character; + index += 1; + if (character === '\\' && index < input.length) { + output += input[index]; + index += 1; + } else if (character === quote) { + quote = null; + } + continue; + } + if (character === '"' || character === "'") { + quote = character; + output += character; + index += 1; + continue; + } + if (character === '/' && input[index + 1] === '*') { + const end = input.indexOf('*/', index + 2); + index = end < 0 ? input.length : end + 2; + continue; + } + if (character !== '\\') { + output += character; + index += 1; + continue; + } + index += 1; + if (index >= input.length) break; + if (input[index] === '\n' || input[index] === '\r' || input[index] === '\f') { + if (input[index] === '\r' && input[index + 1] === '\n') index += 1; + index += 1; + continue; + } + const hex = input.slice(index).match(/^[0-9a-f]{1,6}/i)?.[0]; + if (hex) { + const codePoint = Number.parseInt(hex, 16); + output += String.fromCodePoint( + codePoint === 0 || codePoint > 0x10FFFF ? 0xFFFD : codePoint, + ); + index += hex.length; + if (/[\t\n\f\r ]/.test(input[index] || '')) index += 1; + continue; + } + output += input[index]; + index += 1; + } + return output; +} + +function cssValueHasUrlToken(value) { + const css = canonicalizeCssSyntax(value); + let quote = null; + for (let index = 0; index < css.length;) { + const character = css[index]; + if (quote) { + if (character === '\\') index += 2; + else { + if (character === quote) quote = null; + index += 1; + } + continue; + } + if (character === '"' || character === "'") { + quote = character; + index += 1; + continue; + } + if (!/[a-z_-]/i.test(character)) { + index += 1; + continue; + } + const start = index; + while (index < css.length && /[a-z0-9_-]/i.test(css[index])) index += 1; + const identifier = css.slice(start, index).toLowerCase(); + while (index < css.length && /\s/.test(css[index])) index += 1; + if (identifier === 'url' && css[index] === '(') return true; + } + return false; +} + +function referencedCustomProperties(value) { + const css = canonicalizeCssSyntax(value); + const references = []; + let quote = null; + for (let index = 0; index < css.length;) { + const character = css[index]; + if (quote) { + if (character === '\\') index += 2; + else { + if (character === quote) quote = null; + index += 1; + } + continue; + } + if (character === '"' || character === "'") { + quote = character; + index += 1; + continue; + } + const match = css.slice(index).match(/^var\s*\(\s*(--[a-z0-9_-]+)/i); + if (match) { + references.push(match[1]); + index += match[0].length; + continue; + } + index += 1; + } + return references; +} + +function valueOrVariableHasUrl(value, declarations, seen = new Set()) { + if (cssValueHasUrlToken(value)) return true; + return referencedCustomProperties(value).some((property) => { + if (seen.has(property) || !declarations.has(property)) return false; + const nextSeen = new Set(seen); + nextSeen.add(property); + return valueOrVariableHasUrl(declarations.get(property), declarations, nextSeen); + }); +} + +function textShadowIsVisible(value, declarations, seen = new Set()) { + const normalized = canonicalizeCssSyntax(value).trim().toLowerCase(); + if (!normalized || normalized === 'none') return false; + const references = referencedCustomProperties(value); + if (!references.length) return true; + return references.some((property) => { + if (seen.has(property) || !declarations.has(property)) return false; + const nextSeen = new Set(seen); + nextSeen.add(property); + return textShadowIsVisible(declarations.get(property), declarations, nextSeen); + }); +} + +function styleDeclarations(style) { + const declarations = new Map(); + for (let index = 0; index < style.length; index += 1) { + const property = style[index].toLowerCase(); + declarations.set(property, { + value: style.getPropertyValue(property), + important: style.getPropertyPriority(property) === 'important', + }); + } + return declarations; +} + +function parsedDeclarations(doc, cssText) { + const probe = doc.createElement('div'); + probe.style.cssText = canonicalizeCssSyntax(cssText); + return styleDeclarations(probe.style); +} + +function compareSpecificity(left, right) { + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return left[index] - right[index]; + } + return 0; +} + +function applyCascadeDeclaration(target, property, declaration) { + const current = target.get(property); + const shouldReplace = !current + || Number(declaration.important) > Number(current.important) + || (declaration.important === current.important + && (compareSpecificity(declaration.specificity, current.specificity) > 0 + || (compareSpecificity(declaration.specificity, current.specificity) === 0 + && declaration.sourceOrder >= current.sourceOrder))); + if (shouldReplace) target.set(property, declaration); +} + +function splitSelectorList(selectorText) { + const selectors = []; + let start = 0; + let quote = null; + let squareDepth = 0; + let roundDepth = 0; + for (let index = 0; index < selectorText.length; index += 1) { + const character = selectorText[index]; + if (quote) { + if (character === '\\') index += 1; + else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === '[') squareDepth += 1; + else if (character === ']') squareDepth = Math.max(0, squareDepth - 1); + else if (character === '(') roundDepth += 1; + else if (character === ')') roundDepth = Math.max(0, roundDepth - 1); + else if (character === ',' && squareDepth === 0 && roundDepth === 0) { + selectors.push(selectorText.slice(start, index).trim()); + start = index + 1; + } + } + selectors.push(selectorText.slice(start).trim()); + return selectors.filter(Boolean); +} + +function matchingParenthesis(value, openingIndex) { + let depth = 1; + let quote = null; + for (let index = openingIndex + 1; index < value.length; index += 1) { + const character = value[index]; + if (quote) { + if (character === '\\') index += 1; + else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === '(') depth += 1; + else if (character === ')') { + depth -= 1; + if (depth === 0) return index; + } + } + return value.length - 1; +} + +function replaceCssUrlsForProjection(value) { + const css = canonicalizeCssSyntax(value); + let output = ''; + let quote = null; + for (let index = 0; index < css.length;) { + const character = css[index]; + if (quote) { + output += character; + index += 1; + if (character === '\\' && index < css.length) { + output += css[index]; + index += 1; + } else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") { + quote = character; + output += character; + index += 1; + continue; + } + if (!/[a-z_-]/i.test(character)) { + output += character; + index += 1; + continue; + } + const start = index; + while (index < css.length && /[a-z0-9_-]/i.test(css[index])) index += 1; + const identifier = css.slice(start, index); + let opening = index; + while (opening < css.length && /\s/.test(css[opening])) opening += 1; + if (identifier.toLowerCase() !== 'url' || css[opening] !== '(') { + output += css.slice(start, index); + continue; + } + const closing = matchingParenthesis(css, opening); + output += 'url("#__pptx_paint_server_probe__")'; + index = closing + 1; + } + return output; +} + +function stripCssImports(value) { + const css = String(value || ''); + let output = ''; + let quote = null; + for (let index = 0; index < css.length;) { + const character = css[index]; + if (quote) { + output += character; + index += 1; + if (character === '\\' && index < css.length) { + output += css[index]; + index += 1; + } else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") { + quote = character; + output += character; + index += 1; + continue; + } + const importMatch = css.slice(index).match(/^@import\b/i); + if (!importMatch) { + output += character; + index += 1; + continue; + } + index += importMatch[0].length; + let parentheses = 0; + let importQuote = null; + while (index < css.length) { + const importCharacter = css[index]; + if (importQuote) { + if (importCharacter === '\\') index += 2; + else { + if (importCharacter === importQuote) importQuote = null; + index += 1; + } + } else if (importCharacter === '"' || importCharacter === "'") { + importQuote = importCharacter; + index += 1; + } else if (importCharacter === '(') { + parentheses += 1; + index += 1; + } else if (importCharacter === ')') { + parentheses = Math.max(0, parentheses - 1); + index += 1; + } else if (importCharacter === ';' && parentheses === 0) { + index += 1; + break; + } else index += 1; + } + } + return output; +} + +function maxSpecificity(selectors) { + return selectors.reduce((maximum, selector) => { + const specificity = selectorSpecificity(selector); + return compareSpecificity(specificity, maximum) > 0 ? specificity : maximum; + }, [0, 0, 0, 0]); +} + +function selectorSpecificity(selector) { + const css = canonicalizeCssSyntax(selector); + const specificity = [0, 0, 0, 0]; + let expectsType = true; + for (let index = 0; index < css.length;) { + const character = css[index]; + if (/\s/.test(character) || ['>', '+', '~', ','].includes(character)) { + expectsType = true; + index += 1; + continue; + } + if (character === '#') { + specificity[1] += 1; + index += 1; + while (index < css.length && /[\w-]/.test(css[index])) index += 1; + expectsType = false; + continue; + } + if (character === '.') { + specificity[2] += 1; + index += 1; + while (index < css.length && /[\w-]/.test(css[index])) index += 1; + expectsType = false; + continue; + } + if (character === '[') { + specificity[2] += 1; + let quote = null; + index += 1; + while (index < css.length) { + if (quote) { + if (css[index] === '\\') index += 2; + else { + if (css[index] === quote) quote = null; + index += 1; + } + } else if (css[index] === '"' || css[index] === "'") { + quote = css[index]; + index += 1; + } else if (css[index] === ']') { + index += 1; + break; + } else index += 1; + } + expectsType = false; + continue; + } + if (character === ':') { + const pseudoElement = css[index + 1] === ':'; + index += pseudoElement ? 2 : 1; + const nameStart = index; + while (index < css.length && /[\w-]/.test(css[index])) index += 1; + const name = css.slice(nameStart, index).toLowerCase(); + const legacyPseudoElement = ['before', 'after', 'first-line', 'first-letter'].includes(name); + if (pseudoElement || legacyPseudoElement) specificity[3] += 1; + else if (!['where', 'is', 'not', 'has'].includes(name)) specificity[2] += 1; + if (css[index] === '(') { + const closing = matchingParenthesis(css, index); + const argument = css.slice(index + 1, closing); + if (['is', 'not', 'has'].includes(name)) { + const nested = maxSpecificity(splitSelectorList(argument)); + specificity[1] += nested[1]; + specificity[2] += nested[2]; + specificity[3] += nested[3]; + } else if (['nth-child', 'nth-last-child'].includes(name)) { + const ofMatch = argument.match(/\bof\b([\s\S]*)$/i); + if (ofMatch) { + const nested = maxSpecificity(splitSelectorList(ofMatch[1])); + specificity[1] += nested[1]; + specificity[2] += nested[2]; + specificity[3] += nested[3]; + } + } + index = closing + 1; + } + expectsType = false; + continue; + } + if (character === '*') { + expectsType = false; + index += 1; + continue; + } + if (expectsType && /[a-z_-]/i.test(character)) { + specificity[3] += 1; + while (index < css.length && /[\w-]/.test(css[index])) index += 1; + expectsType = false; + continue; + } + index += 1; + } + return specificity; +} + +function matchingSelectorSpecificity(element, selectorText) { + const matchingSelectors = splitSelectorList(selectorText).filter((selector) => { + try { + return element.matches(selector); + } catch { + return false; + } + }); + return matchingSelectors.length ? maxSpecificity(matchingSelectors) : null; +} + +function walkCssRules(rules, visit) { + for (const rule of rules || []) { + if (rule.selectorText && rule.style) visit(rule); + if (rule.cssRules) walkCssRules(rule.cssRules, visit); + } +} + +function findCssOpeningBrace(css, start) { + let quote = null; + let parentheses = 0; + for (let index = start; index < css.length; index += 1) { + const character = css[index]; + if (quote) { + if (character === '\\') index += 1; + else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === '(') parentheses += 1; + else if (character === ')') parentheses = Math.max(0, parentheses - 1); + else if (character === '{' && parentheses === 0) return index; + else if (character === ';' && parentheses === 0) return -index - 2; + } + return -1; +} + +function findCssClosingBrace(css, openingBrace) { + let quote = null; + let depth = 1; + for (let index = openingBrace + 1; index < css.length; index += 1) { + const character = css[index]; + if (quote) { + if (character === '\\') index += 1; + else if (character === quote) quote = null; + continue; + } + if (character === '"' || character === "'") quote = character; + else if (character === '{') depth += 1; + else if (character === '}') { + depth -= 1; + if (depth === 0) return index; + } + } + return -1; +} + +function walkCssTextRules(cssText, visit) { + const css = canonicalizeCssSyntax(cssText); + let cursor = 0; + while (cursor < css.length) { + while (cursor < css.length && /[\s;]/.test(css[cursor])) cursor += 1; + if (cursor >= css.length) break; + const openingResult = findCssOpeningBrace(css, cursor); + if (openingResult < 0) { + if (openingResult < -1) { + cursor = -openingResult - 1; + continue; + } + break; + } + const closingBrace = findCssClosingBrace(css, openingResult); + if (closingBrace < 0) break; + const prelude = css.slice(cursor, openingResult).trim(); + const block = css.slice(openingResult + 1, closingBrace); + if (/^@(media|supports|layer|container|document)\b/i.test(prelude)) { + walkCssTextRules(block, visit); + } else if (!prelude.startsWith('@')) { + visit(prelude, block); + } + cursor = closingBrace + 1; + } +} + +function collectStylesheetDeclarations(parsed) { + const byElement = new Map(); + let sourceOrder = 0; + for (const authoredStyle of parsed.querySelectorAll('style')) { + const cssDocument = parsed.implementation.createHTMLDocument(''); + const style = cssDocument.createElement('style'); + style.textContent = canonicalizeCssSyntax(authoredStyle.textContent || ''); + cssDocument.head.appendChild(style); + const mergeRule = (selectorText, declarationsToMerge) => { + let targets; + try { + targets = parsed.querySelectorAll(selectorText); + } catch { + return; + } + for (const target of targets) { + const specificity = matchingSelectorSpecificity(target, selectorText); + if (!specificity) continue; + const declarations = byElement.get(target) || new Map(); + const entries = declarationsToMerge instanceof Map + ? declarationsToMerge + : styleDeclarations(declarationsToMerge); + for (const [property, declaration] of entries) { + applyCascadeDeclaration(declarations, property, { + ...declaration, + specificity, + sourceOrder, + }); + } + byElement.set(target, declarations); + } + sourceOrder += 1; + }; + if (style.sheet?.cssRules) { + walkCssRules(style.sheet.cssRules, (rule) => mergeRule(rule.selectorText, rule.style)); + } else { + walkCssTextRules(authoredStyle.textContent || '', (selectorText, declarationText) => { + mergeRule(selectorText, parsedDeclarations(parsed, declarationText)); + }); + } + } + for (const element of parsed.querySelectorAll('svg [fill], svg [stroke], svg[fill], svg[stroke]')) { + const declarations = byElement.get(element) || new Map(); + for (const property of ['fill', 'stroke']) { + if (!element.hasAttribute(property)) continue; + applyCascadeDeclaration(declarations, property, { + value: canonicalizeCssSyntax(element.getAttribute(property)), + important: false, + specificity: [0, 0, 0, 0], + sourceOrder: -1, + }); + } + byElement.set(element, declarations); + } + for (const element of parsed.querySelectorAll('[style]')) { + const declarations = byElement.get(element) || new Map(); + for (const [property, declaration] of parsedDeclarations( + parsed, + element.getAttribute('style') || '', + )) { + applyCascadeDeclaration(declarations, property, { + ...declaration, + specificity: [1, 0, 0, 0], + sourceOrder, + }); + } + byElement.set(element, declarations); + sourceOrder += 1; + } + return byElement; +} + +function declarationsWithInheritedCustomProperties(element, declarationsByElement) { + const declarations = new Map(); + const ancestors = []; + let current = element.parentElement; + while (current) { + ancestors.unshift(current); + current = current.parentElement; + } + for (const ancestor of ancestors) { + for (const [property, declaration] of declarationsByElement.get(ancestor) || []) { + if (property.startsWith('--') || ['fill', 'stroke', 'text-shadow'].includes(property)) { + declarations.set(property, declaration.value); + } + } + } + for (const [property, declaration] of declarationsByElement.get(element) || []) { + declarations.set(property, declaration.value); + } + return declarations; +} + +function auditAuthoredStyles(parsed, slideNumber) { + const declarationsByElement = collectStylesheetDeclarations(parsed); + + for (const [element] of declarationsByElement) { + const declarations = declarationsWithInheritedCustomProperties( + element, + declarationsByElement, + ); + if (element.closest?.('svg')) { + for (const property of ['fill', 'stroke']) { + const value = declarations.get(property); + if (value && valueOrVariableHasUrl(value, declarations)) { + fail( + slideNumber, + element, + 'svg_paint_server_unsupported', + 'SVG paint-server fills and strokes cannot be represented as editable PowerPoint paint.', + ); + } + } + } + const textShadow = declarations.get('text-shadow'); + if (textShadow && textShadowIsVisible(textShadow, declarations)) { + fail( + slideNumber, + element, + 'text_shadow_unsupported', + 'CSS text-shadow cannot be represented as editable PowerPoint text.', + ); + } + } +} + +function makeMountedStyleProjection(parsed) { + const ownerDocument = globalThis.document; + if (!ownerDocument?.body || typeof globalThis.getComputedStyle !== 'function') return null; + + const host = ownerDocument.createElement('div'); + host.setAttribute('aria-hidden', 'true'); + host.style.cssText = [ + 'position:fixed', + 'left:-30000px', + 'top:0', + 'width:1280px', + 'height:720px', + 'overflow:hidden', + 'opacity:0', + 'pointer-events:none', + ].join(';'); + const projectionRoot = host.attachShadow?.({ mode: 'open' }) || host; + const indexedElements = [...parsed.querySelectorAll('*')]; + indexedElements.forEach((element, index) => { + element.setAttribute('data-pptx-preflight-index', String(index)); + }); + + for (const authoredStyle of parsed.querySelectorAll('style')) { + const style = ownerDocument.createElement('style'); + style.textContent = stripCssImports( + replaceCssUrlsForProjection(authoredStyle.textContent || ''), + ); + projectionRoot.appendChild(style); + } + const controlStyle = ownerDocument.createElement('style'); + controlStyle.textContent = [ + '#pptx-cascade-control { fill:url("#__pptx_specificity_control__"); }', + '.pptx-cascade-control { fill:#fff;stroke:url("#__pptx_important_control__") !important; }', + '#pptx-cascade-control { stroke:#fff; }', + ].join(''); + projectionRoot.appendChild(controlStyle); + + const body = ownerDocument.importNode(parsed.body, true); + body.querySelectorAll('style,script').forEach((element) => element.remove()); + for (const element of body.querySelectorAll('*')) { + if (element.hasAttribute('style')) { + element.setAttribute( + 'style', + replaceCssUrlsForProjection(element.getAttribute('style') || ''), + ); + } + for (const property of ['fill', 'stroke']) { + if (element.hasAttribute(property)) { + element.setAttribute( + property, + replaceCssUrlsForProjection(element.getAttribute(property) || ''), + ); + } + } + for (const attribute of ['src', 'srcset', 'poster', 'data']) { + if (element.hasAttribute(attribute)) element.setAttribute(attribute, 'data:,'); + } + for (const attribute of ['href', 'xlink:href']) { + if (element.hasAttribute(attribute)) { + element.setAttribute(attribute, '#__pptx_preflight_resource__'); + } + } + for (const attribute of [...element.attributes]) { + if (attribute.name.toLowerCase().startsWith('on')) element.removeAttribute(attribute.name); + } + } + projectionRoot.appendChild(body); + + const controlSvg = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const control = ownerDocument.createElementNS('http://www.w3.org/2000/svg', 'rect'); + control.id = 'pptx-cascade-control'; + control.setAttribute('class', 'pptx-cascade-control'); + controlSvg.appendChild(control); + projectionRoot.appendChild(controlSvg); + ownerDocument.body.appendChild(host); + + return { + host, + indexedElements, + projectedElement(element) { + const index = element.getAttribute('data-pptx-preflight-index'); + return body.querySelector(`[data-pptx-preflight-index="${index}"]`); + }, + supportsCascade() { + const computed = globalThis.getComputedStyle(control); + return cssValueHasUrlToken(computed.fill) && cssValueHasUrlToken(computed.stroke); + }, + cleanup() { + host.remove(); + indexedElements.forEach((element) => { + element.removeAttribute('data-pptx-preflight-index'); + }); + }, + }; +} + +function auditMountedComputedStyles(parsed, slideNumber) { + let projection; + try { + projection = makeMountedStyleProjection(parsed); + if (!projection || !projection.supportsCascade()) return false; + for (const element of projection.indexedElements) { + const projected = projection.projectedElement(element); + if (!projected) continue; + const computed = globalThis.getComputedStyle(projected); + if (element.closest?.('svg') + && (cssValueHasUrlToken(computed.fill) || cssValueHasUrlToken(computed.stroke))) { + fail( + slideNumber, + element, + 'svg_paint_server_unsupported', + 'SVG paint-server fills and strokes cannot be represented as editable PowerPoint paint.', + ); + } + if (textShadowIsVisible(computed.textShadow, new Map())) { + fail( + slideNumber, + element, + 'text_shadow_unsupported', + 'CSS text-shadow cannot be represented as editable PowerPoint text.', + ); + } + } + return true; + } catch (error) { + if (error instanceof EditableExportError) throw error; + return false; + } finally { + projection?.cleanup(); + } +} + +function isActiveScript(script) { + const type = String(script.getAttribute('type') || '').trim().toLowerCase(); + return !type + || type === 'module' + || type.includes('javascript') + || type.includes('ecmascript'); +} + +export function auditRawSlideForEditableExport(html, slideNumber) { + const parsed = new DOMParser().parseFromString(String(html || ''), 'text/html'); + const unsupported = parsed.querySelector(VISIBLE_UNSUPPORTED_SELECTOR); + if (unsupported) { + fail( + slideNumber, + unsupported, + 'unsupported_visible_html', + `Visible <${unsupported.localName}> content has no editable PowerPoint rewrite.`, + ); + } + const activeScript = [...parsed.querySelectorAll('script')].find(isActiveScript); + if (activeScript) { + fail( + slideNumber, + activeScript, + 'active_content_unsupported', + 'Active script content is not allowed in editable PowerPoint export.', + ); + } + if (!auditMountedComputedStyles(parsed, slideNumber)) { + auditAuthoredStyles(parsed, slideNumber); + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/render.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/render.js index d58244f936..d078c933c1 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/render.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/render.js @@ -193,7 +193,8 @@ function writeSandboxIframeDocument(frame, sanitizedHtml) { function mountSandboxIframeHtml(frame, html, onMounted) { const sanitizedHtml = sanitizeSlideMarkup(normalizeSlideDocument(html)); frame.setAttribute('sandbox', 'allow-same-origin'); - frame.srcdoc = sanitizedHtml; + // Never use srcdoc here: sandboxed srcdoc iframes render blank in Tauri + // WebKit, and the srcdoc navigation would replace the written document. frame.src = 'about:blank'; const mount = () => { diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/sanitize-slide-html.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/sanitize-slide-html.js index c67e743b42..18cb15df1e 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/sanitize-slide-html.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/sanitize-slide-html.js @@ -342,24 +342,24 @@ export function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { }); } - function collectFallbackDiagnostics(root) { + function collectEditableExportDiagnostics(root) { root.querySelectorAll('*').forEach((element) => { const computed = view.getComputedStyle(element); const backgroundImage = String(computed.backgroundImage || element.style?.backgroundImage || ''); if (backgroundImage.includes('gradient')) { addDiagnostic( - 'fallback', + 'rewrite', 'css_gradient', - 'CSS gradient requires fallback rendering; native gradient mapping is not available.', + 'CSS gradient will be rewritten as editable solid strips.', element, ); } const filter = String(computed.filter || element.style?.filter || ''); if (filter && filter !== 'none') { addDiagnostic( - 'fallback', + 'blocking', 'css_filter', - 'CSS filter requires fallback rendering.', + 'CSS filter cannot be represented as editable objects.', element, ); } @@ -367,16 +367,16 @@ export function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { root.querySelectorAll('svg').forEach((svg) => { if (svg.querySelector('filter,mask,foreignObject,use,pattern,textPath,clipPath,image')) { addDiagnostic( - 'fallback', - 'complex_svg_raster', - 'SVG filter, mask, or foreignObject requires local browser raster rendering.', + 'blocking', + 'complex_svg_unsupported', + 'SVG filter, mask, or foreignObject cannot be represented as editable objects.', svg, ); } else if (svg.querySelector('path')) { addDiagnostic( - 'fallback', - 'complex_svg_vector', - 'Complex SVG geometry will be preserved as a local SVG image.', + 'rewrite', + 'svg_path_rewrite', + 'SVG path geometry will be rewritten as editable line segments.', svg, ); } @@ -402,9 +402,9 @@ export function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { return; } matches.forEach((element) => addDiagnostic( - 'fallback', + 'blocking', 'generated_content', - 'Pseudo-element generated content requires fallback rendering.', + 'Pseudo-element generated content cannot be represented as editable objects.', element, )); }); @@ -591,8 +591,11 @@ export function sanitizeSlideDocumentRoot(doc = document, aggressive = false) { (root.head || root.documentElement).appendChild(style); } + document.querySelectorAll('[style]').forEach((element) => { + element.dataset.pptxAuthoredStyle = element.getAttribute('style'); + }); assignSourceIds(); - collectFallbackDiagnostics(document); + collectEditableExportDiagnostics(document); ensureExportCanvas(); repairNestedParagraphs(document); promoteDecoratedSpans(document); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/style.css b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/style.css index 1362a6af61..b2b2adf78d 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/style.css +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/style.css @@ -320,6 +320,9 @@ button, input, select, textarea { font-size: inherit; line-height: inherit; } min-width: 0; min-height: 0; display: grid; + /* Explicit minmax(0, 1fr) column: an implicit auto column would let a long + nowrap status/export message widen the whole layout past the viewport. */ + grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr) 28px; overflow: hidden; background: var(--studio-bg); @@ -2701,6 +2704,8 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } justify-content: space-between; gap: var(--space-4); min-height: 28px; + min-width: 0; + overflow: hidden; padding: 0 var(--space-4); border-top: 1px solid var(--studio-line); color: var(--studio-muted); @@ -2713,6 +2718,14 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } display: flex; align-items: center; gap: var(--space-3); + min-width: 0; +} + +.statusbar-left > #statusLine { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } .statusbar-spinner { diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/dom-repair-diagnostics.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/dom-repair-diagnostics.test.mjs index 4eedb24bef..bb2745bebe 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/dom-repair-diagnostics.test.mjs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/dom-repair-diagnostics.test.mjs @@ -2,8 +2,13 @@ import assert from 'node:assert/strict'; import { createRequire } from 'node:module'; import test from 'node:test'; +import { + EditableExportError, + validateEditableSlideScene, +} from '../src/editable-slide-scene.js'; +import { normalizeDocumentToEditableScene } from '../src/editable-slide-normalize.js'; import { extractSlideDataFromDocument } from '../src/html2pptx-dom-core.js'; -import { buildSlideFromExtracted } from '../src/pptx-html-build.js'; +import { buildSlideFromScene } from '../src/pptx-html-build.js'; import { sanitizeSlideDocumentRoot } from '../src/sanitize-slide-html.js'; import { HTML_ALLOWED_TAGS, @@ -11,13 +16,6 @@ import { isAllowedSanitizedAttribute, sanitizeSlideDocument, } from '../src/sanitize-slide-markup.js'; -import { - buildPageVisualFallbackRequest, - buildRasterFallbackRequests, - buildWholePageVisualFallbackRequest, - renderRasterFallbackPlan, - renderRasterFallbackLayers, -} from '../src/fallback-layer-render.js'; import { formatLocalizedExportDiagnostic, sanitizeDiagnosticSourceId, @@ -30,6 +28,123 @@ const requireFromWebUi = createRequire( ); const { JSDOM, VirtualConsole } = requireFromWebUi('jsdom'); +test('editable scene rejects fallback fields with located diagnostics', () => { + for (const fallbackField of ['rasterBase64', 'renderRaster']) { + const scene = { + slideNumber: 3, + width: 13.333, + height: 7.5, + nodes: [], + [fallbackField]: null, + }; + + assert.throws( + () => validateEditableSlideScene(scene), + (error) => { + assert.ok(error instanceof EditableExportError); + assert.deepEqual( + { + slideNumber: error.diagnostic.slideNumber, + sourceId: error.diagnostic.sourceId, + code: error.diagnostic.code, + }, + { + slideNumber: 3, + sourceId: 'slide-3', + code: 'editable_scene_fallback_forbidden', + }, + ); + return true; + }, + fallbackField, + ); + } +}); + +test('normalizes an allowed hr element into one native editable line', () => { + const doc = createDocument(` +
    + `); + sanitizeSlideDocumentRoot(doc); + doc.querySelector('hr').getBoundingClientRect = () => ({ + x: 96, y: 192, left: 96, top: 192, right: 480, bottom: 192, + width: 384, height: 0, + }); + + const scene = normalizeDocumentToEditableScene(doc, { + slideNumber: 4, + width: 1280 / 96, + height: 720 / 96, + }); + const rules = scene.nodes.filter((node) => node.sourceId === 'section-rule'); + + assert.equal(rules.length, 1); + assert.deepEqual(rules[0], { + type: 'line', + sourceId: 'section-rule', + x1: 1, + y1: 2, + x2: 5, + y2: 2, + paintOrder: 1, + subOrder: 0, + zIndex: 0, + style: { + color: '123456', + width: 1.5, + dash: 'dash', + dashType: 'dash', + }, + }); +}); + +test('blocks SVG defs geometry and local paint-server fills with structured diagnostics', () => { + const cases = [ + { + sourceId: 'hidden-def-shape', + code: 'svg_defs_geometry_unsupported', + markup: ` + + `, + }, + { + sourceId: 'gradient-shape', + code: 'svg_paint_server_unsupported', + markup: ` + + + `, + }, + { + sourceId: 'pattern-line', + code: 'svg_paint_server_unsupported', + markup: ` + + + `, + }, + ]; + + for (const { sourceId, code, markup } of cases) { + const doc = createDocument(markup); + sanitizeSlideDocumentRoot(doc); + assert.throws( + () => normalizeDocumentToEditableScene(doc, { + slideNumber: 9, + width: 1280 / 96, + height: 720 / 96, + }), + (error) => error instanceof EditableExportError + && error.slideNumber === 9 + && error.sourceId === sourceId + && error.code === code, + sourceId, + ); + } +}); + function createSilentDom(markup, options = {}) { return new JSDOM(markup, { ...options, @@ -37,6 +152,15 @@ function createSilentDom(markup, options = {}) { }); } +function decodeSvgLayerMarkup(data) { + const raw = String(data || ''); + const comma = raw.indexOf(','); + const header = raw.slice(0, comma); + const payload = raw.slice(comma + 1); + if (/;base64$/i.test(header)) return Buffer.from(payload, 'base64').toString('utf8'); + return decodeURIComponent(payload); +} + function createDocument(bodyHtml, css = '') { const dom = createSilentDom(` + + + + +
    +

    PPT 形状与元素测试矩阵

    +

    矩形 · 圆角框 · 圆形 · 描边圆 · 三角 · 线条 · SVG · 多级文字样式

    +
    + + + + +
    +

    实心矩形

    +
    +
    +

    圆角矩形

    +
    + + +
    +

    圆形

    +
    +
    +

    描边圆

    +
    + +
    +
    +

    三角

    +
    + + +
    +
    + + +
    +

    文字样式

    +

    常规 · 粗体 · 斜体 · 强调色 · 下划线

    +

    注解级小字 — 脚注与来源标注用途

    +
    + + +
    +
    +

    SVG 元素集

    +
    + + + + + + + + + + + + + + + + + + + + + 矩形 · 圆角 · 描边 · 圆 · 多边形 · 直线 · 曲线 · 虚线 + +
    + + +
    +

    PPT Live 测试 · 形状与元素 · 01 / 03

    +
    + + + diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/fixtures/editable-slide-02.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/fixtures/editable-slide-02.html new file mode 100644 index 0000000000..49c6098fc5 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/fixtures/editable-slide-02.html @@ -0,0 +1,104 @@ + + + + + + + + + +
    +

    系统能力全景与关键运维指标

    +

    六大模块 · 三十二项能力点 · 2024 年度运行数据

    +
    + + +
    + + +
    +

    核心推理引擎

    +
    +
      +
    • 实时推理支持 50ms 内完成单次请求响应
    • +
    • 多模态解析覆盖文本、图像、结构化数据三通道
    • +
    • 动态路由模块按负载自动选择最优计算节点
    • +
    + + +
    +

    数据流水线

    +
    +

    采集层对接 12 种数据源,日均处理 2.3 亿条记录。清洗阶段执行字段校验、去重、脱敏三步操作,端到端延迟控制在 200ms 以内。

    + + +
    +

    安全与合规

    +
    +
      +
    • 全链路 TLS 1.3 加密传输,证书自动轮换
    • +
    • 细粒度 RBAC 权限模型,支持 256 级角色策略
    • +
    • 审计日志保留 180 天,支持全文检索与导出
    • +
    • 数据驻留可选:北京、上海、新加坡三区域
    • +
    + + +
    +

    运维指标摘要

    +
    +

    SLA 99.95% · P99 延迟 80ms · 月均故障恢复 3.2 分钟 · 季度零安全事件

    + +
    + + +
    +

    模块能力评分

    +

    满分 100 · 内部评测 · 2024 Q4

    + + + + + + + + + + + + + + + + + + 推理 + 管道 + 安全 + 监控 + 网关 + 存储 + + 92 + 85 + 95 + 78 + 88 + 82 + +
    + + +
    +

    PPT Live 测试 · 密集文字排版 · 02 / 03

    +
    + + + diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/fixtures/editable-slide-03.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/fixtures/editable-slide-03.html new file mode 100644 index 0000000000..2a1ec53a08 --- /dev/null +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/fixtures/editable-slide-03.html @@ -0,0 +1,136 @@ + + + + + + + + + +
    +

    数据处理流水线与质量看板

    +

    五阶段端到端流程 · 关键质量指标实时监控

    +
    + + +
    +

    端到端流程

    +
    + + + + +
    +

    数据采集

    +

    Kafka / MQ

    +
    + + + + + + + +
    +

    清洗脱敏

    +

    去重 / 脱敏 / 校验

    +
    + + + + + + + +
    +

    特征构建

    +

    向量化 / 聚合

    +
    + + + + + + + +
    +

    模型推理

    +

    打分 / 预测

    +
    + + + + + + + +
    +

    结果分发

    +

    API / Webhook

    +
    + + +
    +

    关键质量指标

    +
    + + +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

    阶段

    输入

    处理动作

    质量门槛

    采集

    Kafka / MQTT

    拉取与推送双模式接入

    完整率 ≥ 99.5%

    清洗

    原始数据流

    字段校验、去重、PII 脱敏

    脏数据率 ≤ 0.3%

    特征

    清洗后数据

    向量化、时序聚合、维度对齐

    特征覆盖率 ≥ 95%

    推理

    特征向量

    模型打分、阈值判定、结果序列化

    P99 延迟 ≤ 80ms

    +
    + + +
    +

    注:质量门槛基于生产环境 30 天滚动窗口统计,触发告警后自动回滚至上一稳定版本。特征构建阶段标记为关键路径(绿色),延迟超标将直接影响 SLA。

    +
    + + +
    +

    PPT Live 测试 · 表格与流程图 · 03 / 03

    +
    + + + diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs index ffff744bfd..0584cd6d35 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs @@ -683,6 +683,132 @@ test('skill defines the workspace root unambiguously and bounded plan-first comp assert.match(skill, /仅检查一次/); }); +function contractSection(source, startLabel, endLabel) { + const start = source.indexOf(startLabel); + const end = source.indexOf(endLabel, start + startLabel.length); + assert.notEqual(start, -1, `missing section: ${startLabel}`); + assert.notEqual(end, -1, `missing section boundary: ${endLabel}`); + return source.slice(start, end); +} + +function assertNoPositiveVisualFallbackAdvice(source, label) { + const suspectLines = source.split('\n').filter((line) => ( + /\b(?:rasterize|screenshot|fallback)\b/i.test(line) + )); + assert.ok(suspectLines.length > 0, `${label} should explicitly forbid fallback techniques`); + for (const line of suspectLines) { + assert.match( + line, + /禁止|不得|不是|不应|不能|无|拒绝|不存在|not|never/i, + `${label} contains positive visual fallback advice: ${line}`, + ); + } +} + +test('authoring contracts separate generation rules from converter legacy rewrites', async () => { + const skillRoot = new URL('../../../../../../../../assembly/core/builtin_skills/ppt-design/', import.meta.url); + const [skill, editable, visualization, slideDecks] = await Promise.all([ + readFile(new URL('SKILL.md', skillRoot), 'utf8'), + readFile(new URL('references/editable-pptx.md', skillRoot), 'utf8'), + readFile(new URL('references/data-information-visualization.md', skillRoot), 'utf8'), + readFile(new URL('references/slide-decks.md', skillRoot), 'utf8'), + ]); + const prompt = buildAgentPrompt({ instruction: '生成一份含图表、流程图和表格的演示稿' }); + + for (const [label, contract] of [ + ['skill', skill], + ['editable reference', editable], + ['visualization reference', visualization], + ['agent prompt', prompt], + ]) { + const authoring = contractSection( + contract, + 'Authoring subset(生成规则)', + 'Converter legacy rewrite boundary(兼容边界,不是生成建议)', + ); + const compatibility = contractSection( + contract, + 'Converter legacy rewrite boundary(兼容边界,不是生成建议)', + 'End editable contract', + ); + + assert.match(contract, /唯一.*editable HTML\s*→\s*EditableSlideScene\s*→\s*OOXML/i, label); + assert.match(authoring, /1280px\s*[×x]\s*720px/i, label); + assert.match(authoring, /只使用 solid color/i, label); + assert.match(authoring, /不得生成.*CSS gradient.*background-image/is, label); + assert.match(authoring, /HTML 文字.*`?

    `?.*`?

    `?.*`?

    `?.*`?
  • `?/s, label); + assert.match( + authoring, + /box-shadow.*单层.*outer.*非 inset.*zero spread.*不支持.*blocking/is, + label, + ); + assert.match(authoring, /text-shadow.*blocking/is, label); + assert.match(authoring, /优先.*`?line`?.*`?polyline`?/is, label); + assert.match(authoring, /base64.*PNG.*JPEG.*WebP.*GIF/s, label); + assert.match( + authoring, + /禁止任意顶点\/非严格对称.*polygon.*仅.*严格对称.*triangle.*diamond/is, + label, + ); + assert.match( + authoring, + /流程箭头.*editable line\s*\+\s*CSS border triangle.*SVG line\s*\+\s*strict symmetric triangle polygon/is, + label, + ); + + assert.match(compatibility, /兼容既有输入.*不是生成许可/s, label); + assert.match(compatibility, /SVG `?text`?.*支持的 SVG 原语/s, label); + assert.match(compatibility, /`?div`?\s*裸文字.*repair.*不应生成/is, label); + assert.match(compatibility, /M\/L\/H\/V\/C\/S\/Q\/T\/Z/, label); + assert.match(compatibility, /`?fill:\s*none`?/i, label); + assert.match(compatibility, /`?Z`?.*闭合/s, label); + assert.match(compatibility, /拒绝.*`?A`?.*transform/is, label); + assert.match(compatibility, /曲线.*采样.*多段 editable line.*不是 PowerPoint curve/is, label); + assert.match(compatibility, /严格对称.*triangle.*diamond/is, label); + assert.match(compatibility, /任意顶点.*非严格对称 polygon.*拒绝/is, label); + assert.match(compatibility, /linear-gradient.*deg.*turn.*rad.*grad/is, label); + assert.match(compatibility, /percentage stop.*缺省 stop.*均匀分配/is, label); + assert.match(compatibility, /拒绝.*radial-gradient.*px\/em stop.*double-position stop.*color hint/is, label); + assert.match(compatibility, /solid strips.*不是生成建议/is, label); + assert.match( + compatibility, + /hard ring.*box-shadow.*0 0 0 Npx.*同心可编辑 shape.*不得依赖 ring rewrite/is, + label, + ); + + assert.doesNotMatch(contract, /禁止任意 SVG polygon/i, label); + assert.doesNotMatch( + contract, + /CSS\/element-model preset|preset (?:arrow|arrowhead)|rightArrow|chevron/i, + label, + ); + assertNoPositiveVisualFallbackAdvice(contract, label); + } + + assert.match(skill, /slide-decks\.md.*1280\s*[×x]\s*720.*editable-only/is); + assert.match(slideDecks, /1280px\s*[×x]\s*720px/i); + assert.match(slideDecks, /editable HTML\s*→\s*EditableSlideScene\s*→\s*OOXML/i); + assert.match(slideDecks, /无法表示.*停止.*报告/s); + assert.doesNotMatch(slideDecks, /960\s*[×x]\s*540\s*pt/i); + assert.doesNotMatch(slideDecks, /fallback 流程/i); +}); + +test('agent prompt keeps native table and editable visualization requirements in authoring section', () => { + const prompt = buildAgentPrompt({ instruction: '生成一份含图表、流程图和表格的演示稿' }); + const authoring = contractSection( + prompt, + 'Authoring subset(生成规则)', + 'Converter legacy rewrite boundary(兼容边界,不是生成建议)', + ); + + assert.match(authoring, /真实的 ``.*native `a:tbl`/s); + assert.match(authoring, /图表.*流程箭头.*虚线.*曲线.*可编辑原语/s); + assert.match(authoring, /禁止 CSS `filter`、`mask`/); + assert.match(authoring, /generated content/i); + assert.match(authoring, /不得生成 CSS gradient 或 `background-image`/i); + assert.match(authoring, /intentional 图片.*PNG.*JPEG.*WebP.*GIF/s); +}); + test('repository exposes and runs the focused PPT Live contract test in CI', async () => { const repoRoot = new URL('../../../../../../../../../../', import.meta.url); const packageJson = JSON.parse(await readFile(new URL('package.json', repoRoot), 'utf8')); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs index b0f63f24da..2e96e78b1e 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs @@ -1,9 +1,15 @@ import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import test from 'node:test'; import { - buildSlideFromExtracted, + EditableExportError, + validateEditableSlideScene, +} from '../src/editable-slide-scene.js'; +import { + buildSlideFromScene, createPptxDeck, } from '../src/pptx-html-build.js'; @@ -15,37 +21,729 @@ const requireFromWebUi = createRequire( const { JSDOM, VirtualConsole } = requireFromWebUi('jsdom'); const PNG_1X1 = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII='; -const BODY_DIMENSIONS = { width: 1280, height: 720, errors: [] }; - -function textElement({ - text, - sourceId, - zIndex, - paintOrder, - x = 1, - y = 1, - w = 4, - h = 0.6, +const PRODUCTION_FIXTURES = [ + { + name: 'editable-slide-01.html', + sha256: 'a6c3462cfb4ec4da8df0bf4b8e462c729a484057dee71aad3be1baa48e189758', + }, + { + name: 'editable-slide-02.html', + sha256: '95d59e268502ba9bdd4c4b008075d45ee95aa27ca209aabc07aaa38fb74a8d6e', + }, + { + name: 'editable-slide-03.html', + sha256: 'f48d20a6e8826993b0374a26903abc2ef8c893ddacb4d1b29a66cc40d48361e4', + }, +]; + +function fixtureSourceId(html, selector, occurrence = 0) { + const fixtureDom = new JSDOM(html); + try { + const { body } = fixtureDom.window.document; + const target = body.querySelectorAll(selector)[occurrence]; + assert.ok(target, `fixture selector ${selector}[${occurrence}] must exist`); + const elements = [body, ...body.querySelectorAll('*')]; + return `pptx-source-${elements.indexOf(target) + 1}`; + } finally { + fixtureDom.window.close(); + } +} + +function sceneNodesBySource(scene, sourceId, type = null) { + return scene.nodes.filter((node) => ( + node.sourceId === sourceId && (type == null || node.type === type) + )); +} + +function assertNear(actual, expected, message, tolerance = 1e-6) { + assert.ok( + Math.abs(actual - expected) <= tolerance, + `${message}: expected ${expected}, received ${actual}`, + ); +} + +function assertLineEndpoints(line, expected, message) { + assertNear(line.x1, expected.x1, `${message}.x1`); + assertNear(line.y1, expected.y1, `${message}.y1`); + assertNear(line.x2, expected.x2, `${message}.x2`); + assertNear(line.y2, expected.y2, `${message}.y2`); +} + +function svgPointMapper({ + leftPt, topPt, widthPt, heightPt, viewWidth, viewHeight, }) { + const leftPx = leftPt * 4 / 3; + const topPx = topPt * 4 / 3; + const widthPx = widthPt * 4 / 3; + const heightPx = heightPt * 4 / 3; + const scale = Math.min(widthPx / viewWidth, heightPx / viewHeight); + const offsetX = (widthPx - viewWidth * scale) / 2; + const offsetY = (heightPx - viewHeight * scale) / 2; + return ({ x, y }) => ({ + x: (leftPx + offsetX + x * scale) / 96, + y: (topPx + offsetY + y * scale) / 96, + }); +} + +function editableSceneWith(node) { + return { + slideNumber: 2, + width: 13.333, + height: 7.5, + nodes: [node], + }; +} + +function editableTableCell(text, overrides = {}) { return { - type: 'p', text, - sourceId, - kind: 'native', - zIndex, - paintOrder, - position: { x, y, w, h }, style: { - fontSize: 20, - fontFace: 'Arial', - color: '111111', + fill: 'FFFFFF', + border: { color: '222222', width: 1 }, align: 'left', - lineSpacing: 24, - margin: 0, }, + ...overrides, }; } +test('editable scene accepts complete payloads for every native node kind', () => { + const scene = { + slideNumber: 2, + width: 13.333, + height: 7.5, + nodes: [ + { + type: 'text', sourceId: 'title', x: 1, y: 1, w: 4, h: 0.6, + text: 'Editable title', style: { fontSize: 20 }, + }, + { + type: 'shape', sourceId: 'panel', x: 0.5, y: 0.5, w: 5, h: 2.5, + shapeType: 'rect', style: { fill: 'DDEEFF' }, + }, + { + type: 'line', sourceId: 'route', x1: 1, y1: 1, x2: 2, y2: 1, + style: { color: '224466', width: 1 }, + }, + { + type: 'table', sourceId: 'table', x: 1, y: 3, w: 5, h: 2, + columnWidths: [2, 3], + rows: [ + { height: 1, cells: [editableTableCell('Header', { colspan: 2 })] }, + { height: 1, cells: [editableTableCell('Label'), editableTableCell('Value')] }, + ], + }, + { + type: 'image', sourceId: 'photo-src', x: 7, y: 1, w: 1, h: 1, + intent: 'user-image', src: PNG_1X1, + }, + { + type: 'image', sourceId: 'photo-data', x: 8, y: 1, w: 1, h: 1, + intent: 'user-image', data: PNG_1X1, + }, + ], + }; + + assert.equal(validateEditableSlideScene(scene), scene); +}); + +test('editable scene accepts the locked native shape type whitelist', () => { + const shapeTypes = [ + 'rect', 'roundRect', 'ellipse', 'triangle', 'diamond', + 'rightArrow', 'leftArrow', 'upArrow', 'downArrow', + 'chevron', 'parallelogram', 'trapezoid', 'hexagon', + ]; + const scene = { + slideNumber: 2, + width: 13.333, + height: 7.5, + nodes: shapeTypes.map((shapeType, index) => ({ + type: 'shape', + sourceId: `shape-${index}`, + x: 1, + y: 1, + w: 2, + h: 1, + shapeType, + style: {}, + })), + }; + + assert.equal(validateEditableSlideScene(scene), scene); +}); + +test('editable scene rejects unknown native shape types', () => { + assert.throws( + () => validateEditableSlideScene(editableSceneWith({ + type: 'shape', + sourceId: 'unsupported-star', + x: 1, + y: 1, + w: 2, + h: 1, + shapeType: 'star5', + style: {}, + })), + (error) => { + assert.equal(error.code, 'editable_scene_payload_invalid'); + assert.equal(error.sourceId, 'unsupported-star'); + return true; + }, + ); +}); + +test('editable scene rejects unknown node kinds with located diagnostics', () => { + assert.throws( + () => validateEditableSlideScene({ + slideNumber: 4, + width: 13.333, + height: 7.5, + nodes: [{ + type: 'raster', + sourceId: 'generated-chart', + x: 1, + y: 1, + w: 4, + h: 3, + }], + }), + (error) => { + assert.ok(error instanceof EditableExportError); + assert.equal(error.diagnostic.slideNumber, 4); + assert.equal(error.diagnostic.sourceId, 'generated-chart'); + assert.equal(error.diagnostic.code, 'editable_scene_node_type_unsupported'); + assert.equal(error.slideNumber, error.diagnostic.slideNumber); + assert.equal(error.sourceId, error.diagnostic.sourceId); + assert.equal(error.code, error.diagnostic.code); + return true; + }, + ); +}); + +test('editable scene recursively rejects nested fallback metadata with node ownership', () => { + const nestedNodes = [ + { + type: 'shape', + sourceId: 'nested-shape', + x: 1, + y: 1, + w: 2, + h: 1, + shapeType: 'rect', + style: { effects: [{ kind: 'raster' }] }, + }, + { + type: 'table', + sourceId: 'nested-table', + x: 1, + y: 1, + w: 4, + h: 2, + columnWidths: [4], + rows: [{ + cells: [editableTableCell('Blocked', { + style: { + fill: 'FFFFFF', + border: { color: '222222', width: 1 }, + align: 'left', + layers: [{ [['full', 'Page', 'Fallback'].join('')]: null }], + }, + })], + }], + }, + { + type: 'image', + sourceId: 'nested-image', + x: 1, + y: 1, + w: 2, + h: 1, + intent: 'user-image', + data: 'data:image/png;base64,AA==', + metadata: [{ kind: 'svg-image' }], + }, + ]; + + for (const node of nestedNodes) { + assert.throws( + () => validateEditableSlideScene(editableSceneWith(node)), + (error) => { + assert.equal(error.code, 'editable_scene_fallback_forbidden'); + assert.equal(error.sourceId, node.sourceId); + return true; + }, + node.sourceId, + ); + } + + assert.throws( + () => validateEditableSlideScene({ + slideNumber: 6, + width: 13.333, + height: 7.5, + nodes: [], + metadata: [{ fallbackLayers: [] }], + }), + (error) => { + assert.equal(error.code, 'editable_scene_fallback_forbidden'); + assert.equal(error.sourceId, 'slide-6'); + return true; + }, + ); +}); + +test('editable scene rejects node-level fallback and raster metadata', () => { + const cases = [ + { + sourceId: 'node-fallback-layers', + node: { + type: 'shape', sourceId: 'node-fallback-layers', x: 1, y: 1, w: 2, h: 2, + shapeType: 'rect', style: {}, fallbackLayers: [], + }, + }, + { + sourceId: 'node-capture', + node: { + type: 'shape', sourceId: 'node-capture', x: 1, y: 1, w: 2, h: 2, + shapeType: 'rect', style: {}, captureStrategy: 'visual-subtree', + }, + }, + { + sourceId: 'node-phase', + node: { + type: 'shape', sourceId: 'node-phase', x: 1, y: 1, w: 2, h: 2, + shapeType: 'rect', style: {}, phase: 'local-visual', + }, + }, + { + sourceId: 'node-canvas', + node: { + type: 'shape', sourceId: 'node-canvas', x: 1, y: 1, w: 2, h: 2, + shapeType: 'rect', style: {}, canvas: 'full-page', + }, + }, + { + sourceId: 'node-data', + node: { + type: 'shape', sourceId: 'node-data', x: 1, y: 1, w: 2, h: 2, + shapeType: 'rect', style: {}, data: 'data:image/png;base64,AA==', + }, + }, + ]; + + for (const { sourceId, node } of cases) { + assert.throws( + () => validateEditableSlideScene(editableSceneWith(node)), + (error) => { + assert.equal(error.code, 'editable_scene_fallback_forbidden'); + assert.equal(error.sourceId, sourceId); + return true; + }, + sourceId, + ); + } +}); + +test('editable scene rejects an image disguised as a raster fallback', () => { + assert.throws( + () => validateEditableSlideScene(editableSceneWith({ + type: 'image', + sourceId: 'raster-image', + x: 1, + y: 1, + w: 2, + h: 2, + intent: 'user-image', + data: 'data:image/png;base64,AA==', + kind: 'raster', + })), + (error) => { + assert.equal(error.code, 'editable_scene_fallback_forbidden'); + assert.equal(error.sourceId, 'raster-image'); + return true; + }, + ); +}); + +test('editable scene rejects non-finite, zero-length, and negative geometry', () => { + const invalidNodes = [ + { + type: 'line', sourceId: 'zero-line', x1: 1, y1: 1, x2: 1, y2: 1, + style: { color: '111111' }, + }, + { + type: 'line', sourceId: 'infinite-line', x1: 1, y1: 1, x2: Number.POSITIVE_INFINITY, y2: 2, + style: { color: '111111' }, + }, + { + type: 'shape', + sourceId: 'negative-width', + x: 1, + y: 1, + w: -1, + h: 2, + shapeType: 'rect', + style: {}, + }, + { + type: 'text', sourceId: 'negative-height', x: 1, y: 1, w: 1, h: -1, + text: 'Invalid', style: {}, + }, + ]; + + for (const node of invalidNodes) { + assert.throws( + () => validateEditableSlideScene(editableSceneWith(node)), + (error) => { + assert.equal(error.code, 'editable_scene_geometry_invalid'); + assert.equal(error.sourceId, node.sourceId); + return true; + }, + node.sourceId, + ); + } +}); + +test('editable scene rejects missing and empty node source ids', () => { + for (const sourceId of [undefined, '', ' ']) { + assert.throws( + () => validateEditableSlideScene(editableSceneWith({ + type: 'text', + ...(sourceId === undefined ? {} : { sourceId }), + x: 1, + y: 1, + w: 2, + h: 1, + text: 'Located text', + style: {}, + })), + (error) => { + assert.equal(error.code, 'editable_scene_source_id_invalid'); + assert.ok(error.sourceId); + return true; + }, + ); + } +}); + +test('editable scene rejects missing required payload for every node kind', () => { + const invalidNodes = [ + { type: 'text', sourceId: 'text-payload', x: 1, y: 1, w: 2, h: 1, style: {} }, + { + type: 'text', sourceId: 'text-style', x: 1, y: 1, w: 2, h: 1, + text: 'Missing style', + }, + { + type: 'shape', sourceId: 'shape-payload', x: 1, y: 1, w: 2, h: 1, + style: {}, + }, + { + type: 'shape', sourceId: 'shape-style', x: 1, y: 1, w: 2, h: 1, + shapeType: 'rect', + }, + { + type: 'line', sourceId: 'line-payload', x1: 1, y1: 1, x2: 2, y2: 2, + }, + { + type: 'table', sourceId: 'table-payload', x: 1, y: 1, w: 2, h: 1, + columnWidths: [2], rows: [{ cells: [] }], + }, + { + type: 'image', sourceId: 'image-payload', x: 1, y: 1, w: 2, h: 1, + src: 'assets/photo.png', + }, + { + type: 'image', sourceId: 'image-source', x: 1, y: 1, w: 2, h: 1, + intent: 'user-image', + }, + ]; + + for (const node of invalidNodes) { + assert.throws( + () => validateEditableSlideScene(editableSceneWith(node)), + (error) => { + assert.equal(error.code, 'editable_scene_payload_invalid'); + assert.equal(error.sourceId, node.sourceId); + return true; + }, + node.sourceId, + ); + } +}); + +test('editable scene rejects incomplete table column, cell style, and span contracts', () => { + const validCell = () => editableTableCell('Value'); + const invalidTables = [ + { + sourceId: 'table-missing-widths', + rows: [{ cells: [validCell()] }], + }, + { + sourceId: 'table-invalid-width', + columnWidths: [0], + rows: [{ cells: [validCell()] }], + }, + { + sourceId: 'table-column-mismatch', + columnWidths: [1, 1], + rows: [{ cells: [validCell()] }], + }, + { + sourceId: 'table-missing-style', + columnWidths: [1], + rows: [{ cells: [{ text: 'Value' }] }], + }, + { + sourceId: 'table-invalid-fill', + columnWidths: [1], + rows: [{ cells: [editableTableCell('Value', { + style: { + fill: 'white', + border: { color: '222222', width: 1 }, + align: 'left', + }, + })] }], + }, + { + sourceId: 'table-invalid-border', + columnWidths: [1], + rows: [{ cells: [editableTableCell('Value', { + style: { + fill: 'FFFFFF', + border: { color: '222222', width: -1 }, + align: 'left', + }, + })] }], + }, + { + sourceId: 'table-invalid-align', + columnWidths: [1], + rows: [{ cells: [editableTableCell('Value', { + style: { + fill: 'FFFFFF', + border: { color: '222222', width: 1 }, + align: 'justify', + }, + })] }], + }, + { + sourceId: 'table-invalid-rowspan', + columnWidths: [1], + rows: [{ cells: [editableTableCell('Value', { rowspan: 0 })] }], + }, + { + sourceId: 'table-invalid-colspan', + columnWidths: [1], + rows: [{ cells: [editableTableCell('Value', { colspan: 1.5 })] }], + }, + ]; + + for (const { sourceId, ...payload } of invalidTables) { + assert.throws( + () => validateEditableSlideScene(editableSceneWith({ + type: 'table', + sourceId, + x: 1, + y: 1, + w: 4, + h: 2, + ...payload, + })), + (error) => { + assert.equal(error.code, 'editable_scene_payload_invalid'); + assert.equal(error.sourceId, sourceId); + return true; + }, + sourceId, + ); + } +}); + +test('editable scene accepts transparent table fill and rejects width or height sum mismatches', () => { + const transparent = editableSceneWith({ + type: 'table', + sourceId: 'transparent-table', + x: 1, + y: 1, + w: 2, + h: 1, + columnWidths: [2], + rows: [{ + height: 1, + cells: [editableTableCell('Transparent', { + style: { + fill: null, + border: { color: '222222', width: 1 }, + align: 'left', + }, + })], + }], + }); + assert.equal(validateEditableSlideScene(transparent), transparent); + + const mismatches = [ + { + sourceId: 'table-width-sum', + w: 2.2, + h: 1, + columnWidths: [1, 1], + rows: [{ + height: 1, + cells: [editableTableCell('A'), editableTableCell('B')], + }], + }, + { + sourceId: 'table-height-sum', + w: 2, + h: 1.2, + columnWidths: [1, 1], + rows: [{ + height: 1, + cells: [editableTableCell('A'), editableTableCell('B')], + }], + }, + ]; + for (const payload of mismatches) { + assert.throws( + () => validateEditableSlideScene(editableSceneWith({ + type: 'table', + x: 1, + y: 1, + ...payload, + })), + (error) => error.code === 'editable_scene_payload_invalid' + && error.sourceId === payload.sourceId, + payload.sourceId, + ); + } +}); + +test('serializes an editable table scene node as native a:tbl OOXML without pictures', async () => { + const pptx = createPptxDeck({ title: 'Native table' }); + const cellStyle = (fill, overrides = {}) => ({ + fill, + border: { + top: { color: '111111', width: 0.75 }, + right: { color: '222222', width: 1.5 }, + bottom: { color: '333333', width: 2.25 }, + left: { color: '444444', width: 3 }, + }, + align: 'left', + valign: 'mid', + fontFamily: 'Arial', + fontSize: 12, + fontColor: '556677', + bold: false, + padding: [0.05, 0.1, 0.15, 0.2], + ...overrides, + }); + const scene = { + slideNumber: 3, + width: 13.333, + height: 7.5, + nodes: [{ + type: 'table', + sourceId: 'native-table', + x: 1, + y: 1.5, + w: 6, + h: 2, + columnWidths: [1.25, 2.25, 2.5], + rows: [ + { + height: 0.6, + cells: [{ + text: [ + { text: 'Native ', options: { color: 'FFFFFF' } }, + { text: 'header', options: { bold: true, color: 'FFEEAA' } }, + ], + colspan: 2, + style: cellStyle('112233', { align: 'center', bold: true }), + }, { + text: 'Metric', + style: cellStyle('223344', { fontColor: 'FFFFFF', bold: true }), + }], + }, + { + height: 0.7, + cells: [{ + text: 'Merged', + rowspan: 2, + style: cellStyle('DDEEFF'), + }, { + text: 'B1', + style: cellStyle('FFFFFF'), + }, { + text: 'C1', + style: cellStyle('FFFFFF'), + }], + }, + { + height: 0.7, + cells: [{ + text: 'B2', + style: cellStyle(null), + }, { + text: 'C2', + style: cellStyle('F5F5F5'), + }], + }, + ], + }], + }; + + await buildSlideFromScene(scene, pptx); + const zip = await writeAndOpen(pptx); + const [slideXml, relsXml] = await Promise.all([ + zipText(zip, 'ppt/slides/slide1.xml'), + zipText(zip, 'ppt/slides/_rels/slide1.xml.rels'), + ]); + + assert.match(slideXml, //); + assert.match(slideXml, /gridSpan="2"/); + assert.match(slideXml, /rowSpan="2"/); + assert.match(slideXml, /Native <\/a:t>/); + assert.match(slideXml, /header<\/a:t>/); + assert.match(slideXml, /]*b="1"/); + assert.match(slideXml, /<\/a:solidFill>/); + assert.deepEqual( + [...slideXml.matchAll(//g)].map((match) => Number(match[1])), + [1143000, 2057400, 2286000], + ); + assert.deepEqual( + [...slideXml.matchAll(//g)].map((match) => Number(match[1])), + [548640, 640080, 640080], + ); + const tableCells = [...slideXml.matchAll(/]*)?>[\s\S]*?<\/a:tc>/g)] + .map((match) => match[0]); + const headerCellXml = tableCells.find((cellXml) => cellXml.includes('Native ')); + assert.ok(headerCellXml); + assert.match(headerCellXml, /]*marL="182880"[^>]*marR="91440"[^>]*marT="45720"[^>]*marB="137160"[^>]*anchor="mid"/); + assert.match(headerCellXml, /]*algn="ctr"/); + assert.match(headerCellXml, /]*sz="1200"[^>]*b="1"/); + assert.match(headerCellXml, /[\s\S]*?<\/a:r>/g)] + .map((match) => match[0]); + const nativeRunXml = headerRuns.find((runXml) => runXml.includes('Native ')); + const emphasizedRunXml = headerRuns.find((runXml) => runXml.includes('header')); + assert.match(nativeRunXml, /<\/a:solidFill>/); + assert.match(emphasizedRunXml, /<\/a:solidFill>/); + const defaultColorCellXml = tableCells.find((cellXml) => cellXml.includes('B1')); + assert.ok(defaultColorCellXml); + assert.match(defaultColorCellXml, /]*>[\s\S]*?<\/a:solidFill>/); + const transparentCellXml = tableCells.find((cellXml) => cellXml.includes('B2')); + assert.ok(transparentCellXml); + assert.match( + transparentCellXml, + /|<\/a:srgbClr><\/a:solidFill>/, + ); + assert.doesNotMatch(slideXml, //); + assert.doesNotMatch(relsXml, /relationships\/image"/); +}); + async function writeAndOpen(pptx) { const output = await pptx.write({ outputType: 'nodebuffer' }); assert.ok(Buffer.isBuffer(output), 'PptxGenJS 4.0.1 must return a Node Buffer'); @@ -59,36 +757,48 @@ async function zipText(zip, path) { return entry.async('string'); } -function topLevelSlideObjects(slideXml) { - return [...slideXml.matchAll(/[\s\S]*?<\/p:\1>/g)] - .map((match) => ({ type: match[1], xml: match[0] })); -} - -function pictureExtents(objectXml) { - const match = objectXml.match( - /[\s\S]*?\s*/, - ); - assert.ok(match, 'picture transform must contain offset and extent'); - return { - x: Number(match[1]), - y: Number(match[2]), - cx: Number(match[3]), - cy: Number(match[4]), - }; -} - -function relatedMediaPaths(relsXml) { - return [...relsXml.matchAll(/Type="[^"]*\/image" Target="\.\.\/media\/([^"]+)"/g)] - .map((match) => `ppt/media/${match[1]}`); -} - -async function withControllableExportDom(run) { +async function withControllableExportDom( + run, + { realisticLayout = false, webkitBorderBoxRegressionSimulation = false } = {}, +) { const dom = new JSDOM('', { pretendToBeVisual: true, virtualConsole: new VirtualConsole(), + url: 'https://ppt-live.test/', }); const { window } = dom; const { document } = window; + if (webkitBorderBoxRegressionSimulation) { + const nativeGetComputedStyle = window.getComputedStyle.bind(window); + const borderPixels = (value) => { + const raw = String(value || '').trim(); + const amount = parseFloat(raw) || 0; + return raw.endsWith('pt') ? amount * 4 / 3 : amount; + }; + window.getComputedStyle = (element, pseudoElement) => { + const computed = nativeGetComputedStyle(element, pseudoElement); + const borderWidth = ['Left', 'Right'] + .reduce((sum, side) => sum + borderPixels(computed[`border${side}Width`]), 0); + const borderHeight = ['Top', 'Bottom'] + .reduce((sum, side) => sum + borderPixels(computed[`border${side}Width`]), 0); + const isZeroContentBorderBox = element.tagName === 'DIV' + && element.style.width !== '' + && element.style.height !== '' + && parseFloat(element.style.width) === 0 + && parseFloat(element.style.height) === 0 + && borderWidth > 0 + && borderHeight > 0; + if (!isZeroContentBorderBox) return computed; + return new Proxy(computed, { + get(target, property) { + if (property === 'width') return `${borderWidth}px`; + if (property === 'height') return `${borderHeight}px`; + if (property === 'boxSizing') return 'border-box'; + return Reflect.get(target, property, target); + }, + }); + }; + } const savedGlobals = new Map(); const globals = { window, @@ -99,6 +809,7 @@ async function withControllableExportDom(run) { getComputedStyle: window.getComputedStyle.bind(window), requestAnimationFrame: window.requestAnimationFrame.bind(window), cancelAnimationFrame: window.cancelAnimationFrame.bind(window), + localStorage: window.localStorage, }; Object.entries(globals).forEach(([key, value]) => { savedGlobals.set(key, Object.getOwnPropertyDescriptor(globalThis, key)); @@ -122,7 +833,7 @@ async function withControllableExportDom(run) { return { left, top, width, height }; }, }); - const measuredRect = (element) => { + const simpleMeasuredRect = (element) => { if (element.classList?.contains('ppt-export-root') || element.classList?.contains('ppt-export-body')) { return rect(0, 0, 1280, 720); @@ -136,6 +847,89 @@ async function withControllableExportDom(run) { parseFloat(style.height) || 48, ); }; + const cssPixels = (value, relativeTo = 0) => { + const raw = String(value || '').trim(); + if (!raw || raw === 'auto' || raw === 'none') return 0; + const amount = Number.parseFloat(raw); + if (!Number.isFinite(amount)) return 0; + if (raw.endsWith('pt')) return amount * 4 / 3; + if (raw.endsWith('%')) return relativeTo * amount / 100; + return amount; + }; + const realisticMeasuredRect = (element) => { + if (element.classList?.contains('ppt-export-root') + || element.classList?.contains('ppt-export-body')) { + return rect(0, 0, 1280, 720); + } + const parent = element.parentElement; + const parentRect = parent ? realisticMeasuredRect(parent) : rect(0, 0, 1280, 720); + const style = element.style || {}; + const authoredStyle = element.dataset?.pptxAuthoredStyle || ''; + const authoredValue = (property) => { + const match = authoredStyle.match(new RegExp(`(?:^|;)\\s*${property}\\s*:\\s*([^;]+)`, 'i')); + return match?.[1]?.trim() || ''; + }; + const layoutValue = (property, cssProperty = property) => ( + style[property] || authoredValue(cssProperty) + ); + const computed = window.getComputedStyle(element); + const tag = element.tagName; + const table = element.closest?.('table'); + if (tag === 'TABLE') { + const rowCount = Math.max(1, element.rows.length); + const width = cssPixels(layoutValue('width'), parentRect.width) || parentRect.width; + return rect(parentRect.left, parentRect.top, width, rowCount * 40); + } + if (tag === 'THEAD' || tag === 'TBODY' || tag === 'TFOOT') { + const rowsBefore = [...table.rows].findIndex((row) => row.parentElement === element); + return rect(parentRect.left, realisticMeasuredRect(table).top + Math.max(0, rowsBefore) * 40, + realisticMeasuredRect(table).width, element.rows.length * 40); + } + if (tag === 'TR') { + const tableRect = realisticMeasuredRect(table); + return rect(tableRect.left, tableRect.top + [...table.rows].indexOf(element) * 40, + tableRect.width, 40); + } + if (tag === 'TD' || tag === 'TH') { + const tableRect = realisticMeasuredRect(table); + const row = element.parentElement; + const cells = [...row.cells]; + const widths = cells.map((cell) => cssPixels(cell.style.width, tableRect.width)); + const declared = widths.reduce((sum, width) => sum + width, 0); + const fallback = (tableRect.width - declared) / Math.max(1, widths.filter((width) => !width).length); + const resolved = widths.map((width) => width || fallback); + const cellIndex = cells.indexOf(element); + const left = tableRect.left + resolved.slice(0, cellIndex).reduce((sum, width) => sum + width, 0); + return rect(left, realisticMeasuredRect(row).top, resolved[cellIndex], 40); + } + const isAbsolute = layoutValue('position') === 'absolute' || computed.position === 'absolute'; + const containing = isAbsolute + ? (element.closest?.('.ppt-export-body') ? rect(0, 0, 1280, 720) : parentRect) + : parentRect; + const leftValue = layoutValue('left') || computed.left; + const topValue = layoutValue('top') || computed.top; + const widthValue = layoutValue('width') || element.getAttribute?.('width') || computed.width; + const heightValue = layoutValue('height') || element.getAttribute?.('height') || computed.height; + const rightValue = layoutValue('right'); + const left = containing.left + cssPixels(leftValue, containing.width); + const top = containing.top + cssPixels(topValue, containing.height); + let width = cssPixels(widthValue, containing.width); + let height = cssPixels(heightValue, containing.height); + if (!width && rightValue) { + width = containing.width - cssPixels(leftValue, containing.width) + - cssPixels(rightValue, containing.width); + } + const borderWidth = ['Left', 'Right'] + .reduce((sum, side) => sum + cssPixels(computed[`border${side}Width`]), 0); + const borderHeight = ['Top', 'Bottom'] + .reduce((sum, side) => sum + cssPixels(computed[`border${side}Width`]), 0); + width = width || (tag === 'SVG' ? parentRect.width : Math.min(parentRect.width, 480)); + height = height || (/^(P|H[1-6]|LI)$/.test(tag) ? 28 : 48); + if (cssPixels(widthValue) === 0 && borderWidth > 0) width = borderWidth; + if (cssPixels(heightValue) === 0 && borderHeight > 0) height = borderHeight; + return rect(left, top, width, height); + }; + const measuredRect = realisticLayout ? realisticMeasuredRect : simpleMeasuredRect; const elementPrototype = window.HTMLElement.prototype; const svgPrototype = window.SVGElement.prototype; const originalHtmlRect = elementPrototype.getBoundingClientRect; @@ -188,149 +982,6 @@ async function withControllableExportDom(run) { } } -test('DOM preparation main path exports editable OOXML plus local visual fallback', async () => { - await withControllableExportDom(async () => { - const [deckExportModule, slideExportModule, elementModelModule] = await Promise.all([ - import('../src/export-deck-browser.js'), - import('../src/export-slide-browser.js'), - import('../src/element-model-html.js'), - ]); - const { exportPptxPrepared } = deckExportModule; - const { prepareSlidesForPptxExport } = slideExportModule; - assert.equal(slideExportModule.buildElementSlideHtml, elementModelModule.buildElementSlideHtml); - const html = ` -
    -

    • First prepared bullet

    -

    • Second prepared bullet

    -

    Editable mixed text

    -
    -

    Merged prepared first

    Merged prepared second

    -
    -
    - -
    Prepared headerPrepared cell
    - - - Prepared SVG text - - native -
    -
    - - - - Prepared complex SVG label - -
    -

    Foreground prepared text

    - `; - const deck = { - title: 'Prepared DOM integration', - slides: [{ id: 'slide-dom', title: 'Prepared DOM integration', html }], - }; - const rasterPhases = []; - - const prepared = await prepareSlidesForPptxExport(deck.slides, { - renderRaster: async (_rasterHtml, _slideIndex, metadata) => { - rasterPhases.push(metadata.phase); - return PNG_1X1.replace(/^data:image\/png;base64,/, ''); - }, - }); - - assert.equal(prepared.length, 1); - assert.ok(rasterPhases.length >= 3); - assert.ok(rasterPhases.every((phase) => phase === 'local-visual')); - assert.ok(prepared[0].slideData.elements.some((item) => item.type === 'list')); - assert.ok(prepared[0].slideData.elements.some((item) => item.type === 'shape')); - assert.ok(prepared[0].slideData.elements.some((item) => item.type === 'image')); - assert.ok(prepared[0].slideData.fallbackLayers.some((item) => item.sourceId === 'gradient-card')); - assert.equal(prepared[0].slideData.fullPageFallback, null); - - const exported = await exportPptxPrepared(deck, prepared); - const pptxBuffer = Buffer.from(exported.base64, 'base64'); - assert.ok(Buffer.isBuffer(pptxBuffer) && pptxBuffer.length > 0); - const zip = await JSZip.loadAsync(pptxBuffer); - const [slideXml, relsXml] = await Promise.all([ - zipText(zip, 'ppt/slides/slide1.xml'), - zipText(zip, 'ppt/slides/_rels/slide1.xml.rels'), - ]); - const objects = topLevelSlideObjects(slideXml); - - for (const text of [ - 'First prepared bullet', - 'Second ', - 'prepared', - ' bullet', - 'Editable ', - 'mixed', - ' text', - 'Merged prepared first', - 'Merged prepared second', - 'Prepared header', - 'Prepared cell', - 'Prepared SVG text', - 'Prepared complex SVG label', - 'Foreground prepared text', - ]) { - assert.match(slideXml, new RegExp(`${text}`), text); - } - assert.match(slideXml, //); - assert.match(slideXml, //g) || []).length >= 4); - assert.ok((slideXml.match(//g) || []).length >= 4); - assert.ok(relatedMediaPaths(relsXml).length >= 4); - assert.ok(objects.some((item) => item.type === 'pic')); - assert.ok(objects.findIndex((item) => item.xml.includes('Foreground prepared text')) - > objects.map((item) => item.type).lastIndexOf('pic')); - assert.ok(!(objects.length === 1 && objects[0].type === 'pic')); - }); -}); - -test('prepare main path records security repairs and escalates unsafe geometry to page visual fallback', async () => { - await withControllableExportDom(async () => { - const { prepareSlidesForPptxExport } = await import('../src/export-slide-browser.js'); - const rendered = []; - const html = ` - -
    - -

    Overflow

    - `; - const prepared = await prepareSlidesForPptxExport([{ id: 'unsafe', html }], { - renderRaster: async (rasterHtml, _index, metadata) => { - rendered.push({ rasterHtml, metadata }); - return PNG_1X1.replace(/^data:image\/png;base64,/, ''); - }, - }); - - assert.ok(prepared[0].diagnostics.some((item) => ( - item.code === 'active_content_removed' && item.severity === 'repaired' - ))); - assert.ok(prepared[0].diagnostics.some((item) => item.code === 'text_out_of_bounds')); - assert.equal(rendered[0].metadata.phase, 'page-visual'); - assert.doesNotMatch(rendered[0].rasterHtml, / { const { analyzeMountedSlideForPptx } = await import('../src/export-slide-browser.js'); const result = analyzeMountedSlideForPptx(null, ''); @@ -347,15 +998,27 @@ test('interactive thumbnail and export preview entry points mount only sanitized `; + // Sandbox iframes are populated via about:blank + document.write (srcdoc + // renders blank in Tauri WebKit), so wait for the mounted document. + const readMountedFrameHtml = async (frame) => { + for (let attempt = 0; attempt < 100; attempt += 1) { + const mountedDoc = frame.contentDocument; + const markup = mountedDoc?.documentElement?.outerHTML || ''; + if (/Safe text/.test(markup)) return markup; + await new Promise((resolve) => { setTimeout(resolve, 10); }); + } + throw new Error('sandbox iframe did not mount its document'); + }; + const exportStage = render.buildExportPreviewStage(unsafe); document.body.append(exportStage); - const exportHtml = exportStage.querySelector('iframe').srcdoc; + const exportHtml = await readMountedFrameHtml(exportStage.querySelector('iframe')); const thumbContainer = document.createElement('div'); thumbContainer.innerHTML = render.slideHtml({ id: 'thumb', html: unsafe }); document.body.append(thumbContainer); render.hydrateHtmlSlideIframes(thumbContainer); - const thumbHtml = thumbContainer.querySelector('iframe').srcdoc; + const thumbHtml = await readMountedFrameHtml(thumbContainer.querySelector('iframe')); const canvas = document.createElement('div'); canvas.id = 'slideCanvas'; @@ -382,242 +1045,976 @@ test('interactive thumbnail and export preview entry points mount only sanitized }); }); -test('real PPTX keeps editable objects and local fallback in OOXML paint order', async () => { - const pptx = createPptxDeck({ title: 'OOXML regression' }); - const result = await buildSlideFromExtracted({ - background: { type: 'color', value: 'FFFFFF' }, - elements: [ +test('UI and export host modules import with the current editable export API', async () => { + await withControllableExportDom(async () => { + const host = await import('../src/export-deck-host.js'); + assert.equal(typeof host.exportEditablePptx, 'function'); + await import('../ui.js'); + await new Promise((resolve) => { setTimeout(resolve, 100); }); + }); +}); + +test('zero CSS border-radius remains a plain editable rectangle', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const html = ` +
    + `; + const scenes = await prepareEditableSlides([{ id: 'plain-rectangle-slide', html }]); + const rectangle = scenes[0].nodes.find((node) => node.sourceId === 'plain-rectangle'); + + assert.equal(rectangle.shapeType, 'rect'); + assert.equal(Object.hasOwn(rectangle.style, 'radius'), false); + }); +}); + +test('overflow-hidden decorative bleed bands do not block editable export', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const html = ` +
    +

    Bleed Cover

    + `; + const scenes = await prepareEditableSlides([{ id: 'bleed-cover', html }]); + const band = scenes[0].nodes.find((node) => node.sourceId === 'bleed-band'); + const title = scenes[0].nodes.find((node) => node.sourceId === 'cover-title'); + assert.ok(band, 'bleed band should export as an editable shape'); + assert.equal(band.type, 'shape'); + assert.ok(band.x < 0, 'bleed band may start off-slide'); + assert.equal(band.style.rotate, 352); // -8deg normalized to [0,360) + assert.ok(title, 'cover title should remain editable text'); + assert.equal(title.type, 'text'); + assert.ok(title.x >= 0 && title.y >= 0); + }, { realisticLayout: true }); +}); + +test('editable scene allows negative shape origins for bleed but rejects negative text origins', () => { + const bleed = editableSceneWith({ + type: 'shape', sourceId: 'bleed-shape', + x: -1.5, y: 2, w: 17, h: 1.3, shapeType: 'rect', + style: { fill: '1E3A8A', rotate: 352 }, + }); + assert.equal(validateEditableSlideScene(bleed), bleed); + + assert.throws( + () => validateEditableSlideScene(editableSceneWith({ + type: 'text', sourceId: 'offslide-text', + x: -1, y: 1, w: 2, h: 1, text: 'Nope', style: {}, + })), + (error) => error.code === 'editable_scene_geometry_invalid' + && error.sourceId === 'offslide-text', + ); +}); + +test('hard CSS ring box-shadow rewrites to a concentric editable shape', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const html = ` +
    + `; + const scenes = await prepareEditableSlides([{ id: 'ring-dot-slide', html }]); + const ringNodes = scenes[0].nodes.filter((node) => ( + node.sourceId === 'pptx-source-12' && node.rewrite === 'css_box_shadow_ring' + )); + const mainNodes = scenes[0].nodes.filter((node) => ( + node.sourceId === 'pptx-source-12' && !node.rewrite + )); + assert.equal(ringNodes.length, 1); + assert.equal(mainNodes.length, 1); + assert.equal(ringNodes[0].style.fill, '000000'); + assert.equal(ringNodes[0].style.transparency, 90); + assert.ok(ringNodes[0].w > mainNodes[0].w); + assert.ok(ringNodes[0].h > mainNodes[0].h); + assert.equal(Object.hasOwn(mainNodes[0].style, 'shadow'), false); + }); +}); + +for (const paintCase of [ + { + name: 'inline SVG fill paint server', + sourceId: 'inline-fill-server', + body: ` + + + `, + }, + { + name: 'inline SVG stroke paint server', + sourceId: 'inline-stroke-server', + body: ` + + + `, + }, + { + name: 'stylesheet SVG computed paint server', + sourceId: 'stylesheet-paint-server', + body: ` + + + + `, + }, +]) { + test(`blocks ${paintCase.name} before sanitizer repair`, async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const html = ` + ${paintCase.body} + `; + await assert.rejects( + prepareEditableSlides([{ id: paintCase.sourceId, html }]), + (error) => error instanceof EditableExportError + && error.slideNumber === 1 + && error.sourceId === paintCase.sourceId + && error.code === 'svg_paint_server_unsupported', + ); + }); + }); +} + +for (const unsupportedCase of [ + { + name: 'canvas', + sourceId: 'visible-canvas', + markup: '', + }, + { + name: 'video', + sourceId: 'visible-video', + markup: '', + }, + { + name: 'audio', + sourceId: 'visible-audio', + markup: '', + }, + { + name: 'iframe', + sourceId: 'visible-iframe', + markup: '', + }, + { + name: 'object', + sourceId: 'visible-object', + markup: '', + }, + { + name: 'embed', + sourceId: 'visible-embed', + markup: '', + }, +]) { + test(`blocks visible unsupported ${unsupportedCase.name} before sanitizer deletion`, async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const html = ` + ${unsupportedCase.markup} + `; + await assert.rejects( + prepareEditableSlides([{ id: unsupportedCase.sourceId, html }]), + (error) => error instanceof EditableExportError + && error.slideNumber === 1 + && error.sourceId === unsupportedCase.sourceId + && error.code === 'unsupported_visible_html', + ); + }); + }); +} + +test('blocks active script but ignores non-visual document metadata in preflight', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const scripted = ` + + `; + await assert.rejects( + prepareEditableSlides([{ id: 'active', html: scripted }]), + (error) => error instanceof EditableExportError + && error.sourceId === 'active-script' + && error.code === 'active_content_unsupported', + ); + + const metadata = ` + + +

    Visible copy

    + `; + const scenes = await prepareEditableSlides([{ id: 'metadata', html: metadata }]); + assert.equal(scenes.length, 1); + }); +}); + +test('blocks escaped and external SVG paint-server CSS without matching comments or strings', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const blocked = [ { - type: 'shape', - text: '', - sourceId: 'panel', - kind: 'native', - zIndex: 0, - paintOrder: 0, - position: { x: 0.5, y: 0.5, w: 5, h: 2.5 }, - shape: { fill: 'DDEEFF', line: { color: '224466', width: 1 }, rectRadius: 0 }, + sourceId: 'escaped-inline-paint', + body: ``, }, - textElement({ - text: [{ text: 'Editable ', options: {} }, { text: 'title', options: { bold: true } }], - sourceId: 'title', - zIndex: 1, - paintOrder: 1, - }), { - type: 'list', - sourceId: 'list', - kind: 'native', - zIndex: 2, - paintOrder: 2, - position: { x: 1, y: 2, w: 4, h: 1.2 }, - items: [ - { text: 'First bullet', options: { bullet: { indent: 18 }, breakLine: true } }, - { text: 'Second bullet', options: { bullet: { indent: 18 } } }, - ], - style: { - fontSize: 18, - fontFace: 'Arial', - color: '222222', - align: 'left', - lineSpacing: 22, - paraSpaceBefore: 0, - paraSpaceAfter: 0, - margin: 0, - }, + sourceId: 'external-inline-paint', + body: ``, }, { - type: 'image', - src: PNG_1X1, - sourceId: 'native-image', - kind: 'native', - zIndex: 3, - paintOrder: 3, - position: { x: 6, y: 1, w: 1, h: 1 }, + sourceId: 'comment-split-stylesheet-paint', + body: ` + `, }, - textElement({ - text: 'Foreground label', - sourceId: 'foreground', - zIndex: 5, - paintOrder: 5, - x: 8, - y: 1, - }), - ], - fallbackLayers: [{ - sourceId: 'gradient-card', - kind: 'raster', - phase: 'local-visual', - zIndex: 4, - paintOrder: 4, - bbox: { x: 7, y: 3, w: 2, h: 1 }, - data: PNG_1X1, - diagnostics: [{ severity: 'fallback', code: 'css_gradient', sourceId: 'gradient-card' }], - }], - placeholders: [], - diagnostics: [{ severity: 'fallback', code: 'css_gradient', sourceId: 'gradient-card' }], - errors: [], - }, BODY_DIMENSIONS, pptx); + { + sourceId: 'inherited-computed-paint', + body: ` + `, + }, + ]; + for (const paint of blocked) { + const html = ` + ${paint.body} + `; + await assert.rejects( + prepareEditableSlides([{ id: paint.sourceId, html }]), + (error) => error instanceof EditableExportError + && error.sourceId === paint.sourceId + && error.code === 'svg_paint_server_unsupported', + paint.sourceId, + ); + } - assert.equal(result.diagnostics[0].sourceId, 'gradient-card'); - const zip = await writeAndOpen(pptx); - const [presentationXml, slideXml, relsXml] = await Promise.all([ - zipText(zip, 'ppt/presentation.xml'), - zipText(zip, 'ppt/slides/slide1.xml'), - zipText(zip, 'ppt/slides/_rels/slide1.xml.rels'), - ]); + const safeHtml = ` + + + `; + const scenes = await prepareEditableSlides([{ id: 'safe-paint', html: safeHtml }]); + assert.equal(scenes.length, 1); + }); +}); - assert.match(presentationXml, /Editable <\/a:t>/); - assert.match(slideXml, /title<\/a:t>/); - assert.match(slideXml, /First bullet<\/a:t>/); - assert.match(slideXml, /Second bullet<\/a:t>/); - assert.match(slideXml, //); - assert.match(slideXml, / { - if (type === 'pic') return 'pic'; - if (xml.includes('Editable ')) return 'editable-title'; - if (xml.includes('First bullet')) return 'editable-list'; - if (xml.includes('Foreground label')) return 'foreground-text'; - return 'basic-shape'; - }), [ - 'basic-shape', - 'editable-title', - 'editable-list', - 'pic', - 'pic', - 'foreground-text', - ]); - assert.equal((slideXml.match(//g) || []).length, 4); - assert.equal((slideXml.match(//g) || []).length, 2); - assert.equal((relsXml.match(/relationships\/image"/g) || []).length, 2); - const mediaPaths = relatedMediaPaths(relsXml); - assert.equal(mediaPaths.length, 2); - mediaPaths.forEach((path) => assert.ok(zip.file(path), `${path} must exist`)); - - const pictureSizes = objects.filter((object) => object.type === 'pic').map((object) => pictureExtents(object.xml)); - assert.ok(pictureSizes.every(({ cx, cy }) => cx < 12192000 && cy < 6858000)); - assert.ok(!( - objects.length === 1 - && objects[0].type === 'pic' - ), 'a normal local fallback must not collapse the slide into one full-page image'); -}); - -test('real PPTX page visual suppresses native visuals but preserves editable text in paint order', async () => { - const pptx = createPptxDeck({ title: 'Page visual suppression' }); - await buildSlideFromExtracted({ - background: { type: 'color', value: 'FFFFFF' }, - elements: [ +test('blocks an SVG paint server selected by higher selector specificity', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const html = ` + + + `; + await assert.rejects( + prepareEditableSlides([{ id: 'specific-paint', html }]), + (error) => error instanceof EditableExportError + && error.sourceId === 'specific-paint' + && error.code === 'svg_paint_server_unsupported', + ); + }); +}); + +test('blocks an SVG paint server selected by important over higher specificity', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const html = ` + + + `; + await assert.rejects( + prepareEditableSlides([{ id: 'important-paint', html }]), + (error) => error instanceof EditableExportError + && error.sourceId === 'important-paint' + && error.code === 'svg_paint_server_unsupported', + ); + }); +}); + +test('does not block SVG paint servers overridden by the winning cascade declaration', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const styles = [ + '.shape { fill:url(#g); } #target { fill:#fff; }', + '#target { fill:url(#g); } .shape { fill:#fff !important; }', + ]; + for (const [index, css] of styles.entries()) { + const sourceId = `overridden-paint-${index + 1}`; + const html = ` + + + `; + const scenes = await prepareEditableSlides([{ id: sourceId, html }]); + assert.equal(scenes.length, 1, sourceId); + } + }); +}); + +test('blocks every non-none text-shadow with located diagnostics', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + for (const body of [ + `

    Shadowed

    `, + ` +

    Shadowed

    `, + ]) { + const sourceId = body.match(/data-pptx-source-id="([^"]+)"/)[1]; + const html = ` + ${body} + `; + await assert.rejects( + prepareEditableSlides([{ id: sourceId, html }]), + (error) => error instanceof EditableExportError + && error.slideNumber === 1 + && error.sourceId === sourceId + && error.code === 'text_shadow_unsupported', + ); + } + }); +}); + +test('preserves a supported CSS outer box-shadow as native scene and OOXML shadow', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const { exportEditablePptx } = await import('../src/export-deck-browser.js'); + const html = ` +
    + `; + const deck = { title: 'Native shadow', slides: [{ id: 'shadow', html }] }; + const scenes = await prepareEditableSlides(deck.slides); + const card = scenes[0].nodes.find((node) => node.sourceId === 'shadow-card'); + + assert.deepEqual(card.style.shadow, { + type: 'outer', + angle: 56, + blur: 6, + color: '112233', + offset: Math.sqrt(52) * 0.75, + opacity: 0.4, + }); + + const exported = await exportEditablePptx(deck, scenes); + const zip = await JSZip.loadAsync(Buffer.from(exported.base64, 'base64')); + const slideXml = await zipText(zip, 'ppt/slides/slide1.xml'); + assert.match(slideXml, /[\s\S]*?/); + }); +}); + +test('maps asymmetric text and merged-text padding as top right bottom left through OOXML', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const { exportEditablePptx } = await import('../src/export-deck-browser.js'); + const html = ` +

    Asymmetric text

    +
    +

    Merged text

    +
    + `; + const deck = { title: 'Asymmetric padding', slides: [{ id: 'padding', html }] }; + const scenes = await prepareEditableSlides(deck.slides); + const textNode = scenes[0].nodes.find((node) => node.sourceId === 'padded-text'); + const mergeNode = scenes[0].nodes.find((node) => node.sourceId === 'padded-merge'); + + assert.deepEqual(textNode.style.margin, [10 / 96, 20 / 96, 30 / 96, 40 / 96]); + assert.deepEqual(mergeNode.style.margin, [11 / 96, 21 / 96, 31 / 96, 41 / 96]); + + const exported = await exportEditablePptx(deck, scenes); + const zip = await JSZip.loadAsync(Buffer.from(exported.base64, 'base64')); + const slideXml = await zipText(zip, 'ppt/slides/slide1.xml'); + const textShape = [...slideXml.matchAll(/[\s\S]*?<\/p:sp>/g)] + .map((match) => match[0]) + .find((xml) => xml.includes('Asymmetric text')); + const mergeShape = [...slideXml.matchAll(/[\s\S]*?<\/p:sp>/g)] + .map((match) => match[0]) + .find((xml) => xml.includes('Merged text')); + + assert.match(textShape, /]*lIns="381000"[^>]*tIns="95250"[^>]*rIns="190500"[^>]*bIns="285750"/); + assert.match(mergeShape, /]*lIns="390525"[^>]*tIns="104775"[^>]*rIns="200025"[^>]*bIns="295275"/); + }); +}); + +test('collapsed table borders tolerate subpixel outer-edge differences', async () => { + await withControllableExportDom(async ({ window }) => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const prototype = window.HTMLElement.prototype; + const measuredRect = prototype.getBoundingClientRect; + prototype.getBoundingClientRect = function getBoundingClientRect() { + const rect = measuredRect.call(this); + if (this.tagName === 'TABLE') { + return { + ...rect, + top: rect.top - 0.6, + bottom: rect.bottom + 0.6, + height: rect.height + 1.2, + toJSON() { + return { + left: rect.left, + top: rect.top - 0.6, + width: rect.width, + height: rect.height + 1.2, + }; + }, + }; + } + if (!['TD', 'TH'].includes(this.tagName) || this.cellIndex !== this.parentElement.cells.length - 1) { + return rect; + } + return { + ...rect, + width: rect.width - 1.2, + right: rect.right - 1.2, + toJSON() { + return { + left: rect.left, + top: rect.top, + width: rect.width - 1.2, + height: rect.height, + }; + }, + }; + }; + try { + const html = ` + + + + + + + + + +
    AB
    CD
    + `; + const scenes = await prepareEditableSlides([{ id: 'collapsed-table-slide', html }]); + const table = scenes[0].nodes.find((node) => node.sourceId === 'collapsed-table'); + + assert.equal(table.type, 'table'); + assert.equal(table.columnWidths.length, 2); + assert.equal(table.rows.length, 2); + } finally { + prototype.getBoundingClientRect = measuredRect; + } + }, { realisticLayout: true }); +}); + +test('CSS border-radius preserves exact editable roundRect OOXML adjustment', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const { exportEditablePptx } = await import('../src/export-deck-browser.js'); + const html = ` +
    + `; + const deck = { title: 'CSS radius', slides: [{ id: 'css-radius', html }] }; + const scenes = await prepareEditableSlides(deck.slides); + const rounded = scenes[0].nodes.find((node) => node.sourceId === 'css-rounded'); + assert.equal(rounded.shapeType, 'roundRect'); + assert.equal(rounded.style.radius, 4 / 96); + + const exported = await exportEditablePptx(deck, scenes); + const zip = await JSZip.loadAsync(Buffer.from(exported.base64, 'base64')); + const slideXml = await zipText(zip, 'ppt/slides/slide1.xml'); + assert.match( + slideXml, + /[\s\S]*?/, + ); + assert.doesNotMatch(slideXml, /fmla="val 16667"/); + }); +}); + +test('HTML preparation and export never call raster or page renderers', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const { exportEditablePptx } = await import('../src/export-deck-browser.js'); + let rasterCalls = 0; + let pageCalls = 0; + const html = ` +
    +

    Editable title

    + + + + `; + const deck = { title: 'Strict HTML', slides: [{ id: 'strict', html }] }; + const scenes = await prepareEditableSlides(deck.slides, { + renderRaster: () => { rasterCalls += 1; }, + renderPage: () => { pageCalls += 1; }, + }); + const result = await exportEditablePptx(deck, scenes); + const zip = await JSZip.loadAsync(Buffer.from(result.base64, 'base64')); + const [slideXml, relsXml] = await Promise.all([ + zipText(zip, 'ppt/slides/slide1.xml'), + zipText(zip, 'ppt/slides/_rels/slide1.xml.rels'), + ]); + + assert.equal(rasterCalls, 0); + assert.equal(pageCalls, 0); + assert.match(slideXml, /Editable title/); + assert.doesNotMatch(slideXml, //); + assert.doesNotMatch(relsXml, /relationships\/image"/); + assert.equal(zip.file(/^ppt\/media\//).length, 0); + }); +}); + +// This Node/JSDOM case simulates WebKit border-box measurements only. Release +// acceptance still requires exporting these fixtures in the real Tauri WebKit +// runtime and inspecting the resulting editable-only OOXML artifact. +test('production fixtures lock OOXML under a WebKit border-box regression simulation', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const { exportEditablePptx } = await import('../src/export-deck-browser.js'); + const fixtureBuffers = await Promise.all(PRODUCTION_FIXTURES.map((fixture) => ( + readFile(new URL(`./fixtures/${fixture.name}`, import.meta.url)) + ))); + fixtureBuffers.forEach((buffer, index) => { + assert.equal( + createHash('sha256').update(buffer).digest('hex'), + PRODUCTION_FIXTURES[index].sha256, + `${PRODUCTION_FIXTURES[index].name} must remain byte-for-byte complete`, + ); + }); + const fixtureHtml = fixtureBuffers.map((buffer) => buffer.toString('utf8')); + const deck = { + title: 'Production editable acceptance', + slides: fixtureHtml.map((html, index) => ({ id: `production-${index + 1}`, html })), + }; + const progress = []; + let rasterCalls = 0; + let pageCalls = 0; + const scenes = await prepareEditableSlides(deck.slides, { + onSlideProgress: (pageNumber) => progress.push(pageNumber), + renderRaster: () => { rasterCalls += 1; }, + renderPage: () => { pageCalls += 1; }, + }); + + assert.deepEqual(progress, [1, 2, 3]); + assert.equal(rasterCalls, 0); + assert.equal(pageCalls, 0); + assert.deepEqual(scenes.map((scene) => scene.slideNumber), [1, 2, 3]); + scenes.forEach((scene, index) => { + assert.equal(scene.nodes.some((node) => node.type === 'image'), false, `slide ${index + 1}`); + }); + + const [first, second, third] = scenes; + const firstShapes = first.nodes.filter((node) => node.type === 'shape'); + const firstLines = first.nodes.filter((node) => node.type === 'line'); + const firstText = first.nodes.filter((node) => node.type === 'text'); + assert.deepEqual( + { shapes: firstShapes.length, lines: firstLines.length, text: firstText.length }, + { shapes: 15, lines: 18, text: 13 }, + ); + const firstMap = svgPointMapper({ + leftPt: 500, topPt: 115, widthPt: 400, heightPt: 235, viewWidth: 400, viewHeight: 260, + }); + const cssUpId = fixtureSourceId(fixtureHtml[0], 'div[style*="border-bottom: 56pt"]'); + const cssRightId = fixtureSourceId(fixtureHtml[0], 'div[style*="border-left: 12pt"]'); + const svgTriangleId = fixtureSourceId(fixtureHtml[0], 'svg polygon'); + assert.equal(new Set([cssUpId, cssRightId, svgTriangleId]).size, 3); + const cssUp = sceneNodesBySource(first, cssUpId, 'shape'); + const cssRight = sceneNodesBySource(first, cssRightId, 'shape'); + const svgTriangle = sceneNodesBySource(first, svgTriangleId, 'shape'); + assert.equal(cssUp.length, 1); + assert.equal(cssRight.length, 1); + assert.equal(svgTriangle.length, 1); + assert.equal(sceneNodesBySource(first, cssUpId).length, 1, + 'CSS up triangle must not also emit partial-border lines'); + assert.equal(sceneNodesBySource(first, cssRightId).length, 1, + 'CSS right triangle must not also emit partial-border lines'); + assert.deepEqual( + [cssUp[0].shapeType, cssUp[0].style.rotate, cssUp[0].style.fill], + ['triangle', 0, '1E293B'], + ); + assertNear(cssUp[0].x, 290 / 72, 'CSS up triangle x'); + assertNear(cssUp[0].y, 195 / 72, 'CSS up triangle y'); + assertNear(cssUp[0].w, 64 / 72, 'CSS up triangle width'); + assertNear(cssUp[0].h, 56 / 72, 'CSS up triangle height'); + assert.deepEqual( + [cssRight[0].shapeType, cssRight[0].style.rotate, cssRight[0].style.fill], + ['triangle', 90, '787774'], + ); + assertNear(cssRight[0].x, 408 / 72, 'CSS right arrow x'); + assertNear(cssRight[0].y, 286 / 72, 'CSS right arrow y'); + assertNear(cssRight[0].w, 12 / 72, 'CSS right arrow width'); + assertNear(cssRight[0].h, 14 / 72, 'CSS right arrow height'); + const svgTriangleStart = firstMap({ x: 240, y: 82 }); + const svgTriangleEnd = firstMap({ x: 320, y: 138 }); + assert.deepEqual( + [svgTriangle[0].shapeType, svgTriangle[0].style.rotate, svgTriangle[0].style.fill], + ['triangle', 0, '1E293B'], + ); + assertNear(svgTriangle[0].x, svgTriangleStart.x, 'SVG triangle x'); + assertNear(svgTriangle[0].y, svgTriangleStart.y, 'SVG triangle y'); + assertNear(svgTriangle[0].w, svgTriangleEnd.x - svgTriangleStart.x, 'SVG triangle width'); + assertNear(svgTriangle[0].h, svgTriangleEnd.y - svgTriangleStart.y, 'SVG triangle height'); + + const curveId = fixtureSourceId(fixtureHtml[0], 'svg path[d*=" Q "]'); + const curveLines = sceneNodesBySource(first, curveId, 'line'); + assert.equal(curveLines.length, 16); + const curveStart = firstMap({ x: 20, y: 200 }); + const curveQMid = firstMap({ x: 115, y: 185 }); + const curveJoin = firstMap({ x: 200, y: 200 }); + const curveTMid = firstMap({ x: 282.5, y: 215 }); + const curveEnd = firstMap({ x: 370, y: 200 }); + assertLineEndpoints(curveLines[0], { + x1: curveStart.x, y1: curveStart.y, + x2: curveLines[0].x2, y2: curveLines[0].y2, + }, 'Q/T first segment'); + assertNear(curveLines[3].x2, curveQMid.x, 'Q midpoint x'); + assertNear(curveLines[3].y2, curveQMid.y, 'Q midpoint y'); + assertNear(curveLines[7].x2, curveJoin.x, 'Q/T join x'); + assertNear(curveLines[7].y2, curveJoin.y, 'Q/T join y'); + assertNear(curveLines[11].x2, curveTMid.x, 'T midpoint x'); + assertNear(curveLines[11].y2, curveTMid.y, 'T midpoint y'); + assertNear(curveLines.at(-1).x2, curveEnd.x, 'Q/T end x'); + assertNear(curveLines.at(-1).y2, curveEnd.y, 'Q/T end y'); + assert.ok(curveLines.every((node) => node.style.color === '0F766E')); + const dashedId = fixtureSourceId(fixtureHtml[0], 'svg path[stroke-dasharray]'); + assert.notEqual(dashedId, curveId); + const dashedLines = sceneNodesBySource(first, dashedId, 'line'); + assert.equal(dashedLines.length, 1); + assert.equal(dashedLines[0].style.dashType, 'dash'); + const dashStart = firstMap({ x: 20, y: 230 }); + const dashEnd = firstMap({ x: 370, y: 230 }); + assertLineEndpoints(dashedLines[0], { + x1: dashStart.x, y1: dashStart.y, x2: dashEnd.x, y2: dashEnd.y, + }, 'independent dashed path'); + const firstRoundRects = firstShapes.filter((node) => node.shapeType === 'roundRect'); + assert.equal(firstRoundRects.length, 4); + const roundRectSpecs = [ { - type: 'shape', sourceId: 'panel', kind: 'native', zIndex: 0, paintOrder: 0, - position: { x: 0.5, y: 0.5, w: 3, h: 2 }, - shape: { fill: '123456', line: null, rectRadius: 0 }, + name: 'CSS rounded card', + sourceId: fixtureSourceId( + fixtureHtml[0], + 'div[style*="width: 170pt"][style*="border-radius: 10pt"]', + ), + geometry: { x: 250 / 72, y: 115 / 72, w: 170 / 72, h: 56 / 72 }, + radius: 10 / 72, }, { - type: 'image', sourceId: 'photo', kind: 'native', zIndex: 1, paintOrder: 1, - src: PNG_1X1, position: { x: 4, y: 1, w: 1, h: 1 }, + name: 'CSS SVG panel', + sourceId: fixtureSourceId( + fixtureHtml[0], + 'div[style*="width: 400pt"][style*="height: 300pt"]', + ), + geometry: { x: 500 / 72, y: 115 / 72, w: 400 / 72, h: 300 / 72 }, + radius: 8 / 72, }, - textElement({ - text: 'Editable text survives', - sourceId: 'copy', - zIndex: 3, - paintOrder: 3, - }), - ], - fallbackLayers: [{ - sourceId: 'slide-visuals', - kind: 'raster', - phase: 'page-visual', - canvas: 'full-page', - zIndex: 0, - paintOrder: 0, - suppressedNativeVisualIds: ['panel', 'photo'], - bbox: { x: 0, y: 0, w: 13.333, h: 7.5 }, - data: PNG_1X1, - }], - placeholders: [], - diagnostics: [], - errors: [], - }, BODY_DIMENSIONS, pptx); + { + name: 'SVG rx8', + sourceId: fixtureSourceId(fixtureHtml[0], 'svg rect[rx="8"]'), + geometry: null, + radius: 0.1004273504, + }, + { + name: 'SVG rx4', + sourceId: fixtureSourceId(fixtureHtml[0], 'svg rect[rx="4"]'), + geometry: null, + radius: 0.0502136752, + }, + ]; + assert.deepEqual( + firstRoundRects.map((node) => node.sourceId), + roundRectSpecs.map((spec) => spec.sourceId), + ); + roundRectSpecs.forEach((spec) => { + const nodes = sceneNodesBySource(first, spec.sourceId, 'shape'); + assert.equal(nodes.length, 1, spec.name); + assert.equal(nodes[0].shapeType, 'roundRect', spec.name); + assertNear(nodes[0].style.radius, spec.radius, `${spec.name} radius`, 1e-4); + if (!spec.geometry) return; + for (const property of ['x', 'y', 'w', 'h']) { + assertNear(nodes[0][property], spec.geometry[property], `${spec.name} ${property}`, 1e-4); + } + }); + assert.match(JSON.stringify(firstText.map((node) => node.text)), /PPT 形状与元素测试矩阵/); + assert.match(JSON.stringify(firstText.map((node) => node.text)), /曲线 · 虚线/); - const zip = await writeAndOpen(pptx); - const [slideXml, relsXml] = await Promise.all([ - zipText(zip, 'ppt/slides/slide1.xml'), - zipText(zip, 'ppt/slides/_rels/slide1.xml.rels'), - ]); - const objects = topLevelSlideObjects(slideXml); - assert.deepEqual(objects.map((object) => ( - object.type === 'pic' ? 'page-visual' : 'editable-text' - )), ['page-visual', 'editable-text']); - assert.match(slideXml, /Editable text survives/); - assert.doesNotMatch(slideXml, /123456/); - assert.equal((relsXml.match(/relationships\/image"/g) || []).length, 1); -}); - -test('real PPTX full-page final fallback is one located picture and nothing else', async () => { - const pptx = createPptxDeck({ title: 'Full-page fallback' }); - const diagnostic = { - severity: 'fallback', - code: 'full_page_fallback', - sourceId: 'slide-2', - slideNumber: 2, - phase: 'full-page', - reason: 'local and page visual rendering failed', - }; - const result = await buildSlideFromExtracted({ - background: { type: 'color', value: 'FFFFFF' }, - elements: [textElement({ - text: 'Must not survive final fallback', - sourceId: 'hidden-text', - zIndex: 1, - paintOrder: 1, - })], - fallbackLayers: [{ - sourceId: 'local-layer', - kind: 'raster', - zIndex: 0, - paintOrder: 0, - bbox: { x: 1, y: 1, w: 2, h: 2 }, - data: PNG_1X1, - }], - fullPageFallback: { - sourceId: 'slide-2', - kind: 'raster', - phase: 'full-page', - data: PNG_1X1, - }, - placeholders: [], - diagnostics: [diagnostic], - errors: [], - }, BODY_DIMENSIONS, pptx); + const secondShapes = second.nodes.filter((node) => node.type === 'shape'); + const secondLines = second.nodes.filter((node) => node.type === 'line'); + const secondText = second.nodes.filter((node) => node.type === 'text'); + assert.deepEqual( + { shapes: secondShapes.length, lines: secondLines.length, text: secondText.length }, + { shapes: 8, lines: 10, text: 25 }, + ); + const secondMap = svgPointMapper({ + leftPt: 600, topPt: 108, widthPt: 300, heightPt: 215, viewWidth: 300, viewHeight: 240, + }); + const gridSources = Array.from({ length: 4 }, (_, index) => ( + fixtureSourceId(fixtureHtml[1], 'svg line[stroke-dasharray]', index) + )); + const gridLines = gridSources.flatMap((sourceId) => sceneNodesBySource(second, sourceId, 'line')); + assert.equal(gridLines.length, 4); + [60, 100, 140, 180].forEach((y, index) => { + const start = secondMap({ x: 30, y }); + const end = secondMap({ x: 290, y }); + assert.equal(gridLines[index].style.dashType, 'dash'); + assert.equal(gridLines[index].style.color, 'E5E7EB'); + assertLineEndpoints(gridLines[index], { + x1: start.x, y1: start.y, x2: end.x, y2: end.y, + }, `grid line ${index + 1}`); + }); + const axisSources = Array.from({ length: 2 }, (_, index) => ( + fixtureSourceId(fixtureHtml[1], 'svg line:not([stroke-dasharray])', index) + )); + const axes = axisSources.flatMap((sourceId) => sceneNodesBySource(second, sourceId, 'line')); + assert.equal(axes.length, 2); + const verticalAxisStart = secondMap({ x: 30, y: 20 }); + const verticalAxisEnd = secondMap({ x: 30, y: 200 }); + const horizontalAxisStart = secondMap({ x: 30, y: 200 }); + const horizontalAxisEnd = secondMap({ x: 290, y: 200 }); + assertLineEndpoints(axes[0], { + x1: verticalAxisStart.x, y1: verticalAxisStart.y, + x2: verticalAxisEnd.x, y2: verticalAxisEnd.y, + }, 'vertical axis'); + assertLineEndpoints(axes[1], { + x1: horizontalAxisStart.x, y1: horizontalAxisStart.y, + x2: horizontalAxisEnd.x, y2: horizontalAxisEnd.y, + }, 'horizontal axis'); + axes.forEach((axis) => { + assert.equal(axis.style.color, '787774'); + assert.equal(axis.style.dashType, undefined); + }); + const barSpecs = [ + [45, 53, 28, 147, '1E293B'], + [85, 64, 28, 136, '1E293B'], + [125, 48, 28, 152, '0F766E'], + [165, 75, 28, 125, '1E293B'], + [205, 59, 28, 141, '1E293B'], + [245, 69, 28, 131, '1E293B'], + ]; + barSpecs.forEach(([x, y, w, h, color], index) => { + const sourceId = fixtureSourceId(fixtureHtml[1], 'svg rect', index); + const bars = sceneNodesBySource(second, sourceId, 'shape'); + assert.equal(bars.length, 1, `bar ${index + 1}`); + const start = secondMap({ x, y }); + const end = secondMap({ x: x + w, y: y + h }); + assert.equal(bars[0].style.fill, color); + assertNear(bars[0].x, start.x, `bar ${index + 1} x`); + assertNear(bars[0].y, start.y, `bar ${index + 1} y`); + assertNear(bars[0].w, end.x - start.x, `bar ${index + 1} width`); + assertNear(bars[0].h, end.y - start.y, `bar ${index + 1} height`); + }); + const chartTextSpecs = [ + [59, 218, '推理', '787774'], [99, 218, '管道', '787774'], + [139, 218, '安全', '787774'], [179, 218, '监控', '787774'], + [219, 218, '网关', '787774'], [259, 218, '存储', '787774'], + [59, 48, '92', '1E293B'], [99, 59, '85', '1E293B'], + [139, 43, '95', '0F766E'], [179, 70, '78', '1E293B'], + [219, 54, '88', '1E293B'], [259, 64, '82', '1E293B'], + ]; + chartTextSpecs.forEach(([x, y, text, color], index) => { + const sourceId = fixtureSourceId(fixtureHtml[1], 'svg text', index); + const labels = sceneNodesBySource(second, sourceId, 'text'); + assert.equal(labels.length, 1, `chart text ${index + 1}`); + assert.equal(labels[0].text, text); + assert.equal(labels[0].style.align, 'center'); + assert.equal(labels[0].style.color?.toUpperCase(), color); + const anchor = secondMap({ x, y }); + assertNear(labels[0].x + labels[0].w / 2, anchor.x, `chart text ${text} anchor x`); + assert.ok(labels[0].y < anchor.y, `chart text ${text} baseline must follow its box`); + }); - assert.deepEqual(result.diagnostics, [diagnostic]); - const zip = await writeAndOpen(pptx); - const [presentationXml, slideXml, relsXml] = await Promise.all([ - zipText(zip, 'ppt/presentation.xml'), - zipText(zip, 'ppt/slides/slide1.xml'), - zipText(zip, 'ppt/slides/_rels/slide1.xml.rels'), - ]); - const objects = topLevelSlideObjects(slideXml); - - assert.equal(objects.length, 1); - assert.equal(objects[0].type, 'pic'); - assert.doesNotMatch(slideXml, /Must not survive final fallback/); - assert.equal((slideXml.match(//g) || []).length, 0); - assert.equal((slideXml.match(//g) || []).length, 1); - assert.equal((relsXml.match(/relationships\/image"/g) || []).length, 1); - const mediaPaths = relatedMediaPaths(relsXml); - assert.equal(mediaPaths.length, 1); - assert.ok(zip.file(mediaPaths[0]), `${mediaPaths[0]} must exist`); - - const slideSize = presentationXml.match(/ node.type === 'shape'); + const thirdLines = third.nodes.filter((node) => node.type === 'line'); + const thirdText = third.nodes.filter((node) => node.type === 'text'); + const thirdTables = third.nodes.filter((node) => node.type === 'table'); + assert.deepEqual( + { + shapes: thirdShapes.length, + lines: thirdLines.length, + text: thirdText.length, + tables: thirdTables.length, + }, + { shapes: 10, lines: 4, text: 16, tables: 1 }, + ); + [205, 375, 545, 715].forEach((leftPt, index) => { + const lineId = fixtureSourceId(fixtureHtml[2], 'body > svg line', index); + const triangleId = fixtureSourceId(fixtureHtml[2], 'body > svg polygon', index); + const lines = sceneNodesBySource(third, lineId, 'line'); + const triangles = sceneNodesBySource(third, triangleId, 'shape'); + assert.equal(lines.length, 1, `flow arrow ${index + 1} line`); + assert.equal(triangles.length, 1, `flow arrow ${index + 1} triangle`); + const arrowMap = svgPointMapper({ + leftPt, topPt: 135.5, widthPt: 40, heightPt: 20, viewWidth: 40, viewHeight: 20, + }); + const lineStart = arrowMap({ x: 2, y: 10 }); + const join = arrowMap({ x: 32, y: 10 }); + const triangleTopLeft = arrowMap({ x: 32, y: 5 }); + const triangleBottomRight = arrowMap({ x: 40, y: 15 }); + assertLineEndpoints(lines[0], { + x1: lineStart.x, y1: lineStart.y, x2: join.x, y2: join.y, + }, `flow arrow ${index + 1} line`); + assert.equal(triangles[0].shapeType, 'triangle'); + assert.equal(triangles[0].style.rotate, 90); + assert.equal(triangles[0].style.fill, '787774'); + assertNear(triangles[0].x, triangleTopLeft.x, `flow arrow ${index + 1} triangle x`); + assertNear(triangles[0].y, triangleTopLeft.y, `flow arrow ${index + 1} triangle y`); + assertNear(triangles[0].w, triangleBottomRight.x - triangleTopLeft.x, + `flow arrow ${index + 1} triangle width`); + assertNear(triangles[0].h, triangleBottomRight.y - triangleTopLeft.y, + `flow arrow ${index + 1} triangle height`); + assertNear(lines[0].x2, triangles[0].x, `flow arrow ${index + 1} geometry join`); + assertNear(lines[0].y2, triangles[0].y + triangles[0].h / 2, + `flow arrow ${index + 1} center join`); + }); + assert.equal(thirdTables.length, 1); + assert.equal(thirdTables[0].rows.length, 5); + assert.equal(thirdTables[0].columnWidths.length, 4); + assert.deepEqual(thirdTables[0].columnWidths, Array(4).fill(35 / 12)); + const tableCells = thirdTables[0].rows.flatMap((row) => row.cells); + assert.equal(tableCells.length, 20); + assert.deepEqual( + tableCells.slice(0, 4).map((cell) => ({ + text: cell.text.map((run) => run.text).join(''), + fill: cell.style.fill, + fontColor: cell.style.fontColor, + bold: cell.style.bold, + })), + [ + { text: '阶段', fill: '1E293B', fontColor: 'FFFFFF', bold: true }, + { text: '输入', fill: '1E293B', fontColor: 'FFFFFF', bold: true }, + { text: '处理动作', fill: '1E293B', fontColor: 'FFFFFF', bold: true }, + { text: '质量门槛', fill: '1E293B', fontColor: 'FFFFFF', bold: true }, + ], + ); + assert.equal(tableCells[4].text.map((run) => run.text).join(''), '采集'); + assert.equal(tableCells[4].style.fontColor, '0F766E'); + assert.equal(tableCells[10].text.map((run) => run.text).join(''), '字段校验、去重、PII 脱敏'); + assert.equal(tableCells[19].text.map((run) => run.text).join(''), 'P99 延迟 ≤ 80ms'); + thirdTables[0].rows.forEach((row, rowIndex) => { + const expectedFill = ['1E293B', 'FFFFFF', 'FAFAF7', 'FFFFFF', 'FAFAF7'][rowIndex]; + const expectedBorder = rowIndex === 0 ? '1E293B' : 'E5E7EB'; + row.cells.forEach((cell) => { + assert.equal(cell.style.fill, expectedFill); + assert.deepEqual(cell.style.padding, [8 / 72, 12 / 72, 8 / 72, 12 / 72]); + for (const side of ['top', 'right', 'bottom', 'left']) { + assert.deepEqual(cell.style.border[side], { color: expectedBorder, width: 1 }); + } + }); + }); + thirdTables[0].rows.forEach((row) => assertNear(row.height, 40 / 96, 'table row height')); + + const exported = await exportEditablePptx(deck, scenes); + assert.ok(exported.base64.length > 1000); + const zip = await JSZip.loadAsync(Buffer.from(exported.base64, 'base64')); + const slideXml = await Promise.all([1, 2, 3].map((page) => ( + zipText(zip, `ppt/slides/slide${page}.xml`) + ))); + const relsXml = await Promise.all([1, 2, 3].map((page) => ( + zipText(zip, `ppt/slides/_rels/slide${page}.xml.rels`) + ))); + slideXml.forEach((xml, index) => { + const shapeCount = (xml.match(//g) || []).length; + const textCount = (xml.match(//g) || []).length; + const tableCount = (xml.match(//g) || []).length; + const scene = scenes[index]; + const expectedShapes = scene.nodes.filter((node) => node.type !== 'table').length; + const expectedTextRuns = scene.nodes.reduce((count, node) => { + if (node.type === 'text') return count + (Array.isArray(node.text) ? node.text.length : 1); + if (node.type === 'table') { + return count + node.rows.flatMap((row) => row.cells) + .reduce((sum, cell) => sum + (Array.isArray(cell.text) ? cell.text.length : 1), 0); + } + return count; + }, 0); + assert.equal(shapeCount, expectedShapes, `slide ${index + 1} exact OOXML p:sp count`); + assert.equal(textCount, expectedTextRuns, `slide ${index + 1} exact OOXML a:t count`); + assert.equal(tableCount, index === 2 ? 1 : 0, `slide ${index + 1} OOXML table count`); + assert.doesNotMatch(xml, //); + assert.doesNotMatch(relsXml[index], /relationships\/image"/); + }); + assert.equal(zip.file(/^ppt\/media\//).length, 0); + + assert.equal((slideXml[0].match(//g) || []).length, 3); + assert.equal((slideXml[0].match(//g) || []).length, 1); + const firstAdjustments = [...slideXml[0].matchAll( + /[\s\S]*?/g, + )].map((match) => Number(match[1])); + assert.deepEqual(firstAdjustments, [17857, 2667, 16000, 8000]); + assert.match(slideXml[0], /PPT 形状与元素测试矩阵/); + assert.match(slideXml[0], /矩形 · 圆角 · 描边 · 圆 · 多边形 · 直线 · 曲线 · 虚线/); + + assert.equal((slideXml[1].match(//g) || []).length, 4); + assert.match(slideXml[1], //); + assert.equal((slideXml[1].match(/]*algn="ctr"/g) || []).length, 12); + assert.match(slideXml[1], /推理<\/a:t>/); + assert.match(slideXml[1], /95<\/a:t>/); + + assert.equal((slideXml[2].match(//g) || []).length, 4); + assert.equal((slideXml[2].match(//); + assert.match(slideXml[2], /阶段<\/a:t>/); + assert.match(slideXml[2], /字段校验、去重、PII 脱敏<\/a:t>/); + assert.match(slideXml[2], /P99 延迟 ≤ 80ms<\/a:t>/); + // PptxGenJS otherwise emits a duplicate cNvPr id for the first table, which + // forces Microsoft PowerPoint into "needs repair" and corrupts table CJK. + slideXml.forEach((xml, index) => { + const objectIds = [...xml.matchAll(/ match[1]); + assert.equal( + new Set(objectIds).size, + objectIds.length, + `slide ${index + 1} cNvPr ids must be unique (got ${objectIds.join(',')})`, + ); + }); + assert.match(slideXml[2], /阶段<\/a:t>/); + }, { realisticLayout: true, webkitBorderBoxRegressionSimulation: true }); +}); + +test('unsupported HTML fails with structured blocking evidence and no successful artifact', async () => { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + let renderPageCalls = 0; + const html = ` +
    + Unsupported +
    + `; + + await assert.rejects( + prepareEditableSlides([{ id: 'blocked', html }], { + renderPage: () => { renderPageCalls += 1; }, + }), + (error) => error instanceof EditableExportError + && error.code === 'css_filter' + && error.diagnostic.severity === 'blocking' + && error.diagnostic.slideNumber === 1 + && error.diagnostic.sourceId === 'filtered', + ); + assert.equal(renderPageCalls, 0); + }); }); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js index 85764cb3fa..9ff0bba09e 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js @@ -35,15 +35,14 @@ import { enhanceFlatSelect, refreshFlatSelect } from './src/flat-select.js'; import { applyI18n, readInputs, readStyleInputs, renderAll, renderInspector, renderSlideCanvas, renderGeneration, renderGenerationOverlay, renderThumbs, slideHtml, hydrateHtmlSlideIframes, fitSlideCanvas, fitHtmlSlideFrame, buildExportPreviewStage, fitExportPreviewFrame, fitThumbPreviews, normalizeSlideDocument, observeThumbPreviews, ensureCanvasFitted, syncDensitySlider, syncFontFamilyToggle, syncColorModeToggle, syncStylePanelFromState } from './src/render.js'; import { buildElementSlideHtml, - prepareSlidesForPptxExport, + prepareEditableSlides, slideExportHtml, EXPORT_VIEWPORT, } from './src/export-slide-browser.js'; import { + exportEditablePptx, exportPdfFromBase64Pages, exportPngZipFromPages, - exportPptxFromDeck, - exportPptxPrepared, } from './src/export-deck-host.js'; import { downloadBase64File, downloadHtmlDeck, fileSafe } from './src/export-html.js'; import { exportFormatIcon, exportFormatTone } from './src/export-format-icons.js'; @@ -2592,20 +2591,10 @@ function getExportLabels(format) { function formatExportDiagnostics(summary) { if (!summary?.hasWarnings && !summary?.hasBlocking) return ''; const countLabels = [ - ['repaired', 'exportDiagnosticsRepaired'], - ['svgImage', 'exportDiagnosticsSvg'], - ['localPng', 'exportDiagnosticsLocalPng'], - ['pageVisual', 'exportDiagnosticsPageVisual'], - ['fullPage', 'exportDiagnosticsFullPage'], + ['rewritten', 'exportDiagnosticsRepaired'], ['blocking', 'exportDiagnosticsBlocking'], ].filter(([countKey]) => summary.counts?.[countKey] > 0) .map(([countKey, labelKey]) => t(labelKey, { count: summary.counts[countKey] })); - const phaseKeys = { - 'local-svg': 'exportDiagnosticsPhaseSvg', - 'local-visual': 'exportDiagnosticsPhaseLocalPng', - 'page-visual': 'exportDiagnosticsPhasePageVisual', - 'full-page': 'exportDiagnosticsPhaseFullPage', - }; const locations = localizeExportDiagnosticLocations(summary.locations || [], getLocale()) .filter((location) => location.sourceId) .slice(0, 3) @@ -2613,10 +2602,9 @@ function formatExportDiagnostics(summary) { slide: location.slideNumber, source: location.sourceId, phase: t( - phaseKeys[location.phase] - || (location.severity === 'blocking' - ? 'exportDiagnosticsPhaseBlocking' - : 'exportDiagnosticsPhaseRepair'), + location.severity === 'blocking' + ? 'exportDiagnosticsPhaseBlocking' + : 'exportDiagnosticsPhaseRepair', ), reason: location.reason, })); @@ -2633,17 +2621,12 @@ function formatBlockingExportDiagnostics(diagnostics) { if (!blocking.length) return ''; return formatExportDiagnostics({ counts: { - repaired: 0, - svgImage: 0, - localPng: 0, - pageVisual: 0, - fullPage: 0, + rewritten: 0, blocking: blocking.length, }, locations: blocking.map((diagnostic) => ({ slideNumber: diagnostic.slideNumber || '?', sourceId: diagnostic.sourceId || '?', - phase: diagnostic.phase || null, severity: 'blocking', code: diagnostic.code, })), @@ -2703,29 +2686,11 @@ async function executeExport(format) { let result; const deckPayload = clone(state); if (format === 'pptx') { - if (slides.some((slide) => slide?.html)) { - const hostDeck = runtime(); - const renderRaster = typeof hostDeck?.deck?.renderPage === 'function' - ? async (html, index) => { - setExportRenderProgress(index, slides.length, 'pptx'); - const base64 = await hostDeck.deck.renderPage({ - html, - format: 'png', - width: EXPORT_VIEWPORT.width, - height: EXPORT_VIEWPORT.height, - }); - return String(base64 || '').replace(/^data:.*;base64,/, ''); - } - : null; - const preparedSlides = await prepareSlidesForPptxExport(slides, { - renderRaster, - onRasterProgress: (index) => setExportRenderProgress(index, slides.length, 'pptx'), - }); - result = await exportPptxPrepared(deckPayload, preparedSlides); - result.exportSummary = summarizePptxExportDiagnostics(preparedSlides); - } else { - result = await exportPptxFromDeck(deckPayload); - } + const scenes = await prepareEditableSlides(slides, { + onSlideProgress: (pageNumber) => setExportRenderProgress(pageNumber - 1, slides.length, 'pptx'), + }); + result = await exportEditablePptx(deckPayload, scenes); + result.exportSummary = summarizePptxExportDiagnostics(scenes); } else if (format === 'pdf') { const pages = await renderSlidesInHostWebView(slides, 'pdf'); result = await exportPdfFromBase64Pages(deckPayload, pages.map((page) => page.base64)); @@ -3421,7 +3386,13 @@ async function confirmExportFromModal() { const localizedDiagnostics = formatBlockingExportDiagnostics(error?.diagnostics); const message = localizedDiagnostics || (error instanceof Error ? error.message : String(error)); - runtime().log?.error?.(`PPT Live ${format} export failed`, { error: message }); + runtime().log?.error?.(`PPT Live ${format} export failed`, { + error: message, + code: error?.code || error?.diagnostic?.code || null, + sourceId: error?.sourceId || error?.diagnostic?.sourceId || null, + slideNumber: error?.slideNumber || error?.diagnostic?.slideNumber || null, + detail: error?.message || null, + }); $('exportOverlay')?.classList.remove('is-exporting'); setExportModalFeedback('error', `${labels.failed} ${message}`); setExportStatus(`${labels.failed} ${message}`); From da027e2e893865bb6c813248c36169ff45996c2e Mon Sep 17 00:00:00 2001 From: limityan Date: Fri, 17 Jul 2026 21:28:29 +0800 Subject: [PATCH 004/174] feat(extensions): add OpenCode subagent compatibility --- .../capability-runtime-integration-design.md | 10 + .../external-ai-work-sources-design.md | 37 +- .../opencode-config-assets-adapter-design.md | 53 +- .../opencode-extension-compatibility.md | 10 +- .../extensions/plugin-runtime-host-design.md | 28 +- docs/architecture/product-architecture.md | 9 +- .../opencode-extension-compatibility-plan.md | 63 +- scripts/core-boundaries/checker.mjs | 2 +- .../core-boundaries/rules/feature-rules.mjs | 4 +- .../rules/source/forbidden-rules.mjs | 1 + .../rules/source/public-api-rules.mjs | 97 + scripts/core-boundaries/self-test.mjs | 40 +- src/apps/cli/src/actions.rs | 16 + src/apps/cli/src/modes/chat.rs | 22 +- src/apps/cli/src/modes/chat/capabilities.rs | 15 +- src/apps/cli/src/modes/chat/commands.rs | 15 +- .../cli/src/modes/chat/external_review.rs | 774 +++++++- .../cli/src/modes/chat/external_sources.rs | 240 ++- src/apps/cli/src/modes/chat/run.rs | 25 +- src/apps/cli/src/modes/chat/tests.rs | 331 +++- src/apps/cli/src/ui/command_palette.rs | 1 + src/apps/cli/src/ui/startup.rs | 5 + src/apps/cli/src/ui/subagent_selector.rs | 4 +- src/apps/desktop/src/api/agentic_api.rs | 1 + src/apps/desktop/src/api/custom_agent_api.rs | 21 + .../desktop/src/api/external_sources_api.rs | 84 +- .../src/api/remote_workspace_policy.rs | 14 +- src/apps/desktop/src/api/subagent_api.rs | 54 +- src/apps/desktop/src/lib.rs | 2 + .../adapters/opencode-adapter/AGENTS-CN.md | 5 +- .../adapters/opencode-adapter/AGENTS.md | 6 +- .../adapters/opencode-adapter/Cargo.toml | 1 + .../opencode-adapter/src/agent_source.rs | 1180 +++++++++++++ .../opencode-adapter/src/command_source.rs | 43 +- .../adapters/opencode-adapter/src/lib.rs | 2 + .../opencode-adapter/src/tool_source.rs | 71 +- .../tests/opencode_subagent_adapter.rs | 283 +++ .../agentic/agents/definitions/external.rs | 90 + .../src/agentic/agents/definitions/mod.rs | 1 + .../assembly/core/src/agentic/agents/mod.rs | 8 +- .../agentic/agents/registry/availability.rs | 11 + .../src/agentic/agents/registry/builtin.rs | 3 + .../src/agentic/agents/registry/custom.rs | 7 + .../src/agentic/agents/registry/external.rs | 407 +++++ .../core/src/agentic/agents/registry/mod.rs | 14 +- .../core/src/agentic/agents/registry/query.rs | 72 + .../src/agentic/agents/registry/resolution.rs | 24 + .../core/src/agentic/agents/registry/tests.rs | 147 +- .../core/src/agentic/agents/registry/types.rs | 9 + .../src/agentic/coordination/coordinator.rs | 251 ++- .../src/agentic/coordination/scheduler.rs | 2 +- .../assembly/core/src/agentic/core/mod.rs | 3 +- .../assembly/core/src/agentic/core/session.rs | 3 +- .../src/agentic/execution/execution_engine.rs | 138 +- .../src/agentic/session/session_manager.rs | 62 +- .../core/src/agentic/skill_agent_snapshot.rs | 1 + .../tools/implementations/task/execution.rs | 80 +- .../agentic/tools/implementations/task/mod.rs | 1 + .../tools/implementations/task/tests.rs | 1 + .../assembly/core/src/external_sources.rs | 1144 +++++++++++- .../assembly/core/src/external_subagents.rs | 1557 +++++++++++++++++ .../assembly/core/src/external_tools.rs | 13 +- .../src/infrastructure/ai/client_factory.rs | 126 +- src/crates/assembly/core/src/lib.rs | 1 + .../core/src/service/config/global.rs | 4 + .../core/src/service/config/service.rs | 19 +- .../assembly/core/src/service/config/types.rs | 39 + .../core/src/service/session_usage/service.rs | 2 + .../assembly/external-sources/src/lib.rs | 15 + .../assembly/external-sources/src/subagent.rs | 451 +++++ .../assembly/external-sources/src/tool.rs | 11 +- .../tests/subagent_coordinator.rs | 249 +++ src/crates/contracts/core-types/src/lib.rs | 4 +- .../contracts/core-types/src/session.rs | 18 + .../contracts/product-domains/Cargo.toml | 2 +- .../product-domains/src/external_sources.rs | 54 + .../product-domains/src/external_subagents.rs | 610 +++++++ .../contracts/product-domains/src/lib.rs | 3 + .../tests/external_source_contracts.rs | 272 ++- .../execution/agent-runtime/src/agents.rs | 13 +- .../execution/agent-runtime/src/session.rs | 64 +- .../tests/agent_registry_contracts.rs | 10 +- .../services-core/src/session/lineage.rs | 3 + .../services-core/src/session/metadata.rs | 8 + .../services-core/src/session/types.rs | 7 +- .../app/scenes/agents/AgentsScene.test.tsx | 9 + .../src/app/scenes/agents/AgentsScene.tsx | 94 +- .../src/app/scenes/agents/agentVisibility.ts | 7 + .../src/app/scenes/agents/agentsStore.ts | 2 +- .../scenes/agents/components/AgentCard.scss | 16 + .../app/scenes/agents/hooks/useAgentsList.ts | 4 +- src/web-ui/src/app/scenes/agents/utils.ts | 2 + .../api/service-api/CustomAgentAPI.ts | 8 +- .../api/service-api/ExternalSourcesAPI.ts | 99 +- .../api/service-api/SubagentAPI.ts | 8 +- .../components/ExternalSourcesConfig.test.tsx | 348 +++- .../components/ExternalSourcesConfig.tsx | 498 +++++- .../src/locales/en-US/scenes/agents.json | 12 +- .../en-US/settings/external-sources.json | 235 ++- .../src/locales/zh-CN/scenes/agents.json | 12 +- .../zh-CN/settings/external-sources.json | 233 ++- .../src/locales/zh-TW/scenes/agents.json | 12 +- .../zh-TW/settings/external-sources.json | 227 ++- 103 files changed, 10722 insertions(+), 707 deletions(-) create mode 100644 src/crates/adapters/opencode-adapter/src/agent_source.rs create mode 100644 src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs create mode 100644 src/crates/assembly/core/src/agentic/agents/definitions/external.rs create mode 100644 src/crates/assembly/core/src/agentic/agents/registry/external.rs create mode 100644 src/crates/assembly/core/src/external_subagents.rs create mode 100644 src/crates/assembly/external-sources/src/subagent.rs create mode 100644 src/crates/assembly/external-sources/tests/subagent_coordinator.rs create mode 100644 src/crates/contracts/product-domains/src/external_subagents.rs diff --git a/docs/architecture/extensions/capability-runtime-integration-design.md b/docs/architecture/extensions/capability-runtime-integration-design.md index 4c405f9b60..5e7a3dcaf4 100644 --- a/docs/architecture/extensions/capability-runtime-integration-design.md +++ b/docs/architecture/extensions/capability-runtime-integration-design.md @@ -123,6 +123,16 @@ Runtime 接口,不经过对外能力门面。只有某个具体 DTO 同时出 | 对外能力门面 | 暴露真实消费者需要的窄用例、只读状态、事件和类型化错误 | 暴露内部 manager、插件 Host ABI、任意服务查找或产品 UI。 | | Host Adapter | 把门面映射为某宿主的 MCP、Skill、Plugin、Hook、SDK 或 Server 调用 | 声称突破宿主未提供的生命周期、状态或替换能力。 | +当前外部 Subagent 输入切片落实了上述边界:`contracts/product-domains` 只定义来源无关的 Subagent contribution、 +provenance、兼容状态、摘要和冲突契约;OpenCode adapter 独立维护本生态来源与字段语义;生命周期协调器隔离 provider +失败并发布不可变候选;现有 AgentRegistry/Task owner 再解析模型、工具、权限上限和同名路由。产品主体不读取 +OpenCode 类型,也不通过统一 agent JSON 理解未来 Codex/Claude Code。 + +fresh external invocation 在 admission 前绑定逻辑名到 runtime generation,并把 generation lease 传入前台或后台 +调度请求。来源更新或撤下只改变后续 admission,不修改已接受调用的 prompt/model/tool 绑定;安全策略收紧仍由现有 +owner 按原规则优先执行。当前外部 Subagent 不支持 session follow-up,结果与管理 surface 必须明确标为 single-run, +不能用持久化 session 绕过重新审批或 generation 解析。 + 对外能力门面不等于 Agent Runtime SDK 的全部接口。SDK 可以包含构建与运行 Agent 所需的底层能力;宿主 adapter 只消费当前场景需要的最小子集。外部产品只需要调用一个 BitFun workflow 时,不应被迫嵌入完整 Agent Runtime。 diff --git a/docs/architecture/extensions/external-ai-work-sources-design.md b/docs/architecture/extensions/external-ai-work-sources-design.md index 4257689289..2e370ed48c 100644 --- a/docs/architecture/extensions/external-ai-work-sources-design.md +++ b/docs/architecture/extensions/external-ai-work-sources-design.md @@ -9,10 +9,12 @@ SDK 或 Server 输出到外部宿主,以及内部 Provider Slot、状态、事 本文同时记录当前可用纵向切片与目标架构。当前 BitFun 已具备通用外部来源目录和生命周期协调器,并通过 OpenCode Prompt Command 适配器接入本地用户全局/项目来源;Desktop 可查看、刷新、抑制和处理跨来源冲突, -CLI/TUI 可列出并执行 prompt-only Command。第二条纵向切片已让受支持的单文件 OpenCode `.js` standalone Tool 经静态 -预览、来源/能力确认和同名冲突选择后进入现有 Tool Runtime;Desktop 与 CLI/TUI 使用同一决策状态。完整 -TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Subagent 仍属于后续阶段,不能因来源被识别 -就宣称已经可用。 +交互式 TUI(ChatMode)可列出并执行 prompt-only Command。第二条纵向切片已让受支持的单文件 OpenCode `.js` standalone Tool 经静态 +预览、来源/能力确认和同名冲突选择后进入现有 Tool Runtime;Desktop 与交互式 TUI(ChatMode)使用同一决策状态。第三条纵向 +切片已把 OpenCode 全局/项目 Subagent 的安全子集通过独立 provider 契约接入现有 Subagent owner:首次启用与 +同名冲突使用非阻塞决策,fresh 调用固定不可变 generation,更新和撤下不会静默切换到同名实现。完整 +TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器、primary agent 替换和外部 Subagent 续接仍属于 +后续阶段,不能因来源被识别就宣称已经可用。 ## 1. 产品判断与竞品启示 @@ -56,7 +58,7 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub ### 3.1 首次发现 -发现始终在后台进行。Desktop、CLI/TUI 和未来 Web 入口消费同一只读来源状态,但按宿主展示: +发现始终在后台进行。Desktop、交互式 TUI(ChatMode)和未来 Web 入口消费同一只读来源状态,但按宿主展示: ```text 已发现 OpenCode 工作内容 @@ -74,6 +76,8 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub 读取凭据或主动联网;提示可以稍后处理。 - 当前阶段范围外的 TypeScript、依赖型 Tool 和 package plugin 只进入“已发现,静态预览”清单,不进入模型可调用 集合;受支持 JS Tool 也必须在明确启用前保持同一状态。 +- 外部 Subagent 即使只是声明文件,也会把 prompt、模型和工具集合带入一次独立 agent 调用,因此按当前行为与能力 + 包络确认;列表和 IPC 只显示描述、来源、模型、工具和诊断摘要,不传输 prompt 正文。 - 全局来源首次在当前执行域识别时提示一次;项目来源按工作区提示。相同全局来源不能在每个项目重复轰炸用户。 - “撤销已应用内容”对持续兼容来源表示在用户选择的当前项目或当前执行域内抑制对应来源/资产并重新计算下一 来源或产品默认;后续 watcher 更新不得绕过该偏好重新应用。该操作不写回外部文件,也不同于显式导入的字段级撤销。 @@ -84,7 +88,7 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub | 信息 | 产品要求 | |---|---| -| 来源 | 产品、规范化位置、用户全局/项目/工作区作用域、实际执行域;敏感路径默认缩略显示。 | +| 来源 | 产品、规范化位置、用户全局/项目/工作区作用域、实际执行域;Agent 普通视图只接收 `/…`、`~/.config/…` 或 `/…` 等安全标签,不传绝对用户路径。 | | 内容 | 配置、Rules、Agents、Skills、Commands、MCP、插件、工具等类别与数量。 | | 状态 | 已发现、已应用、可用、需确认、更新中、沿用上一版本、部分受限、暂时过期、已移除/已停用或不可用。 | | 变化 | 最近成功读取时间、候选摘要、已应用摘要、权限或能力变化。 | @@ -130,6 +134,14 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub | L2 受归属模块保护的外部能力 | 可执行 Skill/Command、远程 Reference、MCP、LSP、Formatter、Provider 连接 | 发现后进入“需确认”;由真实归属模块展示命令、网络、凭据和作用域后启用。 | | L3 任意第三方代码 | JS/TS Tool、服务插件、Hook、TUI target、动态 import | 发现但不 import;首次按来源/target 启用时说明执行用户、工作目录和直接文件/网络/进程的粗粒度执行包络,不能承诺 import 前已知全部动态贡献。 | +OpenCode Subagent 属于 L2:adapter 只读取声明,不执行外部代码;激活仍需确认实际模型、工具、执行域和来源谱系。 +仅 description 等 catalog 文案变化不扩大包络,不重复询问;prompt 行为、provenance、模型或工具变化必须重新确认。 +生态 adapter 只提交类型化的模型请求,不能把 `provider/model` 等来源语法交给通用 owner 解析。Subagent owner 在审批 +前将请求物化为唯一、已启用的具体模型,并形成“配置 ID + 运行配置指纹”的不可变绑定;`inherit`、`primary`、`fast`、 +`auto`、`default` 等字符串在此绑定中都是普通配置 ID,不能再次经过模型选择器解释。无法唯一确定时保持不可用。provider、 +模型名、endpoint 或其他影响运行身份的配置在同一 ID 下变化,也会重建未来 generation 并产生新的审批决策;generation +lease 保留旧绑定事实,执行入口若发现当前配置与旧指纹不一致则安全失败,不能静默改用新配置或父会话模型。 + 确认结果分成两层:来源级加载偏好按“来源限定身份 + target + 执行域 + 更新策略”保存,并明确作用于当前项目 还是当前执行域内所有已通过来源校验的项目;项目/工作区实例只作为“有效来源图 + 粗粒度执行包络 + 已知贡献 摘要”的重新求值上下文,不形成第二个确认键。选择全局作用域后,跨项目本身不重复询问;只有新实例使执行包络、 @@ -160,6 +172,8 @@ TypeScript/Bun、包依赖、package plugin、Codex/Claude Code 适配器和 Sub ``` 在途调用固定使用发起时的激活代次。新调用只在切换完成后使用新代次;旧代次的迟到响应和贡献引用在退出后失效。 +Subagent 通过随调度请求传递的 generation lease 实现这一点;路由撤下后有 lease 的精确运行定义保留到调用结束, +无 route 且无 lease 的旧定义立即回收。该机制不允许新调用使用已删除或已撤销的来源。 文件观察事件不是业务事实。协调器先按来源聚合连续 create/rename/write/remove 事件,并在可配置的文件稳定窗口后 重新扫描完整来源图;编辑器的原子保存不能被误判成删除后重装。用户显式停用、组织策略收紧或安全撤销不等待 @@ -217,6 +231,7 @@ flowchart LR | 文件观察服务 | 提供可订阅、去抖的文件变化事实 | 解释生态路径、决定优先级、提交业务状态。 | | 本地 JSON 存储服务 | 提供跨进程锁、锁内读改写和严格同卷原子替换等通用文件能力;替换失败时保留旧文件 | 定义外部来源偏好 schema、冲突策略或生态语义。 | | 共享生命周期协调器 | 调用已注册 provider、生成不可变候选、按 provider 原子替换、保留隔离诊断,并请求能力 owner 切换 | 按生态 ID 分支业务行为、解析生态文件、直接提交配置、工具、权限或界面状态。 | +| 产品展示投影 | 按作用域、工作区或用户目录关系统一生成安全来源位置,清理可见诊断文本中的已知绝对路径,并按 `Source / Command / Tool / Subagent` 资源类型路由诊断 | 让 GUI/TUI 解析 provider 诊断码前缀、识别 `.opencode`、`.claude` 等私有目录结构,或接收原始用户/工作区路径。 | | 冲突解析 | 对独立 provider 或产品本地能力的同名候选建立版本敏感指纹;未选择时不激活,选择后只在指纹不变时复用 | 用 adapter 优先级静默覆盖另一生态或本地能力,或把选择写回外部文件。 | | 激活策略与各能力 owner | 根据风险、用户选择、组织上限和执行域决定自动应用、等待确认或限制 | 修改生态加载顺序或把策略拒绝伪装成解析失败。 | | Runtime Configuration Service | 应用兼容配置视图,执行显式导入、冲突预览、原子写入和撤销 | 读取凭据值或加载插件代码。 | @@ -278,8 +293,8 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 4. 含 `!shell`、`@file`、`{env:...}`、`{file:...}`、`agent`、`model`、`variant` 或 `subtask` 等未接通语义的命令标记为“部分受限”,不做 静默忽略后的部分执行。 5. Desktop 提供统一来源状态、刷新、按执行域抑制/恢复和冲突候选选择;首次 provider 扫描完成前显示中性检查状态, - 不把暂时空目录误报为最终空结果;已经选择且指纹未变化的冲突退出待处理区。CLI/TUI 使用同一目录列出和执行 - Command;跨 provider 候选以来源限定别名供 CLI 用户直接选择,同次选择也解析本地同名冲突。发现或确认不阻塞 + 不把暂时空目录误报为最终空结果;已经选择且指纹未变化的冲突退出待处理区。交互式 TUI(ChatMode)使用同一目录列出和执行 + Command;跨 provider 候选以来源限定别名供 TUI 用户直接选择,同次选择也解析本地同名冲突。发现或确认不阻塞 普通聊天输入;发现未完成时未限定 slash 别名不猜测冲突结果,显式 `/builtin:` 仍可立即执行。执行域全局偏好 使用独立偏好文件、跨进程锁和严格原子替换,并在查询、刷新和执行前重新读取,使并行 Desktop/CLI 进程不会继续使用 另一进程已停用的来源或丢失并发选择。Desktop IPC 仅返回设置页所需摘要,不携带 Prompt Command 模板正文。 @@ -295,7 +310,7 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 3. 内置、MCP 与外部同名 Tool 使用包含候选身份和内容版本的指纹;选择前保留已有本地实现,不按注册顺序静默覆盖。 候选集合由已识别定义而非成功加载结果计算;候选更新、暂不可用或删除后重新选择,已选外部实现失效时保持 unavailable,不静默回退同名本地实现。其他无关 Tool 和生态不受影响。 -4. Desktop 提供完整审核卡、主动停用/重新审核和冲突候选;CLI/TUI 状态栏只做非阻塞提示,`/external-tools` +4. Desktop 提供完整审核卡、主动停用/重新审核和冲突候选;交互式 TUI(ChatMode)状态栏只做非阻塞提示,`/external-tools` 以编号映射稳定 key 完成启用、保持停用、冲突选择和刷新。普通聊天输入和无关会话不等待用户处理。 5. 每个 target 使用一个持久 Node worker,支持 load/invoke、`AbortSignal` 取消、500 ms 宽限后的 target 级硬终止、 30 秒请求期限和 dispose;产品配置的更短外层期限丢弃调用 future 时也会终止 worker,并在终止完成前保持 target @@ -324,6 +339,10 @@ Command;明确缺失且未被标记失败的 Command 是稳定删除。产品 - 持续来源撤销后不会被下一次 watcher 更新重新应用;当前项目与整个执行域的抑制范围可验证。 - 同名外部候选和产品本地能力在用户选择前均不会被静默覆盖;选择在候选内容版本和参与集合不变时不重复询问, 任一候选更新、删除或参与集合变化后重新进入待选择,即使变化后只剩一个实现也不静默切换。 +- 冲突谱系对 0/1/N 个当前参与者都生成当前指纹;自动更新谱系或将旧选择改为“需重选”时,与用户选择一样原子推进 + `preference_revision`。跨进程旧 revision 的操作必须返回 stale,不得重新写入旧授权或旧候选。 +- GUI/TUI 的外部 Agent 冲突选择在同一上下文中展示将被原子批准的模型、工具、执行域、安全来源、兼容影响和恢复动作; + 同工作区决策串行化,成功后读取权威快照,不以较旧整表覆盖无关的 Command/Tool 新状态。 - 冲突偏好按执行域与命令族只保留当前指纹,并以去重候选身份标记曾发生冲突;连续内容更新不会按历史指纹线性膨胀。 - 显式导入的字段级预览、冲突、撤销和凭据脱敏可验证。 - 当前只支持静态预览的资产不会被产品文案误报为已应用或可执行;支持子集与完整 OpenCode 兼容不会混写。 diff --git a/docs/architecture/extensions/opencode-config-assets-adapter-design.md b/docs/architecture/extensions/opencode-config-assets-adapter-design.md index 37ab4db7c4..4e0e4dd475 100644 --- a/docs/architecture/extensions/opencode-config-assets-adapter-design.md +++ b/docs/architecture/extensions/opencode-config-assets-adapter-design.md @@ -10,9 +10,9 @@ 配置字段与来源以 [OpenCode 配置文档](https://opencode.ai/docs/config/)和稳定提交中的主/TUI 配置实现为准。 -本文同时记录首个可用切片与后续目标。当前 BitFun 已实现本地用户全局/项目 Prompt Command 的来源发现、 -JSON/JSONC/Markdown 解析、参数展开、运行时刷新和冲突选择,但尚未实现本文定义的完整 OpenCode 配置来源图、 -全部合并语义和其他资产映射。 +本文同时记录当前可用切片与后续目标。BitFun 已实现本地用户全局/项目 Prompt Command 的来源发现、 +JSON/JSONC/Markdown 解析、参数展开、运行时刷新和冲突选择,也已接入全局/项目 Subagent 声明的安全子集; +但尚未实现本文定义的完整 OpenCode 配置来源图、全部合并语义和其他资产映射。 ## 1. 目标与边界 @@ -172,16 +172,19 @@ OpenCode 来源顺序决定兼容输入如何合并;BitFun 产品能力上限 ## 5. 声明式资产映射 -下表的“默认行为”是对应交付阶段完成后的目标行为。首个实现切片只接入本地 prompt-only Command:不启动进程、 -不 import module、不读取凭据、不主动联网;其他远程或可执行资产只解析、展示来源与诊断。 +下表的“默认行为”是对应交付阶段完成后的目标行为。当前已接入本地 prompt-only Command 和 Subagent 安全子集。 +OpenCode adapter 在来源发现、解析和审批前不 import module、不读取来源凭据、不主动联网;用户确认模型、工具和 +执行位置后,Subagent owner 才通过现有 Task 执行链发起 fresh single-run 调用。激活后的模型、工具、权限与凭据 +使用仍由对应 owner 按已确认包络控制。除已闭环的 standalone Tool 外,其余尚未闭环的远程或可执行资产仍只 +解析、展示来源与诊断。 | 资产 | OpenCode 输入 | BitFun 归属模块 / 适配方式 | 默认行为 | 降级条件 | |---|---|---|---|---| | Rules / Instructions | 项目/全局 `AGENTS.md`、Claude fallback、`instructions` glob、本地文件、远程 URL | Workspace Instructions 归属模块保存有序来源引用 | 本地内容按 L1 合并并保留来源;主动获取远程 URL 前确认 | 远程或单文件失败只排除该来源。 | -| Agents / Modes | JSON、Markdown、description、mode、prompt、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、permission、disable、options、hidden、color | Agent 归属模块创建兼容定义和作用域视图 | 纯声明字段按 OpenCode 顺序应用;扩大工具、权限或外部能力时确认 | BitFun 不支持字段进入诊断,不阻止其他 Agent。 | +| Agents / Modes | JSON、Markdown、description、mode、prompt、model、variant、temperature、top_p、steps、deprecated `maxSteps`、deprecated `tools`、permission、disable、options、hidden、color | Agent 归属模块创建兼容定义和作用域视图 | 当前支持 Subagent 安全子集;首次按 behavior、provenance、模型和工具包络确认,fresh single-run 调用 | primary/mode、permission、variant/options、采样、steps 与续接保持诊断或阻断,不影响其他 Agent。 | | Skills | `.opencode/.claude/.agents` 项目与用户根、`SKILL.md`、`skills.paths/urls` | Skill 归属模块复用按需加载并补齐规则顺序 | 说明和索引按需加载;URL、脚本或外部依赖按 L2 确认 | URL 或可执行资源失败只降级对应 Skill。 | | References | `references` / 旧 `reference`,本地 path 或 Git repository/branch/description/hidden | **基础能力缺失**:先补 Workspace Reference 的异步准备与 `@alias` 消费接口 | 本地引用保留相对来源;Git 拉取按 L2 确认并保留缓存/隐藏语义 | 拉取失败不阻止项目,外部目录仍遵守工具权限。 | -| Commands | JSON/JSONC、Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | Prompt Command 专属契约;OpenCode adapter 保留发现、覆盖、解析和参数展开语义,CLI/TUI 只消费中立定义与展开结果 | PR1 支持 prompt-only 模板,用户显式选择或输入即确认本次发送;未接通的文件、shell、agent/model/variant/subtask 标为部分受限且不做部分执行 | 已知命令文件无效只回退该命令;稳定删除撤下新调用;目录枚举未知时回退对应目录来源,不能把未知当空目录。 | +| Commands | JSON/JSONC、Markdown、`$ARGUMENTS`、位置参数、`@file`、`!shell`、agent/model/variant/subtask | Prompt Command 专属契约;OpenCode adapter 保留发现、覆盖、解析和参数展开语义,交互式 TUI(ChatMode)只消费中立定义与展开结果 | PR1 支持 prompt-only 模板,用户显式选择或输入即确认本次发送;未接通的文件、shell、agent/model/variant/subtask 标为部分受限且不做部分执行 | 已知命令文件无效只回退该命令;稳定删除撤下新调用;目录枚举未知时回退对应目录来源,不能把未知当空目录。 | | MCP | local 的 command/environment/cwd/timeout,remote 的 URL/headers/oauth/timeout,Agent 选择 | MCP 归属模块创建兼容配置视图 | 首次按命令、网络、凭据和作用域确认,再按来源启用状态运行 | 凭据或网络不可用时单个 Server 不可用。 | | LSP | command、extensions、env、initialization | LSP 归属模块注册兼容实例 | 首次确认外部进程和作用域后按文件类型启动 | 自定义 Server 缺少 extensions 或启动失败时只禁用该项。 | | Formatters | command、environment、extensions、`$FILE` | **基础能力缺失**:先补文件写入后的 Formatter 执行消费点,再做格式转换 | 首次确认命令后执行匹配 Formatter | 超时后标记未格式化,文件写入结果保留。 | @@ -198,13 +201,37 @@ OpenCode 来源顺序决定兼容输入如何合并;BitFun 产品能力上限 ### 5.2 Agents、Modes 与 Skills -兼容定义进入现有 Agent 归属模块,而不是新建 OpenCode Agent Runtime。字段映射分为: +兼容定义进入现有 Agent 归属模块,而不是新建 OpenCode Agent Runtime。当前已实现范围按是否能保持行为等价划分: -- 可等价映射:名称、description、prompt、model、variant、`primary|subagent|all`、隐藏/停用状态、采样选项、 - `steps` / deprecated `maxSteps`、deprecated `tools` 和 options。 -- 策略映射:`permission` pattern、Skill 访问和 task/subagent 调用。 +- 可等价映射并激活:名称、description、prompt、`subagent|all`、隐藏/停用状态、可精确解析的 model,以及能映射到 + 当前有效 Tool route 的明确工具选择;缺省工具使用 BitFun 保守 Subagent 默认集并展示在确认摘要中。 +- 可识别但不激活:`primary`/legacy mode、`permission` pattern、variant/options、temperature/top_p、steps/ + deprecated maxSteps,以及不能精确解析的模型或工具。当前不能把这些字段静默忽略后宣称兼容。 - 展示映射:color 等只影响来源 Surface,不进入运行时权威事实。 -- 未知字段:保留并诊断;后续版本支持时可以重新解释原始来源。 +- 未知字段:进入来源限定诊断,不作为任意 payload 传给 core;后续版本支持时由 OpenCode adapter 更新解释。 + +全局与项目贡献在 adapter 内按稳定 OpenCode 顺序深合并并保留有序 provenance。Core 只消费来源无关候选,按当前 +模型、工具、执行域和本地/其他 provider 同名项生成审批与冲突指纹。无冲突候选首次确认一次;只有 catalog 文案 +变化不重问,prompt 行为、provenance 或实际模型、工具与执行范围变化重新确认。冲突未选择时逻辑名不可用,候选 +变化后不静默回退。 + +OpenCode adapter 负责把 `provider/model` 语法解析成来源无关的 provider 提示与模型名;Core 不解释 OpenCode 字符串 +格式。进入审批前,Subagent owner 必须把该请求或 BitFun 的固定 Subagent 默认项解析成唯一、已启用的具体模型,并把 +具体模型的配置 ID 与运行配置指纹写入决策和 generation 指纹。`inherit`、`primary`、`fast`、`auto`、`default` 在已 +物化绑定中只可能是普通配置 ID,不得再次解释成继承或默认选择;未配置的默认项、歧义匹配或已停用模型保持不可用并 +给出诊断,不能用运行时回退绕过审批。同一 ID 下的 provider、模型名、endpoint 或其他运行身份变化也会异步重建后续 +调用使用的 generation 并要求重新确认;旧 generation lease 保留旧绑定,执行时若指纹已不匹配则安全失败而不静默漂移。 + +通用诊断携带 `Source / Command / Tool / Subagent` 资源类型,产品入口只按该类型路由;`opencode.*` 诊断码仅用于技术详情, +不能成为 Core、GUI 或 TUI 的业务分支条件。能力 provider 契约限制来源、定义、provenance 和诊断集合,校验诊断码、 +可见文本、资源类型及来源归属,异常快照整体拒绝。Adapter 不把原始绝对路径写入诊断文本;产品快照边界还会把结构化 +位置及诊断中的已知路径统一转换为 `/…`、`~/…`、`/…` 等安全标签,`.opencode` 路径识别仍只属于 +本 adapter。 + +Subagent owner 仍通过现有 Task 执行链完成调用。fresh admission 固定 runtime generation 至调用完成;当前不支持外部 session +follow-up、primary agent 替换、OpenCode 会话内核、permission DSL 或 package plugin。Desktop/TUI 摘要不包含 prompt +正文,静态 system prompt 也不因该适配而改写。来源 `description` 只进入审批和管理界面;已批准 Agent +进入现有 `` 动态投影时使用 BitFun 生成的稳定摘要,避免 catalog-only 文案更新绕过行为重批而改变模型上下文。 ### 5.3 Commands @@ -226,7 +253,7 @@ stale selection 并等待重新选择,不能直接执行刚刷新的新内容 的同步 shell 路径;展开有期限、取消和输出大小限制,大输出保存后只把引用交给命令模板。 OpenCode 生态内部仍按其规则覆盖同名内置命令,但跨独立 provider 或与 BitFun 本地命令同名时不得静默覆盖。 -发生冲突后,兼容视图展示全部来源;CLI/TUI 对本地/单一外部冲突提供 `/builtin:name` 与 `/external:name`,对跨 +发生冲突后,兼容视图展示全部来源;交互式 TUI(ChatMode)对本地/单一外部冲突提供 `/builtin:name` 与 `/external:name`,对跨 provider 候选提供 `/external::`。一次外部候选选择同时解析同名本地命令冲突。选择按候选身份和 `content_version` 形成的冲突指纹持久化,同一指纹只询问一次;任一外部候选更新、删除或参与集合变化后指纹变化并 重新询问,即使变化后只剩一个外部或内建候选也不能静默切换实现。持久化只保留每个执行域/命令族的当前指纹和 diff --git a/docs/architecture/extensions/opencode-extension-compatibility.md b/docs/architecture/extensions/opencode-extension-compatibility.md index ec35316414..c4e1bd5858 100644 --- a/docs/architecture/extensions/opencode-extension-compatibility.md +++ b/docs/architecture/extensions/opencode-extension-compatibility.md @@ -69,7 +69,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 - 第一条执行闭环已覆盖官方复数目录和源码验证过的单数目录中的受支持单文件 `.js` standalone tool;`.ts`、模块依赖、 package plugin、完整配置、Hook 和 TUI target 仍只识别或延后。当前范围和完整兼容目标必须分别表达,不能用 一个 JS fixture 宣称 OpenCode runtime 完整兼容。 -- PR2 使用本机 Node.js 验证受限 JS 子集,并在 Desktop 与 CLI/TUI 显示运行时和无 OS 沙箱边界;固定 Bun、完整 +- PR2 使用本机 Node.js 验证受限 JS 子集,并在 Desktop 与交互式 TUI(ChatMode)显示运行时和无 OS 沙箱边界;固定 Bun、完整 TypeScript/Zod 与包依赖仍是后续兼容目标。HarmonyOS PC 原生 CLI/TUI 必须按 [平台专题](../platform-portability-design.md)独立取证,不包含 HarmonyOS 手机 Remote App。 - 扩展调用必须有期限、取消、有界队列、大小检查和可观察的崩溃降级;更细的权限、沙箱和组织策略沿用现有控制点并延期 @@ -81,7 +81,7 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | 优先级 | 可观察结果 | 暂不绑定的工作 | |---|---|---| | OC-E0 | 固定版本、官方 custom tool 契约、受支持单文件 fixture、当前静态预览明确显示“未执行” | 全量配置导入 | -| OC-E1 | 上述 fixture 的真实 `execute` 进入现有 Tool Runtime,支持身份/路径字段、合作式与硬取消,并在 Desktop/CLI/TUI 完成非阻塞审批和冲突选择 | `metadata`/`ask`、依赖型样例、package plugin、Hook、TUI 插件 API | +| OC-E1 | 上述 fixture 的真实 `execute` 进入现有 Tool Runtime,支持身份/路径字段、合作式与硬取消,并在 Desktop/交互式 TUI(ChatMode)完成非阻塞审批和冲突选择 | `metadata`/`ask`、依赖型样例、package plugin、Hook、TUI 插件 API | | OC-E2 | 一个真实 package plugin,仅实现其需要的 loader 和最小 client/context | 全部 loader fallback 和 Client API | | OC-E3 | 按阻塞样例加入 Hook;TUI 先接 command/slash/key,toast 需先有 CLI 类型化状态/通知 owner | 原始 renderer、Server、Remote、连接器 | @@ -103,10 +103,10 @@ OpenCode,和 OpenCode 配置/插件进入 BitFun 是两个独立验收方向 | JSON、JSONC、环境变量、文件引用 | 转换参数 + 明确降级 | 未实现 | 可主要适配 | OC-R1 | 有效配置保持 OpenCode 解码语义;未知字段保留和非安全字段局部恢复属于 BitFun 鲁棒性增强,安全/执行字段无效时不激活受影响结果 | [解析与鲁棒性](opencode-config-assets-adapter-design.md#4-解析与鲁棒性) | | 独立 `tui.json/jsonc` | 融合现有能力 + 转换参数 | 未实现 | 可完整适配 | OC-R1 | 按 global、`OPENCODE_TUI_CONFIG`、project、`.opencode` 独立顺序加载,不能复用主配置优先级 | [TUI 来源](opencode-config-assets-adapter-design.md#32-tui-独立来源顺序) | | Rules / Instructions | 转换参数 | 未实现 | 可完整适配 | OC-R1 | R1 映射本地/已缓存内容;需要主动联网的远程 instruction 在 R2 通过归属模块保护后获取 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | -| Agents / Modes | 融合现有能力 + 转换参数 | 未实现 | 可主要适配 | OC-R1 | 转换 Markdown/JSON、primary/subagent、模型、工具和权限;由 Agent 归属模块提交 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | +| Agents / Modes | 融合现有能力 + 转换参数 | 部分实现:Subagent 安全子集 | 可主要适配 | OC-R1 | 已支持全局/项目 Markdown、JSON/JSONC 的 subagent/all、prompt、description、disable/hidden、精确模型与工具映射,并接入审批、冲突、更新、撤下和 fresh single-run Task;primary/mode、permission、variant/options、采样与续接明确降级 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | Skills | 转换参数 | 未实现 | 可完整适配 | OC-R2 | R1 发现/展示;R2 保留按需加载及 allow/deny/ask 顺序,可执行资源走 Skill 归属模块保护 | [Agents 与 Skills](opencode-config-assets-adapter-design.md#52-agentsmodes-与-skills) | | References | 补基础能力 + 转换参数 | 未实现 | 可主要适配 | OC-R2 | R1 解析;R2 支持本地目录和 Git repository/branch/description/hidden,异步准备并接入 `@alias` | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | -| Commands | 补扩展接口 + 转换参数 | 未实现 | 可完整适配 | OC-R2 | R1 只展示;R2 把声明注册到 CLI action registry,再由既有 controller 执行参数、`@file`、shell 展开、Agent/model/subtask | [Commands](opencode-config-assets-adapter-design.md#53-commands) | +| Commands | 补扩展接口 + 转换参数 | 部分实现:prompt-only 子集 | 可完整适配 | OC-R2 | 已支持全局/项目 JSON、JSONC、Markdown 命令、`$ARGUMENTS` 与位置参数,并接入动态命令目录、运行时刷新和显式冲突选择;`@file`、shell、Agent/model/variant/subtask 保持受限且不做部分执行 | [Commands](opencode-config-assets-adapter-design.md#53-commands) | | Models / Providers 配置 | 融合现有能力 | 未实现 | 可主要适配 | OC-R1 | 静态字段进入模型归属模块;动态模型、鉴权和请求头交给插件运行时 | [声明式资产](opencode-config-assets-adapter-design.md#5-声明式资产映射) | | MCP | 转换参数 | 未实现 | 可完整适配 | OC-R2 | R1 解析;R2 转换本地命令、远程 URL、Headers、OAuth、超时和 Agent 选择并由 MCP owner 启动 | [MCP、LSP 与 Formatter](opencode-config-assets-adapter-design.md#54-mcplsp-与-formatter) | | LSP | 转换参数 | 未实现 | 可完整适配 | OC-R2 | R1 解析;R2 转换 command、extensions、env 和 initialization 并由 LSP owner 启动 | [MCP、LSP 与 Formatter](opencode-config-assets-adapter-design.md#54-mcplsp-与-formatter) | @@ -268,7 +268,7 @@ worker。授权在准备前、准备后 import 前、load 后注册前和每次 会立即撤下该 target 路由并标记 `load_failed`,不回退同名内置/MCP 实现,也不自动重放;下一次 Tool Catalog 暴露前 只消费一次恢复预算,仍失败则等待显式刷新或来源变化,不形成重启风暴。 -CLI/TUI 更新订阅在活动期间持有工作区服务;Desktop/Agent 每次装配模型可见 Tool Catalog 时续期并在首次或空闲 +交互式 TUI(ChatMode)更新订阅在活动期间持有工作区服务;Desktop/Agent 每次装配模型可见 Tool Catalog 时续期并在首次或空闲 回收后同步刷新。首次后台刷新与 catalog 装配共享同一个完成门闩:catalog 等待在途结果,失败后允许下一次装配重试。 没有订阅或目录装配活动的工作区在 5 分钟后撤下路由并回收 worker,避免依次打开多个项目后永久累积 Node 进程; 下一次目录装配会在向模型暴露前恢复仍获批准且仍有效的 route。Remote catalog 与执行解析显式 fail-closed,即使远端 diff --git a/docs/architecture/extensions/plugin-runtime-host-design.md b/docs/architecture/extensions/plugin-runtime-host-design.md index 7498e376b6..e67b2d9650 100644 --- a/docs/architecture/extensions/plugin-runtime-host-design.md +++ b/docs/architecture/extensions/plugin-runtime-host-design.md @@ -87,11 +87,11 @@ OpenCode 的可写钩子不是只读通知。适配器按 OpenCode 顺序执行 | `src/crates/execution/plugin-runtime-host` | 当前请求校验、期限和故障状态 | 承担通用调用可靠性;不依赖具体脚本运行时或生态类型 | | `src/crates/assembly/core` 的产品组装点 | 当前选择插件运行时 binding 与可用性 | 选择并构造已编译的 adapter/provider,注入窄 `PluginRuntimeBinding`、执行服务和产品能力/策略上限;不发现动态来源、不准备依赖、不 import 插件代码 | | `src/crates/contracts/product-domains` / `src/crates/services/services-integrations` 的插件来源模块 | 当前 BitFun 专用目录、内容校验、审核与启停状态 | 继续服务 BitFun 原生包;OpenCode 外部目录由兼容来源发现流程直接读取,不要求重新打包 | -| `src/crates/adapters/opencode-adapter` | 当前静态解释少量 OpenCode 文件 | 承担 OpenCode 格式/进程协议适配和 OpenCode Source Coordinator;不持有 worker、最终工具、配置、权限或界面状态 | -| 目标脚本执行服务 | 当前不存在 | 作为可替换服务管理运行时、依赖、worker 和资源回收;产品组装只依赖窄接口 | +| `src/crates/adapters/opencode-adapter` | 当前分别解释受支持的 Command、standalone Tool 和 Subagent 来源,并映射到独立 provider 契约 | 承担 OpenCode 格式/进程协议适配和 OpenCode Source Coordinator;不持有 worker、最终工具、配置、权限或界面状态 | +| 脚本执行服务 | standalone Tool 已有 provider-neutral `ScriptToolRuntime` 端口和 Node worker;package plugin 的依赖准备、Bun loader 与通用 worker 服务尚不存在 | 作为可替换服务管理运行时、依赖、worker 和资源回收;产品组装只依赖窄接口 | | Tool / Config / Permission / Session / TUI 等消费边界 | Tool、Config、Permission、Session 等已有真实 owner;TUI Input/Command/State/Effect 等仍聚集在现有终端代码 | 复用已有 owner;缺失边界只从真实消费路径增量抽取,不先建通用扩展框架 | -| CLI | 当前消费 BitFun 原生包的管理状态、诊断和 OpenCode 静态预览;尚无真实 OpenCode 插件执行闭环 | 只消费能力服务、状态视图和操作接口,不直接调用主机或适配器 | -| Desktop | 当前没有 managed-plugin 管理或 OpenCode 静态预览的生产 UI/调用方 | 出现真实入口后仍只消费能力服务、状态视图和操作接口 | +| `src/apps/cli` 交互式 TUI(ChatMode) | 当前通过外部来源能力服务使用 Command、管理 standalone Tool 和 Subagent;顶层 headless CLI/`exec` 没有对应入口,也尚无 OpenCode package plugin 执行闭环 | 只消费能力服务、状态视图和操作接口,不直接调用主机或适配器 | +| Desktop | “外部 AI 应用”已展示来源、standalone Tool 与 Subagent 的审批、冲突和诊断;仍没有 managed-plugin/package plugin 的生产管理入口 | 继续只消费能力服务、状态视图和操作接口,不直接调用主机或适配器 | | Web、Server、Remote | 当前没有生产插件执行入口;Server 仅有健康检查、信息与 ping 路由 | 出现真实入口后仍只消费类型化状态和操作接口,不复制插件主机 | `src/crates/assembly/core` 的 `plugin_runtime` 运行时组合点是唯一可以选择具体生态 adapter factory、构造 adapter @@ -288,23 +288,27 @@ OpenCode 来源完成首次激活后,本地默认运行策略以兼容为先 ## 8. 当前实现附录 -当前 P0-C.1/P0-C.2 只验证了 BitFun 原生来源和静态预览链路: +BitFun 受管插件包的 P0-C.1/P0-C.2 链路当前只验证了原生来源和静态预览: 1. 从用户数据目录的 `plugins` 和项目 `.bitfun/plugins` 发现 `bitfun.plugin.json` 包。 2. 校验清单、路径、文件大小和内容摘要,并保存工作区的来源审核与启停状态。 3. CLI 在启用前展示精确内容摘要;内容变化时旧启用状态失效。 -4. OpenCode 适配器只读取固定文件,使用有限字符串规则预览 custom tool 名称。 +4. 该受管包链路中的 OpenCode 投影只读取固定文件,使用有限字符串规则预览 custom tool 名称。 5. 产品组装只能读取带权限要求的工具候选;不加载 JS/TS,不注册或执行工具,也不运行钩子。 -因此当前状态只能表述为“来源可识别、静态候选可预览”,不能表述为“OpenCode 插件可运行”。CLI 已允许 -在包文件缺失或损坏时清理残留启用状态,这是现有链路必须保留的恢复能力。 +因此,受管包链路只能表述为“来源可识别、静态候选可预览”,不能表述为“OpenCode package plugin 可运行”。 +CLI 已允许在包文件缺失或损坏时清理残留启用状态,这是现有链路必须保留的恢复能力。与此独立的外部来源轨道 +已经接入 prompt-only Command、受支持的单文件 `.js` standalone Tool 和 Subagent 安全子集;其中 standalone Tool +通过窄 `ScriptToolRuntime` 真实执行,但这不证明 package plugin、Hook、完整 Client 或 TUI target 已经可运行。 +三个切片当前都只支持本地执行域;Remote 外部来源发现、审批与冲突决策明确返回不支持,不回退读取本机来源。 -`bitfun.plugin.json` 继续作为 BitFun 原生包格式。目标 OpenCode 路径直接发现用户和项目的 `opencode.json`、 -`.opencode/plugins`、`.opencode/tools` 及软件包配置,自动记录当前执行版本;不得要求作者先复制到 -`.bitfun/plugins` 或维护另一份清单。 +`bitfun.plugin.json` 继续作为 BitFun 原生包格式。现有外部来源轨道已直接发现用户和项目的 Command、standalone +Tool 与 Subagent 安全子集,包括配置文件中的受支持声明;完整 OpenCode 配置来源图尚未接入。后续 package plugin +路径还需直接发现 `.opencode/plugins` 和软件包配置并记录当前执行版本,不得要求作者先复制到 `.bitfun/plugins` +或维护另一份清单。 当前内部契约中的 `PluginRuntimeBinding`、通用派发、状态版本和静态候选可在迁移期保留,但不能据此设计完整 -OpenCode API。真实执行接入后,未被消费的过渡对象应删除或收窄,避免新旧两套调用模型长期并存。 +OpenCode API。package plugin 真实执行接入后,未被消费的过渡对象应删除或收窄,避免新旧两套调用模型长期并存。 ## 9. Remote 与多执行域 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index fd08ce6b9f..6a6ba9264c 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -232,9 +232,12 @@ Plugin Runtime P0 只验证了 BitFun 专用插件目录中的来源校验、工 不能称为“OpenCode 插件运行时”。详细代码事实集中在 [`plugin-runtime-host-design.md#8-当前实现附录`](extensions/plugin-runtime-host-design.md#8-当前实现附录)。 -与 Plugin Runtime 分离的 Prompt Command 基线已经通过能力专属 provider 契约接入:可发现本地用户/项目 OpenCode -Command,处理跨来源冲突,并在 CLI/TUI 中执行受支持的 prompt-only 模板。该能力不执行 JS/TS,也不能推导 Tool、 -Hook、Subagent 或完整配置兼容已经可用。 +与 Plugin Runtime 分离的三条纵向基线已经通过各自的能力专属 provider 契约接入:Prompt Command 可发现本地 +用户/项目 OpenCode Command、处理跨来源冲突,并在 CLI/TUI 中执行受支持的 prompt-only 模板;standalone Tool +可把受支持的单文件 `.js` 经确认后接入现有 Tool Runtime;Subagent 可把全局/项目声明的安全子集经确认和同名冲突 +选择后接入现有 Task/Subagent owner,并以 generation lease 固定 fresh single-run 调用。三类贡献对象互不复用, +主体逻辑不按生态分支。当前仍不表示 package plugin、Hook、primary agent、外部 agent 续接、Remote 来源发现或完整 +配置兼容已经可用。 目标路线不要求 OpenCode 插件作者维护 `bitfun.plugin.json` 或复制到 `.bitfun/plugins`。BitFun 直接发现用户和 项目的 OpenCode 配置、插件目录、工具目录和软件包来源;低风险内容按用户偏好自动应用或先询问,可执行来源在 diff --git a/docs/plans/opencode-extension-compatibility-plan.md b/docs/plans/opencode-extension-compatibility-plan.md index f47d89d1f7..0fa0aa197a 100644 --- a/docs/plans/opencode-extension-compatibility-plan.md +++ b/docs/plans/opencode-extension-compatibility-plan.md @@ -6,10 +6,10 @@ [能力装配与宿主集成设计](../architecture/extensions/capability-runtime-integration-design.md)。本计划只覆盖外部 OpenCode 能力进入 BitFun 的渐进导入轨道,不代表 BitFun 能力导出到 OpenCode 已经完成。兼容矩阵是审计库存,不是默认路线图。 -PR1 已将基线推进到通用外部来源目录、生命周期协调器和 OpenCode Prompt Command 纵向切片;BitFun 原有受管 -插件包来源确认和 custom tool 静态预览继续保留。后续不沿用“先做一个大而全的 OpenCode Plugin Runtime”路线, -而是沿用稳定的跨生态来源契约,按 Tool、Subagent 分别交给真实能力 owner。每个 PR 都必须产生用户可直接验证的 -结果,同时不提前承诺尚未执行的生态能力。 +PR1 已建立通用外部来源目录、生命周期协调器和 OpenCode Prompt Command 纵向切片,PR2 已把受支持的单文件 +`.js` standalone Tool 接入现有 Tool Runtime,PR3 已把 Subagent 安全子集交给现有 Subagent owner。BitFun 原有 +受管插件包来源确认和 custom tool 静态预览继续保留,但不等同于 OpenCode package plugin 可执行。三个切片均沿用 +稳定的跨生态来源契约,不建设“大而全的 OpenCode Plugin Runtime”,也不提前承诺尚未执行的生态能力。 ## 1. 稳定架构基线 @@ -21,7 +21,7 @@ Product surfaces (Desktop / CLI / TUI) -> consumes capability-specific provider contracts -> Prompt Command provider contract -> Tool provider contract + provider-neutral script runtime port - -> future Subagent provider contract + -> Subagent provider contract Same-level ecosystem adapters implement those provider contracts -> OpenCode adapter @@ -35,7 +35,7 @@ Product Assembly registers adapter implementations with the coordinator OpenCode/Codex/Claude Code 分支业务行为。 - 每个生态适配器独立维护自己的路径发现、优先级、格式、参数展开和版本兼容语义;兄弟适配器之间不得依赖、复用 私有类型或借用对方身份。 -- Product Assembly 是唯一选择具体适配器的地方。Desktop、CLI/TUI、来源目录和能力 owner 只依赖稳定契约。 +- Product Assembly 是唯一选择具体适配器的地方。Desktop、交互式 TUI(ChatMode)、来源目录和能力 owner 只依赖稳定契约。 - 不建立携带任意 payload 的 `ExtensionAsset`、通用脚本 SDK 或跨生态配置对象。Command、Tool、Subagent 分别走 类型化贡献接口;新增一种能力不能迫使既有能力改写公共对象。 - 来源目录按“提供者 + 来源限定身份”协调代次,provider discovery 独立并发且有期限;某个适配器解析失败、升级 @@ -59,9 +59,9 @@ Product Assembly registers adapter implementations with the coordinator | PR | 用户可观察结果 | 新增能力 owner | 明确不包含 | |---|---|---|---| -| PR1:来源目录 + OpenCode Command | Desktop 可查看、抑制/恢复并刷新全局/项目 OpenCode 来源;CLI/TUI 可列出并执行支持的 `/command`;运行中修改、删除、恢复后自动刷新 | 通用来源目录与生命周期协调器;Prompt Command 契约;OpenCode Command adapter | JS/TS Tool 执行、Hook、MCP、OpenCode Client/Server、Subagent 执行、复制式导入 | -| PR2:OpenCode standalone Tool | 一个真实、受支持的单文件 `.opencode/tools/` 样例经预览和确认后进入现有 Tool Runtime,可调用、取消、更新和撤下 | 现有 Tool Runtime + 独立 Tool 兼容接口 | package plugin、npm 依赖安装、Hook、TUI renderer、完整 `metadata`/`ask` | -| PR3:OpenCode Subagent | 全局/项目 agent 定义进入现有 Subagent owner,可选择、调用、更新和撤下;unsupported 字段有明确诊断 | 现有 Subagent owner + 独立 Subagent 兼容接口 | 原始 OpenCode 会话内核、完整 primary-agent 替换、跨产品通用 agent JSON | +| PR1:来源目录 + OpenCode Command(已实现) | Desktop 可查看、抑制/恢复并刷新全局/项目 OpenCode 来源;交互式 TUI(ChatMode)可列出并执行支持的 `/command`;运行中修改、删除、恢复后自动刷新 | 通用来源目录与生命周期协调器;Prompt Command 契约;OpenCode Command adapter | JS/TS Tool 执行、Hook、MCP、OpenCode Client/Server、Subagent 执行、复制式导入 | +| PR2:OpenCode standalone Tool(已实现) | 一个真实、受支持的单文件 `.opencode/tools/` 样例经预览和确认后进入现有 Tool Runtime,可调用、取消、更新和撤下 | 现有 Tool Runtime + 独立 Tool 兼容接口 | package plugin、npm 依赖安装、Hook、TUI renderer、完整 `metadata`/`ask` | +| PR3:OpenCode Subagent(已实现) | 全局/项目 agent 定义经一次非阻塞确认后进入现有 Subagent owner,可选择、单次调用、更新和撤下;同名冲突由用户选择,unsupported 字段有明确诊断 | 现有 Subagent owner + 独立 Subagent 兼容接口 + generation lease | 原始 OpenCode 会话内核、完整 primary-agent 替换、外部 agent 续接、跨产品通用 agent JSON | Tool 与 Subagent 不复用 Command 的贡献对象,只复用来源身份、状态、代次、诊断和观察生命周期。未来接入 Codex 或 Claude Code 时新增同级 adapter,并在 Product Assembly 注册;不能修改 OpenCode adapter 来容纳其他生态。 @@ -78,7 +78,7 @@ Claude Code 时新增同级 adapter,并在 Product Assembly 注册;不能修 `template`、`description`。Markdown 已知字段按当前 OpenCode schema 校验,类型错误不得静默丢弃;同时保留 OpenCode 对未引用冒号值的兼容重试。 - 保留 OpenCode 生态内部的名称和覆盖顺序;独立 provider 之间或与 BitFun 本地能力同名时不得按适配器优先级静默决胜, - 必须生成版本敏感的冲突指纹并等待用户选择。候选版本不变时只询问一次,更新后重新询问。CLI/TUI 将跨 provider + 必须生成版本敏感的冲突指纹并等待用户选择。候选版本不变时只询问一次,更新后重新询问。交互式 TUI(ChatMode)将跨 provider 候选投影为 `/external::` 明确选择项;一次显式选择同时解决同名 BitFun 本地命令,不连续确认。 - 支持 `$ARGUMENTS` 与 `$1`、`$2` 等位置参数展开。显式选择或输入 `/command ...` 本身就是本次 prompt-only 命令的用户确认;发现阶段不自动向会话发送内容。 @@ -130,7 +130,7 @@ Claude Code 时新增同级 adapter,并在 Product Assembly 注册;不能修 - OpenCode fixture 固定 XDG 全局/项目、`config.json`、单复数目录、JSON/JSONC、Markdown、路径去重、覆盖、参数展开、 大小/数量上限和受限字段。 - watcher 覆盖创建、连续写入、原子替换、稳定删除与重新出现;目录切换不阻塞 TUI 输入。 -- CLI/TUI 覆盖列表、跨 provider 候选选择与直接输入、本地同名冲突只询问一次、版本变化后重新询问、受限命令提示 +- 交互式 TUI(ChatMode)覆盖列表、跨 provider 候选选择与直接输入、本地同名冲突只询问一次、版本变化后重新询问、受限命令提示 和刷新后撤下;首次发现期间未限定别名不误路由,候选删除后不静默切换到剩余外部或内建实现。 - Desktop 覆盖空状态、部分失败、刷新、抑制/恢复、敏感路径缩略显示及 IPC 摘要不包含模板正文。 - 通过相关 crate tests、Web focused tests、`type-check:web`、仓库 hygiene 与 core boundary 检查。 @@ -157,7 +157,7 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo ### 4.2 产品与决策语义 - Desktop 在“外部 AI 应用”中显示来源、文件、工作目录、工具名和直接文件/网络/环境/进程能力,并明确提示当前 worker - 不是 OS 沙箱。CLI/TUI 使用同一快照:状态栏只做一次非阻塞提醒,`/external-tools` 提供静态预览以及 + 不是 OS 沙箱。交互式 TUI(ChatMode)使用同一快照:状态栏只做一次非阻塞提醒,`/external-tools` 提供静态预览以及 `enable`、`disable`、`choose`、`refresh` 操作;等待处理不阻塞输入或普通会话。 - 首次启用键由“来源限定 target + 执行域 + runtime + 能力集合”组成。纯内容更新且能力集合不变时复用已批准 结果;能力、runtime 或执行域扩大时重新确认。用户选择保持停用后,同一内容版本不再主动询问;来源内容更新后 @@ -166,7 +166,7 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo 身份与内容版本;候选来自静态识别定义而不是成功加载集合。选择前保留已有本地实现,选择后只在候选集合和版本 不变时复用;任一候选更新、删除或暂不可用后重新询问,已选 external 失效期间不回退同名内置/MCP。 - Desktop IPC 和 TUI 快照只包含静态摘要与决策 key,不传输模块源码。用户选择落盘使用 PR1 的跨进程锁、锁内 - 合并和原子替换;Desktop 与 CLI/TUI 不分别维护偏好。 + 合并和原子替换;Desktop 与交互式 TUI(ChatMode)不分别维护偏好。 ### 4.3 更新、删除与降级 @@ -195,7 +195,7 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo - 脚本运行时覆盖 load/invoke、内容更新、失败更新撤下、合作式取消、阻塞事件循环硬终止和 dispose。 - Tool 路由覆盖内置/MCP/多外部候选冲突、按工作区选择、候选更新重问、稳定删除、源级隔离、零 route mux 并发注册 和 worker-lost 撤路由/单次恢复;首次后台刷新与 catalog 竞态覆盖等待、成功复用和失败重试。 -- Desktop 覆盖非阻塞发现、首次审批、主动重新审核、停用、冲突选择和不可用原因;CLI/TUI 覆盖提示去重、编号到 +- Desktop 覆盖非阻塞发现、首次审批、主动重新审核、停用、冲突选择和不可用原因;交互式 TUI(ChatMode)覆盖提示去重、编号到 稳定 key 的映射、过期选择拒绝和刷新。通过相关 Rust tests、CLI check/tests、Web focused tests、 `type-check:web`、i18n audit、repo hygiene 与 desktop/core checks。 @@ -208,17 +208,42 @@ Codex、Claude Code 接入同类能力时新增同级 adapter,不修改 OpenCo - 为来源目录瞬时不可读增加类型化 `unknown/last-good` 状态;只有精确物化内容仍可校验时才继续服务,明确删除、 停用和权限收紧仍立即撤下。 - 为保留的零 route mux 增加数量/命中指标;只有证明长期积累后再设计不重新引入注册竞态的安全回收。 -- 统一 Desktop 与 CLI/TUI 的 `load_failed` 恢复文案,并让手动刷新产生的新 diagnostics 保持非阻塞可见;不因此 +- 统一 Desktop 与交互式 TUI(ChatMode)的 `load_failed` 恢复文案,并让手动刷新产生的新 diagnostics 保持非阻塞可见;不因此 引入跨 GUI/TUI 组件协议。 ## 5. PR3:OpenCode Subagent -PR3 为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter 映射 agent Markdown/JSON 定义。它不复用 Tool +PR3 已为现有 Subagent owner 增加独立兼容端口,由 OpenCode adapter 映射 agent Markdown/JSON 定义。它不复用 Tool 运行时,也不把 OpenCode agent 类型提升为跨生态 DTO。 -- 支持的 prompt、description、模式和工具选择进入现有 Subagent 定义;未支持字段显示明确诊断。 -- 全局/项目覆盖、来源抑制、更新和删除复用通用来源生命周期。 -- 选择与执行仍由现有会话/Subagent owner 决定;adapter 不能替换 BitFun Agent Kernel。 +- 支持用户全局、显式配置目录和项目 JSON/JSONC `agent`,以及单复数 agent Markdown 目录;按 OpenCode 稳定 + 顺序深合并并保留有序 provenance。legacy mode 与 primary-only 定义可诊断但不激活。 +- 安全子集为 description、prompt、subagent/all、disable、hidden、可精确解析的 model 和 tool 选择。permission、 + variant/options、采样参数、steps/maxSteps 等不能等价执行的字段 fail closed,不做“忽略后继续运行”。 +- OpenCode adapter 把 model 解析为类型化的 provider 提示和模型名;Subagent owner 在审批前将其或固定默认项物化为 + 唯一、已启用的 BitFun 模型。继承、歧义、缺失或已停用模型保持不可用,不使用动态回退替代明确审批。 +- 首次启用绑定当前 behavior、provenance、具体模型和工具范围;只有影响展示的 catalog 文案更新不重复询问, + 行为或能力变化重新确认。与 builtin/user/project 或其他 provider 同名时保持不可用,直到用户选择;候选变化后 + 旧选择失效,即使只剩一个候选也不静默回退。 +- BitFun 模型配置变化会非阻塞重建后续调用的 generation;审批绑定具体配置 ID 和运行配置指纹,同一 ID 下的 provider、 + 模型名或 endpoint 变化也要求重新确认。已物化 ID 不再解释为 `inherit/primary/fast/auto/default` 选择器;旧 lease 保留 + 旧绑定事实,若执行时配置已漂移则安全失败,不静默切换模型。 +- fresh Task 在进入调度前固定不可变 runtime generation,并以 lease 保持到完成、取消、超时或提交失败;来源稳定 + 删除、显式停用或抑制会立即阻止新调用,短暂读取失败最多在有界窗口内沿用 exact last-valid。 +- Desktop 的“外部 AI 应用”设置和 TUI `/external-agents` 使用同一 generation/revision 校验的非阻塞审批与冲突 + 操作;同一工作区的 Agent 决策串行提交,完成后刷新权威状态并说明具体对象。冲突候选在选择前原位展示模型、 + 工具、执行域、安全来源标签、兼容影响和恢复动作,一次点击才原子完成“选择并批准”。Agents 场景只显示已激活的 + 只读 `External · provider`、`Single-run` 投影,并可跳转到统一管理入口;外部 prompt、绝对用户路径和内部行为摘要 + 不进入 IPC 或普通列表摘要。 +- GUI/TUI 只按通用资源类型路由诊断,不识别 `opencode.*` 前缀;产品快照统一生成安全来源位置,界面不解析 `.opencode` + 等生态私有目录。已启用 Agent 因更新、删除或冲突变为不可用时,Desktop 与 TUI 都给出去重的非阻塞提示。 +- 冲突谱系覆盖 0/1/N 个参与者;参与集合缩减后继续保持逻辑名不可用,等待用户重新选择。自动观察到的新冲突指纹 + 与用户决策使用同一跨进程锁并推进 `preference_revision`,因此其他进程基于旧 revision 的审批或选择必然被拒绝。 +- 当前 Remote 工作区明确不支持外部来源发现与决策,不读取本机同名配置;静态 system prompt 未修改,只有审批后 + 的通用 AgentInfo 动态投影进入现有可用 agent 上下文,且该投影使用 BitFun 自有的稳定描述,不注入来源 catalog 文案。 + +选择与执行仍由现有会话/Subagent owner 决定;adapter 不能替换 BitFun Agent Kernel。外部 agent 当前只支持 fresh +单次调用,前台结果不返回续接入口,历史 external runtime session 的 follow-up 会被类型化拒绝。 ## 6. 暂停条件 diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index 000b4e1de8..5c7c887adc 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -919,7 +919,7 @@ function collectRustUseReexportSymbols(usePath) { } function collectTopLevelRustPublicSymbols(text) { - const symbols = []; + const symbols = Array.from(text.matchAll(/\bexternal_subagent_id!\(\s*([A-Za-z_][A-Za-z0-9_]*)/g), (match) => match[1]); let braceDepth = 0; let pendingUsePath = null; for (const line of text.split(/\r?\n/)) { diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 48a26b1d63..dcc1199b4b 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -108,8 +108,8 @@ export const optionalDependencyFeatureOwnerRules = [ dependencies: [ { depName: 'dirs', ownerFeatures: ['miniapp'] }, { depName: 'log', ownerFeatures: ['function-agents'] }, - { depName: 'hex', ownerFeatures: ['plugin-source'] }, - { depName: 'sha2', ownerFeatures: ['miniapp', 'plugin-source'] }, + { depName: 'hex', ownerFeatures: ['external-sources', 'plugin-source'] }, + { depName: 'sha2', ownerFeatures: ['external-sources', 'miniapp', 'plugin-source'] }, { depName: 'which', ownerFeatures: ['miniapp'] }, ], }, diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index a66b274ac0..ffd6a97fff 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -4117,6 +4117,7 @@ export const forbiddenContentUnderRules = [ 'src/crates/adapters/opencode-adapter/tests/opencode_source_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/opencode_command_adapter.rs', 'src/crates/adapters/opencode-adapter/tests/tool_source_contracts.rs', + 'src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs', 'src/crates/assembly/core/src/plugin_runtime.rs', 'src/crates/assembly/core/src/external_sources.rs', ], diff --git a/scripts/core-boundaries/rules/source/public-api-rules.mjs b/scripts/core-boundaries/rules/source/public-api-rules.mjs index 0f12f3e5f2..e2789e9a63 100644 --- a/scripts/core-boundaries/rules/source/public-api-rules.mjs +++ b/scripts/core-boundaries/rules/source/public-api-rules.mjs @@ -7,6 +7,7 @@ export const publicApiContractSlices = [ 'opencode-adapter-boundary', 'external-source-command-contract', 'external-source-tool-contract', + 'external-source-subagent-contract', ]; const contractSlices = { @@ -16,6 +17,7 @@ const contractSlices = { opencodeAdapterBoundary: 'opencode-adapter-boundary', externalSourceCommandContract: 'external-source-command-contract', externalSourceToolContract: 'external-source-tool-contract', + externalSourceSubagentContract: 'external-source-subagent-contract', }; function pluginRuntimeEntry(symbol, p0, consumer, verification, contractSlice, wireImpact = true) { @@ -192,6 +194,14 @@ export const opencodeAdapterPublicApiEntries = [ 'OpenCodeToolProviderOptions', 'OpenCode standalone-tool adapter fixture tests and explicit environment injection', ), + opencodeAdapterEntry( + 'OpenCodeSubagentProvider', + 'bitfun-core external source composition root and OpenCode subagent adapter tests', + ), + opencodeAdapterEntry( + 'OpenCodeSubagentProviderOptions', + 'OpenCode subagent adapter fixture tests and explicit environment injection', + ), ]; function externalSourceEntry(symbol, owner, consumer, wireImpact = false) { @@ -226,12 +236,30 @@ function externalToolEntry(symbol, owner, consumer, wireImpact = false) { }; } +function externalSubagentEntry(symbol, owner, consumer, wireImpact = false) { + return { + symbol, + owner, + consumer, + verification: + 'external subagent contract, coordinator, OpenCode adapter, product reconciliation, registry lease, TUI, Desktop, and Web tests', + p0: 'PR3 ecosystem-neutral fresh subagent activation and OpenCode agent vertical slice', + contractSlice: contractSlices.externalSourceSubagentContract, + wireImpact, + rationale: + 'PR3 needs typed discovery, approval-envelope, conflict, summary, and fresh-invocation contracts without ecosystem payload leakage', + exit: + 'remove only through a reviewed subagent-capability contract migration with equivalent fail-closed routing and product tests', + }; +} + export const externalSourceContractPublicApiEntries = [ 'ExternalSourceContractError', 'SourceKey', 'SourceQualifiedCommandId', 'ExternalSourceScope', 'ExternalSourceHealth', + 'ExternalSourceAssetKind', 'ExternalSourceDiagnosticSeverity', 'ExternalSourceDiagnostic', 'ExternalSourceRecord', @@ -290,6 +318,41 @@ export const externalSourceContractPublicApiEntries = [ ), ); +export const externalSubagentContractPublicApiEntries = [ + 'ExternalSubagentLocalId', + 'ExternalSubagentCandidateId', + 'ExternalSubagentBehaviorVersion', + 'SecretText', + 'ExternalSubagentContributionId', + 'ExternalSubagentContributionRole', + 'ExternalSubagentProvenanceRef', + 'ExternalSubagentProviderIdentity', + 'ExternalSubagentMode', + 'ExternalSubagentModelRequest', + 'ExternalSubagentToolSelector', + 'ExternalSubagentToolRequest', + 'ExternalSubagentCompatibilityState', + 'ExternalSubagentDefinition', + 'ExternalSubagentDiscoveryInput', + 'ExternalSubagentProviderSnapshot', + 'ExternalSubagentSourceProvider', + 'ExternalSubagentActivationState', + 'ExternalSubagentDiagnosticSummary', + 'ExternalSubagentSummary', + 'ExternalSubagentConflictCandidate', + 'ExternalSubagentConflict', + 'external_subagent_candidate_id', + 'external_subagent_approval_key', + 'external_subagent_conflict_key', +].map((symbol) => + externalSubagentEntry( + symbol, + 'product-domains external subagent contract owner', + 'ecosystem subagent providers, external-subagent coordinator, product reconciliation, and neutral product surfaces', + true, + ), +); + export const externalSourceCoordinatorPublicApiEntries = [ externalSourceEntry( 'ExternalSourceCoordinator', @@ -315,6 +378,18 @@ export const externalSourceCoordinatorPublicApiEntries = [ 'bitfun-core bounded concurrent external-tool provider scheduler', ), ), + ...[ + 'ExternalSubagentCoordinator', + 'ExternalSubagentCoordinatorSnapshot', + 'ExternalSubagentDiscoveryRequest', + 'ExternalSubagentDiscoveryResult', + ].map((symbol) => + externalSubagentEntry( + symbol, + 'external-sources assembly owner', + 'bitfun-core bounded concurrent external-subagent provider scheduler', + ), + ), ]; export const externalSourceCorePublicApiEntries = [ @@ -322,6 +397,7 @@ export const externalSourceCorePublicApiEntries = [ 'ExpandedPromptCommand', 'ExternalSourceCatalogEntry', 'ExternalSourceCatalogSnapshot', + 'ExternalSourceAssetKind', 'ExternalSourceDiagnostic', 'ExternalSourceDiagnosticSeverity', 'ExternalSourceLifecycleState', @@ -361,6 +437,21 @@ export const externalSourceCorePublicApiEntries = [ 'bitfun-cli and desktop host APIs', ), ), + ...[ + 'ExternalSubagentActivationState', + 'ExternalSubagentCompatibilityState', + 'ExternalSubagentConflict', + 'ExternalSubagentConflictCandidate', + 'ExternalSubagentSummary', + 'set_external_subagent_activation', + 'choose_external_subagent_conflict', + ].map((symbol) => + externalSubagentEntry( + symbol, + 'bitfun-core external subagent composition facade', + 'bitfun-cli and desktop host APIs', + ), + ), ]; function pluginSourceEntry(symbol, owner, consumer, verification, wireImpact) { @@ -490,6 +581,12 @@ export const publicApiAllowlistRules = [ 'external source contracts must stay capability-specific, ecosystem-neutral, and explicitly consumer-backed', allowedSymbolEntries: externalSourceContractPublicApiEntries, }, + { + path: 'src/crates/contracts/product-domains/src/external_subagents.rs', + reason: + 'external subagent contracts must stay ecosystem-neutral, fresh-only, and explicitly consumer-backed', + allowedSymbolEntries: externalSubagentContractPublicApiEntries, + }, { path: 'src/crates/assembly/external-sources/src/lib.rs', reason: diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 2a7fc5488f..262d8b908c 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -854,6 +854,9 @@ export function runManifestParserSelfTest({ const opencodeAdapterPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/adapters/opencode-adapter/src/lib.rs', ); + const externalSubagentPublicApiRule = publicApiAllowlistRules.find( + (rule) => rule.path === 'src/crates/contracts/product-domains/src/external_subagents.rs', + ); const managedPluginActivationPublicApiRule = publicApiAllowlistRules.find( (rule) => rule.path === 'src/crates/assembly/core/src/plugin_runtime.rs', ); @@ -907,6 +910,17 @@ export function runManifestParserSelfTest({ ) { throw new Error('public API parser must collect top-level items and re-exports without impl methods'); } + const parsedExternalSubagentIds = collectTopLevelRustPublicSymbols(` + external_subagent_id!(ExternalSubagentLocalId, "local"); + external_subagent_id!( + ExternalSubagentBehaviorVersion, + "behavior" + ); + pub struct SecretText(String); + `); + if (parsedExternalSubagentIds.join(',') !== 'ExternalSubagentLocalId,ExternalSubagentBehaviorVersion,SecretText') { + throw new Error('public API parser must collect external subagent id macro exports'); + } const pluginPublicApiSymbols = (pluginPublicApiRule?.allowedSymbolEntries || []).map( (entry) => entry.symbol, ); @@ -955,10 +969,10 @@ export function runManifestParserSelfTest({ ).map((entry) => entry.symbol); if ( opencodeAdapterPublicApiSymbols.join(',') !== - 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions' + 'load_opencode_package_adapter,OpenCodeCommandProvider,OpenCodeCommandProviderOptions,OpenCodeToolProvider,OpenCodeToolProviderOptions,OpenCodeSubagentProvider,OpenCodeSubagentProviderOptions' ) { throw new Error( - 'OpenCode adapter public API budget must stay limited to the reviewed package factory, command provider, and standalone-tool provider surfaces', + 'OpenCode adapter public API budget must stay limited to the reviewed package factory and capability-specific command, tool, and subagent providers', ); } for (const entry of opencodeAdapterPublicApiRule.allowedSymbolEntries) { @@ -974,6 +988,28 @@ export function runManifestParserSelfTest({ throw new Error(`OpenCode adapter public API entry must not claim wire impact: ${entry.symbol}`); } } + if (!externalSubagentPublicApiRule) { + throw new Error('external subagent contracts must have an independent public API budget rule'); + } + if (!publicApiContractSlices.includes('external-source-subagent-contract')) { + throw new Error('external subagent contracts must have an independent contract slice'); + } + for (const requiredSymbol of [ + 'ExternalSubagentDefinition', + 'ExternalSubagentSourceProvider', + 'ExternalSubagentSummary', + 'external_subagent_approval_key', + 'external_subagent_conflict_key', + ]) { + if (!externalSubagentPublicApiRule.allowedSymbolEntries.some( + (entry) => entry.symbol === requiredSymbol + && entry.contractSlice === 'external-source-subagent-contract' + && entry.consumer + && entry.verification, + )) { + throw new Error(`external subagent public API budget is missing a consumer-backed symbol: ${requiredSymbol}`); + } + } if ( (managedPluginActivationPublicApiRule?.allowedSymbolEntries || []) .map((entry) => entry.symbol) diff --git a/src/apps/cli/src/actions.rs b/src/apps/cli/src/actions.rs index 4bfe1af452..720d9e55c8 100644 --- a/src/apps/cli/src/actions.rs +++ b/src/apps/cli/src/actions.rs @@ -67,6 +67,7 @@ pub(crate) enum ActionHandler { Subagents, McpServers, ExternalTools, + ExternalAgents, AcpHelp, Init, History, @@ -384,6 +385,21 @@ static ACTION_SPECS: &[ActionSpec] = &[ shortcut_label: None, slash_on_startup: false, }, + ActionSpec { + id: "external_agents", + name: "External agents", + aliases: &["/external-agents"], + description: "Review compatible agents from external AI applications", + contexts: CHAT, + availability: ActionAvailability::Always, + handler: ActionHandler::ExternalAgents, + default_bindings: &[], + fallback_bindings: &[], + shortcut_field: None, + palette: palette("Extensions", false), + shortcut_label: None, + slash_on_startup: false, + }, ActionSpec { id: "acp_help", name: "ACP setup", diff --git a/src/apps/cli/src/modes/chat.rs b/src/apps/cli/src/modes/chat.rs index 806fc81c2e..696f672e0a 100644 --- a/src/apps/cli/src/modes/chat.rs +++ b/src/apps/cli/src/modes/chat.rs @@ -62,13 +62,15 @@ use bitfun_core::agentic::tools::implementations::skills::{ ModeSkillInfo, SkillInfo, }; use bitfun_core::external_sources::{ - expand_external_prompt_command, external_source_conflict_choices, external_source_snapshot, - prompt_command_conflict_key, remember_external_source_conflict_choice, - set_external_prompt_command_conflict_choice, set_external_tool_conflict_choice, - set_external_tool_target_decision, subscribe_external_source_updates, - ExternalSourceCatalogSnapshot, ExternalSourceDiagnosticSeverity, ExternalToolActivationState, - ExternalToolCapability, ExternalToolCatalogEntry, ExternalToolRuntimeKind, - PromptCommandAvailability, + choose_external_subagent_conflict, expand_external_prompt_command, + external_source_conflict_choices, external_source_snapshot, prompt_command_conflict_key, + remember_external_source_conflict_choice, set_external_prompt_command_conflict_choice, + set_external_subagent_activation, set_external_tool_conflict_choice, + set_external_tool_target_decision, subscribe_external_source_updates, ExternalSourceAssetKind, + ExternalSourceCatalogSnapshot, ExternalSourceDiagnosticSeverity, + ExternalSubagentActivationState, ExternalSubagentCompatibilityState, + ExternalToolActivationState, ExternalToolCapability, ExternalToolCatalogEntry, + ExternalToolRuntimeKind, PromptCommandAvailability, }; use bitfun_core::service::config::GlobalConfigManager; use bitfun_core::service::session_usage::{ @@ -176,6 +178,9 @@ pub(crate) struct ChatMode { external_tool_notice_key: Option, external_tool_review_snapshot: Option, external_tool_mutation_rx: Option>, + external_agent_notice_key: Option, + external_agent_review_snapshot: Option, + external_agent_mutation_rx: Option>, } /// Map agent_type to a display name for status messages @@ -217,6 +222,9 @@ impl ChatMode { external_tool_notice_key: None, external_tool_review_snapshot: None, external_tool_mutation_rx: None, + external_agent_notice_key: None, + external_agent_review_snapshot: None, + external_agent_mutation_rx: None, } } diff --git a/src/apps/cli/src/modes/chat/capabilities.rs b/src/apps/cli/src/modes/chat/capabilities.rs index 8cd071fe7c..c628a1325c 100644 --- a/src/apps/cli/src/modes/chat/capabilities.rs +++ b/src/apps/cli/src/modes/chat/capabilities.rs @@ -249,6 +249,7 @@ impl ChatMode { workspace_root: Some(workspace.as_path()), list_scope: SubagentListScope::TaskVisible, include_disabled: false, + external_sources_supported: true, })) }); @@ -288,16 +289,26 @@ impl ChatMode { workspace_root: Some(workspace.as_path()), list_scope: SubagentListScope::RegistryManagement, include_disabled: true, + external_sources_supported: true, })) }); + let has_external_subagents = subagents + .iter() + .any(|info| info.subagent_source == Some(SubAgentSource::External)); let subagent_items: Vec = subagents .into_iter() + .filter(|info| info.subagent_source != Some(SubAgentSource::External)) .map(Self::subagent_item_from_info) .collect(); if subagent_items.is_empty() { - chat_state.add_system_message("No subagents found.".to_string()); + chat_state.add_system_message(if has_external_subagents { + "No locally manageable subagents found. External agents are read-only here; use /external-agents to review or change them." + .to_string() + } else { + "No subagents found.".to_string() + }); return; } @@ -382,6 +393,7 @@ impl ChatMode { Some(SubAgentSource::Builtin) => "builtin", Some(SubAgentSource::Project) => "project", Some(SubAgentSource::User) => "user", + Some(SubAgentSource::External) => "external", None => "builtin", } .to_string(); @@ -395,5 +407,4 @@ impl ChatMode { enabled: info.effective_enabled, } } - } diff --git a/src/apps/cli/src/modes/chat/commands.rs b/src/apps/cli/src/modes/chat/commands.rs index 95993bd365..126331b203 100644 --- a/src/apps/cli/src/modes/chat/commands.rs +++ b/src/apps/cli/src/modes/chat/commands.rs @@ -171,6 +171,17 @@ impl ChatMode { self.handle_external_tool_review(arguments, chat_view, chat_state, rt_handle); return Ok(None); } + let can_route_external_agent_review = builtin_action + .is_some_and(|action| action.handler == ActionHandler::ExternalAgents) + && qualifier != CommandQualifier::External + && (qualifier == CommandQualifier::Builtin + || (external.is_none() + && unresolved_candidates.is_empty() + && !builtin_reconfirmation_required)); + if can_route_external_agent_review { + self.handle_external_agent_review(arguments, chat_view, chat_state, rt_handle); + return Ok(None); + } let native_choice_is_active = unresolved_candidates.iter().any(|candidate| { candidate .native_collision @@ -589,6 +600,9 @@ impl ChatMode { ActionHandler::ExternalTools => { self.handle_external_tool_review("", chat_view, chat_state, rt_handle); } + ActionHandler::ExternalAgents => { + self.handle_external_agent_review("", chat_view, chat_state, rt_handle); + } ActionHandler::AcpHelp => { chat_state.add_system_message(crate::acp_cli::acp_help_text("bitfun-cli")); chat_view.set_status(Some( @@ -740,5 +754,4 @@ impl ChatMode { chat_view.insert_paste(&text); } } - } diff --git a/src/apps/cli/src/modes/chat/external_review.rs b/src/apps/cli/src/modes/chat/external_review.rs index 90b17a1987..afd4a5aecc 100644 --- a/src/apps/cli/src/modes/chat/external_review.rs +++ b/src/apps/cli/src/modes/chat/external_review.rs @@ -227,24 +227,23 @@ fn external_tool_target_summaries( fn external_tool_activation_label(activation: &ExternalToolActivationState) -> &'static str { match activation { - ExternalToolActivationState::ApprovalRequired => "approval required", + ExternalToolActivationState::ApprovalRequired => "confirmation required", ExternalToolActivationState::Disabled => "disabled", - ExternalToolActivationState::Active => "active", - ExternalToolActivationState::Conflict => "name conflict", - ExternalToolActivationState::Unsupported { .. } => "unsupported", - ExternalToolActivationState::RuntimeUnavailable { .. } => "runtime unavailable", - ExternalToolActivationState::LoadFailed { .. } => "load failed", + ExternalToolActivationState::Active => "enabled", + ExternalToolActivationState::Conflict => "choose between same-name tools", + ExternalToolActivationState::Unsupported { .. } => "not supported", + ExternalToolActivationState::RuntimeUnavailable { .. } => "run environment unavailable", + ExternalToolActivationState::LoadFailed { .. } => "could not load", _ => "unknown", } } fn external_tool_scope_label(scope: impl std::fmt::Debug) -> &'static str { match format!("{scope:?}").as_str() { - "UserGlobal" => "user global", - "Project" => "project", - "WorkspaceLocal" => "workspace local", - "RemoteUser" => "remote user", - "RemoteProject" => "remote project", + "UserGlobal" => "all workspaces", + "Project" | "WorkspaceLocal" => "current workspace", + "RemoteUser" => "all remote workspaces", + "RemoteProject" => "current remote workspace", _ => "unknown", } } @@ -269,47 +268,45 @@ fn external_tool_reason(summary: &ExternalToolTargetSummary<'_>) -> Option &'static str { match activation { ExternalToolActivationState::ApprovalRequired => { - "Review access, then enable the target or keep it disabled." + "Review the code source and access, then enable it or keep it disabled." } ExternalToolActivationState::Disabled => { - "Enable the target after reviewing its source and access." + "Enable these tools after reviewing their code source and access." } ExternalToolActivationState::Active => { - "No action is required. Disable the target to stop exposing it." + "No action is needed. Disable these tools to stop using this source's tools." + } + ExternalToolActivationState::Conflict => { + "Choose which tool to use below, or leave this name disabled." } - ExternalToolActivationState::Conflict => "Choose a provider below, or disable this target.", ExternalToolActivationState::Unsupported { .. } => { - "Convert the module to the supported standalone JavaScript subset, then refresh." + "Change the code to a single JavaScript file supported by BitFun, then refresh." } ExternalToolActivationState::RuntimeUnavailable { .. } => { - "Restore the required runtime, then refresh." + "Restore the required JavaScript environment, then refresh." } ExternalToolActivationState::LoadFailed { .. } => { - "Refresh to retry. If it still fails, inspect or update the module, or disable this target." + "Refresh to retry. If it still fails, fix the source code or keep these tools disabled." } - _ => "Refresh to retrieve the current target state.", + _ => "Refresh to check the current state.", } } fn external_tool_default_reason(activation: &ExternalToolActivationState) -> &'static str { match activation { ExternalToolActivationState::ApprovalRequired => { - "The module needs approval before BitFun loads it." - } - ExternalToolActivationState::Disabled => "The target was disabled by user choice.", - ExternalToolActivationState::Active => { - "The module loaded successfully and is exposed in this execution domain." + "Review this tool file's access before enabling it." } - ExternalToolActivationState::Conflict => "Another implementation uses the same tool name.", + ExternalToolActivationState::Disabled => "You chose to disable this tool source.", + ExternalToolActivationState::Active => "The tool code is loaded and ready to use.", + ExternalToolActivationState::Conflict => "Another tool uses the same name.", ExternalToolActivationState::Unsupported { .. } => { - "The module uses unsupported syntax or behavior." + "This tool file contains code or operations that BitFun does not support." } ExternalToolActivationState::RuntimeUnavailable { .. } => { - "The required JavaScript runtime is unavailable." - } - ExternalToolActivationState::LoadFailed { .. } => { - "The tool process could not load this module." + "The required JavaScript run environment is unavailable." } + ExternalToolActivationState::LoadFailed { .. } => "BitFun could not load this tool file.", _ => "The current state is unavailable.", } } @@ -397,23 +394,25 @@ fn external_tool_runtime_label(runtime: ExternalToolRuntimeKind) -> &'static str fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) -> String { let Some(snapshot) = snapshot else { - return "External tools\n\nTool discovery has not completed. Run /external-tools refresh and try again." + return "External tools\n\nBitFun has not finished checking external tools. Run /external-tools refresh and try again." .to_string(); }; let mut lines = vec![ "External tools".to_string(), String::new(), - "No external code ran during discovery. Enabling a tool starts its external module with your user permissions and inherited environment variables; BitFun does not provide an OS sandbox or full descendant-process cleanup in this version." + "BitFun does not run external code while checking sources. Enabling tools runs their code with your user permissions and inherited environment variables. The code is not isolated by an OS sandbox, and processes it starts may keep running after cancellation." .to_string(), ]; if snapshot.discovery_pending { lines.push(String::new()); - lines.push("Discovery is still running. Existing results remain usable.".to_string()); + lines.push( + "BitFun is still checking for changes. Existing tools remain usable.".to_string(), + ); } lines.push(String::new()); - lines.push("Targets".to_string()); + lines.push("Tool sources".to_string()); let targets = external_tool_target_summaries(snapshot); if targets.is_empty() { lines.push(" None".to_string()); @@ -440,12 +439,12 @@ fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) - external_tool_activation_label(target.activation()) )); lines.push(format!( - " Source root: {}", + " Source folder: {}", source .map(|source| source.record.location.as_str()) .unwrap_or("unknown") )); - lines.push(" Module files:".to_string()); + lines.push(" Code files:".to_string()); let module_paths = target .tools .iter() @@ -455,23 +454,25 @@ fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) - lines.push(format!(" - {module_path}")); } lines.push(format!( - " Scope: {}", + " Applies to: {}", source .map(|source| external_tool_scope_label(source.record.scope)) .unwrap_or("unknown") )); lines.push(format!( - " Execution domain: {}", + " Runs in: {}", source - .map(|source| source.record.execution_domain_id.as_str()) + .map(|source| external_tool_run_location_label( + source.record.execution_domain_id.as_str(), + )) .unwrap_or("unknown") )); lines.push(format!( - " Working directory: {}", + " Starts in folder: {}", tool.definition.working_directory )); lines.push(format!( - " Runtime: {}", + " Runs with: {}", external_tool_runtime_label(tool.definition.runtime_kind) )); lines.push(format!(" Access: {capabilities}")); @@ -512,56 +513,703 @@ fn external_tool_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) - } else { for (conflict_index, conflict) in pending_conflicts.iter().enumerate() { lines.push(format!( - " {}. Tool '{}' requires a provider choice:", + " {}. Multiple tools are named '{}':", conflict_index + 1, conflict.tool_name )); for (candidate_index, candidate) in conflict.candidates.iter().enumerate() { lines.push(format!( - " {}. {} ({}) - /external-tools choose {} {}", + " {}. {} - /external-tools choose {} {}", candidate_index + 1, candidate.display_name, - candidate.provider_id, conflict_index + 1, candidate_index + 1 )); } lines.push( - " The choice applies to matching candidates in this execution domain." + " Choose which tool BitFun should use for this name. The choice is remembered until one of these tools changes." .to_string(), ); } } + append_external_source_issues(&mut lines, snapshot, ExternalIssueSurface::Tools); + + lines.push(String::new()); + lines.push( + "Use /external-tools refresh after editing, upgrading, or removing external tools." + .to_string(), + ); + lines.join("\n") +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum ExternalAgentReviewAction { + Show, + Refresh, + Decide { + candidate_id: String, + decision_key: String, + approved: bool, + expected_subagent_generation: u64, + expected_preference_revision: u64, + }, + Choose { + conflict_key: String, + candidate_id: String, + approve_external: bool, + expected_subagent_generation: u64, + expected_preference_revision: u64, + }, +} + +struct ExternalAgentMutationResult { + action: ExternalAgentReviewAction, + result: std::result::Result, +} + +fn external_tool_run_location_label(execution_domain_id: &str) -> &'static str { + if execution_domain_id.starts_with("local") { + "this computer" + } else if execution_domain_id.starts_with("remote") { + "current remote environment" + } else { + "unknown" + } +} + +fn external_source_diagnostic_summary(code: &str) -> &'static str { + if code.contains("preference_read_failed") { + "BitFun could not verify saved tool confirmations. Affected tools remain disabled; check BitFun settings storage, then refresh." + } else if code.contains("conflict_history_write_failed") { + "BitFun could not save conflict information. Affected names remain unavailable; check BitFun settings storage, then refresh." + } else if code.contains("discovery_in_progress") { + "One source is still being checked. Existing content remains available." + } else if code.contains("timeout") { + "Checking one source took too long. Other content remains available; refresh to try again." + } else if code.contains("trust_required") { + "A source needs your confirmation before BitFun can use it." + } else if code.contains("too_large") + || code.contains("file_limit") + || code.contains("bytes_limit") + { + "Some files were skipped because the source is too large. Reduce its size, then refresh." + } else if code.contains("invalid") + || code.contains("parse") + || code.contains("definition") + || code.contains("export_missing") + || code.contains("name_unsupported") + { + "Some settings could not be read and were skipped. Fix the source, then refresh." + } else if code.contains("unreadable") + || code.contains("read_failed") + || code.contains("metadata_failed") + || code.contains("directory_") + { + "BitFun could not read part of a source. Check file access, then refresh." + } else if code.contains("projection_only") + || code.contains("unsupported") + || code.contains("restricted") + { + "This type of external content is not supported yet, so BitFun did not load or run it." + } else if code.contains("failed") { + "BitFun could not check one source. Other sources remain available; refresh to retry." + } else { + "BitFun found an issue in one source. The affected content was not enabled." + } +} + +#[derive(Clone, Copy)] +enum ExternalIssueSurface { + Tools, + Agents, +} + +fn is_external_agent_diagnostic( + diagnostic: &bitfun_core::external_sources::ExternalSourceDiagnostic, +) -> bool { + matches!(diagnostic.asset_kind, ExternalSourceAssetKind::Subagent) +} + +fn append_external_source_issues( + lines: &mut Vec, + snapshot: &ExternalSourceCatalogSnapshot, + surface: ExternalIssueSurface, +) { + let diagnostics = snapshot + .diagnostics + .iter() + .filter(|diagnostic| match surface { + ExternalIssueSurface::Tools => !is_external_agent_diagnostic(diagnostic), + ExternalIssueSurface::Agents => is_external_agent_diagnostic(diagnostic), + }) + .collect::>(); lines.push(String::new()); - lines.push("Diagnostics".to_string()); - if snapshot.diagnostics.is_empty() { + lines.push("Issues".to_string()); + if diagnostics.is_empty() { + lines.push(" None".to_string()); + return; + } + for diagnostic in diagnostics { + let severity = match diagnostic.severity { + ExternalSourceDiagnosticSeverity::Info => "info", + ExternalSourceDiagnosticSeverity::Warning => "warning", + ExternalSourceDiagnosticSeverity::Error => "error", + _ => "notice", + }; + let source = diagnostic + .source + .as_ref() + .and_then(|key| { + snapshot + .sources + .iter() + .find(|source| source.record.key == *key) + }) + .map(|source| source.record.display_name.as_str()); + lines.push(format!( + " - {severity}: {}", + external_source_diagnostic_summary(&diagnostic.code) + )); + if let Some(source) = source { + lines.push(format!(" Affected source: {source}")); + } + lines.push(format!( + " Technical details: [{}] {}", + diagnostic.code, + external_tool_user_facing_reason(&diagnostic.message) + )); + } +} + +const DISABLED_EXTERNAL_AGENT_CONFLICT_CHOICE: &str = "__bitfun_disabled__"; + +fn external_agent_activation_label(state: &ExternalSubagentActivationState) -> &'static str { + match state { + ExternalSubagentActivationState::ApprovalRequired => "confirmation required", + ExternalSubagentActivationState::Declined => "kept disabled", + ExternalSubagentActivationState::Disabled => "disabled by source", + ExternalSubagentActivationState::Active => "enabled", + ExternalSubagentActivationState::Conflict => "choose between same-name agents", + ExternalSubagentActivationState::Blocked => "not supported", + ExternalSubagentActivationState::Unavailable => "temporarily unavailable", + } +} + +fn external_agent_compatibility_label(state: ExternalSubagentCompatibilityState) -> &'static str { + match state { + ExternalSubagentCompatibilityState::Ready => "supported", + ExternalSubagentCompatibilityState::ReadyWithDegradation => { + "supported, but some settings will not apply" + } + ExternalSubagentCompatibilityState::Blocked => "not supported", + ExternalSubagentCompatibilityState::Invalid => "configuration error", + } +} + +fn external_agent_model_label(model: Option<&str>) -> &str { + model.unwrap_or("unavailable") +} + +fn external_agent_review_text(snapshot: Option<&ExternalSourceCatalogSnapshot>) -> String { + let Some(snapshot) = snapshot else { + return "External agents\n\nBitFun has not finished checking external agents. Run /external-agents refresh and try again." + .to_string(); + }; + let mut lines = vec![ + "External agents".to_string(), + String::new(), + "BitFun only reads supported settings while checking sources. Agent instructions stay hidden and are not added to the current agent. Once enabled, those instructions guide the selected model and may call the tools listed below. Before enabling, review the model, tools, and where the agent runs. BitFun asks again if the instructions, model, tools, or configuration sources change. Each use starts a new task; follow-up is not supported in this version." + .to_string(), + ]; + if snapshot.discovery_pending { + lines.push(String::new()); + lines.push( + "BitFun is still checking for changes. Previously enabled agents remain usable." + .to_string(), + ); + } + + append_external_source_issues(&mut lines, snapshot, ExternalIssueSurface::Agents); + + lines.push(String::new()); + lines.push("Agents".to_string()); + if snapshot.subagents.is_empty() { lines.push(" None".to_string()); } else { - for diagnostic in &snapshot.diagnostics { - let severity = match diagnostic.severity { - ExternalSourceDiagnosticSeverity::Info => "info", - ExternalSourceDiagnosticSeverity::Warning => "warning", - ExternalSourceDiagnosticSeverity::Error => "error", - _ => "notice", - }; - let source = diagnostic - .source - .as_ref() - .map(|source| format!(" [{}]", source.stable_key())) - .unwrap_or_default(); + for (index, agent) in snapshot.subagents.iter().enumerate() { lines.push(format!( - " - {severity} [{}]{source}: {}", - diagnostic.code, - external_tool_user_facing_reason(&diagnostic.message) + " {}. {} ({}) - {}", + index + 1, + agent.display_name, + agent.logical_id, + external_agent_activation_label(&agent.activation_state) + )); + lines.push(format!(" Source application: {}", agent.provider_label)); + lines.push(format!( + " Applies to: {}", + external_tool_scope_label(agent.scope) + )); + if !agent.source_location_labels.is_empty() { + lines.push(format!( + " Configuration sources: {}", + agent.source_location_labels.join(", ") + )); + } + lines.push(format!( + " Model: {}", + external_agent_model_label(agent.effective_model_label.as_deref()) )); + lines.push(format!( + " Tools: {}", + if agent.effective_tool_labels.is_empty() { + "none".to_string() + } else { + agent.effective_tool_labels.join(", ") + } + )); + lines.push(format!( + " Support: {}", + external_agent_compatibility_label(agent.compatibility_state) + )); + lines.push(" Run behavior: one run only; no follow-up".to_string()); + lines.push(" Runs on: this computer in the current workspace".to_string()); + if !agent.diagnostics.is_empty() { + lines.push(" Compatibility notes:".to_string()); + for diagnostic in &agent.diagnostics { + lines.extend(external_agent_diagnostic_lines( + &diagnostic.code, + diagnostic.blocks_activation, + " ", + )); + } + } + match agent.activation_state { + ExternalSubagentActivationState::ApprovalRequired + | ExternalSubagentActivationState::Declined => lines.push(format!( + " Command: /external-agents enable {}", + index + 1 + )), + ExternalSubagentActivationState::Active => lines.push(format!( + " Command: /external-agents disable {}", + index + 1 + )), + _ => {} + } + } + } + + lines.push(String::new()); + lines.push("Name conflicts".to_string()); + let conflicts = snapshot + .subagent_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .collect::>(); + if conflicts.is_empty() { + lines.push(" None".to_string()); + } else { + for (conflict_index, conflict) in conflicts.iter().enumerate() { + lines.push(format!( + " {}. Multiple agents are named '{}'. Choose one:", + conflict_index + 1, + conflict.logical_id + )); + for (candidate_index, candidate) in conflict.candidates.iter().enumerate() { + let kind = if candidate.external { + "external" + } else { + "BitFun/local" + }; + lines.push(format!( + " {}. {} ({}, {}) - /external-agents choose {} {}", + candidate_index + 1, + candidate.display_name, + candidate.source_label, + kind, + conflict_index + 1, + candidate_index + 1 + )); + if candidate.external { + if let Some(agent) = snapshot + .subagents + .iter() + .find(|agent| agent.candidate_id == candidate.candidate_id) + { + lines.push(format!( + " Model: {}", + external_agent_model_label(agent.effective_model_label.as_deref()) + )); + lines.push(format!( + " Tools: {}", + if agent.effective_tool_labels.is_empty() { + "none".to_string() + } else { + agent.effective_tool_labels.join(", ") + } + )); + lines.push( + " Runs on: this computer in the current workspace".to_string(), + ); + lines.push(format!( + " Support: {}", + external_agent_compatibility_label(agent.compatibility_state) + )); + for location in &agent.source_location_labels { + lines.push(format!(" Source: {location}")); + } + for diagnostic in &agent.diagnostics { + lines.extend(external_agent_diagnostic_lines( + &diagnostic.code, + diagnostic.blocks_activation, + " ", + )); + } + lines.push( + " This choice also confirms the model, tools, run location, and configuration sources shown above." + .to_string(), + ); + } + } + } + lines.push(format!( + " Keep unavailable: /external-agents choose {} 0", + conflict_index + 1 + )); + lines.push( + " The choice is remembered until one of these agents changes.".to_string(), + ); } } lines.push(String::new()); lines.push( - "Use /external-tools refresh after editing, upgrading, or removing external tools." + "Run /external-agents refresh after editing, upgrading, or removing external agent configuration." .to_string(), ); lines.join("\n") } + +fn external_agent_diagnostic_lines( + code: &str, + blocks_activation: bool, + indent: &str, +) -> Vec { + let (reason, next_step) = if code.contains("model_unavailable") { + ( + "The requested model is not available in BitFun.", + "Choose an available model in the source application, or set a fixed Sub-Agent model in BitFun, then refresh.", + ) + } else if code.contains("tool_unavailable") { + ( + "One or more requested tools are not available in BitFun.", + "Remove or replace the unsupported tools in the source application, then refresh.", + ) + } else if code.contains("type_invalid") + || code.contains("definition_invalid") + || code.ends_with("_invalid") + { + ( + "The agent settings have an invalid or missing required value.", + "Correct the agent settings in the source application, then refresh.", + ) + } else if blocks_activation { + ( + "This agent requires behavior or settings that BitFun does not support.", + "Update the agent in the source application to use supported settings and include all required content, then refresh.", + ) + } else { + ( + "BitFun does not use this setting.", + "Before enabling, review the model and tools that will actually be used, and confirm that this setting will not apply.", + ) + }; + let impact = if blocks_activation { + "This agent cannot be enabled." + } else { + "Some settings will not apply. Review the resulting behavior before enabling." + }; + vec![ + format!("{indent}Reason: {reason}"), + format!("{indent}Impact: {impact}"), + format!("{indent}Next step: {next_step}"), + format!("{indent}Technical code: {code}"), + ] +} + +fn external_agent_result_is_stale( + current: Option<&ExternalSourceCatalogSnapshot>, + result: &ExternalSourceCatalogSnapshot, +) -> bool { + current.is_some_and(|current| { + current.subagent_generation > result.subagent_generation + || current.preference_revision > result.preference_revision + }) +} + +fn merge_external_agent_mutation_snapshot( + current: Option<&ExternalSourceCatalogSnapshot>, + mut result: ExternalSourceCatalogSnapshot, +) -> ExternalSourceCatalogSnapshot { + let Some(current) = current else { + return result; + }; + if current.generation <= result.generation { + return result; + } + + // Agent decisions have an independent generation/revision. Preserve a + // newer unrelated command/tool catalog while applying only the returned + // agent partition, so a successful review action cannot roll the TUI back. + let mut merged = current.clone(); + merged.subagent_generation = result.subagent_generation; + merged.preference_revision = result.preference_revision; + merged.subagents = std::mem::take(&mut result.subagents); + merged.subagent_conflicts = std::mem::take(&mut result.subagent_conflicts); + merged.pending_subagent_approvals = std::mem::take(&mut result.pending_subagent_approvals); + merged +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ExternalAgentAttention { + confirmations: usize, + conflicts: usize, + unavailable: usize, + diagnostics: usize, + key: Option, +} + +fn external_agent_attention( + previous: Option<&ExternalSourceCatalogSnapshot>, + snapshot: &ExternalSourceCatalogSnapshot, +) -> ExternalAgentAttention { + let unresolved = snapshot + .subagent_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .map(|conflict| conflict.conflict_key.clone()) + .collect::>(); + let pending_decisions = snapshot + .pending_subagent_approvals + .iter() + .map(|candidate_id| { + snapshot + .subagents + .iter() + .find(|agent| agent.candidate_id == *candidate_id) + .map(|agent| format!("{}:{}", agent.candidate_id, agent.decision_key)) + .unwrap_or_else(|| candidate_id.clone()) + }) + .collect::>(); + let unavailable = previous + .into_iter() + .flat_map(|previous| previous.subagents.iter()) + .filter(|agent| agent.activation_state == ExternalSubagentActivationState::Active) + .filter_map(|previous_agent| { + match snapshot + .subagents + .iter() + .find(|agent| agent.candidate_id == previous_agent.candidate_id) + .map(|agent| &agent.activation_state) + { + None => Some(format!("removed:{}", previous_agent.candidate_id)), + Some(ExternalSubagentActivationState::Active) => None, + Some(state) => Some(format!("{state:?}:{}", previous_agent.candidate_id)), + } + }) + .collect::>(); + let diagnostics = snapshot + .diagnostics + .iter() + .filter(|diagnostic| { + matches!( + diagnostic.severity, + ExternalSourceDiagnosticSeverity::Warning | ExternalSourceDiagnosticSeverity::Error + ) && is_external_agent_diagnostic(diagnostic) + }) + .map(|diagnostic| { + format!( + "{:?}:{}:{}", + diagnostic.severity, diagnostic.code, diagnostic.message + ) + }) + .collect::>(); + let confirmations = snapshot.pending_subagent_approvals.len(); + let conflicts = unresolved.len(); + let unavailable_count = unavailable.len(); + let diagnostic_count = diagnostics.len(); + let key = if confirmations + conflicts + unavailable_count + diagnostic_count == 0 { + None + } else { + Some(format!( + "approvals={};conflicts={};unavailable={};diagnostics={}", + pending_decisions.join(","), + unresolved.join(","), + unavailable.into_iter().collect::>().join(","), + diagnostics.join(",") + )) + }; + ExternalAgentAttention { + confirmations, + conflicts, + unavailable: unavailable_count, + diagnostics: diagnostic_count, + key, + } +} + +fn external_agent_pending_notice_key( + previous: Option<&ExternalSourceCatalogSnapshot>, + snapshot: &ExternalSourceCatalogSnapshot, +) -> Option { + external_agent_attention(previous, snapshot).key +} + +fn parse_external_agent_review_action( + arguments: &str, + current_snapshot: Option<&ExternalSourceCatalogSnapshot>, + reviewed_snapshot: Option<&ExternalSourceCatalogSnapshot>, +) -> Result { + let mut parts = arguments.split_whitespace(); + let Some(command) = parts.next() else { + return Ok(ExternalAgentReviewAction::Show); + }; + if command.eq_ignore_ascii_case("refresh") { + if parts.next().is_some() { + return Err("usage: /external-agents refresh".to_string()); + } + return Ok(ExternalAgentReviewAction::Refresh); + } + if command.eq_ignore_ascii_case("help") { + return Ok(ExternalAgentReviewAction::Show); + } + let snapshot = reviewed_snapshot.or(current_snapshot).ok_or_else(|| { + "BitFun has not finished checking external agents; run /external-agents refresh".to_string() + })?; + if command.eq_ignore_ascii_case("enable") || command.eq_ignore_ascii_case("disable") { + let index = parse_positive_index(parts.next(), "agent number")?; + if parts.next().is_some() { + return Err(format!("usage: /external-agents {command} ")); + } + let agent = snapshot.subagents.get(index).ok_or_else(|| { + "that agent is no longer available; reopen /external-agents".to_string() + })?; + let approved = command.eq_ignore_ascii_case("enable"); + let allowed = if approved { + matches!( + agent.activation_state, + ExternalSubagentActivationState::ApprovalRequired + | ExternalSubagentActivationState::Declined + ) + } else { + matches!( + agent.activation_state, + ExternalSubagentActivationState::Active + ) + }; + if !allowed { + return Err(format!( + "agent {} is {}; reopen /external-agents for its next step", + index + 1, + external_agent_activation_label(&agent.activation_state) + )); + } + return Ok(ExternalAgentReviewAction::Decide { + candidate_id: agent.candidate_id.clone(), + decision_key: agent.decision_key.clone(), + approved, + expected_subagent_generation: snapshot.subagent_generation, + expected_preference_revision: snapshot.preference_revision, + }); + } + if command.eq_ignore_ascii_case("choose") { + let conflict_index = parse_positive_index(parts.next(), "conflict number")?; + let raw_candidate = parts + .next() + .ok_or_else(|| "missing choice number".to_string())?; + let candidate_number = raw_candidate + .parse::() + .map_err(|_| "choice number must be zero or a positive number".to_string())?; + if parts.next().is_some() { + return Err( + "usage: /external-agents choose ".to_string(), + ); + } + let conflict = snapshot + .subagent_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .nth(conflict_index) + .ok_or_else(|| { + "that conflict is no longer available; reopen /external-agents".to_string() + })?; + let (candidate_id, approve_external) = if candidate_number == 0 { + (DISABLED_EXTERNAL_AGENT_CONFLICT_CHOICE.to_string(), false) + } else { + let candidate = conflict + .candidates + .get(candidate_number - 1) + .ok_or_else(|| { + "that choice is no longer available; reopen /external-agents".to_string() + })?; + (candidate.candidate_id.clone(), candidate.external) + }; + return Ok(ExternalAgentReviewAction::Choose { + conflict_key: conflict.conflict_key.clone(), + candidate_id, + approve_external, + expected_subagent_generation: snapshot.subagent_generation, + expected_preference_revision: snapshot.preference_revision, + }); + } + Err("usage: /external-agents [refresh | enable | disable | choose ]".to_string()) +} + +fn external_agent_mutation_result_label( + action: &ExternalAgentReviewAction, + snapshot: &ExternalSourceCatalogSnapshot, +) -> String { + match action { + ExternalAgentReviewAction::Refresh => "External agents refreshed".to_string(), + ExternalAgentReviewAction::Decide { + candidate_id, + approved, + .. + } => { + let active = snapshot + .subagents + .iter() + .find(|agent| agent.candidate_id == *candidate_id); + match (approved, active.map(|agent| &agent.activation_state)) { + (true, Some(ExternalSubagentActivationState::Active)) => { + "External agent enabled".to_string() + } + (false, Some(ExternalSubagentActivationState::Declined)) => { + "External agent disabled".to_string() + } + _ => "External agent decision saved; reopen /external-agents to review its current state" + .to_string(), + } + } + ExternalAgentReviewAction::Choose { + conflict_key, + candidate_id, + .. + } => { + let selected = snapshot.subagent_conflicts.iter().any(|conflict| { + conflict.conflict_key == *conflict_key + && conflict.selected_candidate_id.as_deref() == Some(candidate_id.as_str()) + }); + if selected { + if candidate_id == DISABLED_EXTERNAL_AGENT_CONFLICT_CHOICE { + "Conflicting agent kept unavailable".to_string() + } else { + "Agent source selected".to_string() + } + } else { + "Agent choices changed; reopen /external-agents before choosing".to_string() + } + } + ExternalAgentReviewAction::Show => "External agents".to_string(), + } +} diff --git a/src/apps/cli/src/modes/chat/external_sources.rs b/src/apps/cli/src/modes/chat/external_sources.rs index bbabe8a6de..128a3bf83b 100644 --- a/src/apps/cli/src/modes/chat/external_sources.rs +++ b/src/apps/cli/src/modes/chat/external_sources.rs @@ -32,16 +32,16 @@ fn parse_external_tool_review_action( // so a changed target fails closed instead of reusing the same number for // a different tool after a watcher refresh. let snapshot = reviewed_snapshot.or(current_snapshot).ok_or_else(|| { - "tool discovery has not completed; run /external-tools refresh".to_string() + "BitFun has not finished checking external tools; run /external-tools refresh".to_string() })?; if command.eq_ignore_ascii_case("enable") || command.eq_ignore_ascii_case("disable") { - let index = parse_positive_index(parts.next(), "target number")?; + let index = parse_positive_index(parts.next(), "tool number")?; if parts.next().is_some() { - return Err(format!("usage: /external-tools {command} ")); + return Err(format!("usage: /external-tools {command} ")); } let targets = external_tool_target_summaries(snapshot); let target = targets.get(index).ok_or_else(|| { - "that target is no longer available; reopen /external-tools".to_string() + "that tool is no longer available; reopen /external-tools".to_string() })?; let approved = command.eq_ignore_ascii_case("enable"); let allowed = if approved { @@ -51,7 +51,7 @@ fn parse_external_tool_review_action( }; if !allowed { return Err(format!( - "target {} is {}; reopen /external-tools for its next step", + "tool {} is {}; reopen /external-tools for its next step", index + 1, external_tool_activation_label(target.activation()) )); @@ -65,10 +65,10 @@ fn parse_external_tool_review_action( } if command.eq_ignore_ascii_case("choose") { let conflict_index = parse_positive_index(parts.next(), "conflict number")?; - let candidate_index = parse_positive_index(parts.next(), "candidate number")?; + let candidate_index = parse_positive_index(parts.next(), "choice number")?; if parts.next().is_some() { return Err( - "usage: /external-tools choose ".to_string(), + "usage: /external-tools choose ".to_string(), ); } let conflict = snapshot @@ -80,14 +80,14 @@ fn parse_external_tool_review_action( "that conflict is no longer available; reopen /external-tools".to_string() })?; let candidate = conflict.candidates.get(candidate_index).ok_or_else(|| { - "that candidate is no longer available; reopen /external-tools".to_string() + "that choice is no longer available; reopen /external-tools".to_string() })?; return Ok(ExternalToolReviewAction::Choose { conflict_key: conflict.conflict_key.clone(), candidate_id: candidate.candidate_id.clone(), }); } - Err("usage: /external-tools [refresh | enable | disable | choose ]".to_string()) + Err("usage: /external-tools [refresh | enable | disable | choose ]".to_string()) } fn external_tool_mutation_result_label( @@ -110,33 +110,32 @@ fn external_tool_mutation_result_label( .map(|tool| &tool.activation) .collect::>(); if activations.is_empty() { - "External tool approval saved; reopen /external-tools to review the changed target" + "External tool confirmation saved; reopen /external-tools to review the changed tool" .to_string() } else if activations .iter() .any(|state| matches!(state, ExternalToolActivationState::LoadFailed { .. })) { - "External tool approved, but loading failed".to_string() + "External tool enabled, but loading failed".to_string() } else if activations.iter().any(|state| { matches!( state, ExternalToolActivationState::RuntimeUnavailable { .. } ) }) { - "External tool approved, but its runtime is unavailable".to_string() + "External tool enabled, but its run environment is unavailable".to_string() } else if activations .iter() .any(|state| matches!(state, ExternalToolActivationState::Conflict)) { - "External tool approved; choose a provider before every export is available" - .to_string() + "External tool enabled; choose a source before every tool is available".to_string() } else if activations .iter() .all(|state| matches!(state, ExternalToolActivationState::Active)) { "External tool enabled".to_string() } else { - "External tool approval saved; reopen /external-tools to review its current state" + "External tool confirmation saved; reopen /external-tools to review its current state" .to_string() } } @@ -153,7 +152,7 @@ fn external_tool_mutation_result_label( if disabled { "External tool disabled".to_string() } else { - "External tool decision saved; reopen /external-tools to review the changed target" + "External tool choice saved; reopen /external-tools to review the changed tool" .to_string() } } @@ -166,17 +165,15 @@ fn external_tool_mutation_result_label( && conflict.selected_candidate_id.as_deref() == Some(candidate_id.as_str()) }); if selected { - "External tool provider selected".to_string() + "External tool source selected".to_string() } else { - "External tool candidates changed; reopen /external-tools before choosing" - .to_string() + "External tool choices changed; reopen /external-tools before choosing".to_string() } } ExternalToolReviewAction::Show => "External tools".to_string(), } } -#[derive(Debug, Clone, PartialEq, Eq)] struct BuiltinCommandReconfirmation { conflict_key: String, candidate_id: String, @@ -323,7 +320,6 @@ fn command_route( } } - impl ChatMode { fn external_conflict_preferences(&self) -> ExternalSourceConflictPreferences { ExternalSourceConflictPreferences { @@ -522,4 +518,206 @@ impl ChatMode { true } + fn take_external_agent_notice( + &mut self, + snapshot: &ExternalSourceCatalogSnapshot, + ) -> Option { + let attention = external_agent_attention(self.external_source_snapshot.as_ref(), snapshot); + let next_key = attention.key.clone(); + if next_key == self.external_agent_notice_key { + return None; + } + self.external_agent_notice_key = next_key; + if attention.confirmations + + attention.conflicts + + attention.unavailable + + attention.diagnostics + == 0 + { + None + } else { + let mut details = Vec::new(); + if attention.confirmations > 0 { + details.push(format!("{} confirmations", attention.confirmations)); + } + if attention.conflicts > 0 { + details.push(format!("{} name conflicts", attention.conflicts)); + } + if attention.unavailable > 0 { + details.push(format!( + "{} enabled agents unavailable", + attention.unavailable + )); + } + if attention.diagnostics > 0 { + details.push(format!("{} issues", attention.diagnostics)); + } + Some(format!( + "External agents need attention: {} - run /external-agents", + details.join(", ") + )) + } + } + + fn handle_external_agent_review( + &mut self, + arguments: &str, + chat_view: &mut ChatView, + chat_state: &ChatState, + rt_handle: &tokio::runtime::Handle, + ) { + let action = match parse_external_agent_review_action( + arguments, + self.external_source_snapshot.as_ref(), + self.external_agent_review_snapshot.as_ref(), + ) { + Ok(action) => action, + Err(error) => { + chat_view.set_status(Some(error)); + return; + } + }; + if matches!(action, ExternalAgentReviewAction::Show) { + self.external_agent_review_snapshot = self.external_source_snapshot.clone(); + chat_view.show_info_popup(external_agent_review_text( + self.external_agent_review_snapshot.as_ref(), + )); + return; + } + if self.external_agent_mutation_rx.is_some() { + chat_view.set_status(Some( + "An external agent update is already running; input and cancellation remain available." + .to_string(), + )); + return; + } + + let workspace = self.workspace_path_for_sync(chat_state); + let pending_status = match &action { + ExternalAgentReviewAction::Refresh => "Refreshing external agents", + ExternalAgentReviewAction::Decide { approved: true, .. } => "Enabling external agent", + ExternalAgentReviewAction::Decide { + approved: false, .. + } => "Disabling external agent", + ExternalAgentReviewAction::Choose { .. } => "Selecting agent source", + ExternalAgentReviewAction::Show => unreachable!(), + }; + let task_action = action.clone(); + let (sender, receiver) = mpsc::channel(); + rt_handle.spawn(async move { + let result = match &task_action { + ExternalAgentReviewAction::Refresh => { + external_source_snapshot(Some(&workspace), true).await + } + ExternalAgentReviewAction::Decide { + candidate_id, + decision_key, + approved, + expected_subagent_generation, + expected_preference_revision, + } => { + set_external_subagent_activation( + Some(&workspace), + candidate_id, + *approved, + *expected_subagent_generation, + *expected_preference_revision, + decision_key, + ) + .await + } + ExternalAgentReviewAction::Choose { + conflict_key, + candidate_id, + approve_external, + expected_subagent_generation, + expected_preference_revision, + } => { + choose_external_subagent_conflict( + Some(&workspace), + conflict_key, + candidate_id, + *approve_external, + *expected_subagent_generation, + *expected_preference_revision, + ) + .await + } + ExternalAgentReviewAction::Show => unreachable!(), + } + .map_err(|error| error.to_string()); + let _ = sender.send(ExternalAgentMutationResult { + action: task_action, + result, + }); + }); + self.external_agent_mutation_rx = Some(receiver); + chat_view.set_status(Some(format!( + "{pending_status}; you can continue typing or cancel other UI work" + ))); + } + + fn poll_external_agent_mutation(&mut self, chat_view: &mut ChatView) -> bool { + let outcome = match self + .external_agent_mutation_rx + .as_ref() + .map(Receiver::try_recv) + { + Some(Ok(outcome)) => outcome, + Some(Err(MpscTryRecvError::Empty)) | None => return false, + Some(Err(MpscTryRecvError::Disconnected)) => { + self.external_agent_mutation_rx = None; + chat_view.set_status(Some( + "External agent update stopped before returning a result; reopen /external-agents and retry." + .to_string(), + )); + return true; + } + }; + self.external_agent_mutation_rx = None; + match outcome.result { + Ok(snapshot) => { + if external_agent_result_is_stale(self.external_source_snapshot.as_ref(), &snapshot) + { + chat_view.set_status(Some( + "External agent update completed; newer agent results are already displayed." + .to_string(), + )); + return true; + } + let snapshot = merge_external_agent_mutation_snapshot( + self.external_source_snapshot.as_ref(), + snapshot, + ); + self.update_external_source_view(chat_view, &snapshot); + self.external_agent_notice_key = + external_agent_pending_notice_key(Some(&snapshot), &snapshot); + let confirmations = snapshot.pending_subagent_approvals.len(); + let conflicts = snapshot + .subagent_conflicts + .iter() + .filter(|conflict| conflict.selected_candidate_id.is_none()) + .count(); + let result_label = external_agent_mutation_result_label(&outcome.action, &snapshot); + self.external_source_snapshot = Some(snapshot); + if confirmations + conflicts == 0 { + chat_view.set_status(Some(result_label)); + } else { + chat_view.set_status(Some(format!( + "{result_label}; {confirmations} confirmations and {conflicts} conflicts remain - run /external-agents" + ))); + } + } + Err(error) => { + tracing::warn!("External agent review action failed: {}", error); + let reason = error + .split_once(':') + .map_or(error.as_str(), |(_, reason)| reason.trim()); + chat_view.set_status(Some(format!( + "External agent choice was not applied: {reason}. Reopen /external-agents to review current results." + ))); + } + } + true + } } diff --git a/src/apps/cli/src/modes/chat/run.rs b/src/apps/cli/src/modes/chat/run.rs index 23cccb0ea4..07999bd2fe 100644 --- a/src/apps/cli/src/modes/chat/run.rs +++ b/src/apps/cli/src/modes/chat/run.rs @@ -114,14 +114,21 @@ impl ChatMode { .filter(|conflict| conflict.selected_candidate_id.is_none()) .count(); let tool_notice = self.take_external_tool_notice(&snapshot); + let agent_notice = self.take_external_agent_notice(&snapshot); self.update_external_source_view(&mut chat_view, &snapshot); self.external_source_snapshot = Some(snapshot.clone()); if snapshot.discovery_pending { chat_view.set_status(Some( "Checking compatible content from external AI applications".to_string(), )); - } else if let Some(notice) = tool_notice { - chat_view.set_status(Some(notice)); + } else if tool_notice.is_some() || agent_notice.is_some() { + chat_view.set_status(Some( + [tool_notice, agent_notice] + .into_iter() + .flatten() + .collect::>() + .join("; "), + )); } else if available + restricted > 0 || pending_conflicts > 0 { chat_view.set_status(Some(format!( "External sources: {available} commands available, {restricted} restricted, {pending_conflicts} need a choice" @@ -215,6 +222,9 @@ impl ChatMode { if self.poll_external_tool_mutation(&mut chat_view) { needs_redraw = true; } + if self.poll_external_agent_mutation(&mut chat_view) { + needs_redraw = true; + } let mut external_source_closed = false; if let Some(receiver) = external_source_rx.as_mut() { @@ -245,13 +255,20 @@ impl ChatMode { self.replace_external_conflict_preferences(preferences); } let tool_notice = self.take_external_tool_notice(&snapshot); + let agent_notice = self.take_external_agent_notice(&snapshot); self.update_external_source_view(&mut chat_view, &snapshot); if snapshot.discovery_pending { chat_view.set_status(Some( "Checking compatible content from external AI applications".to_string(), )); - } else if let Some(notice) = tool_notice { - chat_view.set_status(Some(notice)); + } else if tool_notice.is_some() || agent_notice.is_some() { + chat_view.set_status(Some( + [tool_notice, agent_notice] + .into_iter() + .flatten() + .collect::>() + .join("; "), + )); } else if discovery_just_finished { let (available, restricted) = external_command_counts(&snapshot); let pending_conflicts = snapshot diff --git a/src/apps/cli/src/modes/chat/tests.rs b/src/apps/cli/src/modes/chat/tests.rs index 4d48a011a8..eb22b5c1f2 100644 --- a/src/apps/cli/src/modes/chat/tests.rs +++ b/src/apps/cli/src/modes/chat/tests.rs @@ -3,19 +3,25 @@ mod tests { use tokio::sync::broadcast::error::TryRecvError; use super::{ - agent_event_stream_failure, builtin_command_reconfirmation, command_route, - external_command_projections, external_tool_mutation_result_label, - external_tool_pending_notice_key, external_tool_result_is_stale, external_tool_review_text, - apply_model_selection_feedback, mark_active_turn_failed, parse_command_token, - parse_external_tool_review_action, CommandQualifier, CommandRoute, - ExternalSourceConflictPreferences, ExternalToolReviewAction, ModelSelectionApplyOutcome, + agent_event_stream_failure, apply_model_selection_feedback, builtin_command_reconfirmation, + command_route, external_agent_attention, external_agent_diagnostic_lines, + external_agent_pending_notice_key, external_agent_result_is_stale, + external_agent_review_text, external_command_projections, + external_tool_mutation_result_label, external_tool_pending_notice_key, + external_tool_result_is_stale, external_tool_review_text, external_tool_run_location_label, + mark_active_turn_failed, merge_external_agent_mutation_snapshot, parse_command_token, + parse_external_agent_review_action, parse_external_tool_review_action, CommandQualifier, + CommandRoute, ExternalAgentReviewAction, ExternalSourceConflictPreferences, + ExternalToolReviewAction, ModelSelectionApplyOutcome, }; use crate::actions::{ActionState, ResolvedKeymap}; use crate::chat_state::ChatState; use crate::config::ShortcutsConfig; use crate::ui::command_menu::{ExternalCommandProjection, NativeCommandCollisionProjection}; use bitfun_core::external_sources::{ - ExternalSourceCatalogSnapshot, ExternalToolActivationState, + ExternalSourceAssetKind, ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, + ExternalSourceDiagnosticSeverity, ExternalSubagentActivationState, + ExternalToolActivationState, }; use std::collections::{BTreeMap, BTreeSet}; @@ -54,7 +60,7 @@ mod tests { "displayName": "OpenCode project tools", "sourceKind": "tools", "scope": "project", - "location": "D:/repo/.opencode/tools", + "location": "/.opencode/tools", "executionDomainId": "local:D:/repo", "health": "available", "contentVersion": "source-v1" @@ -73,8 +79,8 @@ mod tests { }, "name": "review", "descriptionPreview": "Review a change", - "modulePath": "D:/repo/.opencode/tools/review.js", - "workingDirectory": "D:/repo", + "modulePath": "/.opencode/tools/review.js", + "workingDirectory": "/", "runtimeKind": "java_script", "capabilities": ["file_system", "network", "environment", "process"], "contentVersion": "content-v1", @@ -94,8 +100,8 @@ mod tests { }, "name": "weather", "descriptionPreview": "Read weather", - "modulePath": "D:/repo/.opencode/tools/weather.js", - "workingDirectory": "D:/repo", + "modulePath": "/.opencode/tools/weather.js", + "workingDirectory": "/", "runtimeKind": "java_script", "capabilities": ["network"], "contentVersion": "content-v1", @@ -115,8 +121,8 @@ mod tests { }, "name": "deploy", "descriptionPreview": "Deploy a build", - "modulePath": "D:/repo/.opencode/tools/deploy.js", - "workingDirectory": "D:/repo", + "modulePath": "/.opencode/tools/deploy.js", + "workingDirectory": "/", "runtimeKind": "java_script", "capabilities": ["process"], "contentVersion": "content-v1", @@ -136,8 +142,8 @@ mod tests { }, "name": "broken", "descriptionPreview": "Broken tool", - "modulePath": "D:/repo/.opencode/tools/broken.ts", - "workingDirectory": "D:/repo", + "modulePath": "/.opencode/tools/broken.ts", + "workingDirectory": "/", "runtimeKind": "type_script", "capabilities": ["file_system"], "contentVersion": "content-v1", @@ -159,8 +165,8 @@ mod tests { }, "sourceDisplayName": "OpenCode project tools", "sourceScope": "project", - "sourceLocation": "D:/repo/.opencode/tools/review.js", - "workingDirectory": "D:/repo", + "sourceLocation": "/.opencode/tools/review.js", + "workingDirectory": "/", "runtimeKind": "java_script", "capabilities": ["file_system", "network", "environment", "process"], "contentVersion": "content-v1", @@ -182,7 +188,7 @@ mod tests { "providerId": "opencode.tools", "contentVersion": "content-v1", "source": { "providerId": "opencode.tools", "sourceId": "project" }, - "sourceLocation": "D:/repo/.opencode/tools/review.js" + "sourceLocation": "/.opencode/tools/review.js" }] }], "diagnostics": [{ @@ -199,22 +205,25 @@ mod tests { fn external_tool_review_summary_discloses_execution_boundary_and_commands() { let summary = external_tool_review_text(Some(&external_tool_review_snapshot())); - assert!(summary.contains("No external code ran during discovery")); + assert!(summary.contains("BitFun does not run external code while checking sources")); assert!(summary.contains("filesystem, network, process, environment variables")); assert!(summary.contains("inherited environment variables")); - assert!(summary.contains("full descendant-process cleanup")); + assert!(summary.contains("processes it starts may keep running after cancellation")); assert!(summary.contains("/external-tools enable 1")); assert!(summary.contains("/external-tools choose 1 2")); - assert!(summary.contains("D:/repo/.opencode/tools/review.js")); - assert!(summary.contains("Source root: D:/repo/.opencode/tools")); - assert!(summary.contains("Scope: project")); - assert!(summary.contains("Execution domain: local:D:/repo")); + assert!(summary.contains("/.opencode/tools/review.js")); + assert!(summary.contains("Source folder: /.opencode/tools")); + assert!(summary.contains("Applies to: current workspace")); + assert!(summary.contains("Runs in: this computer")); + assert!(!summary.contains("local:D:/repo")); assert!(summary.contains("disabled")); - assert!(summary.contains("active")); - assert!(summary.contains("loaded successfully")); - assert!(summary.contains("load failed")); - assert!(summary.contains("D:/repo/.opencode/tools/broken.ts")); - assert!(summary.contains("Diagnostics")); + assert!(summary.contains("enabled")); + assert!(summary.contains("loaded and ready to use")); + assert!(summary.contains("could not load")); + assert!(summary.contains("/.opencode/tools/broken.ts")); + assert!(!summary.contains("D:/repo")); + assert!(summary.contains("Issues")); + assert!(summary.contains("Technical details:")); assert!(summary.contains("opencode.tool.directory_read_failed")); assert!(!summary.contains("PR2")); } @@ -287,7 +296,7 @@ mod tests { assert_eq!( external_tool_mutation_result_label(&action, &snapshot), - "External tool approved, but loading failed" + "External tool enabled, but loading failed" ); } @@ -609,4 +618,264 @@ mod tests { assert!(help.contains("Ctrl+P")); assert!(help.contains("Command Palette")); } + fn external_agent_review_snapshot() -> ExternalSourceCatalogSnapshot { + serde_json::from_value(serde_json::json!({ + "generation": 9, + "discoveryPending": false, + "sources": [], + "commands": [], + "subagentGeneration": 4, + "preferenceRevision": 7, + "subagents": [{ + "candidateId": "external_subagent:opencode:review:v1", + "logicalId": "review", + "displayName": "Review agent", + "description": "Review a change", + "providerLabel": "OpenCode", + "scope": "project", + "sourceKeys": [{ + "providerId": "opencode.agents", + "sourceId": "project-review" + }], + "sourceLocationLabels": ["/.opencode/agents/review.md"], + "sourceCount": 1, + "effectiveModelLabel": "fast", + "effectiveToolLabels": ["read", "search"], + "supportsFollowUp": false, + "compatibilityState": "ready", + "diagnostics": [], + "activationState": { "state": "approval_required" }, + "decisionKey": "decision-v1" + }], + "subagentConflicts": [{ + "conflictKey": "conflict-v1", + "logicalId": "review", + "candidates": [{ + "candidateId": "bitfun:review", + "displayName": "BitFun review", + "sourceLabel": "BitFun", + "external": false + }, { + "candidateId": "external_subagent:opencode:review:v1", + "displayName": "Review agent", + "sourceLabel": "OpenCode", + "external": true + }] + }], + "pendingSubagentApprovals": ["external_subagent:opencode:review:v1"] + })) + .unwrap() + } + + #[test] + fn external_agent_review_is_explicit_single_run_and_does_not_expose_prompt() { + let summary = external_agent_review_text(Some(&external_agent_review_snapshot())); + + assert!(summary.contains("one run only; no follow-up")); + assert!(summary.contains("Model: fast")); + assert!(summary.contains("Tools: read, search")); + assert!(summary.contains("/external-agents enable 1")); + assert!(summary.contains("/external-agents choose 1 2")); + assert!(summary.contains("/external-agents choose 1 0")); + assert!(summary.contains("Runs on: this computer in the current workspace")); + assert!(summary.contains("instructions guide the selected model")); + assert!(summary.contains("may call the tools listed below")); + assert!(summary.contains("asks again if the instructions, model, tools")); + assert!(summary.contains("/.opencode/agents/review.md")); + assert!(summary.contains("This choice also confirms")); + assert!(!summary.contains("D:/repo")); + assert!(!summary.to_ascii_lowercase().contains("system prompt")); + + let mut unavailable = external_agent_review_snapshot(); + unavailable.subagents[0].effective_model_label = None; + assert!(external_agent_review_text(Some(&unavailable)).contains("Model: unavailable")); + } + + #[test] + fn external_agent_review_shows_agent_storage_issues_only_on_the_agent_surface() { + let mut snapshot = external_agent_review_snapshot(); + snapshot.diagnostics.push(ExternalSourceDiagnostic { + severity: ExternalSourceDiagnosticSeverity::Warning, + asset_kind: ExternalSourceAssetKind::Subagent, + code: "external_subagent.conflict_history_write_failed".to_string(), + message: "routes remain unavailable".to_string(), + source: None, + }); + snapshot.diagnostics.push(ExternalSourceDiagnostic { + severity: ExternalSourceDiagnosticSeverity::Error, + asset_kind: ExternalSourceAssetKind::Subagent, + code: "future_host.agent_map_invalid".to_string(), + message: "agent map is invalid".to_string(), + source: None, + }); + + let agents = external_agent_review_text(Some(&snapshot)); + assert!(agents.contains("BitFun could not save conflict information")); + assert!(agents.contains("check BitFun settings storage, then refresh")); + assert!(agents.contains("external_subagent.conflict_history_write_failed")); + assert!(agents.contains("future_host.agent_map_invalid")); + + let tools = external_tool_review_text(Some(&snapshot)); + assert!(!tools.contains("external_subagent.conflict_history_write_failed")); + assert!(!tools.contains("future_host.agent_map_invalid")); + } + + #[test] + fn external_agent_review_actions_bind_generation_revision_and_stable_keys() { + let snapshot = external_agent_review_snapshot(); + + assert_eq!( + parse_external_agent_review_action("enable 1", Some(&snapshot), None).unwrap(), + ExternalAgentReviewAction::Decide { + candidate_id: "external_subagent:opencode:review:v1".to_string(), + decision_key: "decision-v1".to_string(), + approved: true, + expected_subagent_generation: 4, + expected_preference_revision: 7, + } + ); + assert_eq!( + parse_external_agent_review_action("choose 1 2", Some(&snapshot), None).unwrap(), + ExternalAgentReviewAction::Choose { + conflict_key: "conflict-v1".to_string(), + candidate_id: "external_subagent:opencode:review:v1".to_string(), + approve_external: true, + expected_subagent_generation: 4, + expected_preference_revision: 7, + } + ); + assert_eq!( + parse_external_agent_review_action("choose 1 0", Some(&snapshot), None).unwrap(), + ExternalAgentReviewAction::Choose { + conflict_key: "conflict-v1".to_string(), + candidate_id: "__bitfun_disabled__".to_string(), + approve_external: false, + expected_subagent_generation: 4, + expected_preference_revision: 7, + } + ); + } + + #[test] + fn external_agent_freshness_ignores_unrelated_catalog_generation() { + let current = external_agent_review_snapshot(); + let mut unrelated_update = current.clone(); + unrelated_update.generation += 1; + + assert!(!external_agent_result_is_stale( + Some(&unrelated_update), + ¤t + )); + + let mut notice_only_update = unrelated_update.clone(); + notice_only_update.subagent_generation += 1; + notice_only_update.preference_revision += 1; + let notice_key = external_agent_pending_notice_key(None, ¤t); + assert!(notice_key.is_some()); + assert_eq!( + external_agent_pending_notice_key(None, ¬ice_only_update), + notice_key + ); + + notice_only_update.subagents[0].decision_key = "agent-decision-v2".to_string(); + assert_ne!( + external_agent_pending_notice_key(None, ¬ice_only_update), + notice_key + ); + } + + #[test] + fn external_agent_attention_reports_active_agents_that_become_unavailable_or_disappear() { + let mut previous = external_agent_review_snapshot(); + previous.pending_subagent_approvals.clear(); + previous.subagent_conflicts.clear(); + previous.subagents[0].activation_state = ExternalSubagentActivationState::Active; + + let mut blocked = previous.clone(); + blocked.subagent_generation += 1; + blocked.subagents[0].activation_state = ExternalSubagentActivationState::Blocked; + let blocked_attention = external_agent_attention(Some(&previous), &blocked); + assert_eq!(blocked_attention.unavailable, 1); + assert!(external_agent_pending_notice_key(Some(&previous), &blocked).is_some()); + + let mut removed = previous.clone(); + removed.subagent_generation += 1; + removed.subagents.clear(); + let removed_attention = external_agent_attention(Some(&previous), &removed); + assert_eq!(removed_attention.unavailable, 1); + assert!(external_agent_pending_notice_key(Some(&previous), &removed).is_some()); + } + + #[test] + fn external_agent_attention_includes_only_agent_warning_and_error_diagnostics() { + let mut snapshot = external_agent_review_snapshot(); + snapshot.pending_subagent_approvals.clear(); + snapshot.subagent_conflicts.clear(); + snapshot.diagnostics = vec![ + ExternalSourceDiagnostic { + severity: ExternalSourceDiagnosticSeverity::Warning, + asset_kind: ExternalSourceAssetKind::Subagent, + code: "future_host.agent_map_invalid".to_string(), + message: "agent map is invalid".to_string(), + source: None, + }, + ExternalSourceDiagnostic { + severity: ExternalSourceDiagnosticSeverity::Warning, + asset_kind: ExternalSourceAssetKind::Tool, + code: "opencode.tool.directory_read_failed".to_string(), + message: "tool directory is unavailable".to_string(), + source: None, + }, + ]; + + let attention = external_agent_attention(None, &snapshot); + assert_eq!(attention.diagnostics, 1); + assert!(external_agent_pending_notice_key(None, &snapshot).is_some()); + } + + #[test] + fn external_agent_result_preserves_newer_unrelated_catalog_partitions() { + let result = external_agent_review_snapshot(); + let mut current = result.clone(); + current.generation += 1; + current.commands.clear(); + current.tools.clear(); + + let merged = merge_external_agent_mutation_snapshot(Some(¤t), result.clone()); + + assert_eq!(merged.generation, current.generation); + assert!(merged.commands.is_empty()); + assert!(merged.tools.is_empty()); + assert_eq!(merged.subagents, result.subagents); + assert_eq!(merged.subagent_conflicts, result.subagent_conflicts); + assert_eq!( + merged.pending_subagent_approvals, + result.pending_subagent_approvals + ); + } + + #[test] + fn external_review_copy_classifies_unknown_locations_and_agent_diagnostics_safely() { + assert_eq!(external_tool_run_location_label("custom-domain"), "unknown"); + + let prompt = + external_agent_diagnostic_lines("opencode_agent_prompt_not_imported", true, "") + .join(" "); + assert!(prompt.contains("does not support")); + assert!(!prompt.contains("invalid or missing required value")); + + let default_permissions = external_agent_diagnostic_lines( + "opencode_default_permission_semantics_not_imported", + false, + "", + ) + .join(" "); + assert!(default_permissions.contains("does not use this setting")); + assert!(!default_permissions.contains("cannot be enabled")); + + let invalid = + external_agent_diagnostic_lines("opencode_agent_definition_type_invalid", true, "") + .join(" "); + assert!(invalid.contains("invalid or missing required value")); + } } diff --git a/src/apps/cli/src/ui/command_palette.rs b/src/apps/cli/src/ui/command_palette.rs index 80c4888085..bce8a8f847 100644 --- a/src/apps/cli/src/ui/command_palette.rs +++ b/src/apps/cli/src/ui/command_palette.rs @@ -51,6 +51,7 @@ const DEFAULT_ITEM_ORDER: &[&str] = &[ "switch_agent", "mcp_servers", "external_tools", + "external_agents", "login", "logout", "help", diff --git a/src/apps/cli/src/ui/startup.rs b/src/apps/cli/src/ui/startup.rs index 39ad5c0f8f..ee41654c34 100644 --- a/src/apps/cli/src/ui/startup.rs +++ b/src/apps/cli/src/ui/startup.rs @@ -1044,6 +1044,7 @@ impl StartupPage { ActionHandler::ClearConversation | ActionHandler::ReloadSkills | ActionHandler::ExternalTools + | ActionHandler::ExternalAgents | ActionHandler::History | ActionHandler::Interrupt | ActionHandler::ToggleFocusedTool @@ -1996,6 +1997,7 @@ impl StartupPage { workspace_root: Some(workspace.as_path()), list_scope: SubagentListScope::TaskVisible, include_disabled: false, + external_sources_supported: false, }, )) }); @@ -2032,12 +2034,14 @@ impl StartupPage { workspace_root: Some(workspace.as_path()), list_scope: SubagentListScope::RegistryManagement, include_disabled: true, + external_sources_supported: false, }, )) }); let subagent_items: Vec = subagents .into_iter() + .filter(|info| info.subagent_source != Some(SubAgentSource::External)) .map(Self::subagent_item_from_info) .collect(); @@ -2103,6 +2107,7 @@ impl StartupPage { Some(SubAgentSource::Builtin) => "builtin", Some(SubAgentSource::Project) => "project", Some(SubAgentSource::User) => "user", + Some(SubAgentSource::External) => "external", None => "builtin", } .to_string(); diff --git a/src/apps/cli/src/ui/subagent_selector.rs b/src/apps/cli/src/ui/subagent_selector.rs index bf9187376e..e7c9379775 100644 --- a/src/apps/cli/src/ui/subagent_selector.rs +++ b/src/apps/cli/src/ui/subagent_selector.rs @@ -20,7 +20,7 @@ pub(crate) struct SubagentItem { pub id: String, pub name: String, pub description: String, - pub source: String, // "builtin", "project", or "user" + pub source: String, // "builtin", "project", "user", or "external" pub enabled: bool, } @@ -342,11 +342,13 @@ impl SubagentSelectorState { "builtin" => "B", "project" => "P", "user" => "U", + "external" => "E", _ => "?", }; let source_style = match subagent.source.as_str() { "builtin" => theme.style(StyleKind::Success), "project" => theme.style(StyleKind::Info), + "external" => theme.style(StyleKind::Warning), _ => theme.style(StyleKind::Muted), }; let name_style = theme.style(StyleKind::Primary).add_modifier(Modifier::BOLD); diff --git a/src/apps/desktop/src/api/agentic_api.rs b/src/apps/desktop/src/api/agentic_api.rs index baa64a33ba..8209bedf4c 100644 --- a/src/apps/desktop/src/api/agentic_api.rs +++ b/src/apps/desktop/src/api/agentic_api.rs @@ -817,6 +817,7 @@ pub async fn create_session( remote_connection_id: remote_conn.clone(), remote_ssh_host: remote_ssh_host.clone(), model_id: c.model_name, + ..Default::default() }) .unwrap_or(SessionConfig { workspace_path: Some(request.workspace_path.clone()), diff --git a/src/apps/desktop/src/api/custom_agent_api.rs b/src/apps/desktop/src/api/custom_agent_api.rs index 357110a42d..413db1f7b5 100644 --- a/src/apps/desktop/src/api/custom_agent_api.rs +++ b/src/apps/desktop/src/api/custom_agent_api.rs @@ -18,6 +18,22 @@ fn workspace_root_from_request(workspace_path: Option<&str>) -> Option .map(PathBuf::from) } +fn reject_external_agent_mutation( + state: &AppState, + agent_id: &str, + workspace: Option<&std::path::Path>, +) -> Result<(), String> { + if state + .agent_registry + .is_external_subagent_route(agent_id, workspace) + { + return Err( + "external_subagent_read_only: manage external agents in External AI Apps".to_string(), + ); + } + Ok(()) +} + fn validate_agent_id(id: &str) -> Result<(), String> { let id = id.trim(); if id.is_empty() { @@ -108,6 +124,7 @@ pub async fn get_custom_agent_detail( request: GetCustomAgentDetailRequest, ) -> Result { let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + reject_external_agent_mutation(&state, &request.agent_id, workspace.as_deref())?; state .agent_registry .get_custom_agent_detail(&request.agent_id, workspace.as_deref()) @@ -300,6 +317,7 @@ pub async fn update_custom_agent( } let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + reject_external_agent_mutation(&state, &request.agent_id, workspace.as_deref())?; let current = state .agent_registry .get_custom_agent_detail(&request.agent_id, workspace.as_deref()) @@ -349,6 +367,7 @@ pub async fn update_custom_agent( #[serde(rename_all = "camelCase")] pub struct DeleteCustomAgentRequest { pub agent_id: String, + pub workspace_path: Option, } #[tauri::command] @@ -357,6 +376,8 @@ pub async fn delete_custom_agent( request: DeleteCustomAgentRequest, ) -> Result<(), String> { let agent_id = request.agent_id; + let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + reject_external_agent_mutation(&state, &agent_id, workspace.as_deref())?; if let Some(path) = state .agent_registry diff --git a/src/apps/desktop/src/api/external_sources_api.rs b/src/apps/desktop/src/api/external_sources_api.rs index 514fde9c55..1666929fd0 100644 --- a/src/apps/desktop/src/api/external_sources_api.rs +++ b/src/apps/desktop/src/api/external_sources_api.rs @@ -1,11 +1,13 @@ //! Desktop host API for ecosystem-neutral external AI application sources. use bitfun_core::external_sources::{ - external_source_snapshot, set_external_prompt_command_conflict_choice, - set_external_source_enabled, set_external_tool_conflict_choice, + choose_external_subagent_conflict, external_source_snapshot, + set_external_prompt_command_conflict_choice, set_external_source_enabled, + set_external_subagent_activation, set_external_tool_conflict_choice, set_external_tool_target_decision, ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, - ExternalSourceDiagnostic, ExternalToolApprovalRequest, ExternalToolCatalogEntry, - ExternalToolConflict, PromptCommandAvailability, + ExternalSourceDiagnostic, ExternalSubagentConflict, ExternalSubagentSummary, + ExternalToolApprovalRequest, ExternalToolCatalogEntry, ExternalToolConflict, + PromptCommandAvailability, }; use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use bitfun_product_domains::external_sources::{PromptCommandConflict, SourceQualifiedCommandId}; @@ -53,6 +55,29 @@ pub struct SetExternalToolConflictChoiceRequest { pub candidate_id: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SetExternalSubagentActivationRequest { + pub workspace_path: Option, + pub candidate_id: String, + pub approved: bool, + pub expected_subagent_generation: u64, + pub expected_preference_revision: u64, + pub decision_key: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ChooseExternalSubagentConflictRequest { + pub workspace_path: Option, + pub conflict_key: String, + pub candidate_id: String, + #[serde(default)] + pub approve_external: bool, + pub expected_subagent_generation: u64, + pub expected_preference_revision: u64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceSnapshotResponse { @@ -68,6 +93,16 @@ pub struct ExternalSourceSnapshotResponse { pub tool_approval_requests: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tool_conflicts: Vec, + #[serde(default)] + pub subagent_generation: u64, + #[serde(default)] + pub preference_revision: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subagents: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subagent_conflicts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pending_subagent_approvals: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub diagnostics: Vec, } @@ -111,6 +146,11 @@ impl From for ExternalSourceSnapshotResponse { tools: snapshot.tools, tool_approval_requests: snapshot.tool_approval_requests, tool_conflicts: snapshot.tool_conflicts, + subagent_generation: snapshot.subagent_generation, + preference_revision: snapshot.preference_revision, + subagents: snapshot.subagents, + subagent_conflicts: snapshot.subagent_conflicts, + pending_subagent_approvals: snapshot.pending_subagent_approvals, diagnostics: snapshot.diagnostics, } } @@ -126,7 +166,7 @@ async fn require_local_workspace(workspace_path: Option<&str>) -> Result Result { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + set_external_subagent_activation( + workspace, + &request.candidate_id, + request.approved, + request.expected_subagent_generation, + request.expected_preference_revision, + &request.decision_key, + ) + .await + .map(Into::into) +} + +#[tauri::command] +pub async fn choose_external_subagent_conflict_command( + request: ChooseExternalSubagentConflictRequest, +) -> Result { + let workspace = require_local_workspace(request.workspace_path.as_deref()).await?; + choose_external_subagent_conflict( + workspace, + &request.conflict_key, + &request.candidate_id, + request.approve_external, + request.expected_subagent_generation, + request.expected_preference_revision, + ) + .await + .map(Into::into) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index 6edf3191ca..5d72932902 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -217,6 +217,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = RemoteWorkspacePolicy::LegacyUnaudited, ), ("check_path_exists", RemoteWorkspacePolicy::LegacyUnaudited), + ( + "choose_external_subagent_conflict_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ( "cleanup_invalid_workspaces", RemoteWorkspacePolicy::LegacyUnaudited, @@ -725,7 +729,7 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ( "list_manageable_subagents", - RemoteWorkspacePolicy::LegacyUnaudited, + RemoteWorkspacePolicy::RemoteRouted, ), ("list_mcp_prompts", RemoteWorkspacePolicy::LegacyUnaudited), ("list_mcp_resources", RemoteWorkspacePolicy::LegacyUnaudited), @@ -740,10 +744,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = ), ("list_sessions", RemoteWorkspacePolicy::LegacyUnaudited), ("list_skill_market", RemoteWorkspacePolicy::LegacyUnaudited), - ("list_subagents", RemoteWorkspacePolicy::LegacyUnaudited), + ("list_subagents", RemoteWorkspacePolicy::RemoteRouted), ( "list_visible_subagents", - RemoteWorkspacePolicy::LegacyUnaudited, + RemoteWorkspacePolicy::RemoteRouted, ), ( "load_acp_json_config", @@ -1367,6 +1371,10 @@ pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = "set_external_tool_target_decision_command", RemoteWorkspacePolicy::RemoteUnsupported, ), + ( + "set_external_subagent_activation_command", + RemoteWorkspacePolicy::RemoteUnsupported, + ), ("set_macos_edit_menu_mode", RemoteWorkspacePolicy::LocalOnly), ( "set_miniapp_draft_storage", diff --git a/src/apps/desktop/src/api/subagent_api.rs b/src/apps/desktop/src/api/subagent_api.rs index 2c61c8d4bd..f633deeb2b 100644 --- a/src/apps/desktop/src/api/subagent_api.rs +++ b/src/apps/desktop/src/api/subagent_api.rs @@ -6,6 +6,7 @@ use bitfun_core::agentic::agents::{ SubagentListScope, SubagentQueryContext, }; use bitfun_core::service::config::SubagentModelSelection; +use bitfun_core::service::remote_ssh::workspace_state::is_remote_path; use log::warn; use serde::{Deserialize, Serialize}; use std::collections::HashSet; @@ -39,19 +40,48 @@ fn workspace_root_from_request(workspace_path: Option<&str>) -> Option .map(PathBuf::from) } +async fn supports_local_external_sources(workspace_path: Option<&str>) -> bool { + match workspace_path { + Some(path) if !path.is_empty() => !is_remote_path(path).await, + _ => true, + } +} + +fn reject_external_subagent_mutation( + state: &AppState, + subagent_id: &str, + workspace: Option<&std::path::Path>, +) -> Result<(), String> { + if state + .agent_registry + .is_external_subagent_route(subagent_id, workspace) + { + return Err( + "external_subagent_read_only: manage external agents in External AI Apps".to_string(), + ); + } + Ok(()) +} + #[tauri::command] pub async fn list_subagents( state: State<'_, AppState>, request: ListSubagentsRequest, ) -> Result, String> { + let external_sources_supported = + supports_local_external_sources(request.workspace_path.as_deref()).await; let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let query_workspace = external_sources_supported + .then_some(workspace.as_deref()) + .flatten(); let list = state .agent_registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: None, - workspace_root: workspace.as_deref(), + workspace_root: query_workspace, list_scope: SubagentListScope::RegistryManagement, include_disabled: true, + external_sources_supported, }) .await; @@ -71,14 +101,20 @@ pub async fn list_visible_subagents( state: State<'_, AppState>, request: ListVisibleSubagentsRequest, ) -> Result, String> { + let external_sources_supported = + supports_local_external_sources(request.workspace_path.as_deref()).await; let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let query_workspace = external_sources_supported + .then_some(workspace.as_deref()) + .flatten(); Ok(state .agent_registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some(request.parent_agent_type.as_str()), - workspace_root: workspace.as_deref(), + workspace_root: query_workspace, list_scope: SubagentListScope::TaskVisible, include_disabled: false, + external_sources_supported, }) .await) } @@ -88,14 +124,20 @@ pub async fn list_manageable_subagents( state: State<'_, AppState>, request: ListManageableSubagentsRequest, ) -> Result, String> { + let external_sources_supported = + supports_local_external_sources(request.workspace_path.as_deref()).await; let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + let query_workspace = external_sources_supported + .then_some(workspace.as_deref()) + .flatten(); Ok(state .agent_registry .get_subagents_for_query(&SubagentQueryContext { parent_agent_type: Some(request.parent_agent_type.as_str()), - workspace_root: workspace.as_deref(), + workspace_root: query_workspace, list_scope: SubagentListScope::RegistryManagement, include_disabled: true, + external_sources_supported, }) .await) } @@ -113,6 +155,7 @@ pub async fn get_subagent_detail( request: GetSubagentDetailRequest, ) -> Result { let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + reject_external_subagent_mutation(&state, &request.subagent_id, workspace.as_deref())?; state .agent_registry .get_custom_subagent_detail(&request.subagent_id, workspace.as_deref()) @@ -124,6 +167,7 @@ pub async fn get_subagent_detail( #[serde(rename_all = "camelCase")] pub struct DeleteSubagentRequest { pub subagent_id: String, + pub workspace_path: Option, } #[tauri::command] @@ -132,6 +176,8 @@ pub async fn delete_subagent( request: DeleteSubagentRequest, ) -> Result<(), String> { let subagent_id = request.subagent_id; + let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + reject_external_subagent_mutation(&state, &subagent_id, workspace.as_deref())?; let file_path = state .agent_registry @@ -178,6 +224,7 @@ pub async fn update_subagent( return Err("Prompt cannot be empty".to_string()); } let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + reject_external_subagent_mutation(&state, &request.subagent_id, workspace.as_deref())?; state .agent_registry .update_custom_subagent_definition( @@ -406,6 +453,7 @@ pub async fn update_subagent_config( ) -> Result { let subagent_id = &request.subagent_id; let workspace = workspace_root_from_request(request.workspace_path.as_deref()); + reject_external_subagent_mutation(&state, subagent_id, workspace.as_deref())?; if let Some(workspace) = workspace.as_deref() { state.agent_registry.load_custom_subagents(workspace).await; } diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index e144064f9e..a15751550b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -933,6 +933,8 @@ pub async fn run() { set_external_source_conflict_choice_command, set_external_tool_target_decision_command, set_external_tool_conflict_choice_command, + set_external_subagent_activation_command, + choose_external_subagent_conflict_command, api::context_upload_api::upload_image_contexts, get_all_tools_info, get_readonly_tools_info, diff --git a/src/crates/adapters/opencode-adapter/AGENTS-CN.md b/src/crates/adapters/opencode-adapter/AGENTS-CN.md index d0256a0654..fa95aa580f 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS-CN.md +++ b/src/crates/adapters/opencode-adapter/AGENTS-CN.md @@ -2,8 +2,9 @@ # OpenCode Adapter -当前 crate 负责现有受管包路径使用的 P0 OpenCode 静态来源预览,以及能力专属 provider 契约的 OpenCode -实现。它保留 OpenCode 来源发现、优先级、格式、参数展开和版本化兼容语义。共享来源目录、生命周期协调、 +当前 crate 负责现有受管包路径使用的 P0 OpenCode 静态来源预览,以及 Command、standalone Tool 和 Subagent +能力专属 provider 契约的 OpenCode 实现。它保留 OpenCode 来源发现、优先级、格式、参数展开和版本化兼容语义。 +共享来源目录、生命周期协调、 文件观察实现、产品策略、界面、凭据、worker 监督和最终结果写入均由其他 owner 负责。 ## 产品来源边界 diff --git a/src/crates/adapters/opencode-adapter/AGENTS.md b/src/crates/adapters/opencode-adapter/AGENTS.md index e3e722a59c..4f4f42cc97 100644 --- a/src/crates/adapters/opencode-adapter/AGENTS.md +++ b/src/crates/adapters/opencode-adapter/AGENTS.md @@ -3,9 +3,9 @@ # OpenCode Adapter The current crate owns the static OpenCode source preview used by the existing -managed-package path and the OpenCode-specific implementations of command and -standalone-tool provider contracts. It preserves OpenCode source discovery, -precedence, formats, argument expansion, and versioned compatibility semantics. +managed-package path and the OpenCode-specific implementations of command, +standalone-tool, and subagent provider contracts. It preserves OpenCode source +discovery, precedence, formats, argument expansion, and versioned compatibility semantics. Shared source catalog, lifecycle coordination, file-watch implementation, product policy, UI, credentials, worker supervision, and final effect writes belong elsewhere. diff --git a/src/crates/adapters/opencode-adapter/Cargo.toml b/src/crates/adapters/opencode-adapter/Cargo.toml index 5d387d73cd..873d9d448b 100644 --- a/src/crates/adapters/opencode-adapter/Cargo.toml +++ b/src/crates/adapters/opencode-adapter/Cargo.toml @@ -21,6 +21,7 @@ hex = { workspace = true } regex = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +serde_yaml = { workspace = true } sha2 = { workspace = true } thiserror = { workspace = true } urlencoding = { workspace = true } diff --git a/src/crates/adapters/opencode-adapter/src/agent_source.rs b/src/crates/adapters/opencode-adapter/src/agent_source.rs new file mode 100644 index 0000000000..fde64900cc --- /dev/null +++ b/src/crates/adapters/opencode-adapter/src/agent_source.rs @@ -0,0 +1,1180 @@ +use bitfun_product_domains::external_sources::{ + EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, + ExternalWatchRoot, SourceKey, +}; +use bitfun_product_domains::external_subagents::{ + external_subagent_candidate_id, ExternalSubagentBehaviorVersion, + ExternalSubagentCompatibilityState, ExternalSubagentContributionId, + ExternalSubagentContributionRole, ExternalSubagentDefinition, ExternalSubagentDiscoveryInput, + ExternalSubagentLocalId, ExternalSubagentMode, ExternalSubagentModelRequest, + ExternalSubagentProvenanceRef, ExternalSubagentProviderIdentity, + ExternalSubagentProviderSnapshot, ExternalSubagentSourceProvider, ExternalSubagentToolRequest, + ExternalSubagentToolSelector, SecretText, +}; +use bitfun_services_core::markdown::FrontMatterMarkdown; +use serde_json::{Map, Value}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +const PROVIDER_ID: &str = "opencode.agents"; +const ECOSYSTEM_ID: &str = "opencode"; +const MAX_CONFIG_FILE_BYTES: u64 = 1024 * 1024; +const MAX_AGENT_FILE_BYTES: u64 = 256 * 1024; +const MAX_AGENT_FILES: usize = 2048; +const MAX_TOTAL_PROMPT_BYTES: usize = 8 * 1024 * 1024; + +const KNOWN_AGENT_FIELDS: &[&str] = &[ + "description", + "prompt", + "model", + "variant", + "temperature", + "top_p", + "tools", + "disable", + "mode", + "hidden", + "color", + "steps", + "maxSteps", + "permission", + "options", +]; + +const NATIVE_AGENT_IDS: &[&str] = &[ + "build", + "plan", + "general", + "explore", + "compaction", + "title", + "summary", +]; + +#[derive(Debug, Clone)] +pub struct OpenCodeSubagentProviderOptions { + pub user_config_dir: PathBuf, + pub legacy_user_config_dir: Option, + pub explicit_config_file: Option, + pub explicit_config_dir: Option, + pub project_config_enabled: bool, + /// A test/product-host override for workspaces whose project boundary is + /// already known. Normal environment discovery leaves this unset. + pub project_root_override: Option, +} + +impl OpenCodeSubagentProviderOptions { + pub fn from_environment() -> Self { + let home = dirs::home_dir(); + let user_config_dir = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| home.as_ref().map(|home| home.join(".config"))) + .unwrap_or_else(|| PathBuf::from(".config")) + .join("opencode"); + Self { + user_config_dir, + legacy_user_config_dir: home.map(|home| home.join(".opencode")), + explicit_config_file: std::env::var_os("OPENCODE_CONFIG").map(PathBuf::from), + explicit_config_dir: std::env::var_os("OPENCODE_CONFIG_DIR").map(PathBuf::from), + project_config_enabled: !environment_truthy("OPENCODE_DISABLE_PROJECT_CONFIG"), + project_root_override: None, + } + } +} + +impl Default for OpenCodeSubagentProviderOptions { + fn default() -> Self { + Self::from_environment() + } +} + +pub struct OpenCodeSubagentProvider { + options: OpenCodeSubagentProviderOptions, +} + +impl OpenCodeSubagentProvider { + pub fn new(options: OpenCodeSubagentProviderOptions) -> Self { + Self { options } + } + + fn project_root(&self, workspace_root: &Path) -> PathBuf { + self.options + .project_root_override + .clone() + .unwrap_or_else(|| find_project_root(workspace_root)) + } + + fn discover_layers( + &self, + context: &ExternalSourceContext, + ) -> Result, ExternalSourceProviderError> { + let mut layers = Vec::new(); + push_config_file( + &mut layers, + &self.options.user_config_dir.join("config.json"), + ExternalSourceScope::UserGlobal, + "OpenCode user configuration", + ); + push_config_files( + &mut layers, + &self.options.user_config_dir, + ExternalSourceScope::UserGlobal, + "OpenCode user configuration", + ); + if let Some(path) = &self.options.explicit_config_file { + push_config_file( + &mut layers, + path, + ExternalSourceScope::UserGlobal, + "OpenCode OPENCODE_CONFIG", + ); + } + if self.options.project_config_enabled { + if let Some(workspace_root) = &context.workspace_root { + let project_root = self.project_root(workspace_root); + for directory in directories_between(&project_root, workspace_root) { + push_config_files( + &mut layers, + &directory, + ExternalSourceScope::Project, + "OpenCode project configuration", + ); + } + } + } + push_agent_files( + &mut layers, + &self.options.user_config_dir, + ExternalSourceScope::UserGlobal, + "OpenCode user agents", + )?; + if self.options.project_config_enabled { + if let Some(workspace_root) = &context.workspace_root { + let project_root = self.project_root(workspace_root); + for directory in directories_between(&project_root, workspace_root) { + let opencode = directory.join(".opencode"); + push_config_files( + &mut layers, + &opencode, + ExternalSourceScope::Project, + "OpenCode project agent configuration", + ); + push_agent_files( + &mut layers, + &opencode, + ExternalSourceScope::Project, + "OpenCode project agents", + )?; + } + } + } + if let Some(legacy) = &self.options.legacy_user_config_dir { + if legacy != &self.options.user_config_dir { + push_config_files( + &mut layers, + legacy, + ExternalSourceScope::UserGlobal, + "OpenCode legacy user configuration", + ); + push_agent_files( + &mut layers, + legacy, + ExternalSourceScope::UserGlobal, + "OpenCode legacy user agents", + )?; + } + } + if let Some(directory) = &self.options.explicit_config_dir { + push_config_files( + &mut layers, + directory, + ExternalSourceScope::WorkspaceLocal, + "OpenCode OPENCODE_CONFIG_DIR", + ); + push_agent_files( + &mut layers, + directory, + ExternalSourceScope::WorkspaceLocal, + "OpenCode explicit agents", + )?; + } + Ok(deduplicate_layers_keep_last(layers)) + } +} + +impl Default for OpenCodeSubagentProvider { + fn default() -> Self { + Self::new(OpenCodeSubagentProviderOptions::default()) + } +} + +impl ExternalSubagentSourceProvider for OpenCodeSubagentProvider { + fn identity(&self) -> ExternalSubagentProviderIdentity { + ExternalSubagentProviderIdentity::new(PROVIDER_ID, ECOSYSTEM_ID, "OpenCode") + .expect("static OpenCode subagent provider identity must be valid") + } + + fn discover( + &self, + input: &ExternalSubagentDiscoveryInput, + ) -> Result { + if input + .context + .workspace_root + .as_ref() + .is_some_and(|workspace_root| !workspace_root.is_absolute()) + { + return Err(ExternalSourceProviderError::new( + "opencode.agent.workspace_invalid", + "workspace root must be absolute", + false, + )); + } + + let provider = self.identity(); + let mut sources = Vec::new(); + let mut diagnostics = Vec::new(); + let mut patches = BTreeMap::>::new(); + let mut ambient_permission_sources = Vec::new(); + let mut total_prompt_bytes = 0usize; + + for layer in self.discover_layers(&input.context)? { + let source_key = source_key(&layer); + if input.suppressed_sources.contains(&source_key) { + continue; + } + let parsed = parse_layer(&layer)?; + total_prompt_bytes = total_prompt_bytes.saturating_add(parsed.prompt_bytes); + if total_prompt_bytes > MAX_TOTAL_PROMPT_BYTES { + return Err(ExternalSourceProviderError::new( + "opencode.agent.total_prompt_bytes_limit", + "OpenCode agent prompts exceed the 8 MiB provider limit", + false, + )); + } + let mut record = ExternalSourceRecord { + key: source_key.clone(), + ecosystem_id: EcosystemId::new(ECOSYSTEM_ID) + .expect("static OpenCode ecosystem id must be valid"), + display_name: layer.display_name.clone(), + source_kind: layer.source_kind().to_string(), + scope: layer.scope, + location: layer.path.to_string_lossy().to_string(), + execution_domain_id: input.context.execution_domain_id.clone(), + health: if parsed.diagnostics.is_empty() { + ExternalSourceHealth::Available + } else { + ExternalSourceHealth::Partial + }, + content_version: parsed.content_version, + diagnostics: Vec::new(), + }; + for diagnostic in parsed.diagnostics { + let diagnostic = ExternalSourceDiagnostic { + asset_kind: ExternalSourceAssetKind::Subagent, + source: Some(source_key.clone()), + ..diagnostic + }; + record.diagnostics.push(diagnostic.clone()); + diagnostics.push(diagnostic); + } + if parsed.ambient_permission { + ambient_permission_sources.push(source_key.clone()); + } + for mut patch in parsed.patches { + patch.source = source_key.clone(); + patches + .entry(patch.logical_id.clone()) + .or_default() + .push(patch); + } + sources.push(record); + } + + let mut definitions = Vec::new(); + for (logical_id, contributions) in patches { + definitions.push(materialize_definition( + &provider, + logical_id, + contributions, + &ambient_permission_sources, + )?); + } + sources.sort_by(|left, right| left.key.cmp(&right.key)); + definitions.sort_by(|left, right| left.logical_id.cmp(&right.logical_id)); + for diagnostic in &mut diagnostics { + diagnostic.asset_kind = ExternalSourceAssetKind::Subagent; + } + for source in &mut sources { + for diagnostic in &mut source.diagnostics { + diagnostic.asset_kind = ExternalSourceAssetKind::Subagent; + } + } + let snapshot = ExternalSubagentProviderSnapshot { + provider, + sources, + definitions, + diagnostics, + }; + snapshot.validate().map_err(|error| { + ExternalSourceProviderError::new( + "opencode.agent.snapshot_invalid", + error.to_string(), + false, + ) + })?; + Ok(snapshot) + } + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { + let mut roots = BTreeMap::new(); + add_directory_watch_roots(&mut roots, &self.options.user_config_dir); + if let Some(path) = &self.options.legacy_user_config_dir { + add_directory_watch_roots(&mut roots, path); + } + if let Some(path) = &self.options.explicit_config_file { + if let Some(parent) = path.parent() { + add_nearest_existing_watch_root(&mut roots, parent); + } + } + if let Some(path) = &self.options.explicit_config_dir { + add_directory_watch_roots(&mut roots, path); + } + if self.options.project_config_enabled { + if let Some(workspace_root) = &context.workspace_root { + let project_root = self.project_root(workspace_root); + for directory in directories_between(&project_root, workspace_root) { + add_watch_root(&mut roots, directory.clone(), false); + add_directory_watch_roots(&mut roots, &directory.join(".opencode")); + } + } + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() + } +} + +#[derive(Debug, Clone)] +struct AgentLayer { + path: PathBuf, + scope: ExternalSourceScope, + display_name: String, + kind: AgentLayerKind, +} + +impl AgentLayer { + fn source_kind(&self) -> &'static str { + match self.kind { + AgentLayerKind::Config => "opencode_agent_config", + AgentLayerKind::Markdown { legacy: false, .. } => "opencode_agent_markdown", + AgentLayerKind::Markdown { legacy: true, .. } => "opencode_legacy_mode_markdown", + } + } +} + +#[derive(Debug, Clone)] +enum AgentLayerKind { + Config, + Markdown { logical_id: String, legacy: bool }, +} + +#[derive(Debug)] +struct ParsedAgentLayer { + patches: Vec, + ambient_permission: bool, + diagnostics: Vec, + content_version: String, + prompt_bytes: usize, +} + +#[derive(Debug, Clone)] +struct AgentPatch { + source: SourceKey, + logical_id: String, + fields: Map, + legacy: bool, +} + +fn parse_layer(layer: &AgentLayer) -> Result { + let metadata = fs::metadata(&layer.path).map_err(|error| { + ExternalSourceProviderError::new( + "opencode.agent.source_unreadable", + format!("Failed to inspect OpenCode agent source: {error}"), + true, + ) + })?; + let limit = match layer.kind { + AgentLayerKind::Config => MAX_CONFIG_FILE_BYTES, + AgentLayerKind::Markdown { .. } => MAX_AGENT_FILE_BYTES, + }; + if metadata.len() > limit { + return Err(ExternalSourceProviderError::new( + "opencode.agent.source_too_large", + "OpenCode agent source exceeds the compatibility size limit", + false, + )); + } + let content = fs::read_to_string(&layer.path).map_err(|error| { + ExternalSourceProviderError::new( + "opencode.agent.source_unreadable", + format!("Failed to read OpenCode agent source: {error}"), + true, + ) + })?; + let content_version = digest([layer.path.to_string_lossy().as_ref(), content.as_str()]); + match &layer.kind { + AgentLayerKind::Config => parse_config_layer(&content, content_version), + AgentLayerKind::Markdown { logical_id, legacy } => { + parse_markdown_layer(logical_id, *legacy, &content, content_version) + } + } +} + +fn parse_config_layer( + content: &str, + content_version: String, +) -> Result { + let value = serde_json::from_str::(&strip_jsonc(content)).map_err(|error| { + ExternalSourceProviderError::new( + "opencode.agent.config_invalid", + format!("Failed to parse OpenCode agent config: {error}"), + true, + ) + })?; + let Some(root) = value.as_object() else { + return Err(ExternalSourceProviderError::new( + "opencode.agent.config_invalid", + "OpenCode configuration root must be an object", + false, + )); + }; + let ambient_permission = root.contains_key("permission"); + let mut patches = Vec::new(); + let mut diagnostics = Vec::new(); + if let Some(agents) = root.get("agent") { + if let Some(agents) = agents.as_object() { + for (logical_id, value) in agents { + patches.push(AgentPatch { + source: placeholder_source_key(), + logical_id: normalize_logical_id(logical_id), + fields: value.as_object().cloned().unwrap_or_else(|| { + let mut fields = Map::new(); + fields.insert("__invalid_definition_type".to_string(), value.clone()); + fields + }), + legacy: false, + }); + } + } else { + diagnostics.push(ExternalSourceDiagnostic::error( + "opencode.agent.map_invalid", + "OpenCode 'agent' configuration must be an object", + None, + )); + } + } + Ok(ParsedAgentLayer { + prompt_bytes: patches + .iter() + .filter_map(|patch| patch.fields.get("prompt")?.as_str()) + .map(str::len) + .sum(), + patches, + ambient_permission, + diagnostics, + content_version, + }) +} + +fn parse_markdown_layer( + logical_id: &str, + legacy: bool, + content: &str, + content_version: String, +) -> Result { + let (mut fields, body) = if content.starts_with("---\n") || content.starts_with("---\r\n") { + let (metadata, body) = FrontMatterMarkdown::load_str(content).map_err(|error| { + ExternalSourceProviderError::new( + "opencode.agent.markdown_invalid", + format!("Failed to parse OpenCode agent Markdown: {error}"), + true, + ) + })?; + let value = serde_yaml::from_value::(metadata).map_err(|error| { + ExternalSourceProviderError::new( + "opencode.agent.markdown_invalid", + format!("Failed to normalize OpenCode agent front matter: {error}"), + false, + ) + })?; + let Some(fields) = value.as_object().cloned() else { + return Err(ExternalSourceProviderError::new( + "opencode.agent.markdown_invalid", + "OpenCode agent front matter must be an object", + false, + )); + }; + (fields, body) + } else { + (Map::new(), content.to_string()) + }; + fields.insert("prompt".to_string(), Value::String(body.trim().to_string())); + if legacy { + fields.insert("mode".to_string(), Value::String("primary".to_string())); + } + Ok(ParsedAgentLayer { + prompt_bytes: body.len(), + patches: vec![AgentPatch { + source: placeholder_source_key(), + logical_id: normalize_logical_id(logical_id), + fields, + legacy, + }], + ambient_permission: false, + diagnostics: Vec::new(), + content_version, + }) +} + +fn materialize_definition( + provider: &ExternalSubagentProviderIdentity, + logical_id: String, + contributions: Vec, + ambient_permission_sources: &[SourceKey], +) -> Result { + let local_id = ExternalSubagentLocalId::new(logical_id.clone()).map_err(|error| { + ExternalSourceProviderError::new("opencode.agent.id_invalid", error.to_string(), false) + })?; + let mut effective = Value::Object(Map::new()); + let mut provenance = Vec::new(); + let mut legacy = false; + for (index, contribution) in contributions.iter().enumerate() { + deep_merge(&mut effective, Value::Object(contribution.fields.clone())); + provenance.push(ExternalSubagentProvenanceRef { + contribution_id: ExternalSubagentContributionId::new( + contribution.source.clone(), + local_id.clone(), + ), + role: if index == 0 { + ExternalSubagentContributionRole::Base + } else { + ExternalSubagentContributionRole::Overlay + }, + }); + legacy |= contribution.legacy; + } + for source in ambient_permission_sources { + if !provenance + .iter() + .any(|item| &item.contribution_id.source == source) + { + provenance.push(ExternalSubagentProvenanceRef { + contribution_id: ExternalSubagentContributionId::new( + source.clone(), + local_id.clone(), + ), + role: ExternalSubagentContributionRole::Overlay, + }); + } + } + let fields = effective + .as_object() + .expect("agent merge remains an object"); + let mut invalid = Vec::new(); + let mut blocked = Vec::new(); + let mut degraded = Vec::new(); + + if fields.contains_key("__invalid_definition_type") { + invalid.push("opencode_agent_definition_type_invalid".to_string()); + } + if fields.keys().any(|field| { + field.as_str() != "__invalid_definition_type" + && !KNOWN_AGENT_FIELDS.contains(&field.as_str()) + }) { + blocked.push("opencode_unknown_agent_field".to_string()); + } + if !ambient_permission_sources.is_empty() { + blocked.push("opencode_ambient_permission_not_imported".to_string()); + } + if fields.contains_key("permission") { + blocked.push("opencode_agent_permission_not_imported".to_string()); + } + if fields + .get("options") + .is_some_and(|value| !value.as_object().is_some_and(Map::is_empty)) + { + blocked.push("opencode_agent_options_not_imported".to_string()); + } + for field in [ + "variant", + "temperature", + "top_p", + "steps", + "maxSteps", + "color", + ] { + if fields.contains_key(field) { + degraded.push(format!("opencode_agent_{field}_not_imported")); + } + } + + let prompt = match fields.get("prompt") { + Some(Value::String(value)) if !value.trim().is_empty() => value.clone(), + Some(Value::String(_)) | None => { + blocked.push("opencode_agent_prompt_not_imported".to_string()); + String::new() + } + Some(_) => { + invalid.push("opencode_agent_prompt_type_invalid".to_string()); + String::new() + } + }; + if NATIVE_AGENT_IDS + .iter() + .any(|native_id| native_id.eq_ignore_ascii_case(&logical_id)) + { + blocked.push("opencode_native_agent_overlay_not_imported".to_string()); + } + if legacy { + blocked.push("opencode_legacy_primary_mode_not_imported".to_string()); + } + let description = string_field(fields, "description", &mut invalid) + .unwrap_or_else(|| format!("OpenCode agent {logical_id}")); + let display_name = logical_id.clone(); + let mode = match string_field(fields, "mode", &mut invalid).as_deref() { + Some("subagent") => ExternalSubagentMode::Subagent, + Some("all") | None => { + degraded.push("opencode_primary_facet_not_imported".to_string()); + ExternalSubagentMode::All + } + Some("primary") => { + blocked.push("opencode_primary_agent_not_imported".to_string()); + ExternalSubagentMode::Primary + } + Some(_) => { + invalid.push("opencode_agent_mode_invalid".to_string()); + ExternalSubagentMode::Subagent + } + }; + let disabled = bool_field(fields, "disable", false, &mut invalid); + let hidden = bool_field(fields, "hidden", false, &mut invalid); + let requested_model = match fields.get("model") { + None => ExternalSubagentModelRequest::Default, + Some(Value::String(model)) if !model.trim().is_empty() => { + let model = model.trim(); + let (provider_hint, model_name) = model + .split_once('/') + .map(|(provider, model_name)| (Some(provider.to_string()), model_name.to_string())) + .unwrap_or_else(|| (None, model.to_string())); + ExternalSubagentModelRequest::Exact { + provider_hint, + model_name, + } + } + Some(_) => { + invalid.push("opencode_agent_model_type_invalid".to_string()); + ExternalSubagentModelRequest::Default + } + }; + let requested_tools = tool_request(fields, &mut invalid, &mut blocked, &mut degraded); + let compatibility = if !invalid.is_empty() { + ExternalSubagentCompatibilityState::Invalid + } else if !blocked.is_empty() { + ExternalSubagentCompatibilityState::Blocked + } else if !degraded.is_empty() { + ExternalSubagentCompatibilityState::ReadyWithDegradation + } else { + ExternalSubagentCompatibilityState::Ready + }; + let mut diagnostic_codes = invalid; + diagnostic_codes.extend(blocked); + diagnostic_codes.extend(degraded); + diagnostic_codes.sort(); + diagnostic_codes.dedup(); + + let behavior_diagnostic_codes = diagnostic_codes + .iter() + .filter(|code| code.as_str() != "opencode_agent_color_not_imported") + .cloned() + .collect::>(); + let behavior_version = ExternalSubagentBehaviorVersion::new(format!( + "sha256:{}", + digest([ + logical_id.as_str(), + prompt.as_str(), + mode_label(mode), + if disabled { "disabled" } else { "enabled" }, + if hidden { "hidden" } else { "visible" }, + &serde_json::to_string(&requested_model).expect("model request serializes"), + &serde_json::to_string(&requested_tools).expect("tool request serializes"), + &provenance + .iter() + .map(|item| item.contribution_id.stable_key()) + .collect::>() + .join("|"), + &behavior_diagnostic_codes.join("|"), + ]) + )) + .expect("hashed behavior version must be valid"); + let candidate_id = + external_subagent_candidate_id(&provider.provider_id, &logical_id, &provenance); + let definition = ExternalSubagentDefinition { + candidate_id, + logical_id, + provenance, + display_name, + description, + prompt: SecretText::new(prompt), + mode, + disabled, + hidden, + requested_model, + requested_tools, + compatibility, + diagnostic_codes, + behavior_version, + }; + definition.validate().map_err(|error| { + ExternalSourceProviderError::new( + "opencode.agent.definition_invalid", + error.to_string(), + false, + ) + })?; + Ok(definition) +} + +fn string_field( + fields: &Map, + key: &str, + invalid: &mut Vec, +) -> Option { + match fields.get(key) { + None => None, + Some(Value::String(value)) => Some(value.clone()), + Some(_) => { + invalid.push(format!("opencode_agent_{key}_type_invalid")); + None + } + } +} + +fn bool_field( + fields: &Map, + key: &str, + default: bool, + invalid: &mut Vec, +) -> bool { + match fields.get(key) { + None => default, + Some(Value::Bool(value)) => *value, + Some(_) => { + invalid.push(format!("opencode_agent_{key}_type_invalid")); + default + } + } +} + +fn tool_request( + fields: &Map, + invalid: &mut Vec, + blocked: &mut Vec, + degraded: &mut Vec, +) -> ExternalSubagentToolRequest { + let Some(value) = fields.get("tools") else { + if !fields.contains_key("permission") { + degraded.push("opencode_default_permission_semantics_not_imported".to_string()); + } + return ExternalSubagentToolRequest { + selectors: [ + ("list", "LS"), + ("read", "Read"), + ("glob", "Glob"), + ("grep", "Grep"), + ] + .into_iter() + .map(|(source_name, canonical)| ExternalSubagentToolSelector { + source_name: source_name.to_string(), + canonical_host_name: Some(canonical.to_string()), + allowed: true, + }) + .collect(), + uses_conservative_default: true, + }; + }; + let Some(entries) = value.as_object() else { + invalid.push("opencode_agent_tools_type_invalid".to_string()); + return ExternalSubagentToolRequest { + selectors: Vec::new(), + uses_conservative_default: false, + }; + }; + let mut selectors = Vec::new(); + for (name, allowed) in entries { + let Some(allowed) = allowed.as_bool() else { + invalid.push("opencode_agent_tool_selector_type_invalid".to_string()); + continue; + }; + if name + .bytes() + .any(|byte| matches!(byte, b'*' | b'?' | b'[' | b']')) + { + if allowed { + blocked.push("opencode_agent_tool_pattern_not_imported".to_string()); + } + continue; + } + let canonical = match name.to_ascii_lowercase().as_str() { + "list" => Some("LS"), + "read" => Some("Read"), + "glob" => Some("Glob"), + "grep" => Some("Grep"), + _ => None, + }; + selectors.push(ExternalSubagentToolSelector { + source_name: name.clone(), + canonical_host_name: canonical.map(str::to_string), + allowed, + }); + } + ExternalSubagentToolRequest { + selectors, + uses_conservative_default: false, + } +} + +fn deep_merge(target: &mut Value, incoming: Value) { + match (target, incoming) { + (Value::Object(target), Value::Object(incoming)) => { + for (key, value) in incoming { + match target.get_mut(&key) { + Some(existing) => deep_merge(existing, value), + None => { + target.insert(key, value); + } + } + } + } + (target, incoming) => *target = incoming, + } +} + +fn mode_label(mode: ExternalSubagentMode) -> &'static str { + match mode { + ExternalSubagentMode::Subagent => "subagent", + ExternalSubagentMode::All => "all", + ExternalSubagentMode::Primary => "primary", + } +} + +fn push_config_files( + layers: &mut Vec, + directory: &Path, + scope: ExternalSourceScope, + display_name: &str, +) { + for name in ["opencode.json", "opencode.jsonc"] { + push_config_file(layers, &directory.join(name), scope, display_name); + } +} + +fn push_config_file( + layers: &mut Vec, + path: &Path, + scope: ExternalSourceScope, + display_name: &str, +) { + if path.is_file() { + layers.push(AgentLayer { + path: path.to_path_buf(), + scope, + display_name: display_name.to_string(), + kind: AgentLayerKind::Config, + }); + } +} + +fn push_agent_files( + layers: &mut Vec, + directory: &Path, + scope: ExternalSourceScope, + display_name: &str, +) -> Result<(), ExternalSourceProviderError> { + let mut files = Vec::new(); + for (name, legacy) in [ + ("agent", false), + ("agents", false), + ("mode", true), + ("modes", true), + ] { + let root = directory.join(name); + collect_markdown_files(&root, &mut files)?; + for path in files.drain(..) { + let logical_id = markdown_logical_id(&root, &path).ok_or_else(|| { + ExternalSourceProviderError::new( + "opencode.agent.markdown_id_invalid", + "OpenCode agent Markdown path cannot form an identifier", + false, + ) + })?; + layers.push(AgentLayer { + path, + scope, + display_name: display_name.to_string(), + kind: AgentLayerKind::Markdown { logical_id, legacy }, + }); + } + } + Ok(()) +} + +fn collect_markdown_files( + directory: &Path, + files: &mut Vec, +) -> Result<(), ExternalSourceProviderError> { + let metadata = match fs::symlink_metadata(directory) { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => { + return Err(ExternalSourceProviderError::new( + "opencode.agent.directory_unreadable", + format!("Failed to inspect OpenCode agent directory: {error}"), + true, + )); + } + }; + if !metadata.is_dir() || metadata.file_type().is_symlink() { + return Ok(()); + } + let mut entries = fs::read_dir(directory) + .map_err(|error| { + ExternalSourceProviderError::new( + "opencode.agent.directory_unreadable", + format!("Failed to enumerate OpenCode agent directory: {error}"), + true, + ) + })? + .collect::, _>>() + .map_err(|error| { + ExternalSourceProviderError::new( + "opencode.agent.directory_unreadable", + format!("Failed to read OpenCode agent directory entry: {error}"), + true, + ) + })?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { + if files.len() >= MAX_AGENT_FILES { + return Err(ExternalSourceProviderError::new( + "opencode.agent.file_limit", + format!("OpenCode agent directories exceed the {MAX_AGENT_FILES} file limit"), + false, + )); + } + let path = entry.path(); + let file_type = entry.file_type().map_err(|error| { + ExternalSourceProviderError::new( + "opencode.agent.directory_unreadable", + format!("Failed to inspect OpenCode agent directory entry: {error}"), + true, + ) + })?; + if file_type.is_symlink() { + continue; + } + if file_type.is_dir() { + collect_markdown_files(&path, files)?; + } else if path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("md")) + { + files.push(path); + } + } + Ok(()) +} + +fn markdown_logical_id(root: &Path, path: &Path) -> Option { + let mut name = path + .strip_prefix(root) + .ok()? + .to_string_lossy() + .replace('\\', "/"); + if name.to_ascii_lowercase().ends_with(".md") { + name.truncate(name.len() - 3); + } + (!name.is_empty()).then(|| normalize_logical_id(&name)) +} + +fn normalize_logical_id(value: &str) -> String { + value.trim().replace('\\', "/") +} + +fn source_key(layer: &AgentLayer) -> SourceKey { + let identity_path = + dunce::canonicalize(&layer.path).unwrap_or_else(|_| normalize_path_lexically(&layer.path)); + let source_id = format!( + "{}-{}", + layer.source_kind(), + &digest([ + layer.source_kind(), + identity_path.to_string_lossy().as_ref() + ])[..24] + ); + SourceKey::new(PROVIDER_ID, source_id).expect("hashed OpenCode agent source id must be valid") +} + +fn placeholder_source_key() -> SourceKey { + SourceKey::new(PROVIDER_ID, "pending-source").expect("static placeholder source key") +} + +fn deduplicate_layers_keep_last(layers: Vec) -> Vec { + let mut seen = BTreeSet::new(); + let mut unique = layers + .into_iter() + .rev() + .filter(|layer| seen.insert(source_key(layer))) + .collect::>(); + unique.reverse(); + unique +} + +fn normalize_path_lexically(path: &Path) -> PathBuf { + use std::path::Component; + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::CurDir => {} + Component::ParentDir => { + normalized.pop(); + } + component => normalized.push(component.as_os_str()), + } + } + normalized +} + +fn strip_jsonc(input: &str) -> String { + let mut output = String::with_capacity(input.len()); + let chars = input.chars().collect::>(); + let mut index = 0; + let mut in_string = false; + let mut escaped = false; + while index < chars.len() { + let current = chars[index]; + if in_string { + output.push(current); + if escaped { + escaped = false; + } else if current == '\\' { + escaped = true; + } else if current == '"' { + in_string = false; + } + index += 1; + continue; + } + if current == '"' { + in_string = true; + output.push(current); + index += 1; + } else if current == '/' && chars.get(index + 1) == Some(&'/') { + index += 2; + while index < chars.len() && chars[index] != '\n' { + index += 1; + } + } else if current == '/' && chars.get(index + 1) == Some(&'*') { + index += 2; + while index + 1 < chars.len() && !(chars[index] == '*' && chars[index + 1] == '/') { + index += 1; + } + index = (index + 2).min(chars.len()); + } else { + output.push(current); + index += 1; + } + } + output +} + +fn digest(parts: impl IntoIterator>) -> String { + let mut hasher = Sha256::new(); + for part in parts { + let value = part.as_ref(); + hasher.update(value.len().to_le_bytes()); + hasher.update(value.as_bytes()); + } + hex::encode(hasher.finalize()) +} + +fn environment_truthy(key: &str) -> bool { + std::env::var(key) + .ok() + .is_some_and(|value| matches!(value.to_ascii_lowercase().as_str(), "true" | "1")) +} + +fn find_project_root(start: &Path) -> PathBuf { + let start = if start.is_file() { + start.parent().unwrap_or(start) + } else { + start + }; + start + .ancestors() + .find(|path| path.join(".git").exists()) + .unwrap_or(start) + .to_path_buf() +} + +fn directories_between(root: &Path, opened: &Path) -> Vec { + let opened = if opened.is_file() { + opened.parent().unwrap_or(opened) + } else { + opened + }; + let mut directories = opened + .ancestors() + .take_while(|path| path.starts_with(root)) + .map(Path::to_path_buf) + .collect::>(); + directories.reverse(); + directories +} + +fn nearest_existing_path(mut path: PathBuf) -> Option { + loop { + if path.exists() { + return Some(path); + } + if !path.pop() { + return None; + } + } +} + +fn add_watch_root(roots: &mut BTreeMap, path: PathBuf, recursive: bool) { + roots + .entry(path) + .and_modify(|existing| *existing |= recursive) + .or_insert(recursive); +} + +fn add_nearest_existing_watch_root(roots: &mut BTreeMap, path: &Path) { + if let Some(path) = nearest_existing_path(path.to_path_buf()) { + add_watch_root(roots, path, false); + } +} + +fn add_directory_watch_roots(roots: &mut BTreeMap, directory: &Path) { + if directory.exists() { + add_watch_root(roots, directory.to_path_buf(), true); + } else if let Some(parent) = directory.parent() { + add_nearest_existing_watch_root(roots, parent); + } +} diff --git a/src/crates/adapters/opencode-adapter/src/command_source.rs b/src/crates/adapters/opencode-adapter/src/command_source.rs index d5087a6439..85efeb52c1 100644 --- a/src/crates/adapters/opencode-adapter/src/command_source.rs +++ b/src/crates/adapters/opencode-adapter/src/command_source.rs @@ -1,9 +1,9 @@ use bitfun_product_domains::external_sources::{ - EcosystemId, ExpandedPromptCommand, ExternalSourceContext, ExternalSourceDiagnostic, - ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, - ExternalWatchRoot, PromptCommandAvailability, PromptCommandDefinition, - PromptCommandProviderIdentity, PromptCommandProviderSnapshot, PromptCommandSourceProvider, - SourceKey, SourceQualifiedCommandId, + EcosystemId, ExpandedPromptCommand, ExternalSourceAssetKind, ExternalSourceContext, + ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceProviderError, + ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, PromptCommandAvailability, + PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot, + PromptCommandSourceProvider, SourceKey, SourceQualifiedCommandId, }; use bitfun_services_core::markdown::FrontMatterMarkdown; use regex::Regex; @@ -275,6 +275,14 @@ impl PromptCommandSourceProvider for OpenCodeCommandProvider { }); } + for diagnostic in &mut diagnostics { + diagnostic.asset_kind = ExternalSourceAssetKind::Command; + } + for source in &mut sources { + for diagnostic in &mut source.diagnostics { + diagnostic.asset_kind = ExternalSourceAssetKind::Command; + } + } Ok(PromptCommandProviderSnapshot { provider: self.identity(), sources, @@ -715,10 +723,7 @@ fn collect_markdown_files( Err(error) => { diagnostics.push(ExternalSourceDiagnostic::error( "opencode.command.directory_unreadable", - format!( - "Failed to inspect OpenCode command directory '{}': {error}", - directory.display() - ), + format!("Failed to inspect an OpenCode command directory: {error}"), None, )); return true; @@ -726,10 +731,7 @@ fn collect_markdown_files( Ok(metadata) if !metadata.is_dir() => { diagnostics.push(ExternalSourceDiagnostic::error( "opencode.command.directory_invalid", - format!( - "OpenCode command directory path '{}' is not a directory", - directory.display() - ), + "An OpenCode command directory path is not a directory", None, )); return true; @@ -741,10 +743,7 @@ fn collect_markdown_files( Err(error) => { diagnostics.push(ExternalSourceDiagnostic::error( "opencode.command.directory_unreadable", - format!( - "Failed to resolve OpenCode command directory '{}': {error}", - directory.display() - ), + format!("Failed to resolve an OpenCode command directory: {error}"), None, )); return true; @@ -758,10 +757,7 @@ fn collect_markdown_files( Err(error) => { diagnostics.push(ExternalSourceDiagnostic::error( "opencode.command.directory_unreadable", - format!( - "Failed to read OpenCode command directory '{}': {error}", - directory.display() - ), + format!("Failed to read an OpenCode command directory: {error}"), None, )); return true; @@ -776,10 +772,7 @@ fn collect_markdown_files( failed = true; diagnostics.push(ExternalSourceDiagnostic::error( "opencode.command.directory_unreadable", - format!( - "Failed to enumerate OpenCode command directory '{}': {error}", - directory.display() - ), + format!("Failed to enumerate an OpenCode command directory: {error}"), None, )); } diff --git a/src/crates/adapters/opencode-adapter/src/lib.rs b/src/crates/adapters/opencode-adapter/src/lib.rs index a39ab43a54..d3f79e36fa 100644 --- a/src/crates/adapters/opencode-adapter/src/lib.rs +++ b/src/crates/adapters/opencode-adapter/src/lib.rs @@ -5,10 +5,12 @@ //! Host adapter plus typed dispatch targets. The adapter does not execute //! JavaScript, install npm packages, or depend on a user-local `opencode` CLI. +mod agent_source; mod command_source; mod source_adapter; mod tool_source; +pub use agent_source::{OpenCodeSubagentProvider, OpenCodeSubagentProviderOptions}; pub use command_source::{OpenCodeCommandProvider, OpenCodeCommandProviderOptions}; pub use source_adapter::load_opencode_package_adapter; pub use tool_source::{OpenCodeToolProvider, OpenCodeToolProviderOptions}; diff --git a/src/crates/adapters/opencode-adapter/src/tool_source.rs b/src/crates/adapters/opencode-adapter/src/tool_source.rs index 1af66d6199..48cba82389 100644 --- a/src/crates/adapters/opencode-adapter/src/tool_source.rs +++ b/src/crates/adapters/opencode-adapter/src/tool_source.rs @@ -1,10 +1,10 @@ use bitfun_product_domains::external_sources::{ - EcosystemId, ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, - ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalToolCapability, - ExternalToolDefinition, ExternalToolProviderIdentity, ExternalToolProviderSnapshot, - ExternalToolRuntimeKind, ExternalToolSourceProvider, ExternalToolStaticStatus, - ExternalWatchRoot, PreparedExternalToolExport, PreparedExternalToolTarget, SourceKey, - SourceQualifiedToolId, SourceQualifiedToolTargetId, + EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceDiagnostic, + ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, + ExternalToolCapability, ExternalToolDefinition, ExternalToolProviderIdentity, + ExternalToolProviderSnapshot, ExternalToolRuntimeKind, ExternalToolSourceProvider, + ExternalToolStaticStatus, ExternalWatchRoot, PreparedExternalToolExport, + PreparedExternalToolTarget, SourceKey, SourceQualifiedToolId, SourceQualifiedToolTargetId, }; use regex::Regex; use sha2::{Digest, Sha256}; @@ -202,10 +202,7 @@ impl ExternalToolSourceProvider for OpenCodeToolProvider { Err(error) => { diagnostics.push(ExternalSourceDiagnostic::warning( "opencode.tool.directory_metadata_failed", - format!( - "Failed to inspect tool directory '{}': {error}", - directory.path.display() - ), + format!("Failed to inspect {}: {error}", directory.display_name), Some(source_key), )); continue; @@ -218,10 +215,7 @@ impl ExternalToolSourceProvider for OpenCodeToolProvider { Err(error) => { diagnostics.push(ExternalSourceDiagnostic::warning( "opencode.tool.directory_read_failed", - format!( - "Failed to read tool directory '{}': {error}", - directory.path.display() - ), + format!("Failed to read {}: {error}", directory.display_name), Some(source_key), )); continue; @@ -235,8 +229,8 @@ impl ExternalToolSourceProvider for OpenCodeToolProvider { diagnostics.push(ExternalSourceDiagnostic::warning( "opencode.tool.directory_entry_failed", format!( - "Failed to read an entry in tool directory '{}': {error}", - directory.path.display() + "Failed to read an entry in {}: {error}", + directory.display_name ), Some(source_key.clone()), )); @@ -254,7 +248,10 @@ impl ExternalToolSourceProvider for OpenCodeToolProvider { source_has_restrictions = true; diagnostics.push(ExternalSourceDiagnostic::warning( "opencode.tool.file_metadata_failed", - format!("Failed to inspect tool file '{}': {error}", path.display()), + format!( + "Failed to inspect tool file '{}': {error}", + tool_file_label(&path) + ), Some(source_key.clone()), )); continue; @@ -270,8 +267,8 @@ impl ExternalToolSourceProvider for OpenCodeToolProvider { diagnostics.push(ExternalSourceDiagnostic::warning( "opencode.tool.file_limit", format!( - "Tool directory '{}' contains more than {MAX_TOOL_FILES} supported files; additional files were ignored", - directory.path.display() + "{} contains more than {MAX_TOOL_FILES} supported files; additional files were ignored", + directory.display_name ), Some(source_key.clone()), )); @@ -287,7 +284,7 @@ impl ExternalToolSourceProvider for OpenCodeToolProvider { "opencode.tool.file_too_large", format!( "Tool file '{}' exceeds the supported size limit", - path.display() + tool_file_label(&path) ), Some(source_key.clone()), )); @@ -297,7 +294,7 @@ impl ExternalToolSourceProvider for OpenCodeToolProvider { source_has_restrictions = true; diagnostics.push(ExternalSourceDiagnostic::warning( "opencode.tool.file_read_failed", - format!("Failed to read '{}': {error}", path.display()), + format!("Failed to read '{}': {error}", tool_file_label(&path)), Some(source_key.clone()), )); continue; @@ -310,7 +307,10 @@ impl ExternalToolSourceProvider for OpenCodeToolProvider { source_has_restrictions = true; diagnostics.push(ExternalSourceDiagnostic::warning( "opencode.tool.export_missing", - format!("Tool file '{}' has no supported exports", path.display()), + format!( + "Tool file '{}' has no supported exports", + tool_file_label(&path) + ), Some(source_key.clone()), )); continue; @@ -342,7 +342,7 @@ impl ExternalToolSourceProvider for OpenCodeToolProvider { format!( "Tool export '{}' in '{}' does not map to a portable tool name", export_name, - path.display() + tool_file_label(&path) ), Some(source_key.clone()), )); @@ -391,6 +391,14 @@ impl ExternalToolSourceProvider for OpenCodeToolProvider { } } tools.sort_by(|left, right| left.name.cmp(&right.name).then(left.id.cmp(&right.id))); + for diagnostic in &mut diagnostics { + diagnostic.asset_kind = ExternalSourceAssetKind::Tool; + } + for source in &mut sources { + for diagnostic in &mut source.diagnostics { + diagnostic.asset_kind = ExternalSourceAssetKind::Tool; + } + } let snapshot = ExternalToolProviderSnapshot { provider, sources, @@ -658,6 +666,13 @@ fn normalize_path(path: &Path) -> PathBuf { }) } +fn tool_file_label(path: &Path) -> String { + path.file_name() + .and_then(|name| name.to_str()) + .unwrap_or("tool file") + .to_string() +} + fn runtime_kind(path: &Path) -> ExternalToolRuntimeKind { match path.extension().and_then(|value| value.to_str()) { Some("ts") => ExternalToolRuntimeKind::TypeScript, @@ -700,8 +715,8 @@ fn file_url(path: &Path) -> Result { ExternalSourceProviderError::new( "opencode.tool.module_url_invalid", format!( - "Could not convert '{}' to an absolute file URL", - normalized.display() + "Could not convert tool file '{}' to an absolute file URL", + tool_file_label(&normalized) ), false, ) @@ -711,7 +726,7 @@ fn file_url(path: &Path) -> Result { fn io_error(code: &str, path: &Path, error: std::io::Error) -> ExternalSourceProviderError { ExternalSourceProviderError::new( code, - format!("Failed to read '{}': {error}", path.display()), + format!("Failed to read '{}': {error}", tool_file_label(path)), true, ) } @@ -891,5 +906,9 @@ mod tests { .find(|diagnostic| diagnostic.code == "opencode.tool.directory_read_failed") .expect("failed directory must produce a diagnostic"); assert_eq!(diagnostic.source.as_ref(), Some(&failed_source)); + assert!(diagnostic.message.contains("OpenCode user tools")); + assert!(!diagnostic + .message + .contains(temp.path().to_string_lossy().as_ref())); } } diff --git a/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs new file mode 100644 index 0000000000..8bebd22241 --- /dev/null +++ b/src/crates/adapters/opencode-adapter/tests/opencode_subagent_adapter.rs @@ -0,0 +1,283 @@ +use bitfun_opencode_adapter::{OpenCodeSubagentProvider, OpenCodeSubagentProviderOptions}; +use bitfun_product_domains::external_sources::{ + ExecutionDomainId, ExternalSourceContext, ExternalSourceScope, +}; +use bitfun_product_domains::external_subagents::{ + ExternalSubagentCompatibilityState, ExternalSubagentDiscoveryInput, ExternalSubagentMode, + ExternalSubagentModelRequest, ExternalSubagentSourceProvider, +}; +use std::collections::BTreeSet; +use std::fs; +use std::path::PathBuf; +use tempfile::TempDir; + +fn context(workspace: PathBuf) -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(workspace), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +fn provider(temp: &TempDir, workspace: &std::path::Path) -> OpenCodeSubagentProvider { + OpenCodeSubagentProvider::new(OpenCodeSubagentProviderOptions { + user_config_dir: temp.path().join("user"), + legacy_user_config_dir: Some(temp.path().join("legacy")), + explicit_config_file: None, + explicit_config_dir: None, + project_config_enabled: true, + project_root_override: Some(workspace.to_path_buf()), + }) +} + +fn discover( + provider: &OpenCodeSubagentProvider, + workspace: PathBuf, + suppressed_sources: BTreeSet, +) -> bitfun_product_domains::external_subagents::ExternalSubagentProviderSnapshot { + provider + .discover(&ExternalSubagentDiscoveryInput { + context: context(workspace), + suppressed_sources, + }) + .expect("discover OpenCode agents") +} + +#[test] +fn global_and_project_agent_fields_deep_merge_with_ordered_provenance() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agent": { + "review": { + "description": "Global review", + "prompt": "Review using the global policy", + "mode": "subagent", + "model": "openrouter/anthropic/claude-sonnet-4", + "tools": { "read": true, "grep": false } + } + } + }"#, + ) + .unwrap(); + fs::write( + workspace.join("opencode.jsonc"), + r#"{ + // Project copy must not alter execution behavior. + "agent": { "review": { "description": "Project review", "color": "blue" } } + }"#, + ) + .unwrap(); + + let provider = provider(&temp, &workspace); + let first = discover(&provider, workspace.clone(), BTreeSet::new()); + let definition = &first.definitions[0]; + assert_eq!(definition.logical_id, "review"); + assert_eq!(definition.description, "Project review"); + assert_eq!(definition.prompt.expose(), "Review using the global policy"); + assert_eq!(definition.provenance.len(), 2); + assert_eq!(definition.mode, ExternalSubagentMode::Subagent); + assert_eq!( + definition.requested_model, + ExternalSubagentModelRequest::Exact { + provider_hint: Some("openrouter".to_string()), + model_name: "anthropic/claude-sonnet-4".to_string(), + } + ); + assert_eq!(definition.requested_tools.selectors.len(), 2); + assert_eq!( + definition.compatibility, + ExternalSubagentCompatibilityState::ReadyWithDegradation + ); + let behavior = definition.behavior_version.clone(); + + fs::write( + workspace.join("opencode.jsonc"), + r#"{ "agent": { "review": { "description": "Project review updated", "color": "red" } } }"#, + ) + .unwrap(); + let updated = discover(&provider, workspace.clone(), BTreeSet::new()); + assert_eq!(updated.definitions[0].behavior_version, behavior); + assert_eq!(updated.definitions[0].description, "Project review updated"); + fs::write( + workspace.join("opencode.jsonc"), + r#"{ "agent": { "review": { "description": "Project review updated" } } }"#, + ) + .unwrap(); + let color_removed = discover(&provider, workspace.clone(), BTreeSet::new()); + assert_eq!(color_removed.definitions[0].behavior_version, behavior); + + let project_source = updated + .sources + .iter() + .find(|source| source.scope == ExternalSourceScope::Project) + .unwrap() + .key + .clone(); + let without_project = discover(&provider, workspace, [project_source].into_iter().collect()); + assert_eq!(without_project.definitions[0].description, "Global review"); + assert_eq!(without_project.definitions[0].provenance.len(), 1); +} + +#[test] +fn safe_subset_is_fail_closed_and_default_tools_are_explicit() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "permission": { "bash": "deny" }, + "agent": { + "defaulted": { "prompt": "Use safe defaults", "mode": "subagent" }, + "unsafe": { + "prompt": "do-not-leak-this-prompt", + "mode": "subagent", + "permission": { "bash": "allow" }, + "options": { "providerSecret": "do-not-leak-value" }, + "futureField": "do-not-leak-unknown" + }, + "wrongType": { "prompt": 42, "mode": "subagent" }, + "primaryOnly": { "prompt": "Primary", "mode": "primary" }, + "sampling": { "prompt": "Sampling", "temperature": 0.2, "color": "blue" } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let find = |id: &str| { + snapshot + .definitions + .iter() + .find(|item| item.logical_id == id) + .unwrap() + }; + let defaulted = find("defaulted"); + assert_eq!( + defaulted + .requested_tools + .selectors + .iter() + .map(|item| item.canonical_host_name.as_deref().unwrap()) + .collect::>(), + vec!["LS", "Read", "Glob", "Grep"] + ); + assert!(defaulted.requested_tools.uses_conservative_default); + assert_eq!( + defaulted.compatibility, + ExternalSubagentCompatibilityState::Blocked, + "ambient permission blocks every agent from this source" + ); + assert_eq!( + find("unsafe").compatibility, + ExternalSubagentCompatibilityState::Blocked + ); + assert_eq!( + find("wrongType").compatibility, + ExternalSubagentCompatibilityState::Invalid + ); + assert_eq!( + find("primaryOnly").compatibility, + ExternalSubagentCompatibilityState::Blocked + ); + assert_eq!( + find("sampling").compatibility, + ExternalSubagentCompatibilityState::Blocked + ); + let debug = format!("{snapshot:?}"); + assert!(!debug.contains("do-not-leak-this-prompt")); + assert!(!debug.contains("do-not-leak-value")); + assert!(!debug.contains("do-not-leak-unknown")); +} + +#[test] +fn markdown_agent_directories_are_supported_and_legacy_modes_are_visible_but_blocked() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user/agents/review")).unwrap(); + fs::create_dir_all(temp.path().join("user/mode")).unwrap(); + fs::write( + temp.path().join("user/agents/review/security.md"), + "---\ndescription: Security review\nmode: subagent\ntools:\n read: true\n---\nReview security boundaries.", + ) + .unwrap(); + fs::write( + temp.path().join("user/mode/legacy.md"), + "---\ndescription: Legacy primary mode\n---\nAct as a primary agent.", + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + let markdown = snapshot + .definitions + .iter() + .find(|item| item.logical_id == "review/security") + .unwrap(); + assert_eq!(markdown.description, "Security review"); + assert_eq!(markdown.prompt.expose(), "Review security boundaries."); + assert_eq!( + markdown.compatibility, + ExternalSubagentCompatibilityState::Ready + ); + + let legacy = snapshot + .definitions + .iter() + .find(|item| item.logical_id == "legacy") + .unwrap(); + assert_eq!( + legacy.compatibility, + ExternalSubagentCompatibilityState::Blocked + ); + assert!(legacy + .diagnostic_codes + .contains(&"opencode_legacy_primary_mode_not_imported".to_string())); +} + +#[test] +fn missing_prompt_and_native_overlays_are_blocked_not_invalid() { + let temp = TempDir::new().unwrap(); + let workspace = temp.path().join("workspace"); + fs::create_dir_all(workspace.join(".git")).unwrap(); + fs::create_dir_all(temp.path().join("user")).unwrap(); + fs::write( + temp.path().join("user/opencode.json"), + r#"{ + "agent": { + "missing": { "description": "Relies on OpenCode defaults" }, + "defaulted": { "prompt": "Use conservative defaults", "mode": "subagent" }, + "Build": { "prompt": "Overlay native build", "mode": "subagent" } + } + }"#, + ) + .unwrap(); + + let snapshot = discover(&provider(&temp, &workspace), workspace, BTreeSet::new()); + for id in ["missing", "Build"] { + assert_eq!( + snapshot + .definitions + .iter() + .find(|item| item.logical_id == id) + .unwrap() + .compatibility, + ExternalSubagentCompatibilityState::Blocked + ); + } + let defaulted = snapshot + .definitions + .iter() + .find(|item| item.logical_id == "defaulted") + .unwrap(); + assert_eq!( + defaulted.compatibility, + ExternalSubagentCompatibilityState::ReadyWithDegradation + ); + assert_eq!(defaulted.requested_tools.selectors.len(), 4); +} diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/external.rs b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs new file mode 100644 index 0000000000..9ea1a78779 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/definitions/external.rs @@ -0,0 +1,90 @@ +use crate::agentic::agents::{ + default_custom_agent_user_context_policy, Agent, CustomAgentKind, PromptBuilder, + PromptBuilderContext, UserContextPolicy, +}; +use crate::agentic::session::SystemPromptCacheIdentity; +use crate::util::errors::BitFunResult; +use async_trait::async_trait; + +/// Immutable, generation-keyed projection of an approved external definition. +/// Prompt text remains backend-only and the type deliberately implements no +/// serialization or content-bearing debug representation. +pub(crate) struct ExternalProvidedSubagent { + runtime_key: String, + name: String, + description: String, + prompt: String, + tools: Vec, + readonly: bool, + behavior_version: String, +} + +impl ExternalProvidedSubagent { + pub(crate) fn new( + runtime_key: String, + name: String, + description: String, + prompt: String, + tools: Vec, + readonly: bool, + behavior_version: String, + ) -> Self { + Self { + runtime_key, + name, + description, + prompt, + tools, + readonly, + behavior_version, + } + } +} + +#[async_trait] +impl Agent for ExternalProvidedSubagent { + fn as_any(&self) -> &dyn std::any::Any { + self + } + + fn id(&self) -> &str { + &self.runtime_key + } + + fn name(&self) -> &str { + &self.name + } + + fn description(&self) -> &str { + &self.description + } + + fn prompt_template_name(&self, _model_name: Option<&str>) -> &str { + "" + } + + fn system_prompt_cache_identity(&self, _model_name: Option<&str>) -> SystemPromptCacheIdentity { + SystemPromptCacheIdentity::new(format!( + "external_subagent_behavior:{}", + self.behavior_version + )) + } + + async fn build_prompt(&self, context: &PromptBuilderContext) -> BitFunResult { + PromptBuilder::new(context.clone()) + .build_prompt_from_template(&self.prompt) + .await + } + + fn default_tools(&self) -> Vec { + self.tools.clone() + } + + fn user_context_policy(&self) -> UserContextPolicy { + default_custom_agent_user_context_policy(CustomAgentKind::Subagent) + } + + fn is_readonly(&self) -> bool { + self.readonly + } +} diff --git a/src/crates/assembly/core/src/agentic/agents/definitions/mod.rs b/src/crates/assembly/core/src/agentic/agents/definitions/mod.rs index 81c0d74b20..470972aa08 100644 --- a/src/crates/assembly/core/src/agentic/agents/definitions/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/definitions/mod.rs @@ -1,4 +1,5 @@ pub(super) mod custom; +pub(super) mod external; pub(super) mod hidden; pub(super) mod modes; pub(super) mod review; diff --git a/src/crates/assembly/core/src/agentic/agents/mod.rs b/src/crates/assembly/core/src/agentic/agents/mod.rs index 7cb0ef2495..ca52676f59 100644 --- a/src/crates/assembly/core/src/agentic/agents/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/mod.rs @@ -22,6 +22,7 @@ pub use bitfun_agent_runtime::custom_agent::{ default_custom_agent_user_context_policy, CustomAgentKind, CustomAgentLevel, }; pub use definitions::custom::{CustomMode, CustomSubagent, CustomSubagentKind}; +pub(crate) use definitions::external::ExternalProvidedSubagent; pub use definitions::hidden::{CodeReviewAgent, DeepReviewAgent, GenerateDocAgent}; pub use definitions::modes::{ AgenticMode, ClawMode, CoworkMode, DebugMode, DeepResearchMode, MultitaskMode, PlanMode, @@ -43,6 +44,7 @@ pub use prompt_builder::{ UserContextPolicy, UserContextSection, }; pub use registry::catalog::{builtin_agent_specs, BuiltinAgentSpec}; +pub(crate) use registry::external_subagent_runtime_key; pub use registry::types::{ subagent_source_from_custom_kind, AgentCategory, AgentInfo, AgentSource, AgentToolPolicy, CustomSubagentConfig, SubAgentSource, SubagentListScope, SubagentQueryContext, @@ -51,7 +53,11 @@ pub use registry::types::{ pub use registry::visibility::{ BuiltinSubagentExposure, SubagentVisibilityPolicy, SubagentVisibilitySummary, }; -pub use registry::{get_agent_registry, AgentRegistry, CustomAgentDetail, CustomSubagentDetail}; +pub use registry::{ + get_agent_registry, AgentRegistry, CustomAgentDetail, CustomSubagentDetail, + ExternalSubagentGenerationLease, ExternalSubagentInvocationBinding, + ExternalSubagentModelBinding, ExternalSubagentRegistration, ExternalSubagentRoute, +}; use std::any::Any; // Include embedded prompts generated at compile time diff --git a/src/crates/assembly/core/src/agentic/agents/registry/availability.rs b/src/crates/assembly/core/src/agentic/agents/registry/availability.rs index bff4b8dec8..8881d2f5e2 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/availability.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/availability.rs @@ -75,6 +75,7 @@ fn resolve_override_layers( ) .map(to_runtime_override_state), }, + Some(SubAgentSource::External) => ResolvedOverrideLayers::default(), None => ResolvedOverrideLayers::default(), } } @@ -153,6 +154,15 @@ mod tests { "user.md".to_string(), CustomSubagentKind::User, )), + SubAgentSource::External => Arc::new(CustomSubagent::new( + id.to_string(), + "External subagent".to_string(), + vec!["Read".to_string()], + "prompt".to_string(), + true, + "external.md".to_string(), + CustomSubagentKind::User, + )), }; AgentEntry { @@ -161,6 +171,7 @@ mod tests { SubAgentSource::Builtin => AgentSource::Builtin, SubAgentSource::Project => AgentSource::Project, SubAgentSource::User => AgentSource::User, + SubAgentSource::External => AgentSource::External, }, subagent_source: Some(source), agent, diff --git a/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs b/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs index 21b373342a..48b9bb0d99 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/builtin.rs @@ -63,6 +63,9 @@ impl AgentRegistry { agents: std::sync::RwLock::new(Self::build_builtin_agents()), project_subagents: std::sync::RwLock::new(HashMap::new()), user_custom_agents_loaded: std::sync::RwLock::new(false), + external_subagents: std::sync::Arc::new( + super::external::ExternalSubagentRegistryState::new(), + ), } } diff --git a/src/crates/assembly/core/src/agentic/agents/registry/custom.rs b/src/crates/assembly/core/src/agentic/agents/registry/custom.rs index a30d05cb5f..f0b5e62094 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/custom.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/custom.rs @@ -127,6 +127,9 @@ impl AgentRegistry { AgentSource::Project => { project_entries.entry(id).or_insert(entry); } + AgentSource::External => { + debug_assert!(false, "file-backed discovery cannot create external agents"); + } } } @@ -853,6 +856,10 @@ impl AgentRegistry { .await?; Ok(()) } + Some(SubAgentSource::External) => Err(BitFunError::agent(format!( + "External subagent '{}' is read-only; manage it in External AI Apps", + agent_id + ))), None => Err(BitFunError::agent(format!( "Agent '{}' has no subagent source", agent_id diff --git a/src/crates/assembly/core/src/agentic/agents/registry/external.rs b/src/crates/assembly/core/src/agentic/agents/registry/external.rs new file mode 100644 index 0000000000..2f1f39ff45 --- /dev/null +++ b/src/crates/assembly/core/src/agentic/agents/registry/external.rs @@ -0,0 +1,407 @@ +use super::types::{AgentCategory, AgentEntry, AgentInfo, AgentSource, SubAgentSource}; +use super::AgentRegistry; +use crate::agentic::agents::{Agent, SubagentVisibilityPolicy}; +use bitfun_agent_runtime::prompt_cache::prompt_cache_scope_key; +use bitfun_core_types::{SessionContinuationPolicy, SessionModelBindingPolicy}; +use std::collections::{BTreeMap, HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, RwLock, Weak}; + +pub(crate) const EXTERNAL_SUBAGENT_RUNTIME_KEY_PREFIX: &str = "external_subagent_runtime:"; + +pub(crate) fn external_subagent_runtime_key(digest: &str) -> String { + format!("{EXTERNAL_SUBAGENT_RUNTIME_KEY_PREFIX}{digest}") +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ExternalSubagentModelBinding { + pub model_id: String, + pub configuration_fingerprint: String, +} + +#[derive(Clone)] +pub struct ExternalSubagentRegistration { + pub runtime_key: String, + pub logical_id: String, + pub provider_label: String, + pub model_binding: ExternalSubagentModelBinding, + pub hidden: bool, + pub agent: Arc, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ExternalSubagentRoute { + Local, + External(String), + Unavailable, +} + +#[derive(Clone)] +struct ExternalSubagentGenerationEntry { + registration: ExternalSubagentRegistration, + agent_entry: AgentEntry, + lease_count: usize, +} + +pub(super) struct ExternalSubagentRegistryState { + generations: RwLock>, + workspace_routes: RwLock>>, +} + +impl ExternalSubagentRegistryState { + pub(super) fn new() -> Self { + Self { + generations: RwLock::new(HashMap::new()), + workspace_routes: RwLock::new(HashMap::new()), + } + } + + fn read_generations( + &self, + ) -> std::sync::RwLockReadGuard<'_, HashMap> { + self.generations + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write_generations( + &self, + ) -> std::sync::RwLockWriteGuard<'_, HashMap> { + self.generations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn read_routes( + &self, + ) -> std::sync::RwLockReadGuard<'_, HashMap>> + { + self.workspace_routes + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn write_routes( + &self, + ) -> std::sync::RwLockWriteGuard<'_, HashMap>> + { + self.workspace_routes + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + pub(super) fn find_generation_entry(&self, runtime_key: &str) -> Option { + self.read_generations() + .get(runtime_key) + .map(|entry| entry.agent_entry.clone()) + } + + pub(super) fn has_generation(&self, runtime_key: &str) -> bool { + self.read_generations().contains_key(runtime_key) + } + + fn prune_unrouted_generations(&self) { + let routed = self + .read_routes() + .values() + .flat_map(BTreeMap::values) + .filter_map(|route| match route { + ExternalSubagentRoute::External(runtime_key) => Some(runtime_key.clone()), + ExternalSubagentRoute::Local | ExternalSubagentRoute::Unavailable => None, + }) + .collect::>(); + self.write_generations() + .retain(|runtime_key, entry| entry.lease_count > 0 || routed.contains(runtime_key)); + } + + fn acquire(self: &Arc, runtime_key: &str) -> Option { + let mut generations = self.write_generations(); + let entry = generations.get_mut(runtime_key)?; + entry.lease_count = entry.lease_count.saturating_add(1); + Some(ExternalSubagentInvocationBinding { + runtime_agent_key: runtime_key.to_string(), + logical_id: entry.registration.logical_id.clone(), + supports_follow_up: false, + continuation_policy: SessionContinuationPolicy::FreshOnly, + model_binding_policy: SessionModelBindingPolicy::ApprovedImmutable, + lease: Some(ExternalSubagentGenerationLease { + state: Arc::downgrade(self), + runtime_key: runtime_key.to_string(), + model_binding: entry.registration.model_binding.clone(), + }), + }) + } + + fn release(&self, runtime_key: &str) { + if let Some(entry) = self.write_generations().get_mut(runtime_key) { + entry.lease_count = entry.lease_count.saturating_sub(1); + } + self.prune_unrouted_generations(); + } +} + +pub struct ExternalSubagentGenerationLease { + state: Weak, + runtime_key: String, + model_binding: ExternalSubagentModelBinding, +} + +impl std::fmt::Debug for ExternalSubagentGenerationLease { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ExternalSubagentGenerationLease") + .field("runtime_key", &self.runtime_key) + .field("model_binding", &self.model_binding) + .finish_non_exhaustive() + } +} + +impl Clone for ExternalSubagentGenerationLease { + fn clone(&self) -> Self { + if let Some(state) = self.state.upgrade() { + if let Some(entry) = state.write_generations().get_mut(&self.runtime_key) { + entry.lease_count = entry.lease_count.saturating_add(1); + } + } + Self { + state: self.state.clone(), + runtime_key: self.runtime_key.clone(), + model_binding: self.model_binding.clone(), + } + } +} + +impl ExternalSubagentGenerationLease { + pub fn model_binding(&self) -> &ExternalSubagentModelBinding { + &self.model_binding + } +} + +impl Drop for ExternalSubagentGenerationLease { + fn drop(&mut self) { + if let Some(state) = self.state.upgrade() { + state.release(&self.runtime_key); + } + } +} + +pub struct ExternalSubagentInvocationBinding { + pub runtime_agent_key: String, + pub logical_id: String, + pub supports_follow_up: bool, + pub continuation_policy: SessionContinuationPolicy, + pub model_binding_policy: SessionModelBindingPolicy, + pub lease: Option, +} + +impl AgentRegistry { + /// Returns whether the logical id is owned by an external route in the + /// requested workspace. `Unavailable` remains externally owned so a + /// withdrawn candidate cannot expose a same-name local mutation path. + pub fn is_external_subagent_route( + &self, + logical_id: &str, + workspace_root: Option<&Path>, + ) -> bool { + let routes = self.external_subagents.read_routes(); + let logical_key = normalize_external_logical_id(logical_id); + let is_external = |route: &ExternalSubagentRoute| { + matches!( + route, + ExternalSubagentRoute::External(_) | ExternalSubagentRoute::Unavailable + ) + }; + workspace_root.is_some_and(|workspace| { + routes + .get(workspace) + .and_then(|workspace_routes| workspace_routes.get(&logical_key)) + .is_some_and(is_external) + }) + } + + pub fn install_external_subagent_routes( + &self, + workspace_root: &Path, + registrations: Vec, + routes: BTreeMap, + ) { + { + let mut generations = self.external_subagents.write_generations(); + for registration in registrations { + let runtime_key = registration.runtime_key.clone(); + let lease_count = generations + .get(&runtime_key) + .map_or(0, |entry| entry.lease_count); + let agent_entry = AgentEntry { + category: AgentCategory::SubAgent, + source: AgentSource::External, + subagent_source: Some(SubAgentSource::External), + agent: registration.agent.clone(), + visibility_policy: SubagentVisibilityPolicy::public(), + custom_config: None, + }; + generations.insert( + runtime_key, + ExternalSubagentGenerationEntry { + registration, + agent_entry, + lease_count, + }, + ); + } + } + let mut routes = routes + .into_iter() + .map(|(logical_id, route)| (normalize_external_logical_id(&logical_id), route)) + .collect::>(); + let previous = self + .external_subagents + .read_routes() + .get(workspace_root) + .cloned() + .unwrap_or_default(); + // An active external implementation disappearing must never expose a + // same-name local implementation implicitly. Keep a fail-closed route + // until the external candidate returns or product reconciliation + // records an explicit Local choice. + for (logical_id, previous_route) in previous { + if !routes.contains_key(&logical_id) + && matches!( + previous_route, + ExternalSubagentRoute::External(_) | ExternalSubagentRoute::Unavailable + ) + { + routes.insert(logical_id, ExternalSubagentRoute::Unavailable); + } + } + self.external_subagents + .write_routes() + .insert(workspace_root.to_path_buf(), routes); + self.external_subagents.prune_unrouted_generations(); + } + + pub fn release_external_subagent_workspace(&self, workspace_root: &Path) { + self.external_subagents + .write_routes() + .remove(workspace_root); + self.external_subagents.prune_unrouted_generations(); + } + + pub fn resolve_subagent_for_fresh_invocation( + &self, + logical_id: &str, + workspace_root: Option<&Path>, + external_sources_supported: bool, + ) -> Option { + let logical_key = normalize_external_logical_id(logical_id); + if external_sources_supported { + if let Some(workspace_root) = workspace_root { + if let Some(route) = self + .external_subagents + .read_routes() + .get(workspace_root) + .and_then(|routes| routes.get(&logical_key)) + .cloned() + { + return match route { + ExternalSubagentRoute::Local => self + .find_agent_entry(logical_id, Some(workspace_root)) + .map(|_| local_binding(logical_id)), + ExternalSubagentRoute::External(runtime_key) => { + self.external_subagents.acquire(&runtime_key) + } + ExternalSubagentRoute::Unavailable => None, + }; + } + } + } + self.find_agent_entry(logical_id, workspace_root) + .map(|_| local_binding(logical_id)) + } + + pub(super) fn apply_external_routes_to_query( + &self, + workspace_root: &Path, + mut local: Vec, + ) -> Vec { + let routes = self + .external_subagents + .read_routes() + .get(workspace_root) + .cloned() + .unwrap_or_default(); + let generations = self.external_subagents.read_generations(); + for (logical_id, route) in routes { + match route { + ExternalSubagentRoute::Local => {} + ExternalSubagentRoute::Unavailable => { + local.retain(|agent| normalize_external_logical_id(&agent.id) != logical_id); + } + ExternalSubagentRoute::External(runtime_key) => { + local.retain(|agent| normalize_external_logical_id(&agent.id) != logical_id); + let Some(entry) = generations.get(&runtime_key) else { + continue; + }; + if entry.registration.hidden { + continue; + } + local.push(external_agent_info(entry)); + } + } + } + local + } +} + +fn normalize_external_logical_id(logical_id: &str) -> String { + logical_id.to_ascii_lowercase() +} + +fn local_binding(logical_id: &str) -> ExternalSubagentInvocationBinding { + ExternalSubagentInvocationBinding { + runtime_agent_key: logical_id.to_string(), + logical_id: logical_id.to_string(), + supports_follow_up: true, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, + lease: None, + } +} + +fn external_agent_info(entry: &ExternalSubagentGenerationEntry) -> AgentInfo { + let agent = entry.registration.agent.as_ref(); + let default_tools = agent.default_tools(); + AgentInfo { + key: format!( + "external::{}::{}", + entry.registration.provider_label.to_ascii_lowercase(), + entry.registration.logical_id + ), + id: entry.registration.logical_id.clone(), + name: agent.name().to_string(), + description: agent.description().to_string(), + is_readonly: agent.is_readonly(), + is_review: false, + tool_count: default_tools.len(), + default_tools, + prompt_cache_scope_key: prompt_cache_scope_key( + &agent.system_prompt_cache_identity(None), + &agent.user_context_cache_identity(), + ), + config_profile_id: None, + config_profile_label: None, + config_profile_member_mode_ids: Vec::new(), + default_enabled: true, + effective_enabled: true, + override_state: None, + state_reason: None, + source: AgentSource::External, + subagent_source: Some(SubAgentSource::External), + path: None, + model: Some(entry.registration.model_binding.model_id.clone()), + model_is_explicit: Some(true), + visibility: Some(SubagentVisibilityPolicy::public().summary()), + external_provider_label: Some(entry.registration.provider_label.clone()), + supports_follow_up: false, + } +} diff --git a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs index 9de2e41697..0820c567fb 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/mod.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/mod.rs @@ -2,6 +2,7 @@ mod availability; mod builtin; pub(super) mod catalog; mod custom; +mod external; mod query; mod resolution; mod support; @@ -20,6 +21,12 @@ use std::path::{Path, PathBuf}; use std::sync::RwLock; use std::sync::{Arc, OnceLock}; +pub(crate) use external::external_subagent_runtime_key; +pub use external::{ + ExternalSubagentGenerationLease, ExternalSubagentInvocationBinding, + ExternalSubagentModelBinding, ExternalSubagentRegistration, ExternalSubagentRoute, +}; + /// Full file-backed custom agent definition for editing. #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -48,6 +55,7 @@ pub struct AgentRegistry { /// workspace root -> (project subagent id -> agent_entry) project_subagents: RwLock>>, user_custom_agents_loaded: RwLock, + external_subagents: Arc, } impl Default for AgentRegistry { @@ -106,6 +114,9 @@ impl AgentRegistry { agent_type: &str, workspace_root: Option<&Path>, ) -> Option { + if let Some(entry) = self.external_subagents.find_generation_entry(agent_type) { + return Some(entry); + } if let Some(entry) = self.read_agents().get(agent_type).cloned() { return Some(entry); } @@ -128,7 +139,8 @@ impl AgentRegistry { /// Check if an agent exists pub fn check_agent_exists(&self, agent_type: &str) -> bool { - self.read_agents().contains_key(agent_type) + self.external_subagents.has_generation(agent_type) + || self.read_agents().contains_key(agent_type) || self .read_project_subagents() .values() diff --git a/src/crates/assembly/core/src/agentic/agents/registry/query.rs b/src/crates/assembly/core/src/agentic/agents/registry/query.rs index 3b5ce45d33..0597e576f6 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/query.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/query.rs @@ -16,6 +16,46 @@ use std::collections::HashSet; use std::path::Path; impl AgentRegistry { + /// Return every effective local subagent definition that can participate in + /// product-level external-source conflict resolution. This deliberately + /// ignores presentation visibility: a hidden but explicitly addressable + /// local agent must still block a same-name external route from being + /// selected silently. + pub(crate) async fn get_local_subagents_for_external_resolution( + &self, + workspace_root: Option<&Path>, + ) -> Vec { + self.ensure_user_custom_agents_loaded().await; + if let Some(workspace_root) = workspace_root { + if !self.read_project_subagents().contains_key(workspace_root) { + self.load_custom_agents(Some(workspace_root)).await; + } + } + + let user_overrides = get_subagent_overrides().await; + let project_overrides = match workspace_root { + Some(workspace_root) => load_project_subagent_overrides_local(workspace_root) + .await + .ok(), + None => None, + }; + let mut result = Vec::new(); + { + let map = self.read_agents(); + result.extend(map.values().filter_map(|entry| { + local_conflict_info(entry, None, project_overrides.as_ref(), &user_overrides) + })); + } + if let Some(workspace_root) = workspace_root { + if let Some(project_entries) = self.read_project_subagents().get(workspace_root) { + result.extend(project_entries.values().filter_map(|entry| { + local_conflict_info(entry, None, project_overrides.as_ref(), &user_overrides) + })); + } + } + Self::sort_subagents_for_presentation(result) + } + fn sort_subagents_for_presentation(mut result: Vec) -> Vec { result.sort_by(|a, b| { subagent_source_presentation_rank(a.subagent_source) @@ -109,11 +149,13 @@ impl AgentRegistry { AgentSource::Builtin => mode_presentation_rank(&a.id), AgentSource::User => 100, AgentSource::Project => 101, + AgentSource::External => 102, }; let b_rank = match b.source { AgentSource::Builtin => mode_presentation_rank(&b.id), AgentSource::User => 100, AgentSource::Project => 101, + AgentSource::External => 102, }; a_rank .cmp(&b_rank) @@ -219,6 +261,7 @@ impl AgentRegistry { workspace_root, list_scope: SubagentListScope::RegistryManagement, include_disabled: true, + external_sources_supported: true, }) .await } @@ -301,6 +344,11 @@ impl AgentRegistry { ); } } + if query.external_sources_supported { + if let Some(workspace_root) = query.workspace_root { + result = self.apply_external_routes_to_query(workspace_root, result); + } + } Self::sort_subagents_for_presentation(result) } @@ -315,6 +363,7 @@ impl AgentRegistry { workspace_root, list_scope: SubagentListScope::TaskVisible, include_disabled: false, + external_sources_supported: false, }; let user_overrides = get_subagent_overrides().await; let project_overrides = match query.workspace_root { @@ -343,3 +392,26 @@ impl AgentRegistry { }) } } + +fn local_conflict_info( + entry: &AgentEntry, + parent_agent_type: Option<&str>, + project_overrides: Option<&crate::service::config::types::AgentSubagentOverrideConfig>, + user_overrides: &crate::service::config::types::AgentSubagentOverrideConfig, +) -> Option { + if entry.category != AgentCategory::SubAgent || entry.source == AgentSource::External { + return None; + } + let availability = + resolve_availability(entry, parent_agent_type, project_overrides, user_overrides); + if !availability.effective_enabled { + return None; + } + let mut info = AgentInfo::from_agent_entry(entry); + info.subagent_source = entry.subagent_source; + info.default_enabled = availability.default_enabled; + info.effective_enabled = availability.effective_enabled; + info.override_state = availability.override_state; + info.state_reason = availability.state_reason; + Some(info) +} diff --git a/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs b/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs index 604406cd6f..896fd2a804 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/resolution.rs @@ -3,11 +3,35 @@ use super::types::AgentCategory; use super::AgentRegistry; use crate::service::config::global::GlobalConfigManager; use crate::service::config::GlobalConfig; +use crate::service::config::SubagentModelSelection; use crate::util::errors::{BitFunError, BitFunResult}; use log::{debug, error, warn}; use std::path::Path; impl AgentRegistry { + /// Returns a source-neutral explicit model selection for a delegated + /// subagent. Callers apply product defaults only when this returns `None`. + pub fn get_explicit_subagent_model_selection( + &self, + agent_type: &str, + workspace_root: Option<&Path>, + ) -> Option { + let config = self + .find_agent_entry(agent_type, workspace_root)? + .custom_config?; + if !config.model_is_explicit { + return None; + } + let model = config.model.trim(); + if model.is_empty() { + None + } else if model == "inherit" { + Some(SubagentModelSelection::Inherit) + } else { + Some(SubagentModelSelection::fixed(model)) + } + } + /// Resolves an execution fallback for an agent whose session has no model. /// /// Delegated subagents receive an explicit resolved model from the 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 15ad29ae47..272bc3802c 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/tests.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/tests.rs @@ -1,5 +1,5 @@ use super::support::merge_dynamic_mcp_tools; -use super::AgentRegistry; +use super::{AgentRegistry, ExternalSubagentRegistration, ExternalSubagentRoute}; use crate::agentic::agents::definitions::custom::{CustomMode, CustomSubagent, CustomSubagentKind}; use crate::agentic::agents::registry::builtin::default_model_id_for_builtin_agent; use crate::agentic::agents::registry::types::{ @@ -17,7 +17,7 @@ use bitfun_agent_runtime::custom_agent::{ CustomAgentKind, CustomAgentLevel, }; use bitfun_agent_runtime::sdk::{RuntimeAgentRegistry, RuntimeAgentRegistryQuery}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -354,6 +354,7 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { workspace_root: None, list_scope: SubagentListScope::TaskVisible, include_disabled: false, + external_sources_supported: false, }) .await; assert!(agentic_visible.iter().any(|agent| agent.id == "Explore")); @@ -379,6 +380,7 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { workspace_root: None, list_scope: SubagentListScope::TaskVisible, include_disabled: false, + external_sources_supported: false, }) .await; assert!(deep_review_visible @@ -394,6 +396,7 @@ async fn task_visible_subagents_are_filtered_by_parent_agent() { workspace_root: None, list_scope: SubagentListScope::TaskVisible, include_disabled: false, + external_sources_supported: false, }) .await; assert!(deep_research_visible @@ -527,6 +530,7 @@ async fn prompt_stability_task_visible_subagents_are_sorted_deterministically() workspace_root: Some(&workspace), list_scope: SubagentListScope::RegistryManagement, include_disabled: false, + external_sources_supported: false, }) .await; @@ -603,6 +607,7 @@ async fn parent_subagent_overrides_follow_source_scopes() { workspace_root: Some(&workspace), list_scope: SubagentListScope::RegistryManagement, include_disabled: true, + external_sources_supported: false, }; let project_override_key = "project::bitfun::ProjectScout".to_string(); @@ -1066,3 +1071,141 @@ fn unique_suffix() -> String { .as_nanos() .to_string() } + +#[tokio::test] +async fn external_routes_are_workspace_scoped_fail_closed_and_generation_leased() { + let registry = AgentRegistry::new(); + let workspace = PathBuf::from("C:/workspace/external-agent-registry"); + let runtime_v1 = "external::candidate::behavior-v1"; + let agent_v1: Arc = Arc::new(TestAgent { + id: runtime_v1.to_string(), + }); + registry.install_external_subagent_routes( + &workspace, + vec![ExternalSubagentRegistration { + runtime_key: runtime_v1.to_string(), + logical_id: "Explore".to_string(), + provider_label: "OpenCode".to_string(), + model_binding: super::ExternalSubagentModelBinding { + model_id: "inherit".to_string(), + configuration_fingerprint: "model-config-v1".to_string(), + }, + hidden: false, + agent: agent_v1, + }], + [( + "explore".to_string(), + ExternalSubagentRoute::External(runtime_v1.to_string()), + )] + .into_iter() + .collect(), + ); + + let local_only = registry + .get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some("agentic"), + workspace_root: Some(&workspace), + list_scope: SubagentListScope::TaskVisible, + include_disabled: false, + external_sources_supported: false, + }) + .await; + assert!(local_only + .iter() + .any(|agent| { agent.id == "Explore" && agent.source == AgentSource::Builtin })); + + let external = registry + .get_subagents_for_query(&SubagentQueryContext { + parent_agent_type: Some("agentic"), + workspace_root: Some(&workspace), + list_scope: SubagentListScope::TaskVisible, + include_disabled: false, + external_sources_supported: true, + }) + .await; + let projected = external + .iter() + .find(|agent| agent.id == "Explore") + .expect("external route replaces the same-name local projection"); + assert_eq!(projected.source, AgentSource::External); + assert_eq!( + projected.external_provider_label.as_deref(), + Some("OpenCode") + ); + assert_eq!(projected.model.as_deref(), Some("inherit")); + assert_eq!(projected.model_is_explicit, Some(true)); + assert!(!projected.supports_follow_up); + assert!(registry.is_external_subagent_route("Explore", Some(&workspace))); + assert!(registry.is_external_subagent_route("EXPLORE", Some(&workspace))); + assert!(!registry.is_external_subagent_route("Explore", None)); + assert!(!registry.is_external_subagent_route("Explore", Some(Path::new("C:/workspace/other")))); + + let binding = registry + .resolve_subagent_for_fresh_invocation("Explore", Some(&workspace), true) + .expect("external invocation binding"); + assert_eq!(binding.runtime_agent_key, runtime_v1); + assert!(!binding.supports_follow_up); + let leased_model = binding + .lease + .as_ref() + .expect("external binding keeps a generation lease") + .model_binding(); + assert_eq!(leased_model.model_id, "inherit"); + assert_eq!(leased_model.configuration_fingerprint, "model-config-v1"); + + let runtime_v2 = "external::candidate::behavior-v2"; + let agent_v2: Arc = Arc::new(TestAgent { + id: runtime_v2.to_string(), + }); + registry.install_external_subagent_routes( + &workspace, + vec![ExternalSubagentRegistration { + runtime_key: runtime_v2.to_string(), + logical_id: "Explore".to_string(), + provider_label: "OpenCode".to_string(), + model_binding: super::ExternalSubagentModelBinding { + model_id: "inherit".to_string(), + configuration_fingerprint: "model-config-v2".to_string(), + }, + hidden: false, + agent: agent_v2, + }], + [( + "explore".to_string(), + ExternalSubagentRoute::External(runtime_v2.to_string()), + )] + .into_iter() + .collect(), + ); + assert!(registry.get_agent(runtime_v1, Some(&workspace)).is_some()); + assert_eq!( + binding + .lease + .as_ref() + .expect("old generation remains leased") + .model_binding() + .configuration_fingerprint, + "model-config-v1" + ); + + registry.install_external_subagent_routes( + &workspace, + Vec::new(), + [("Explore".to_string(), ExternalSubagentRoute::Unavailable)] + .into_iter() + .collect(), + ); + assert!(registry.get_agent(runtime_v1, Some(&workspace)).is_some()); + assert!(registry.get_agent(runtime_v2, Some(&workspace)).is_none()); + assert!(registry + .resolve_subagent_for_fresh_invocation("Explore", Some(&workspace), true) + .is_none()); + assert!(registry.is_external_subagent_route("Explore", Some(&workspace))); + registry.install_external_subagent_routes(&workspace, Vec::new(), BTreeMap::new()); + assert!(registry + .resolve_subagent_for_fresh_invocation("Explore", Some(&workspace), true) + .is_none()); + assert!(registry.is_external_subagent_route("Explore", Some(&workspace))); + drop(binding); + assert!(registry.get_agent(runtime_v1, Some(&workspace)).is_none()); +} diff --git a/src/crates/assembly/core/src/agentic/agents/registry/types.rs b/src/crates/assembly/core/src/agentic/agents/registry/types.rs index 371640b110..65829a2c78 100644 --- a/src/crates/assembly/core/src/agentic/agents/registry/types.rs +++ b/src/crates/assembly/core/src/agentic/agents/registry/types.rs @@ -38,6 +38,7 @@ pub enum AgentSource { Builtin, Project, User, + External, } #[derive(Debug, Clone)] @@ -103,6 +104,10 @@ pub struct AgentInfo { pub model_is_explicit: Option, #[serde(skip_serializing_if = "Option::is_none")] pub visibility: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub external_provider_label: Option, + #[serde(default = "default_true")] + pub supports_follow_up: bool, } fn default_true() -> bool { @@ -138,11 +143,13 @@ pub(super) fn subagent_key_for( let _custom = agent.as_any().downcast_ref::()?; "bitfun" } + SubAgentSource::External => "external", }; let prefix = match source { SubAgentSource::Builtin => "builtin", SubAgentSource::Project => "project", SubAgentSource::User => "user", + SubAgentSource::External => "external", }; Some(format!("{prefix}::{slot}::{}", agent.id())) } @@ -213,6 +220,8 @@ impl AgentInfo { model_is_explicit, visibility: (entry.category == AgentCategory::SubAgent) .then(|| entry.visibility_policy.summary()), + external_provider_label: None, + supports_follow_up: true, } } } diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 1911de52f5..7a002fd819 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -13,7 +13,8 @@ use crate::agentic::agents::get_agent_registry; use crate::agentic::context_profile::ContextProfilePolicy; use crate::agentic::core::{ InternalReminderKind, Message, MessageContent, MessageSemanticKind, ProcessingPhase, Session, - SessionConfig, SessionKind, SessionState, SessionSummary, TurnStats, + SessionConfig, SessionContinuationPolicy, SessionKind, SessionModelBindingPolicy, SessionState, + SessionSummary, TurnStats, }; use crate::agentic::events::{ AgenticEvent, DeepReviewQueueState, EventPriority, EventQueue, EventRouter, EventSubscriber, @@ -242,12 +243,22 @@ pub(crate) struct SubagentExecutionRequest { pub(crate) context_mode: SubagentContextMode, pub(crate) target_session_id: Option, pub(crate) subagent_type: Option, + /// Stable user-facing id. External adapters may use a generation-specific + /// `subagent_type` internally while keeping this logical id in events and + /// persisted relationship metadata. + pub(crate) logical_subagent_type: Option, + pub(crate) continuation_policy: SessionContinuationPolicy, + pub(crate) model_binding_policy: SessionModelBindingPolicy, pub(crate) workspace_path: Option, pub(crate) model_id: Option, pub(crate) subagent_parent_info: SubagentParentInfo, pub(crate) context: HashMap, /// Execution policy for the child subagent session being launched. pub(crate) delegation_policy: DelegationPolicy, + /// Pins an immutable external generation from Task validation until the + /// queued or running invocation reaches a terminal state. + pub(crate) external_generation_lease: + Option, } #[derive(Debug, Clone)] @@ -421,6 +432,7 @@ fn background_subagent_delivery_metadata( fn build_subagent_session_relationship( parent_info: Option<&SubagentParentInfo>, agent_type: &str, + continuation_policy: SessionContinuationPolicy, ) -> SessionRelationship { SessionRelationship { kind: Some(SessionRelationshipKind::Subagent), @@ -430,9 +442,17 @@ fn build_subagent_session_relationship( parent_turn_index: None, parent_tool_call_id: parent_info.map(|info| info.tool_call_id.clone()), subagent_type: Some(agent_type.to_string()), + continuation_policy: Some(continuation_policy), } } +fn logical_subagent_type_or_runtime( + logical_subagent_type: Option<&str>, + runtime_type: &str, +) -> String { + logical_subagent_type.unwrap_or(runtime_type).to_string() +} + fn fork_subagent_system_reminder() -> String { "You are now running as a forked subagent. Messages before this reminder were inherited from the parent agent as context. Messages after this reminder are the request for you. Do not call the Task tool to launch another subagent. Use the tools available to complete the task directly.".to_string() } @@ -458,6 +478,7 @@ pub(crate) struct HiddenSubagentExecutionRequest { dialog_turn_id: Option, session_name: String, agent_type: String, + logical_agent_type: String, session_config: SessionConfig, initial_messages: Vec, user_input_text: String, @@ -470,6 +491,7 @@ pub(crate) struct HiddenSubagentExecutionRequest { session_kind: SessionKind, emit_lifecycle_events: bool, prepared_session_created: bool, + external_generation_lease: Option, } impl HiddenSubagentExecutionRequest { @@ -481,8 +503,8 @@ impl HiddenSubagentExecutionRequest { self.dialog_turn_id = Some(dialog_turn_id); } - pub(super) fn agent_type(&self) -> &str { - &self.agent_type + pub(super) fn logical_agent_type(&self) -> &str { + &self.logical_agent_type } pub(super) fn user_input_text(&self) -> &str { @@ -4944,6 +4966,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet dialog_turn_id, session_name, agent_type, + logical_agent_type, session_config, initial_messages, user_input_text, @@ -4956,8 +4979,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_kind, emit_lifecycle_events, prepared_session_created, + external_generation_lease: _external_generation_lease, } = request; let prepared_target_session_id = target_session_id.clone(); + let continuation_policy = session_config.continuation_policy; let requested_timeout_seconds = timeout_seconds.filter(|seconds| *seconds > 0); let parent_thread_goal_active = if let Some(parent_info) = subagent_parent_info.as_ref() { @@ -5130,7 +5155,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .create_hidden_agent_session( None, session_name.clone(), - agent_type.clone(), + logical_agent_type.clone(), session_config.clone(), created_by.clone(), session_kind, @@ -5179,7 +5204,11 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .persist_session_lineage( &session_id, - build_subagent_session_relationship(subagent_parent_info.as_ref(), &agent_type), + build_subagent_session_relationship( + subagent_parent_info.as_ref(), + &logical_agent_type, + continuation_policy, + ), ) .await { @@ -5243,7 +5272,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .session_manager .start_dialog_turn_with_existing_context( &session_id, - agent_type.clone(), + logical_agent_type.clone(), user_input_text.clone(), Some(requested_dialog_turn_id.clone()), None, @@ -5269,7 +5298,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet parent_session_id: parent_info.session_id.clone(), parent_dialog_turn_id: parent_info.dialog_turn_id.clone(), parent_tool_call_id: parent_info.tool_call_id.clone(), - agent_type: Some(agent_type.clone()), + agent_type: Some(logical_agent_type.clone()), model_id: self .session_manager .get_session(&session_id) @@ -5521,7 +5550,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &session_id, &dialog_turn_id, turn_index, - &agent_type, + &logical_agent_type, &user_input_text, subagent_workspace_path.as_deref(), subagent_session_storage_path.as_deref(), @@ -5608,7 +5637,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &session_id, &dialog_turn_id, turn_index, - &agent_type, + &logical_agent_type, &user_input_text, subagent_workspace_path.as_deref(), subagent_session_storage_path.as_deref(), @@ -5679,7 +5708,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &session_id, &dialog_turn_id, turn_index, - &agent_type, + &logical_agent_type, &user_input_text, subagent_workspace_path.as_deref(), subagent_session_storage_path.as_deref(), @@ -5733,7 +5762,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let event = self.session_manager.record_subagent_partial_timeout( &parent_info.session_id, &parent_info.dialog_turn_id, - &agent_type, + &logical_agent_type, &partial_result.text, Some("timeout"), ); @@ -5768,7 +5797,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &session_id, &dialog_turn_id, turn_index, - &agent_type, + &logical_agent_type, &user_input_text, subagent_workspace_path.as_deref(), subagent_session_storage_path.as_deref(), @@ -5833,7 +5862,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &session_id, &dialog_turn_id, turn_index, - &agent_type, + &logical_agent_type, &user_input_text, subagent_workspace_path.as_deref(), subagent_session_storage_path.as_deref(), @@ -5864,7 +5893,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &session_id, &dialog_turn_id, turn_index, - &agent_type, + &logical_agent_type, &user_input_text, subagent_workspace_path.as_deref(), subagent_session_storage_path.as_deref(), @@ -6126,17 +6155,29 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet parent_session_id )) })?; + let persisted_metadata = self + .session_manager + .load_session_metadata(&binding.session_storage_dir(), target_session_id) + .await?; + if persisted_metadata.as_ref().is_some_and(|metadata| { + metadata + .relationship + .as_ref() + .and_then(|relationship| relationship.continuation_policy) + == Some(SessionContinuationPolicy::FreshOnly) + }) { + return Err(BitFunError::Validation( + "subagent_follow_up_unsupported: this subagent session is fresh-only; start a new Task invocation" + .to_string(), + )); + } if let Some(allow_review_follow_up) = background_allow_review_follow_up { - let metadata = self - .session_manager - .load_session_metadata(&binding.session_storage_dir(), target_session_id) - .await? - .ok_or_else(|| { - BitFunError::NotFound(format!( - "Session metadata not found: {}", - target_session_id - )) - })?; + let metadata = persisted_metadata.ok_or_else(|| { + BitFunError::NotFound(format!( + "Session metadata not found: {}", + target_session_id + )) + })?; if !metadata.is_subagent() { return Err(BitFunError::Validation(format!( "Subagent execution target must be a subagent session: {}", @@ -6212,6 +6253,13 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet } }; + if session.config.continuation_policy == SessionContinuationPolicy::FreshOnly { + return Err(BitFunError::Validation( + "subagent_follow_up_unsupported: this subagent session is fresh-only; start a new Task invocation" + .to_string(), + )); + } + if session.kind != SessionKind::Subagent { return Err(BitFunError::Validation(format!( "Subagent execution target must be a subagent session: {}", @@ -6368,18 +6416,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet let defaults = Self::agent_model_defaults().await; let registry = get_agent_registry(); let configured_selection = registry - .get_custom_subagent_config(agent_type, Some(Path::new(workspace_path))) - .filter(|custom| custom.model_is_explicit) - .map(|custom| { - if custom.model.trim() == "inherit" { - SubagentModelSelection::Inherit - } else { - SubagentModelSelection::fixed( - trimmed_model_id(Some(custom.model.as_str())) - .unwrap_or_else(|| "fast".to_string()), - ) - } - }) + .get_explicit_subagent_model_selection(agent_type, Some(Path::new(workspace_path))) .unwrap_or_else(|| defaults.builtin_subagent_selection(agent_type)); let parent_model_id = if explicit_model_id.is_none() && matches!(&configured_selection, SubagentModelSelection::Inherit) @@ -6419,6 +6456,10 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "session-{}", request.subagent_parent_info.session_id )); + let approved_model_binding = request + .external_generation_lease + .as_ref() + .map(|lease| lease.model_binding().clone()); match request.context_mode { SubagentContextMode::Fresh => { @@ -6470,6 +6511,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet dialog_turn_id: None, session_name: session.session_name.clone(), agent_type: session.agent_type.clone(), + logical_agent_type: session.agent_type.clone(), session_config: session.config.clone(), initial_messages, user_input_text: task_description, @@ -6484,6 +6526,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_kind: SessionKind::Subagent, emit_lifecycle_events: true, prepared_session_created: false, + external_generation_lease: request.external_generation_lease, }); } @@ -6506,25 +6549,55 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) .await?; } - let resolved_model_id = self - .resolve_fresh_subagent_model_id( + let resolved_model_id = if matches!( + request.model_binding_policy, + SessionModelBindingPolicy::ApprovedImmutable + ) { + if model_id.is_some() { + return Err(BitFunError::Validation( + "An approved immutable subagent model cannot be overridden".to_string(), + )); + } + approved_model_binding + .as_ref() + .map(|binding| binding.model_id.clone()) + .ok_or_else(|| { + BitFunError::Validation( + "Approved immutable subagent generation has no concrete model binding" + .to_string(), + ) + })? + } else { + self.resolve_fresh_subagent_model_id( model_id.as_deref(), &agent_type, &workspace_path, &request.subagent_parent_info.session_id, ) - .await?; + .await? + }; + let logical_agent_type = logical_subagent_type_or_runtime( + request.logical_subagent_type.as_deref(), + &agent_type, + ); + let mut session_config = Self::build_session_config_for_workspace( + workspace_path, + Some(resolved_model_id), + ) + .await; + session_config.continuation_policy = request.continuation_policy; + session_config.model_binding_policy = request.model_binding_policy; + session_config.model_binding_fingerprint = approved_model_binding + .as_ref() + .map(|binding| binding.configuration_fingerprint.clone()); Ok(HiddenSubagentExecutionRequest { target_session_id: None, dialog_turn_id: None, session_name: format!("Subagent: {}", task_description), agent_type, - session_config: Self::build_session_config_for_workspace( - workspace_path, - Some(resolved_model_id), - ) - .await, + logical_agent_type, + session_config, initial_messages: vec![Message::user(task_description.clone())], user_input_text: task_description, created_by, @@ -6538,6 +6611,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_kind: SessionKind::Subagent, emit_lifecycle_events: true, prepared_session_created: false, + external_generation_lease: request.external_generation_lease, }) } SubagentContextMode::Fork => { @@ -6604,6 +6678,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet dialog_turn_id: None, session_name: format!("Fork: {}", task_description), agent_type: snapshot.parent_agent_type.clone(), + logical_agent_type: snapshot.parent_agent_type.clone(), session_config, initial_messages, user_input_text: task_description, @@ -6618,6 +6693,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_kind: SessionKind::Subagent, emit_lifecycle_events: true, prepared_session_created: false, + external_generation_lease: request.external_generation_lease, }) } } @@ -6650,7 +6726,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet .create_hidden_agent_session( None, request.session_name.clone(), - request.agent_type.clone(), + request.logical_agent_type.clone(), request.session_config.clone(), request.created_by.clone(), request.session_kind, @@ -7006,12 +7082,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet "task_description is required when creating an internal agent session".to_string(), )); } + let logical_agent_type = request.agent_type.clone(); let hidden_request = HiddenSubagentExecutionRequest { target_session_id: None, dialog_turn_id: None, session_name: request.session_name, agent_type: request.agent_type, + logical_agent_type, session_config: Self::build_session_config_for_workspace( request.workspace_path, request.model_id, @@ -7028,6 +7106,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet session_kind: request.session_kind, emit_lifecycle_events: request.emit_lifecycle_events, prepared_session_created: false, + external_generation_lease: None, }; self.execute_hidden_subagent_internal(hidden_request, cancel_token, timeout_seconds) @@ -7063,7 +7142,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet ) })? .to_string(); - let agent_type = request.agent_type.clone(); + let logical_agent_type = request.logical_agent_type.clone(); let subagent_parent_info = match request.subagent_parent_info.clone() { Some(info) => info, None => { @@ -7197,7 +7276,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &subagent_session_id_for_delivery, &subagent_dialog_turn_id_for_delivery, parent_requires_tool_confirmation, - &agent_type, + &logical_agent_type, &task_description, ); @@ -7331,7 +7410,7 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet &subagent_session_id_for_delivery, &subagent_dialog_turn_id_for_delivery, parent_requires_tool_confirmation, - &agent_type, + &logical_agent_type, &task_description, ); @@ -8344,7 +8423,8 @@ fn merge_prepended_messages_for_turn( #[cfg(test)] mod tests { use super::{ - background_subagent_delivery_metadata, merge_prepended_messages_for_turn, + background_subagent_delivery_metadata, build_subagent_session_relationship, + logical_subagent_type_or_runtime, merge_prepended_messages_for_turn, normalize_subagent_max_concurrency, resolve_agent_session_create_created_by, resolve_agent_submission_turn_id, resolve_subagent_model_selection, runtime_port_error_preserving_message, should_require_tool_confirmation, @@ -8354,7 +8434,7 @@ mod tests { use crate::agentic::agents::{CustomSubagent, CustomSubagentKind, UserContextPolicy}; use crate::agentic::core::{ InternalReminderKind, Message, MessageContent, MessageRole, MessageSemanticKind, Session, - SessionConfig, SessionKind, + SessionConfig, SessionContinuationPolicy, SessionKind, SessionModelBindingPolicy, }; use crate::agentic::events::{EventQueue, EventQueueConfig, EventRouter}; use crate::agentic::execution::{ @@ -8713,6 +8793,43 @@ mod tests { assert!(!non_peer_metadata.contains_key("require_tool_confirmation")); } + #[test] + fn external_subagent_surfaces_use_logical_id_instead_of_runtime_generation_key() { + let runtime_type = "external_subagent_runtime:generation-hash"; + let logical_type = logical_subagent_type_or_runtime(Some("Reviewer"), runtime_type); + + assert_eq!(logical_type, "Reviewer"); + let relationship = build_subagent_session_relationship( + None, + &logical_type, + SessionContinuationPolicy::FreshOnly, + ); + assert_eq!(relationship.subagent_type.as_deref(), Some("Reviewer")); + assert_eq!( + relationship.continuation_policy, + Some(SessionContinuationPolicy::FreshOnly) + ); + + let parent = SubagentParentInfo { + session_id: "parent-session".to_string(), + dialog_turn_id: "parent-turn".to_string(), + tool_call_id: "tool-call".to_string(), + }; + let metadata = background_subagent_delivery_metadata( + "background-task", + &parent, + "subagent-session", + "subagent-turn", + false, + &logical_type, + "Review", + ); + assert_eq!(metadata["subagentType"], serde_json::json!("Reviewer")); + assert!(!serde_json::Value::Object(metadata) + .to_string() + .contains(runtime_type)); + } + #[tokio::test] async fn completed_background_control_cannot_be_claimed_for_late_cancellation() { let (coordinator, _) = test_coordinator(); @@ -8818,6 +8935,9 @@ mod tests { context_mode: SubagentContextMode::Fresh, target_session_id: None, subagent_type: Some("CodeReview".to_string()), + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: Some(workspace_path.to_string_lossy().into_owned()), model_id: None, subagent_parent_info: SubagentParentInfo { @@ -8827,6 +8947,7 @@ mod tests { }, context: HashMap::new(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, }; let resolved = coordinator @@ -8866,6 +8987,9 @@ mod tests { context_mode: SubagentContextMode::Fresh, target_session_id: Some(review_session.session_id.clone()), subagent_type: None, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: None, subagent_parent_info: SubagentParentInfo { @@ -8875,6 +8999,7 @@ mod tests { }, context: HashMap::new(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, }; let resolved = coordinator @@ -8926,6 +9051,9 @@ mod tests { context_mode: SubagentContextMode::Fresh, target_session_id: Some(review_session.session_id.clone()), subagent_type: None, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: Some("fast".to_string()), subagent_parent_info: SubagentParentInfo { @@ -8935,6 +9063,7 @@ mod tests { }, context: HashMap::new(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, }; let error = coordinator @@ -8998,6 +9127,7 @@ mod tests { tool_call_id: "task-tool".to_string(), }), "CodeReview", + SessionContinuationPolicy::Reusable, )); session_manager .save_session_metadata(&storage_binding.session_storage_dir(), &metadata) @@ -9010,6 +9140,9 @@ mod tests { context_mode: SubagentContextMode::Fresh, target_session_id: Some(review_session_id.clone()), subagent_type: None, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: Some("fast".to_string()), subagent_parent_info: SubagentParentInfo { @@ -9019,6 +9152,7 @@ mod tests { }, context: HashMap::new(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, }; let error = coordinator @@ -9080,6 +9214,9 @@ mod tests { context_mode: SubagentContextMode::Fresh, target_session_id: Some(review_session_id.clone()), subagent_type: None, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: None, subagent_parent_info: SubagentParentInfo { @@ -9089,6 +9226,7 @@ mod tests { }, context: HashMap::new(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, }; let error = coordinator @@ -9189,6 +9327,9 @@ mod tests { context_mode: SubagentContextMode::Fresh, target_session_id: Some(review_session_id.clone()), subagent_type: None, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: None, subagent_parent_info: SubagentParentInfo { @@ -9198,6 +9339,7 @@ mod tests { }, context: HashMap::new(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, }; let error = coordinator @@ -9243,6 +9385,7 @@ mod tests { parent_turn_index: None, parent_tool_call_id: None, subagent_type: None, + continuation_policy: None, }; assert!(super::session_lineage_matches_parent( @@ -9683,6 +9826,9 @@ mod tests { context_mode: SubagentContextMode::Fresh, target_session_id: Some(subagent_session.session_id.clone()), subagent_type: None, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: Some("fast".to_string()), subagent_parent_info: SubagentParentInfo { @@ -9692,6 +9838,7 @@ mod tests { }, context: HashMap::new(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, }; let prepared = coordinator @@ -9749,6 +9896,9 @@ mod tests { context_mode: SubagentContextMode::Fork, target_session_id: None, subagent_type: None, + logical_subagent_type: None, + continuation_policy: SessionContinuationPolicy::Reusable, + model_binding_policy: SessionModelBindingPolicy::Mutable, workspace_path: None, model_id: Some("fast".to_string()), subagent_parent_info: SubagentParentInfo { @@ -9758,6 +9908,7 @@ mod tests { }, context: HashMap::new(), delegation_policy: DelegationPolicy::top_level().spawn_child(), + external_generation_lease: None, }; let prepared = coordinator diff --git a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs index 44e758a5c1..3b0f366b3e 100644 --- a/src/crates/assembly/core/src/agentic/coordination/scheduler.rs +++ b/src/crates/assembly/core/src/agentic/coordination/scheduler.rs @@ -943,7 +943,7 @@ impl DialogScheduler { .to_string(); let resolved_turn_id = format!("subagent-{}", Uuid::new_v4()); request.set_dialog_turn_id(resolved_turn_id.clone()); - let agent_type = request.agent_type().to_string(); + let agent_type = request.logical_agent_type().to_string(); let user_input = request.user_input_text().to_string(); let session = self .session_manager diff --git a/src/crates/assembly/core/src/agentic/core/mod.rs b/src/crates/assembly/core/src/agentic/core/mod.rs index 6fecbd7cca..93187168dc 100644 --- a/src/crates/assembly/core/src/agentic/core/mod.rs +++ b/src/crates/assembly/core/src/agentic/core/mod.rs @@ -21,6 +21,7 @@ pub use message::{ pub use messages_helper::{MessageHelper, RequestReasoningTokenPolicy}; pub use session::{ sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, - SessionConfig, SessionKind, SessionSummary, + SessionConfig, SessionContinuationPolicy, SessionKind, SessionModelBindingPolicy, + SessionSummary, }; pub use state::{ProcessingPhase, SessionState, ToolExecutionState}; diff --git a/src/crates/assembly/core/src/agentic/core/session.rs b/src/crates/assembly/core/src/agentic/core/session.rs index aed9561490..3eb1f7101b 100644 --- a/src/crates/assembly/core/src/agentic/core/session.rs +++ b/src/crates/assembly/core/src/agentic/core/session.rs @@ -2,5 +2,6 @@ pub use bitfun_agent_runtime::session::{ sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, - SessionConfig, SessionKind, SessionSummary, + SessionConfig, SessionContinuationPolicy, SessionKind, SessionModelBindingPolicy, + SessionSummary, }; diff --git a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs index 64ab6974bf..b093786503 100644 --- a/src/crates/assembly/core/src/agentic/execution/execution_engine.rs +++ b/src/crates/assembly/core/src/agentic/execution/execution_engine.rs @@ -38,7 +38,9 @@ use crate::agentic::tools::{ use crate::agentic::WorkspaceBinding; use crate::infrastructure::ai::get_global_ai_client_factory; use crate::service::config::get_global_config_service; -use crate::service::config::types::{ModelCapability, ModelCategory}; +use crate::service::config::types::{ + model_runtime_binding_fingerprint, ModelCapability, ModelCategory, +}; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::token_counter::TokenCounter; use crate::util::types::Message as AIMessage; @@ -47,6 +49,7 @@ use crate::util::{elapsed_ms_u64, truncate_at_char_boundary}; use bitfun_agent_runtime::output_surface::TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY; use bitfun_agent_runtime::remote_file_delivery::TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY; use bitfun_ai_adapters::ModelExchangeTraceConfig; +use bitfun_core_types::SessionModelBindingPolicy; use log::{debug, error, info, trace, warn}; use sha2::{Digest, Sha256}; use std::collections::{HashMap, HashSet}; @@ -886,6 +889,7 @@ impl ExecutionEngine { async fn resolve_primary_model_context( model_id: &str, + model_binding_policy: SessionModelBindingPolicy, ai_client_model: &str, ai_client_api_format: &str, unavailable_log_message: &str, @@ -895,7 +899,16 @@ impl ExecutionEngine { let ai_config: crate::service::config::types::AIConfig = service.get_config(Some("ai")).await.unwrap_or_default(); - let resolved_id = Self::resolve_configured_model_id(&ai_config, model_id); + let resolved_id = if matches!( + model_binding_policy, + SessionModelBindingPolicy::ApprovedImmutable + ) { + ai_config + .resolve_model_reference(model_id) + .unwrap_or_else(|| model_id.to_string()) + } else { + Self::resolve_configured_model_id(&ai_config, model_id) + }; let model_cfg = ai_config.models.iter().find(|m| m.id == resolved_id); let supports = model_cfg.is_some_and(|m| { @@ -1221,11 +1234,6 @@ impl ExecutionEngine { original_user_input: &str, turn_index: usize, ) -> BitFunResult { - let agent_registry = get_agent_registry(); - let fallback_model_id = agent_registry - .get_model_id_for_agent(agent_type, workspace.map(|binding| binding.root_path())) - .await - .map_err(|e| BitFunError::AIClient(format!("Failed to get model ID: {}", e)))?; let config_service = get_global_config_service().await.map_err(|e| { BitFunError::AIClient(format!( "Failed to get config service for model resolution: {}", @@ -1236,6 +1244,56 @@ impl ExecutionEngine { .get_config(Some("ai")) .await .unwrap_or_default(); + if matches!( + session.config.model_binding_policy, + SessionModelBindingPolicy::ApprovedImmutable + ) { + let model_id = session + .config + .model_id + .as_deref() + .map(str::trim) + .filter(|model_id| !model_id.is_empty()) + .ok_or_else(|| { + BitFunError::AIClient( + "Approved immutable session has no concrete model id".to_string(), + ) + })?; + let expected_fingerprint = session + .config + .model_binding_fingerprint + .as_deref() + .ok_or_else(|| { + BitFunError::AIClient( + "Approved immutable session has no model binding fingerprint".to_string(), + ) + })?; + let mut matches = ai_config + .models + .iter() + .filter(|model| model.enabled && model.id == model_id); + let model = matches.next().ok_or_else(|| { + BitFunError::AIClient(format!( + "Approved model configuration is unavailable: {}", + model_id + )) + })?; + if matches.next().is_some() + || model_runtime_binding_fingerprint(model) != expected_fingerprint + { + return Err(BitFunError::AIClient(format!( + "Approved model binding changed before execution: {}", + model_id + ))); + } + return Ok(model_id.to_string()); + } + + let agent_registry = get_agent_registry(); + let fallback_model_id = agent_registry + .get_model_id_for_agent(agent_type, workspace.map(|binding| binding.root_path())) + .await + .map_err(|e| BitFunError::AIClient(format!("Failed to get model ID: {}", e)))?; let configured_model_id = session .config .model_id @@ -1770,18 +1828,33 @@ impl ExecutionEngine { let ai_client_factory = get_global_ai_client_factory().await.map_err(|e| { BitFunError::AIClient(format!("Failed to get AI client factory: {}", e)) })?; - let ai_client = ai_client_factory - .get_client_resolved(&model_id) - .await - .map_err(|e| { - BitFunError::AIClient(format!( - "Failed to get AI client (model_id={}): {}", - model_id, e - )) - })?; + let ai_client_result = if matches!( + session.config.model_binding_policy, + SessionModelBindingPolicy::ApprovedImmutable + ) { + ai_client_factory + .get_client_by_approved_binding( + &model_id, + session + .config + .model_binding_fingerprint + .as_deref() + .unwrap_or_default(), + ) + .await + } else { + ai_client_factory.get_client_resolved(&model_id).await + }; + let ai_client = ai_client_result.map_err(|e| { + BitFunError::AIClient(format!( + "Failed to get AI client (model_id={}): {}", + model_id, e + )) + })?; let primary_model_facts = Self::resolve_primary_model_context( &model_id, + session.config.model_binding_policy, &ai_client.config.model, &ai_client.config.format, "Config service unavailable, assuming compression model is text-only for image input gating", @@ -2561,19 +2634,34 @@ impl ExecutionEngine { })?; // Get AI client by model ID - let ai_client = ai_client_factory - .get_client_resolved(&model_id) - .await - .map_err(|e| { - BitFunError::AIClient(format!( - "Failed to get AI client (model_id={}): {}", - model_id, e - )) - })?; + let ai_client_result = if matches!( + session.config.model_binding_policy, + SessionModelBindingPolicy::ApprovedImmutable + ) { + ai_client_factory + .get_client_by_approved_binding( + &model_id, + session + .config + .model_binding_fingerprint + .as_deref() + .unwrap_or_default(), + ) + .await + } else { + ai_client_factory.get_client_resolved(&model_id).await + }; + let ai_client = ai_client_result.map_err(|e| { + BitFunError::AIClient(format!( + "Failed to get AI client (model_id={}): {}", + model_id, e + )) + })?; // Primary model vision capability (tools + system prompt appendix; also used below for API message stripping). let primary_model_facts = Self::resolve_primary_model_context( &model_id, + session.config.model_binding_policy, &ai_client.config.model, &ai_client.config.format, "Config service unavailable, assuming primary model is text-only for image input gating", diff --git a/src/crates/assembly/core/src/agentic/session/session_manager.rs b/src/crates/assembly/core/src/agentic/session/session_manager.rs index bec6052d88..46c5982591 100644 --- a/src/crates/assembly/core/src/agentic/session/session_manager.rs +++ b/src/crates/assembly/core/src/agentic/session/session_manager.rs @@ -5,7 +5,7 @@ use crate::agentic::core::{ new_turn_id, CompressionContract, CompressionState, InternalReminderKind, Message, MessageContent, MessageRole, MessageSemanticKind, ProcessingPhase, Session, SessionConfig, - SessionKind, SessionState, SessionSummary, TurnStats, + SessionKind, SessionModelBindingPolicy, SessionState, SessionSummary, TurnStats, }; use crate::agentic::image_analysis::ImageContextData; use crate::agentic::keyed_lock::{KeyedAsyncLock, KeyedAsyncLockGuard}; @@ -80,6 +80,19 @@ impl Default for SessionManagerConfig { } } +fn should_auto_migrate_session_model( + binding_policy: SessionModelBindingPolicy, + current_model_id: &str, + invalidated_model_ids: &HashSet<&str>, +) -> bool { + session_model_allows_automatic_migration(binding_policy) + && invalidated_model_ids.contains(current_model_id) +} + +fn session_model_allows_automatic_migration(binding_policy: SessionModelBindingPolicy) -> bool { + binding_policy == SessionModelBindingPolicy::Mutable +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct CompressionTranscriptReference { pub uri: String, @@ -1602,7 +1615,14 @@ impl SessionManager { .filter_map(|entry| { let session = entry.value(); let current = session.config.model_id.as_deref()?.trim().to_string(); - if invalid.contains(current.as_str()) { + // External generations pin the model that the user approved. + // If that model disappears, execution must fail closed instead + // of silently changing the approved behavior to `auto`. + if should_auto_migrate_session_model( + session.config.model_binding_policy, + current.as_str(), + &invalid, + ) { Some((session.session_id.clone(), current)) } else { None @@ -3851,7 +3871,9 @@ impl SessionManager { // will pick a model via the normal auto/agent/default pipeline. if let Some(persisted_model_id) = session.config.model_id.as_deref() { let trimmed = persisted_model_id.trim(); - let needs_migration = if trimmed.is_empty() { + let needs_migration = if trimmed.is_empty() + || !session_model_allows_automatic_migration(session.config.model_binding_policy) + { false } else if let Some(ai_config) = ai_config_for_restore.as_ref() { !Self::is_session_model_id_usable(ai_config, trimmed) @@ -5855,10 +5877,13 @@ impl SessionManager { #[cfg(test)] mod tests { - use super::{CoreSessionStorePort, SessionManager, SessionManagerConfig}; + use super::{ + should_auto_migrate_session_model, CoreSessionStorePort, SessionManager, + SessionManagerConfig, + }; use crate::agentic::core::{ Message, MessageContent, MessageRole, ProcessingPhase, Session, SessionConfig, - SessionState, ToolCall, ToolResult, + SessionModelBindingPolicy, SessionState, ToolCall, ToolResult, }; use crate::agentic::persistence::PersistenceManager; use crate::agentic::session::{ @@ -5907,6 +5932,27 @@ mod tests { } } + #[test] + fn invalidated_model_migration_preserves_approved_external_generation_binding() { + let invalidated = HashSet::from(["removed-model"]); + + assert!(should_auto_migrate_session_model( + SessionModelBindingPolicy::Mutable, + "removed-model", + &invalidated, + )); + assert!(!should_auto_migrate_session_model( + SessionModelBindingPolicy::ApprovedImmutable, + "removed-model", + &invalidated, + )); + assert!(!should_auto_migrate_session_model( + SessionModelBindingPolicy::Mutable, + "active-model", + &invalidated, + )); + } + #[test] fn persisted_round_preserves_deferred_wire_call_and_effective_identity() { let assistant = Message::assistant_with_tools( @@ -7028,6 +7074,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: None, subagent_type: None, + continuation_policy: None, }, ) .await @@ -7049,6 +7096,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: None, subagent_type: None, + continuation_policy: None, }) ); @@ -7088,6 +7136,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: Some("tool-1".to_string()), subagent_type: Some("Explore".to_string()), + continuation_policy: None, }); persistence_manager .save_session_metadata(workspace.path(), &matched_root) @@ -7109,6 +7158,7 @@ mod tests { parent_turn_index: None, parent_tool_call_id: Some("tool-child".to_string()), subagent_type: Some("Explore".to_string()), + continuation_policy: None, }); persistence_manager .save_session_metadata(workspace.path(), &matched_grandchild) @@ -7130,6 +7180,7 @@ mod tests { parent_turn_index: Some(1), parent_tool_call_id: Some("tool-2".to_string()), subagent_type: Some("Explore".to_string()), + continuation_policy: None, }); persistence_manager .save_session_metadata(workspace.path(), &unmatched_root) @@ -7150,6 +7201,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: None, subagent_type: None, + continuation_policy: None, }); persistence_manager .save_session_metadata(workspace.path(), &visible_review_child) diff --git a/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs index f3e9b5afc9..51144990e7 100644 --- a/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs +++ b/src/crates/assembly/core/src/agentic/skill_agent_snapshot.rs @@ -183,6 +183,7 @@ async fn load_subagent_entries( workspace_root, list_scope: SubagentListScope::TaskVisible, include_disabled: false, + external_sources_supported: false, }) .await; diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs index c49ab9c942..a6d2910b0b 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/execution.rs @@ -1,4 +1,5 @@ use super::*; +use crate::agentic::core::{SessionContinuationPolicy, SessionModelBindingPolicy}; fn build_deep_review_subagent_context( role: DeepReviewSubagentRole, @@ -52,6 +53,9 @@ struct BackgroundTaskStartRequest<'a> { context_mode: SubagentContextMode, target_session_id: Option, subagent_type: Option, + logical_subagent_type: Option, + continuation_policy: SessionContinuationPolicy, + model_binding_policy: SessionModelBindingPolicy, effective_workspace_path: Option, model_id: Option, subagent_context: Option>, @@ -61,6 +65,7 @@ struct BackgroundTaskStartRequest<'a> { tool_call_id: String, session_id: String, dialog_turn_id: String, + external_generation_lease: Option, } impl TaskTool { @@ -182,6 +187,11 @@ impl TaskTool { let is_auto_retry = is_retry && requested_auto_retry; let is_deep_review_parent = Self::is_deep_review_context(Some(context)); + let mut external_generation_lease = None; + let mut supports_follow_up = true; + let mut logical_subagent_type = None; + let mut continuation_policy = SessionContinuationPolicy::Reusable; + let mut model_binding_policy = SessionModelBindingPolicy::Mutable; let subagent_type = match context_mode { SubagentContextMode::Fresh => { if target_session_id.is_some() { @@ -201,12 +211,44 @@ impl TaskTool { all_agent_types.join(", ") ))); } - Some(subagent_type) + let binding = get_agent_registry() + .resolve_subagent_for_fresh_invocation( + &subagent_type, + context.workspace_root(), + !context.is_remote(), + ) + .ok_or_else(|| { + BitFunError::tool(format!( + "candidate_unavailable: subagent_type {} changed before the invocation could start", + subagent_type + )) + })?; + supports_follow_up = binding.supports_follow_up; + if !supports_follow_up && model_id.is_some() { + return Err(BitFunError::tool( + "external_subagent_model_override_unsupported: external subagents use the approved model binding" + .to_string(), + )); + } + if !supports_follow_up && allow_review_follow_up { + return Err(BitFunError::tool( + "external_subagent_follow_up_unsupported: external subagents are fresh-only" + .to_string(), + )); + } + logical_subagent_type = Some(binding.logical_id.clone()); + continuation_policy = binding.continuation_policy; + model_binding_policy = binding.model_binding_policy; + external_generation_lease = binding.lease; + Some(binding.runtime_agent_key) } } SubagentContextMode::Fork => None, }; - let delegate_target_label = match subagent_type.as_deref() { + let delegate_target_label = match logical_subagent_type + .as_deref() + .or(subagent_type.as_deref()) + { Some(subagent_type) => format!("subagent '{}'", subagent_type), None if target_session_id.is_some() => "existing subagent session".to_string(), None => "forked subagent".to_string(), @@ -546,6 +588,9 @@ impl TaskTool { context_mode, target_session_id, subagent_type, + logical_subagent_type, + continuation_policy, + model_binding_policy, effective_workspace_path, model_id, subagent_context, @@ -555,6 +600,7 @@ impl TaskTool { tool_call_id, session_id, dialog_turn_id, + external_generation_lease, }) .await; } @@ -565,6 +611,9 @@ impl TaskTool { context_mode, target_session_id, subagent_type, + logical_subagent_type, + continuation_policy, + model_binding_policy, effective_workspace_path, model_id, subagent_context, @@ -583,6 +632,8 @@ impl TaskTool { deep_review_effective_policy, is_retry, start_time, + supports_follow_up, + external_generation_lease, ) .await } @@ -596,6 +647,9 @@ impl TaskTool { context_mode, target_session_id, subagent_type, + logical_subagent_type, + continuation_policy, + model_binding_policy, effective_workspace_path, model_id, subagent_context, @@ -605,6 +659,7 @@ impl TaskTool { tool_call_id, session_id, dialog_turn_id, + external_generation_lease, } = request; let parent_info = SubagentParentInfo { tool_call_id, @@ -618,11 +673,15 @@ impl TaskTool { context_mode, target_session_id, subagent_type, + logical_subagent_type, + continuation_policy, + model_binding_policy, workspace_path: effective_workspace_path, model_id, subagent_parent_info: parent_info, context: subagent_context.unwrap_or_default(), delegation_policy: context.delegation_policy().spawn_child(), + external_generation_lease, }, timeout_seconds, allow_review_follow_up, @@ -651,6 +710,9 @@ impl TaskTool { context_mode: SubagentContextMode, target_session_id: Option, subagent_type: Option, + logical_subagent_type: Option, + continuation_policy: SessionContinuationPolicy, + model_binding_policy: SessionModelBindingPolicy, effective_workspace_path: Option, model_id: Option, subagent_context: Option>, @@ -669,6 +731,8 @@ impl TaskTool { deep_review_effective_policy: Option, is_retry: bool, start_time: Instant, + supports_follow_up: bool, + external_generation_lease: Option, ) -> BitFunResult> { let mut deep_review_active_guard = deep_review_active_guard; let mut provider_capacity_retry = @@ -699,11 +763,15 @@ impl TaskTool { context_mode, target_session_id: target_session_id.clone(), subagent_type: subagent_type.clone(), + logical_subagent_type: logical_subagent_type.clone(), + continuation_policy, + model_binding_policy, workspace_path: effective_workspace_path.clone(), model_id: model_id.clone(), subagent_parent_info: parent_info, context: subagent_context.clone().unwrap_or_default(), delegation_policy: context.delegation_policy().spawn_child(), + external_generation_lease: external_generation_lease.clone(), }, context.cancellation_token(), timeout_seconds, @@ -976,12 +1044,14 @@ impl TaskTool { result.ledger_event_id(), &retry_hint, ); - if let Some(subagent_session_id) = result.session_id() { - data["session_id"] = json!(subagent_session_id); - result_for_assistant.push_str(&format!( + if supports_follow_up { + if let Some(subagent_session_id) = result.session_id() { + data["session_id"] = json!(subagent_session_id); + result_for_assistant.push_str(&format!( "\nUse this session_id to continue the same subagent session.", subagent_session_id )); + } } Ok(vec![ToolResult::Result { diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs index e0c0c849a7..ee5b67a29f 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/mod.rs @@ -100,6 +100,7 @@ impl TaskTool { workspace_root, list_scope: SubagentListScope::TaskVisible, include_disabled: false, + external_sources_supported: context.is_none_or(|ctx| !ctx.is_remote()), }) .await } diff --git a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs index 874c9644ef..4dae389cee 100644 --- a/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs +++ b/src/crates/assembly/core/src/agentic/tools/implementations/task/tests.rs @@ -62,6 +62,7 @@ fn register_prompt_order_test_subagent( SubAgentSource::Builtin => crate::agentic::agents::AgentSource::Builtin, SubAgentSource::Project => crate::agentic::agents::AgentSource::Project, SubAgentSource::User => crate::agentic::agents::AgentSource::User, + SubAgentSource::External => crate::agentic::agents::AgentSource::External, }, Some(source), custom_config, diff --git a/src/crates/assembly/core/src/external_sources.rs b/src/crates/assembly/core/src/external_sources.rs index 8ac90b26b8..3b2aa6d192 100644 --- a/src/crates/assembly/core/src/external_sources.rs +++ b/src/crates/assembly/core/src/external_sources.rs @@ -4,29 +4,42 @@ //! catalog and product surfaces remain provider- and ecosystem-neutral. pub use bitfun_product_domains::external_sources::{ - prompt_command_conflict_key, ExpandedPromptCommand, ExternalSourceCatalogEntry, - ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, - ExternalSourceLifecycleState, ExternalToolActivationState, ExternalToolApprovalRequest, - ExternalToolCapability, ExternalToolCatalogEntry, ExternalToolConflict, - ExternalToolRuntimeKind, PromptCommandAvailability, PromptCommandCatalogEntry, - PromptCommandDefinition, SourceKey, + prompt_command_conflict_key, ExpandedPromptCommand, ExternalSourceAssetKind, + ExternalSourceCatalogEntry, ExternalSourceCatalogSnapshot, ExternalSourceDiagnostic, + ExternalSourceDiagnosticSeverity, ExternalSourceLifecycleState, ExternalToolActivationState, + ExternalToolApprovalRequest, ExternalToolCapability, ExternalToolCatalogEntry, + ExternalToolConflict, ExternalToolRuntimeKind, PromptCommandAvailability, + PromptCommandCatalogEntry, PromptCommandDefinition, SourceKey, +}; +pub use bitfun_product_domains::external_subagents::{ + ExternalSubagentActivationState, ExternalSubagentCompatibilityState, ExternalSubagentConflict, + ExternalSubagentConflictCandidate, ExternalSubagentSummary, }; +use crate::external_subagents::{ + reconcile_external_subagents, ExternalSubagentDecisions, ExternalSubagentProductState, + DISABLED_SUBAGENT_CONFLICT_CHOICE, +}; use crate::external_tools::{ begin_external_tool_workspace_recovery, external_tool_workspace_requires_recovery, merge_tool_state, reconcile_external_tools, release_external_tool_workspace, reset_external_tool_workspace_recovery_budget, ExternalToolDecisions, ExternalToolProductState, TOOL_CONFLICT_RESELECTION_REQUIRED, UNRESOLVED_TOOL_CONFLICT_CHOICE, }; +use crate::service::config::{subscribe_config_updates, ConfigUpdateEvent}; use bitfun_external_sources::{ ExternalSourceCoordinator, ExternalSourceDiscoveryRequest, ExternalSourceDiscoveryResult, + ExternalSubagentCoordinator, ExternalSubagentDiscoveryRequest, ExternalSubagentDiscoveryResult, ExternalToolCoordinator, ExternalToolDiscoveryRequest, ExternalToolDiscoveryResult, }; -use bitfun_opencode_adapter::{OpenCodeCommandProvider, OpenCodeToolProvider}; +use bitfun_opencode_adapter::{ + OpenCodeCommandProvider, OpenCodeSubagentProvider, OpenCodeToolProvider, +}; use bitfun_product_domains::external_sources::{ - ExecutionDomainId, ExternalSourceContext, ExternalToolSourceProvider, + ExecutionDomainId, ExternalSourceContext, ExternalSourceScope, ExternalToolSourceProvider, PromptCommandSourceProvider, }; +use bitfun_product_domains::external_subagents::ExternalSubagentSourceProvider; use bitfun_services_core::json_store::JsonFileStore; use bitfun_services_integrations::file_watch::{FileWatchService, FileWatcherConfig}; use dashmap::{mapref::entry::Entry, DashMap}; @@ -42,6 +55,7 @@ use tokio::sync::broadcast; const PROVIDER_DISCOVERY_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); const EXTERNAL_SOURCE_PREFERENCES_FILE: &str = "external-sources.json"; +const SUBAGENT_CONFLICT_RESELECTION_REQUIRED: &str = "__bitfun_reselection_required__"; #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] @@ -60,6 +74,16 @@ struct ExternalSourcesConfig { declined_tool_decisions: BTreeMap, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] tool_conflict_choices: BTreeMap, + #[serde(default)] + preference_revision: u64, + #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] + approved_subagent_envelopes: BTreeSet, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + declined_subagent_decisions: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + subagent_conflict_choices: BTreeMap, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + subagent_conflict_lineage_current_keys: BTreeMap, } #[derive(Debug, Clone)] @@ -103,6 +127,7 @@ impl ExternalSourcePreferenceStore { type SharedDiscoveryTask = Shared>; type SharedToolDiscoveryTask = Shared>; +type SharedSubagentDiscoveryTask = Shared>; struct InFlightDiscovery { task: SharedDiscoveryTask, @@ -114,6 +139,11 @@ struct InFlightToolDiscovery { wake_scheduled: bool, } +struct InFlightSubagentDiscovery { + task: SharedSubagentDiscoveryTask, + wake_scheduled: bool, +} + #[derive(Clone, Copy)] enum WorkerRecoveryPolicy { Preserve, @@ -121,10 +151,15 @@ enum WorkerRecoveryPolicy { ResetAndAttempt, } +fn config_update_refreshes_external_model_bindings(event: &ConfigUpdateEvent) -> bool { + matches!(event, ConfigUpdateEvent::ModelConfigurationUpdated) +} + struct WorkspaceExternalSourceService { workspace_root: Option, coordinator: Arc>, tool_coordinator: Arc>, + subagent_coordinator: Arc>, snapshot: StdMutex, updates: broadcast::Sender, watch_states: tokio::sync::Mutex>, @@ -136,11 +171,15 @@ struct WorkspaceExternalSourceService { tool_discovery_tasks: tokio::sync::Mutex< BTreeMap, >, + subagent_discovery_tasks: tokio::sync::Mutex< + BTreeMap, + >, initial_refresh_completed: AtomicBool, background_refresh_scheduled: AtomicBool, initial_refresh_gate: tokio::sync::Mutex<()>, keepalive_started: AtomicBool, last_access_epoch_seconds: AtomicU64, + subagent_expiry_schedule: AtomicU64, watcher: Arc, #[cfg(test)] tool_decision_gate_waiting: tokio::sync::Notify, @@ -160,7 +199,11 @@ impl WorkspaceExternalSourceService { let mut coordinator = ExternalSourceCoordinator::new(context.clone(), providers)?; let tool_providers: Vec> = vec![Arc::new(OpenCodeToolProvider::default())]; - let mut tool_coordinator = ExternalToolCoordinator::new(context, tool_providers)?; + let mut tool_coordinator = ExternalToolCoordinator::new(context.clone(), tool_providers)?; + let subagent_providers: Vec> = + vec![Arc::new(OpenCodeSubagentProvider::default())]; + let mut subagent_coordinator = + ExternalSubagentCoordinator::new(context, subagent_providers)?; let preferences = read_external_sources_config().await?; let suppressed_sources = preferences .suppressed_source_keys @@ -168,22 +211,26 @@ impl WorkspaceExternalSourceService { .cloned() .collect::>(); coordinator.replace_suppressed_sources(suppressed_sources.clone()); - tool_coordinator.replace_suppressed_sources(suppressed_sources); + tool_coordinator.replace_suppressed_sources(suppressed_sources.clone()); + subagent_coordinator.replace_suppressed_sources(suppressed_sources); coordinator.replace_conflict_choices(preferences.conflict_choices.clone()); coordinator.replace_conflict_lineage_current_keys( preferences.conflict_lineage_current_keys.clone(), ); coordinator.replace_conflicted_candidate_ids(preferences.conflicted_candidate_ids.clone()); - let initial_snapshot = merge_tool_state( + let mut initial_snapshot = merge_tool_state( coordinator.snapshot(), &tool_coordinator.snapshot(), ExternalToolProductState::default(), ); + initial_snapshot.subagent_generation = subagent_coordinator.snapshot().generation; + initial_snapshot.preference_revision = preferences.preference_revision; let (updates, _) = broadcast::channel(32); let service = Arc::new(Self { workspace_root, coordinator: Arc::new(StdMutex::new(coordinator)), tool_coordinator: Arc::new(StdMutex::new(tool_coordinator)), + subagent_coordinator: Arc::new(StdMutex::new(subagent_coordinator)), snapshot: StdMutex::new(initial_snapshot), updates, watch_states: tokio::sync::Mutex::new(BTreeMap::new()), @@ -191,11 +238,13 @@ impl WorkspaceExternalSourceService { product_rebuild_gate: tokio::sync::Mutex::new(()), discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), tool_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), + subagent_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), initial_refresh_completed: AtomicBool::new(false), background_refresh_scheduled: AtomicBool::new(false), initial_refresh_gate: tokio::sync::Mutex::new(()), keepalive_started: AtomicBool::new(false), last_access_epoch_seconds: AtomicU64::new(epoch_seconds()), + subagent_expiry_schedule: AtomicU64::new(0), watcher: Arc::new(FileWatchService::new(FileWatcherConfig::default())), #[cfg(test)] tool_decision_gate_waiting: tokio::sync::Notify::new(), @@ -203,6 +252,7 @@ impl WorkspaceExternalSourceService { tool_decision_gate_acquired: tokio::sync::Notify::new(), }); service.start_watching().await; + service.start_model_config_watching(); Ok(service) } @@ -250,14 +300,24 @@ impl WorkspaceExternalSourceService { let scheduled = self.prepare_discovery_tasks(requests).await; let tool_requests = lock_tool_coordinator(&self.tool_coordinator).discovery_requests(); let tool_scheduled = self.prepare_tool_discovery_tasks(tool_requests).await; - let (polled, tool_polled) = tokio::join!( + let subagent_requests = + lock_subagent_coordinator(&self.subagent_coordinator).discovery_requests(); + let subagent_scheduled = self + .prepare_subagent_discovery_tasks(subagent_requests) + .await; + let (polled, tool_polled, subagent_polled) = tokio::join!( poll_discovery_tasks(scheduled, PROVIDER_DISCOVERY_TIMEOUT), poll_tool_discovery_tasks(tool_scheduled, PROVIDER_DISCOVERY_TIMEOUT), + poll_subagent_discovery_tasks(subagent_scheduled, PROVIDER_DISCOVERY_TIMEOUT), ); let results = self.finish_discovery_poll(polled).await; let tool_results = self.finish_tool_discovery_poll(tool_polled).await; + let subagent_results = self.finish_subagent_discovery_poll(subagent_polled).await; let command_snapshot = lock_coordinator(&self.coordinator).apply_discovery_results(results); lock_tool_coordinator(&self.tool_coordinator).apply_discovery_results(tool_results); + let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator) + .apply_discovery_results(subagent_results); + self.schedule_subagent_last_valid_expiry(&subagent_snapshot); self.ensure_watch_roots().await; let snapshot = self .rebuild_product_snapshot_with_worker_recovery(command_snapshot, &recovery_targets) @@ -311,7 +371,7 @@ impl WorkspaceExternalSourceService { ) -> Result { let _rebuild_guard = self.product_rebuild_gate.lock().await; let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); - let preferences = read_external_sources_config().await?; + let mut preferences = read_external_sources_config().await?; let mut state = reconcile_external_tools( self.workspace_root.as_deref(), "local-user", @@ -327,6 +387,7 @@ impl WorkspaceExternalSourceService { if let Err(error) = persist_observed_tool_conflicts(&state.conflicts).await { state.diagnostics.push(ExternalSourceDiagnostic { severity: bitfun_product_domains::external_sources::ExternalSourceDiagnosticSeverity::Warning, + asset_kind: bitfun_product_domains::external_sources::ExternalSourceAssetKind::Tool, code: "external_tool.conflict_history_write_failed".to_string(), message: format!( "Could not persist external tool conflict history; the current catalog remains fail-closed: {error}" @@ -336,7 +397,90 @@ impl WorkspaceExternalSourceService { } let tool_snapshot = lock_tool_coordinator(&self.tool_coordinator).snapshot(); let mut snapshot = merge_tool_state(command_snapshot, &tool_snapshot, state); + let subagent_snapshot = lock_subagent_coordinator(&self.subagent_coordinator).snapshot(); + let mut subagent_state = reconcile_external_subagents( + self.workspace_root.as_deref(), + "local-user", + &subagent_snapshot, + ExternalSubagentDecisions { + approved_envelopes: &preferences.approved_subagent_envelopes, + declined_decisions: &preferences.declined_subagent_decisions, + conflict_choices: &preferences.subagent_conflict_choices, + conflict_lineage_current_keys: &preferences.subagent_conflict_lineage_current_keys, + }, + ) + .await; + match persist_observed_subagent_conflicts( + &subagent_state.observed_conflict_lineage_current_keys, + ) + .await + { + Ok((_history_changed, authoritative)) => { + let decisions_changed = authoritative.preference_revision + != preferences.preference_revision + || authoritative.approved_subagent_envelopes + != preferences.approved_subagent_envelopes + || authoritative.declined_subagent_decisions + != preferences.declined_subagent_decisions + || authoritative.subagent_conflict_choices + != preferences.subagent_conflict_choices + || authoritative.subagent_conflict_lineage_current_keys + != preferences.subagent_conflict_lineage_current_keys; + preferences = authoritative; + if decisions_changed { + subagent_state = reconcile_external_subagents( + self.workspace_root.as_deref(), + "local-user", + &subagent_snapshot, + ExternalSubagentDecisions { + approved_envelopes: &preferences.approved_subagent_envelopes, + declined_decisions: &preferences.declined_subagent_decisions, + conflict_choices: &preferences.subagent_conflict_choices, + conflict_lineage_current_keys: &preferences + .subagent_conflict_lineage_current_keys, + }, + ) + .await; + } + } + Err(error) => { + snapshot.diagnostics.push(ExternalSourceDiagnostic::warning( + "external_subagent.conflict_history_write_failed", + format!( + "Could not persist external subagent conflict history; routes remain unavailable: {error}" + ), + None, + ).with_asset_kind(ExternalSourceAssetKind::Subagent)); + } + } + merge_subagent_state( + &mut snapshot, + &subagent_snapshot, + &subagent_state, + preferences.preference_revision, + ); + if let Some(workspace_root) = self.workspace_root.as_deref() { + crate::agentic::agents::get_agent_registry().install_external_subagent_routes( + workspace_root, + subagent_state.registrations, + subagent_state.routes, + ); + } + sanitize_external_snapshot_locations(&mut snapshot, self.workspace_root.as_deref()); let mut current = lock_snapshot(&self.snapshot); + let subagent_changed = snapshot.subagents != current.subagents + || snapshot.subagent_conflicts != current.subagent_conflicts + || snapshot.pending_subagent_approvals != current.pending_subagent_approvals + || snapshot.preference_revision != current.preference_revision; + snapshot.subagent_generation = if subagent_changed { + snapshot + .subagent_generation + .max(current.subagent_generation.saturating_add(1)) + } else { + snapshot + .subagent_generation + .max(current.subagent_generation) + }; snapshot.generation = snapshot .generation .max(current.generation.saturating_add(1)); @@ -502,6 +646,86 @@ impl WorkspaceExternalSourceService { results } + async fn prepare_subagent_discovery_tasks( + &self, + requests: Vec, + ) -> Vec<( + bitfun_product_domains::external_sources::ProviderId, + SharedSubagentDiscoveryTask, + bool, + )> { + let mut tasks = self.subagent_discovery_tasks.lock().await; + requests + .into_iter() + .map(|request| { + let provider_id = request.provider_id().clone(); + if let Some(in_flight) = tasks.get(&provider_id) { + return (provider_id, in_flight.task.clone(), false); + } + let task = spawn_subagent_discovery_task(request); + tasks.insert( + provider_id.clone(), + InFlightSubagentDiscovery { + task: task.clone(), + wake_scheduled: false, + }, + ); + (provider_id, task, true) + }) + .collect() + } + + async fn finish_subagent_discovery_poll( + self: &Arc, + polled: Vec, + ) -> Vec { + let mut results = Vec::with_capacity(polled.len()); + let mut wake_tasks = Vec::new(); + let mut tasks = self.subagent_discovery_tasks.lock().await; + for poll in polled { + match poll { + SubagentDiscoveryPoll::Complete(result) => { + tasks.remove(&result.provider_id().clone()); + results.push(result); + } + SubagentDiscoveryPoll::InFlight(provider_id) => { + results.push(subagent_discovery_failure( + provider_id, + "external_subagent.discovery_in_progress", + "subagent provider discovery is still running; using its last valid version", + )); + } + SubagentDiscoveryPoll::TimedOut(provider_id) => { + if let Some(in_flight) = tasks.get_mut(&provider_id) { + if !in_flight.wake_scheduled { + in_flight.wake_scheduled = true; + wake_tasks.push((provider_id.clone(), in_flight.task.clone())); + } + } + results.push(subagent_discovery_failure( + provider_id, + "external_subagent.discovery_timeout", + "subagent provider discovery exceeded the 5 second deadline", + )); + } + } + } + drop(tasks); + for (provider_id, task) in wake_tasks { + let weak = Arc::downgrade(self); + tokio::spawn(async move { + let result = task.await; + let Some(service) = weak.upgrade() else { + return; + }; + service + .complete_deferred_subagent_discovery(provider_id, result) + .await; + }); + } + results + } + async fn complete_deferred_discovery( &self, provider_id: bitfun_product_domains::external_sources::ProviderId, @@ -547,6 +771,60 @@ impl WorkspaceExternalSourceService { } } + async fn complete_deferred_subagent_discovery( + self: &Arc, + provider_id: bitfun_product_domains::external_sources::ProviderId, + result: ExternalSubagentDiscoveryResult, + ) { + let _refresh_guard = self.refresh_gate.lock().await; + if self + .subagent_discovery_tasks + .lock() + .await + .remove(&provider_id) + .is_none() + { + return; + } + let subagent_snapshot = + lock_subagent_coordinator(&self.subagent_coordinator).apply_discovery_result(result); + self.schedule_subagent_last_valid_expiry(&subagent_snapshot); + self.ensure_watch_roots().await; + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + if let Ok(snapshot) = self.rebuild_product_snapshot(command_snapshot).await { + let _ = self.updates.send(snapshot); + } + } + + fn schedule_subagent_last_valid_expiry( + self: &Arc, + snapshot: &bitfun_external_sources::ExternalSubagentCoordinatorSnapshot, + ) { + let schedule = self + .subagent_expiry_schedule + .fetch_add(1, Ordering::AcqRel) + .saturating_add(1); + let Some(deadline) = snapshot.next_refresh_deadline else { + return; + }; + let weak = Arc::downgrade(self); + tokio::spawn(async move { + tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await; + let Some(service) = weak.upgrade() else { + return; + }; + if service.subagent_expiry_schedule.load(Ordering::Acquire) != schedule { + return; + } + let _refresh_guard = service.refresh_gate.lock().await; + lock_subagent_coordinator(&service.subagent_coordinator).expire_last_valid(); + let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { + let _ = service.updates.send(snapshot); + } + }); + } + fn ensure_background_refresh(self: &Arc) { if self.initial_refresh_completed.load(Ordering::Acquire) || self @@ -616,6 +894,10 @@ impl WorkspaceExternalSourceService { if should_remove { workspace_services().remove(&key); release_external_tool_workspace(key.as_deref()).await; + if let Some(workspace_root) = key.as_deref() { + crate::agentic::agents::get_agent_registry() + .release_external_subagent_workspace(workspace_root); + } } } break; @@ -644,7 +926,13 @@ impl WorkspaceExternalSourceService { let known = coordinator.set_source_enabled(stable_key, enabled).is_ok(); (previous, known) }; - if !command_known && !tool_known { + let (previous_subagents, subagent_known) = { + let mut coordinator = lock_subagent_coordinator(&self.subagent_coordinator); + let previous = coordinator.suppressed_sources().clone(); + let known = coordinator.set_source_enabled(stable_key, enabled).is_ok(); + (previous, known) + }; + if !command_known && !tool_known && !subagent_known { return Err(format!("unknown external source: {stable_key}")); } let authoritative = match persist_source_enabled_change(stable_key, enabled).await { @@ -653,12 +941,16 @@ impl WorkspaceExternalSourceService { lock_coordinator(&self.coordinator).replace_suppressed_sources(previous_commands); lock_tool_coordinator(&self.tool_coordinator) .replace_suppressed_sources(previous_tools); + lock_subagent_coordinator(&self.subagent_coordinator) + .replace_suppressed_sources(previous_subagents); return Err(error); } }; lock_coordinator(&self.coordinator).replace_suppressed_sources(authoritative.clone()); lock_tool_coordinator(&self.tool_coordinator) .replace_suppressed_sources(authoritative.clone()); + lock_subagent_coordinator(&self.subagent_coordinator) + .replace_suppressed_sources(authoritative.clone()); propagate_suppressed_sources(&authoritative); let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); self.rebuild_product_snapshot(command_snapshot).await @@ -780,6 +1072,126 @@ impl WorkspaceExternalSourceService { self.rebuild_product_snapshot(command_snapshot).await } + async fn set_subagent_activation( + &self, + candidate_id: &str, + approved: bool, + expected_subagent_generation: u64, + expected_preference_revision: u64, + decision_key: &str, + ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + let snapshot = self.snapshot(); + if snapshot.subagent_generation != expected_subagent_generation + || snapshot.preference_revision != expected_preference_revision + { + return Err( + "stale_action: external subagent catalog changed; refresh before retrying" + .to_string(), + ); + } + let summary = snapshot + .subagents + .iter() + .find(|summary| { + summary.candidate_id == candidate_id && summary.decision_key == decision_key + }) + .ok_or_else(|| { + "candidate_unavailable: external subagent candidate is no longer available" + .to_string() + })?; + if matches!( + summary.activation_state, + ExternalSubagentActivationState::Blocked + | ExternalSubagentActivationState::Unavailable + | ExternalSubagentActivationState::Conflict + ) { + return Err( + "unsupported_definition: external subagent cannot be activated in its current state" + .to_string(), + ); + } + validate_subagent_decision_value(candidate_id, "candidate id")?; + validate_subagent_decision_value(decision_key, "decision key")?; + let preferences = + persist_subagent_activation(decision_key, approved, expected_preference_revision) + .await?; + propagate_subagent_preferences(&preferences); + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await + } + + async fn choose_subagent_conflict( + &self, + conflict_key: &str, + candidate_id: &str, + approve_external: bool, + expected_subagent_generation: u64, + expected_preference_revision: u64, + ) -> Result { + let _refresh_guard = self.refresh_gate.lock().await; + let snapshot = self.snapshot(); + if snapshot.subagent_generation != expected_subagent_generation + || snapshot.preference_revision != expected_preference_revision + { + return Err( + "stale_action: external subagent catalog changed; refresh before retrying" + .to_string(), + ); + } + let conflict = snapshot + .subagent_conflicts + .iter() + .find(|conflict| conflict.conflict_key == conflict_key) + .ok_or_else(|| { + "conflict_choice_required: external subagent conflict is stale or unknown" + .to_string() + })?; + let external = if candidate_id == DISABLED_SUBAGENT_CONFLICT_CHOICE { + false + } else { + conflict + .candidates + .iter() + .find(|candidate| candidate.candidate_id == candidate_id) + .map(|candidate| candidate.external) + .ok_or_else(|| { + "candidate_unavailable: conflict candidate is no longer available".to_string() + })? + }; + let approval_key = if external { + let summary = snapshot + .subagents + .iter() + .find(|summary| summary.candidate_id == candidate_id) + .ok_or_else(|| { + "candidate_unavailable: external subagent candidate is no longer available" + .to_string() + })?; + if !approve_external { + return Err( + "approval_required: selecting an external subagent must atomically approve its current capability envelope" + .to_string(), + ); + } + Some(summary.decision_key.clone()) + } else { + None + }; + validate_subagent_decision_value(conflict_key, "conflict key")?; + validate_subagent_decision_value(candidate_id, "candidate id")?; + let preferences = persist_subagent_conflict_choice( + conflict_key, + candidate_id, + approval_key.as_deref(), + expected_preference_revision, + ) + .await?; + propagate_subagent_preferences(&preferences); + let command_snapshot = lock_coordinator(&self.coordinator).snapshot(); + self.rebuild_product_snapshot(command_snapshot).await + } + async fn expand_command( self: &Arc, name: &str, @@ -856,6 +1268,43 @@ impl WorkspaceExternalSourceService { }); } + fn start_model_config_watching(self: &Arc) { + let Some(mut receiver) = subscribe_config_updates() else { + return; + }; + let weak = Arc::downgrade(self); + tokio::spawn(async move { + loop { + let should_refresh = match receiver.recv().await { + Ok(event) => config_update_refreshes_external_model_bindings(&event), + Err(broadcast::error::RecvError::Lagged(_)) => true, + Err(broadcast::error::RecvError::Closed) => break, + }; + if !should_refresh { + continue; + } + let Some(service) = weak.upgrade() else { + break; + }; + let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + match service.rebuild_product_snapshot(command_snapshot).await { + Ok(snapshot) => { + let _ = service.updates.send(snapshot); + } + Err(error) => log::warn!( + "External source model-binding refresh failed for '{}': {}", + service + .workspace_root + .as_deref() + .map(|path| path.display().to_string()) + .unwrap_or_else(|| "user-global".to_string()), + error + ), + } + } + }); + } + async fn ensure_watch_roots(&self) { let watch_roots = self.watch_roots(); let watcher = Arc::clone(&self.watcher); @@ -894,6 +1343,7 @@ impl WorkspaceExternalSourceService { .watch_roots() .into_iter() .chain(lock_tool_coordinator(&self.tool_coordinator).watch_roots()) + .chain(lock_subagent_coordinator(&self.subagent_coordinator).watch_roots()) { roots .entry(root.path) @@ -929,6 +1379,12 @@ enum ToolDiscoveryPoll { TimedOut(bitfun_product_domains::external_sources::ProviderId), } +enum SubagentDiscoveryPoll { + Complete(ExternalSubagentDiscoveryResult), + InFlight(bitfun_product_domains::external_sources::ProviderId), + TimedOut(bitfun_product_domains::external_sources::ProviderId), +} + async fn poll_discovery_tasks( scheduled: Vec<( bitfun_product_domains::external_sources::ProviderId, @@ -983,6 +1439,33 @@ async fn poll_tool_discovery_tasks( .await } +async fn poll_subagent_discovery_tasks( + scheduled: Vec<( + bitfun_product_domains::external_sources::ProviderId, + SharedSubagentDiscoveryTask, + bool, + )>, + timeout: std::time::Duration, +) -> Vec { + join_all( + scheduled + .into_iter() + .map(|(provider_id, task, is_new)| async move { + if !is_new { + return match task.clone().now_or_never() { + Some(result) => SubagentDiscoveryPoll::Complete(result), + None => SubagentDiscoveryPoll::InFlight(provider_id), + }; + } + match tokio::time::timeout(timeout, task).await { + Ok(result) => SubagentDiscoveryPoll::Complete(result), + Err(_) => SubagentDiscoveryPoll::TimedOut(provider_id), + } + }), + ) + .await +} + fn spawn_discovery_task(request: ExternalSourceDiscoveryRequest) -> SharedDiscoveryTask { let provider_id = request.provider_id().clone(); async move { @@ -1015,6 +1498,24 @@ fn spawn_tool_discovery_task(request: ExternalToolDiscoveryRequest) -> SharedToo .shared() } +fn spawn_subagent_discovery_task( + request: ExternalSubagentDiscoveryRequest, +) -> SharedSubagentDiscoveryTask { + let provider_id = request.provider_id().clone(); + async move { + match tokio::task::spawn_blocking(move || request.execute()).await { + Ok(result) => result, + Err(error) => subagent_discovery_failure( + provider_id, + "external_subagent.discovery_task_failed", + &format!("subagent provider discovery task failed: {error}"), + ), + } + } + .boxed() + .shared() +} + fn discovery_failure( provider_id: bitfun_product_domains::external_sources::ProviderId, code: &str, @@ -1041,6 +1542,19 @@ fn tool_discovery_failure( ) } +fn subagent_discovery_failure( + provider_id: bitfun_product_domains::external_sources::ProviderId, + code: &str, + message: &str, +) -> ExternalSubagentDiscoveryResult { + ExternalSubagentDiscoveryResult::failed( + provider_id, + bitfun_product_domains::external_sources::ExternalSourceProviderError::new( + code, message, true, + ), + ) +} + fn lock_coordinator( coordinator: &StdMutex, ) -> MutexGuard<'_, ExternalSourceCoordinator> { @@ -1062,6 +1576,15 @@ fn lock_tool_coordinator( } } +fn lock_subagent_coordinator( + coordinator: &StdMutex, +) -> MutexGuard<'_, ExternalSubagentCoordinator> { + match coordinator.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } +} + fn lock_snapshot( snapshot: &StdMutex, ) -> MutexGuard<'_, ExternalSourceCatalogSnapshot> { @@ -1098,6 +1621,205 @@ fn normalize_workspace_root(workspace_root: Option<&Path>) -> Result Option { + let normalized_location = location.replace('\\', "/"); + let normalized_root = root.to_string_lossy().replace('\\', "/"); + let root = normalized_root.trim_end_matches('/'); + let prefix = normalized_location.get(..root.len())?; + let relative = normalized_location.get(root.len()..)?; + (prefix.eq_ignore_ascii_case(root) && relative.starts_with('/')) + .then(|| relative.trim_start_matches('/').to_string()) + .or_else(|| (prefix.eq_ignore_ascii_case(root) && relative.is_empty()).then(String::new)) +} + +pub(super) fn safe_external_source_location( + scope: ExternalSourceScope, + location: &str, + workspace_root: Option<&Path>, +) -> String { + let normalized = location.replace('\\', "/"); + let components = normalized + .split('/') + .filter(|component| { + !component.is_empty() + && *component != "." + && *component != ".." + && !component.ends_with(':') + }) + .collect::>(); + let generic_tail = || { + components + .iter() + .position(|component| *component == ".config") + .map(|index| components[index..].join("/")) + .unwrap_or_else(|| components[components.len().saturating_sub(3)..].join("/")) + }; + + match scope { + ExternalSourceScope::Project | ExternalSourceScope::WorkspaceLocal => { + let relative = workspace_root + .and_then(|root| relative_display_path(&normalized, root)) + .unwrap_or_else(generic_tail); + format!("/{}", relative.trim_start_matches('/')) + } + ExternalSourceScope::UserGlobal => { + let relative = dirs::home_dir() + .as_deref() + .and_then(|home| relative_display_path(&normalized, home)) + .unwrap_or_else(generic_tail); + format!("~/{}", relative.trim_start_matches('/')) + } + ExternalSourceScope::RemoteUser | ExternalSourceScope::RemoteProject => { + format!("/{}", generic_tail().trim_start_matches('/')) + } + _ => format!( + "/{}", + generic_tail().trim_start_matches('/') + ), + } +} + +fn sanitize_external_snapshot_locations( + snapshot: &mut ExternalSourceCatalogSnapshot, + workspace_root: Option<&Path>, +) { + let source_scopes = snapshot + .sources + .iter() + .map(|source| (source.record.key.clone(), source.record.scope)) + .collect::>(); + let mut replacements = Vec::new(); + let mut remember_location = |scope: ExternalSourceScope, location: &str| { + if location.is_empty() { + return; + } + let safe = safe_external_source_location(scope, location, workspace_root); + let safe_prefix = format!("{}/", safe.trim_end_matches('/')); + for raw in [ + location.to_string(), + location.replace('\\', "/"), + location.replace('/', "\\"), + ] { + for (raw, safe) in [ + (format!("{raw}/"), safe_prefix.clone()), + (format!("{raw}\\"), safe_prefix.clone()), + (raw, safe.clone()), + ] { + if raw != safe && !replacements.iter().any(|(known, _)| known == &raw) { + replacements.push((raw, safe)); + } + } + } + }; + for source in &snapshot.sources { + remember_location(source.record.scope, &source.record.location); + } + for conflict in &snapshot.command_conflicts { + for candidate in &conflict.candidates { + remember_location(candidate.source_scope, &candidate.source_location); + } + } + for request in &snapshot.tool_approval_requests { + remember_location(request.source_scope, &request.source_location); + remember_location(request.source_scope, &request.working_directory); + } + for tool in &snapshot.tools { + let scope = source_scopes + .get(&tool.definition.id.target.source) + .copied() + .unwrap_or(ExternalSourceScope::WorkspaceLocal); + remember_location(scope, &tool.definition.module_path); + remember_location(scope, &tool.definition.working_directory); + } + for conflict in &snapshot.tool_conflicts { + for candidate in &conflict.candidates { + let Some(location) = candidate.source_location.as_deref() else { + continue; + }; + let scope = candidate + .source + .as_ref() + .and_then(|source| source_scopes.get(source)) + .copied() + .unwrap_or(ExternalSourceScope::WorkspaceLocal); + remember_location(scope, location); + } + } + drop(remember_location); + replacements.sort_by(|left, right| right.0.len().cmp(&left.0.len())); + let sanitize_message = |message: &mut String| { + for (raw, safe) in &replacements { + if message.contains(raw) { + *message = message.replace(raw, safe); + } + } + }; + for diagnostic in &mut snapshot.diagnostics { + sanitize_message(&mut diagnostic.message); + } + for source in &mut snapshot.sources { + for diagnostic in &mut source.record.diagnostics { + sanitize_message(&mut diagnostic.message); + } + } + + for source in &mut snapshot.sources { + source.record.location = safe_external_source_location( + source.record.scope, + &source.record.location, + workspace_root, + ); + } + for conflict in &mut snapshot.command_conflicts { + for candidate in &mut conflict.candidates { + candidate.source_location = safe_external_source_location( + candidate.source_scope, + &candidate.source_location, + workspace_root, + ); + } + } + for request in &mut snapshot.tool_approval_requests { + request.source_location = safe_external_source_location( + request.source_scope, + &request.source_location, + workspace_root, + ); + request.working_directory = safe_external_source_location( + request.source_scope, + &request.working_directory, + workspace_root, + ); + } + for tool in &mut snapshot.tools { + let scope = source_scopes + .get(&tool.definition.id.target.source) + .copied() + .unwrap_or(ExternalSourceScope::WorkspaceLocal); + tool.definition.module_path = + safe_external_source_location(scope, &tool.definition.module_path, workspace_root); + tool.definition.working_directory = safe_external_source_location( + scope, + &tool.definition.working_directory, + workspace_root, + ); + } + for conflict in &mut snapshot.tool_conflicts { + for candidate in &mut conflict.candidates { + let Some(location) = candidate.source_location.as_mut() else { + continue; + }; + let scope = candidate + .source + .as_ref() + .and_then(|source| source_scopes.get(source)) + .copied() + .unwrap_or(ExternalSourceScope::WorkspaceLocal); + *location = safe_external_source_location(scope, location, workspace_root); + } + } +} + async fn service_for( workspace_root: Option<&Path>, ) -> Result, String> { @@ -1203,6 +1925,88 @@ async fn persist_observed_tool_conflicts(conflicts: &[ExternalToolConflict]) -> .map(|_| ()) } +async fn persist_observed_subagent_conflicts( + observed: &BTreeMap, +) -> Result<(bool, ExternalSourcesConfig), String> { + let store = ExternalSourcePreferenceStore::global()?; + persist_observed_subagent_conflicts_with_store(&store, observed).await +} + +async fn persist_observed_subagent_conflicts_with_store( + store: &ExternalSourcePreferenceStore, + observed: &BTreeMap, +) -> Result<(bool, ExternalSourcesConfig), String> { + if observed.is_empty() { + return store.read().await.map(|config| (false, config)); + } + let observed = observed.clone(); + store + .update(move |config| { + let mut changed = false; + for (lineage, current_key) in observed { + let previous_key = config + .subagent_conflict_lineage_current_keys + .get(&lineage) + .cloned(); + if previous_key.as_deref() == Some(current_key.as_str()) { + continue; + } + config + .subagent_conflict_lineage_current_keys + .insert(lineage, current_key.clone()); + changed = true; + let previous_choice = previous_key + .as_ref() + .and_then(|previous_key| config.subagent_conflict_choices.remove(previous_key)); + if previous_choice.is_some() { + let replaced = config.subagent_conflict_choices.insert( + current_key, + SUBAGENT_CONFLICT_RESELECTION_REQUIRED.to_string(), + ); + changed |= replaced.as_deref() != Some(SUBAGENT_CONFLICT_RESELECTION_REQUIRED); + } + } + if changed { + config.preference_revision = config.preference_revision.saturating_add(1); + } + changed + }) + .await +} + +fn merge_subagent_state( + snapshot: &mut ExternalSourceCatalogSnapshot, + coordinator_snapshot: &bitfun_external_sources::ExternalSubagentCoordinatorSnapshot, + state: &ExternalSubagentProductState, + preference_revision: u64, +) { + snapshot.generation = snapshot.generation.max(coordinator_snapshot.generation); + snapshot.discovery_pending |= coordinator_snapshot.discovery_pending; + let known_sources = snapshot + .sources + .iter() + .map(|entry| entry.stable_key.clone()) + .collect::>(); + snapshot.sources.extend( + coordinator_snapshot + .sources + .iter() + .filter(|source| !known_sources.contains(&source.stable_key)) + .cloned(), + ); + snapshot + .sources + .sort_by(|left, right| left.stable_key.cmp(&right.stable_key)); + snapshot.subagent_generation = coordinator_snapshot.generation; + snapshot.preference_revision = preference_revision; + snapshot.subagents = state.summaries.clone(); + snapshot.subagent_conflicts = state.conflicts.clone(); + snapshot.pending_subagent_approvals = state.pending_approvals.clone(); + snapshot + .diagnostics + .extend(coordinator_snapshot.diagnostics.clone()); +} + fn reconcile_observed_tool_conflict(choices: &mut BTreeMap, conflict_key: &str) { if choices.contains_key(conflict_key) { return; @@ -1331,6 +2135,93 @@ async fn persist_tool_conflict_choice( .map(|(_, config)| config) } +async fn persist_subagent_activation( + approval_key: &str, + approved: bool, + expected_preference_revision: u64, +) -> Result { + let approval_key = approval_key.to_string(); + ExternalSourcePreferenceStore::global()? + .update(move |config| { + if config.preference_revision != expected_preference_revision { + return false; + } + if approved { + config + .approved_subagent_envelopes + .insert(approval_key.clone()); + config.declined_subagent_decisions.remove(&approval_key); + } else { + config.approved_subagent_envelopes.remove(&approval_key); + config + .declined_subagent_decisions + .insert(approval_key.clone(), approval_key); + } + config.preference_revision = config.preference_revision.saturating_add(1); + true + }) + .await + .and_then(|(applied, config)| { + applied.then_some(config).ok_or_else(|| { + "stale_action: external subagent preferences changed; refresh before retrying" + .to_string() + }) + }) +} + +async fn persist_subagent_conflict_choice( + conflict_key: &str, + candidate_id: &str, + approval_key: Option<&str>, + expected_preference_revision: u64, +) -> Result { + let store = ExternalSourcePreferenceStore::global()?; + persist_subagent_conflict_choice_with_store( + &store, + conflict_key, + candidate_id, + approval_key, + expected_preference_revision, + ) + .await +} + +async fn persist_subagent_conflict_choice_with_store( + store: &ExternalSourcePreferenceStore, + conflict_key: &str, + candidate_id: &str, + approval_key: Option<&str>, + expected_preference_revision: u64, +) -> Result { + let conflict_key = conflict_key.to_string(); + let candidate_id = candidate_id.to_string(); + let approval_key = approval_key.map(str::to_string); + store + .update(move |config| { + if config.preference_revision != expected_preference_revision { + return false; + } + config + .subagent_conflict_choices + .insert(conflict_key, candidate_id); + if let Some(approval_key) = approval_key { + config + .approved_subagent_envelopes + .insert(approval_key.clone()); + config.declined_subagent_decisions.remove(&approval_key); + } + config.preference_revision = config.preference_revision.saturating_add(1); + true + }) + .await + .and_then(|(applied, config)| { + applied.then_some(config).ok_or_else(|| { + "stale_action: external subagent preferences changed; refresh before retrying" + .to_string() + }) + }) +} + fn reconcile_versioned_tool_conflict_choice( choices: &mut BTreeMap, conflict_key: String, @@ -1354,6 +2245,8 @@ fn propagate_suppressed_sources(sources: &BTreeSet) { lock_coordinator(&service.coordinator).replace_suppressed_sources(sources.clone()); lock_tool_coordinator(&service.tool_coordinator) .replace_suppressed_sources(sources.clone()); + lock_subagent_coordinator(&service.subagent_coordinator) + .replace_suppressed_sources(sources.clone()); tokio::spawn(async move { let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { @@ -1400,6 +2293,20 @@ fn propagate_tool_preferences(_preferences: &ExternalSourcesConfig) { } } +fn propagate_subagent_preferences(_preferences: &ExternalSourcesConfig) { + for service in workspace_services().iter() { + let Some(service) = service.value().upgrade() else { + continue; + }; + tokio::spawn(async move { + let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); + if let Ok(snapshot) = service.rebuild_product_snapshot(command_snapshot).await { + let _ = service.updates.send(snapshot); + } + }); + } +} + pub(crate) fn notify_external_tool_registry_changed() { TOOL_REGISTRY_CHANGE_EPOCH.fetch_add(1, Ordering::AcqRel); if TOOL_REGISTRY_REBUILD_SCHEDULED.swap(true, Ordering::AcqRel) { @@ -1470,6 +2377,15 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R }; let tool_changed = { let mut coordinator = lock_tool_coordinator(&service.tool_coordinator); + if coordinator.suppressed_sources() != &suppressed_sources { + coordinator.replace_suppressed_sources(suppressed_sources.clone()); + true + } else { + false + } + }; + let subagent_changed = { + let mut coordinator = lock_subagent_coordinator(&service.subagent_coordinator); if coordinator.suppressed_sources() != &suppressed_sources { coordinator.replace_suppressed_sources(suppressed_sources); true @@ -1477,7 +2393,9 @@ async fn sync_service_preferences(service: &WorkspaceExternalSourceService) -> R false } }; - if command_changed || tool_changed { + let subagent_preferences_changed = + service.snapshot().preference_revision != preferences.preference_revision; + if command_changed || tool_changed || subagent_changed || subagent_preferences_changed { let command_snapshot = lock_coordinator(&service.coordinator).snapshot(); let snapshot = service.rebuild_product_snapshot(command_snapshot).await?; let _ = service.updates.send(snapshot); @@ -1495,6 +2413,15 @@ fn validate_conflict_preference(conflict_key: &str, candidate_id: &str) -> Resul Ok(()) } +fn validate_subagent_decision_value(value: &str, label: &str) -> Result<(), String> { + if value.is_empty() || value.len() > 512 || value.chars().any(char::is_control) { + return Err(format!( + "invalid_request: external subagent {label} is invalid" + )); + } + Ok(()) +} + pub async fn external_source_conflict_choices() -> Result< ( BTreeMap, @@ -1578,6 +2505,46 @@ pub async fn set_external_tool_conflict_choice( .await } +pub async fn set_external_subagent_activation( + workspace_root: Option<&Path>, + candidate_id: &str, + approved: bool, + expected_subagent_generation: u64, + expected_preference_revision: u64, + decision_key: &str, +) -> Result { + service_for(workspace_root) + .await? + .set_subagent_activation( + candidate_id, + approved, + expected_subagent_generation, + expected_preference_revision, + decision_key, + ) + .await +} + +pub async fn choose_external_subagent_conflict( + workspace_root: Option<&Path>, + conflict_key: &str, + candidate_id: &str, + approve_external: bool, + expected_subagent_generation: u64, + expected_preference_revision: u64, +) -> Result { + service_for(workspace_root) + .await? + .choose_subagent_conflict( + conflict_key, + candidate_id, + approve_external, + expected_subagent_generation, + expected_preference_revision, + ) + .await +} + pub async fn external_source_snapshot( workspace_root: Option<&Path>, force_refresh: bool, @@ -1673,12 +2640,93 @@ impl ExternalSourceSubscription { mod tests { use super::*; use bitfun_product_domains::external_sources::{ - ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, + EcosystemId, ExternalSourceHealth, ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, PromptCommandAvailability, PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot, SourceQualifiedCommandId, }; use std::sync::atomic::{AtomicUsize, Ordering}; + #[test] + fn only_model_configuration_events_refresh_external_model_bindings() { + assert!(config_update_refreshes_external_model_bindings( + &ConfigUpdateEvent::ModelConfigurationUpdated + )); + assert!(!config_update_refreshes_external_model_bindings( + &ConfigUpdateEvent::ThemeUpdated { + theme_id: "dark".to_string(), + } + )); + } + + #[test] + fn final_catalog_redacts_known_absolute_paths_from_diagnostics() { + let source_key = SourceKey::new("future.tools", "project").unwrap(); + let raw_root = r"C:\Users\alice\repo\.future-ai\tools"; + let raw_file = format!(r"{raw_root}\review.js"); + let mut snapshot = ExternalSourceCatalogSnapshot { + generation: 1, + discovery_pending: false, + sources: vec![ExternalSourceCatalogEntry { + stable_key: source_key.stable_key(), + record: ExternalSourceRecord { + key: source_key.clone(), + ecosystem_id: EcosystemId::new("future-ai").unwrap(), + display_name: "Future AI project tools".to_string(), + source_kind: "standalone_tools".to_string(), + scope: ExternalSourceScope::Project, + location: raw_root.to_string(), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + health: ExternalSourceHealth::Partial, + content_version: "source-v1".to_string(), + diagnostics: vec![ExternalSourceDiagnostic::warning( + "future.tool.directory_read_failed", + format!("Failed to read '{raw_root}'"), + Some(source_key.clone()), + ) + .with_asset_kind(ExternalSourceAssetKind::Tool)], + }, + lifecycle: ExternalSourceLifecycleState::Degraded, + }], + commands: Vec::new(), + command_conflicts: Vec::new(), + tools: Vec::new(), + tool_approval_requests: Vec::new(), + tool_conflicts: Vec::new(), + subagent_generation: 0, + preference_revision: 0, + subagents: Vec::new(), + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), + diagnostics: vec![ExternalSourceDiagnostic::warning( + "future.tool.file_read_failed", + format!("Failed to read '{raw_file}'"), + Some(source_key), + ) + .with_asset_kind(ExternalSourceAssetKind::Tool)], + }; + + sanitize_external_snapshot_locations( + &mut snapshot, + Some(Path::new(r"C:\Users\alice\repo")), + ); + + assert_eq!( + snapshot.sources[0].record.location, + "/.future-ai/tools" + ); + assert_eq!( + snapshot.sources[0].record.diagnostics[0].message, + "Failed to read '/.future-ai/tools'" + ); + assert_eq!( + snapshot.diagnostics[0].message, + "Failed to read '/.future-ai/tools/review.js'" + ); + let serialized = serde_json::to_string(&snapshot).unwrap(); + assert!(!serialized.contains("C:\\\\Users\\\\alice")); + assert!(!serialized.contains("C:/Users/alice")); + } + struct DelayedProvider { identity: PromptCommandProviderIdentity, source: SourceKey, @@ -1771,7 +2819,8 @@ mod tests { }; let (updates, _) = broadcast::channel(8); let coordinator = ExternalSourceCoordinator::new(context.clone(), providers).unwrap(); - let tool_coordinator = ExternalToolCoordinator::new(context, Vec::new()).unwrap(); + let tool_coordinator = ExternalToolCoordinator::new(context.clone(), Vec::new()).unwrap(); + let subagent_coordinator = ExternalSubagentCoordinator::new(context, Vec::new()).unwrap(); let snapshot = merge_tool_state( coordinator.snapshot(), &tool_coordinator.snapshot(), @@ -1781,6 +2830,7 @@ mod tests { workspace_root: None, coordinator: Arc::new(StdMutex::new(coordinator)), tool_coordinator: Arc::new(StdMutex::new(tool_coordinator)), + subagent_coordinator: Arc::new(StdMutex::new(subagent_coordinator)), snapshot: StdMutex::new(snapshot), updates, watch_states: tokio::sync::Mutex::new(BTreeMap::new()), @@ -1788,6 +2838,7 @@ mod tests { product_rebuild_gate: tokio::sync::Mutex::new(()), discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), tool_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), + subagent_discovery_tasks: tokio::sync::Mutex::new(BTreeMap::new()), initial_refresh_completed: AtomicBool::new(false), background_refresh_scheduled: AtomicBool::new(false), initial_refresh_gate: tokio::sync::Mutex::new(()), @@ -1796,6 +2847,7 @@ mod tests { watcher: Arc::new(FileWatchService::new(FileWatcherConfig::default())), tool_decision_gate_waiting: tokio::sync::Notify::new(), tool_decision_gate_acquired: tokio::sync::Notify::new(), + subagent_expiry_schedule: AtomicU64::new(0), }) } @@ -1844,6 +2896,64 @@ mod tests { ); } + #[tokio::test] + async fn subagent_conflict_history_advances_revision_and_rejects_stale_process_actions() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("external-sources.json"); + let process_a = ExternalSourcePreferenceStore::new(path.clone()); + let process_b = ExternalSourcePreferenceStore::new(path); + let lineage = "external_subagent_lineage:local-user:workspace:review"; + let conflict_v1 = "external_subagent:local-user:workspace:review:v1"; + let conflict_v2 = "external_subagent:local-user:workspace:review:v2"; + + let (changed, observed_v1) = persist_observed_subagent_conflicts_with_store( + &process_a, + &BTreeMap::from([(lineage.to_string(), conflict_v1.to_string())]), + ) + .await + .unwrap(); + assert!(changed); + assert_eq!(observed_v1.preference_revision, 1); + + let selected_v1 = persist_subagent_conflict_choice_with_store( + &process_a, + conflict_v1, + "external_subagent:candidate-v1", + None, + observed_v1.preference_revision, + ) + .await + .unwrap(); + assert_eq!(selected_v1.preference_revision, 2); + + let (changed, observed_v2) = persist_observed_subagent_conflicts_with_store( + &process_b, + &BTreeMap::from([(lineage.to_string(), conflict_v2.to_string())]), + ) + .await + .unwrap(); + assert!(changed); + assert_eq!(observed_v2.preference_revision, 3); + assert!(!observed_v2 + .subagent_conflict_choices + .contains_key(conflict_v1)); + assert_eq!( + observed_v2.subagent_conflict_choices[conflict_v2], + SUBAGENT_CONFLICT_RESELECTION_REQUIRED + ); + + let error = persist_subagent_conflict_choice_with_store( + &process_a, + conflict_v1, + "external_subagent:candidate-v1", + None, + selected_v1.preference_revision, + ) + .await + .expect_err("the stale process must not overwrite the new conflict generation"); + assert!(error.starts_with("stale_action:")); + } + #[tokio::test] async fn invalid_preference_file_is_an_error_instead_of_resetting_choices() { let temp = tempfile::tempdir().unwrap(); diff --git a/src/crates/assembly/core/src/external_subagents.rs b/src/crates/assembly/core/src/external_subagents.rs new file mode 100644 index 0000000000..a7a5d8af73 --- /dev/null +++ b/src/crates/assembly/core/src/external_subagents.rs @@ -0,0 +1,1557 @@ +//! Product-level activation and routing for provider-neutral external subagents. +//! +//! Ecosystem parsing stays in adapters and discovery lifecycle stays in the +//! external-sources coordinator. This module only resolves current BitFun +//! model/tool facts, applies persisted user decisions, and installs immutable +//! generation entries in the existing agent registry. + +use crate::agentic::agents::{ + external_subagent_runtime_key, get_agent_registry, AgentInfo, AgentSource, + ExternalProvidedSubagent, ExternalSubagentModelBinding, ExternalSubagentRegistration, + ExternalSubagentRoute, +}; +use crate::agentic::tools::registry::get_all_registered_tools; +use crate::external_sources::safe_external_source_location; +use crate::external_tools::resolve_external_tool_for_workspace; +use crate::service::config::global::GlobalConfigManager; +use crate::service::config::types::{model_runtime_binding_fingerprint, AIConfig, AIModelConfig}; +use crate::service::config::SubagentModelSelection; +use bitfun_external_sources::ExternalSubagentCoordinatorSnapshot; +use bitfun_product_domains::external_sources::{ExternalSourceScope, ProviderId, SourceKey}; +use bitfun_product_domains::external_subagents::{ + external_subagent_approval_key, external_subagent_conflict_key, + ExternalSubagentActivationState, ExternalSubagentCompatibilityState, ExternalSubagentConflict, + ExternalSubagentConflictCandidate, ExternalSubagentDefinition, + ExternalSubagentDiagnosticSummary, ExternalSubagentModelRequest, ExternalSubagentSummary, +}; +use sha2::{Digest, Sha256}; +use std::collections::{BTreeMap, BTreeSet}; +use std::path::Path; +use std::sync::Arc; + +pub(super) const DISABLED_SUBAGENT_CONFLICT_CHOICE: &str = "__bitfun_disabled__"; + +pub(super) struct ExternalSubagentDecisions<'a> { + pub approved_envelopes: &'a BTreeSet, + pub declined_decisions: &'a BTreeMap, + pub conflict_choices: &'a BTreeMap, + pub conflict_lineage_current_keys: &'a BTreeMap, +} + +#[derive(Default)] +pub(super) struct ExternalSubagentProductState { + pub summaries: Vec, + pub conflicts: Vec, + pub pending_approvals: Vec, + pub registrations: Vec, + pub routes: BTreeMap, + pub observed_conflict_lineage_current_keys: BTreeMap, +} + +#[derive(Clone)] +struct ResolvedToolFact { + name: String, + binding_fingerprint: String, + readonly: bool, +} + +#[derive(Clone)] +struct LocalCandidateFact { + logical_id: String, + candidate_id: String, + display_name: String, + source_label: String, + behavior_version: String, +} + +#[derive(Default)] +struct ProductFacts { + ai_config: AIConfig, + tools: BTreeMap, + locals: BTreeMap, +} + +struct ResolvedExternalCandidate { + definition: ExternalSubagentDefinition, + provider_label: String, + scope: ExternalSourceScope, + source_keys: Vec, + source_location_labels: Vec, + model_id: String, + model_label: String, + model_configuration_fingerprint: String, + tools: Vec, + readonly: bool, + activation_envelope: String, + approval_key: String, + conflict_behavior_version: String, + diagnostics: Vec, + compatibility: ExternalSubagentCompatibilityState, +} + +pub(super) async fn reconcile_external_subagents( + workspace_root: Option<&Path>, + execution_domain_id: &str, + snapshot: &ExternalSubagentCoordinatorSnapshot, + decisions: ExternalSubagentDecisions<'_>, +) -> ExternalSubagentProductState { + let facts = gather_product_facts(workspace_root, &snapshot.definitions).await; + reconcile_with_facts( + workspace_root, + execution_domain_id, + snapshot, + decisions, + &facts, + ) +} + +async fn gather_product_facts( + workspace_root: Option<&Path>, + definitions: &[ExternalSubagentDefinition], +) -> ProductFacts { + let ai_config = match GlobalConfigManager::get_service().await { + Ok(service) => service + .get_config::(Some("ai")) + .await + .unwrap_or_default(), + Err(_) => AIConfig::default(), + }; + + let requested_names = definitions + .iter() + .flat_map(|definition| &definition.requested_tools.selectors) + .filter(|selector| selector.allowed) + .map(|selector| { + selector + .canonical_host_name + .as_deref() + .unwrap_or(&selector.source_name) + .to_string() + }) + .collect::>(); + let mut tools = BTreeMap::new(); + for registered in get_all_registered_tools().await { + let name = registered.name().to_string(); + if !requested_names.contains(&name) { + continue; + } + let Some(selected) = resolve_external_tool_for_workspace(registered, workspace_root) else { + continue; + }; + if !selected.is_enabled().await { + continue; + } + let metadata = selected + .dynamic_tool_info() + .and_then(|info| serde_json::to_string(&info).ok()) + .unwrap_or_default(); + let readonly = selected.is_readonly(); + tools.insert( + name.clone(), + ResolvedToolFact { + name: name.clone(), + binding_fingerprint: stable_digest([ + name.as_str(), + metadata.as_str(), + if readonly { "readonly" } else { "writable" }, + if selected.needs_permissions(None) { + "permissioned" + } else { + "unpermissioned" + }, + ]), + readonly, + }, + ); + } + + let registry = get_agent_registry(); + let mut locals = BTreeMap::new(); + for info in registry + .get_local_subagents_for_external_resolution(workspace_root) + .await + { + let logical_key = normalize_logical_id(&info.id); + if locals.contains_key(&logical_key) { + // AgentRegistry currently resolves the first global entry before a + // project entry. Preserve that effective local route rather than + // offering a candidate that the Local route could not execute. + continue; + } + let model_selection = registry + .get_explicit_subagent_model_selection(&info.id, workspace_root) + .unwrap_or_else(|| { + ai_config + .agent_model_defaults + .builtin_subagent_selection(&info.id) + }); + let model = + serde_json::to_string(&model_selection).unwrap_or_else(|_| "unavailable".to_string()); + locals.insert(logical_key, local_candidate_fact(&info, &model)); + } + + ProductFacts { + ai_config, + tools, + locals, + } +} + +fn local_candidate_fact(info: &AgentInfo, model: &str) -> LocalCandidateFact { + let mut tools = info.default_tools.clone(); + tools.sort(); + let source = match info.source { + AgentSource::Builtin => "BitFun built-in", + AgentSource::User => "BitFun user", + AgentSource::Project => "BitFun project", + AgentSource::External => "External", + }; + let path_identity = info.path.as_deref().unwrap_or_default(); + let candidate_id = format!( + "local_subagent:{}", + stable_digest([ + &format!("{:?}", info.source), + info.id.as_str(), + path_identity, + ]) + ); + let behavior_version = stable_digest( + [ + info.prompt_cache_scope_key.as_str(), + model, + if info.is_readonly { + "readonly" + } else { + "writable" + }, + if info.is_review { "review" } else { "standard" }, + if info.effective_enabled { + "enabled" + } else { + "disabled" + }, + ] + .into_iter() + .chain(tools.iter().map(String::as_str)), + ); + LocalCandidateFact { + logical_id: info.id.clone(), + candidate_id, + display_name: info.name.clone(), + source_label: source.to_string(), + behavior_version, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct ResolvedModelFact { + runtime_id: String, + display_label: String, + configuration_fingerprint: String, +} + +fn model_display_label(model: &AIModelConfig) -> String { + let provider = model.name.trim(); + let model_name = model.model_name.trim(); + if provider.is_empty() { + model_name.to_string() + } else { + format!("{provider} · {model_name}") + } +} + +fn normalize_model_provider(value: &str) -> String { + value + .chars() + .filter(|character| character.is_alphanumeric()) + .flat_map(char::to_lowercase) + .collect() +} + +fn resolve_exact_external_model( + provider_hint: Option<&str>, + model_name: &str, + ai_config: &AIConfig, +) -> Option { + let model_name = model_name.trim(); + if model_name.is_empty() { + return None; + } + + let mut matches = ai_config + .models + .iter() + .filter(|model| model.enabled) + .filter(|model| { + if provider_hint.is_none() && model.id == model_name { + return true; + } + if model.model_name != model_name { + return false; + } + provider_hint.is_none_or(|provider| { + [model.provider.as_str(), model.name.as_str()] + .into_iter() + .map(normalize_model_provider) + .any(|candidate| candidate == normalize_model_provider(provider)) + }) + }); + let model = matches.next()?; + if matches.next().is_some() { + return None; + } + Some(ResolvedModelFact { + runtime_id: model.id.clone(), + display_label: model_display_label(model), + configuration_fingerprint: model_runtime_binding_fingerprint(model), + }) +} + +fn resolve_bitfun_subagent_model( + logical_id: &str, + ai_config: &AIConfig, +) -> Option { + match ai_config + .agent_model_defaults + .builtin_subagent_selection(logical_id) + { + SubagentModelSelection::Inherit => None, + SubagentModelSelection::Fixed { model_id } => { + let requested = model_id.trim(); + if requested.is_empty() { + return None; + } + let runtime_id = ai_config.resolve_model_selection(requested)?; + let model = ai_config + .models + .iter() + .find(|model| model.enabled && model.id == runtime_id)?; + Some(ResolvedModelFact { + runtime_id, + display_label: model_display_label(model), + configuration_fingerprint: model_runtime_binding_fingerprint(model), + }) + } + } +} + +fn reconcile_with_facts( + workspace_root: Option<&Path>, + execution_domain_id: &str, + snapshot: &ExternalSubagentCoordinatorSnapshot, + decisions: ExternalSubagentDecisions<'_>, + facts: &ProductFacts, +) -> ExternalSubagentProductState { + let workspace_scope = workspace_scope_key(workspace_root); + let source_map = snapshot + .sources + .iter() + .map(|entry| (entry.record.key.clone(), &entry.record)) + .collect::>(); + let mut state = ExternalSubagentProductState::default(); + let mut by_logical = BTreeMap::>::new(); + + for definition in &snapshot.definitions { + let resolved = resolve_external_candidate( + workspace_root, + execution_domain_id, + definition, + &source_map, + &snapshot.provider_labels, + facts, + ); + let summary = summary_for(&resolved, initial_activation_state(&resolved)); + if resolved.definition.disabled { + state.summaries.push(summary); + continue; + } + if matches!( + resolved.compatibility, + ExternalSubagentCompatibilityState::Blocked + | ExternalSubagentCompatibilityState::Invalid + ) { + state.summaries.push(summary); + continue; + } + by_logical + .entry(normalize_logical_id(&definition.logical_id)) + .or_default() + .push(resolved); + } + + let tracked_logical_ids = decisions + .conflict_lineage_current_keys + .keys() + .filter_map(|lineage| { + let (domain, scope, logical_id) = parse_conflict_lineage(lineage)?; + (domain == execution_domain_id && scope == workspace_scope) + .then(|| (normalize_logical_id(logical_id), logical_id.to_string())) + }) + .collect::>(); + let logical_keys = by_logical + .keys() + .cloned() + .chain(tracked_logical_ids.keys().cloned()) + .collect::>(); + + for logical_key in logical_keys { + let mut external_candidates = by_logical.remove(&logical_key).unwrap_or_default(); + external_candidates.sort_by(|left, right| { + left.definition + .candidate_id + .cmp(&right.definition.candidate_id) + }); + let local = facts.locals.get(&logical_key); + let logical_id = external_candidates + .first() + .map(|candidate| candidate.definition.logical_id.clone()) + .or_else(|| local.map(|candidate| candidate.logical_id.clone())) + .or_else(|| tracked_logical_ids.get(&logical_key).cloned()) + .unwrap_or(logical_key); + let participant_count = external_candidates.len() + usize::from(local.is_some()); + let lineage = conflict_lineage(execution_domain_id, &workspace_scope, &logical_id); + let previously_conflicted = decisions + .conflict_lineage_current_keys + .contains_key(&lineage); + if participant_count == 0 && previously_conflicted { + let conflict_key = external_subagent_conflict_key( + execution_domain_id, + &workspace_scope, + &logical_id, + std::iter::empty::<(&str, &str)>(), + ); + state + .observed_conflict_lineage_current_keys + .insert(lineage, conflict_key); + state + .routes + .insert(logical_id, ExternalSubagentRoute::Unavailable); + } else if participant_count > 1 || previously_conflicted { + reconcile_conflict_group( + execution_domain_id, + &workspace_scope, + &logical_id, + local, + external_candidates, + &decisions, + &mut state, + ); + } else if let Some(candidate) = external_candidates.pop() { + reconcile_nonconflicting_candidate(candidate, &decisions, &mut state); + } + } + + state.summaries.sort_by(|left, right| { + left.logical_id + .cmp(&right.logical_id) + .then(left.candidate_id.cmp(&right.candidate_id)) + }); + state.conflicts.sort_by(|left, right| { + left.logical_id + .cmp(&right.logical_id) + .then(left.conflict_key.cmp(&right.conflict_key)) + }); + state.pending_approvals.sort(); + state.pending_approvals.dedup(); + state +} + +fn resolve_external_candidate( + workspace_root: Option<&Path>, + execution_domain_id: &str, + definition: &ExternalSubagentDefinition, + sources: &BTreeMap, + provider_labels: &BTreeMap, + facts: &ProductFacts, +) -> ResolvedExternalCandidate { + let mut compatibility = definition.compatibility; + let mut diagnostics = definition + .diagnostic_codes + .iter() + .map(|code| ExternalSubagentDiagnosticSummary { + code: code.clone(), + blocks_activation: matches!( + compatibility, + ExternalSubagentCompatibilityState::Blocked + | ExternalSubagentCompatibilityState::Invalid + ), + }) + .collect::>(); + let model = match &definition.requested_model { + ExternalSubagentModelRequest::Default => { + resolve_bitfun_subagent_model(&definition.logical_id, &facts.ai_config) + } + ExternalSubagentModelRequest::Exact { + provider_hint, + model_name, + } => resolve_exact_external_model(provider_hint.as_deref(), model_name, &facts.ai_config), + }; + let model = match model { + Some(model) => model, + None => { + compatibility = ExternalSubagentCompatibilityState::Blocked; + diagnostics.push(ExternalSubagentDiagnosticSummary { + code: "external_subagent.model_unavailable".to_string(), + blocks_activation: true, + }); + ResolvedModelFact { + runtime_id: "unavailable".to_string(), + display_label: "unavailable".to_string(), + configuration_fingerprint: "unavailable".to_string(), + } + } + }; + + let mut tools = Vec::new(); + for selector in definition + .requested_tools + .selectors + .iter() + .filter(|selector| selector.allowed) + { + let name = selector + .canonical_host_name + .as_deref() + .unwrap_or(&selector.source_name); + match facts.tools.get(name) { + Some(tool) => tools.push(tool.clone()), + None => { + compatibility = ExternalSubagentCompatibilityState::Blocked; + diagnostics.push(ExternalSubagentDiagnosticSummary { + code: "external_subagent.tool_unavailable".to_string(), + blocks_activation: true, + }); + } + } + } + tools.sort_by(|left, right| left.name.cmp(&right.name)); + tools.dedup_by(|left, right| left.name == right.name); + diagnostics.sort_by(|left, right| left.code.cmp(&right.code)); + diagnostics.dedup_by(|left, right| left.code == right.code); + let readonly = tools.iter().all(|tool| tool.readonly); + let provenance = definition + .provenance + .iter() + .map(|item| item.contribution_id.stable_key()) + .collect::>(); + let activation_envelope = stable_digest( + [ + execution_domain_id, + definition.candidate_id.as_str(), + &format!("{:?}", definition.mode), + model.runtime_id.as_str(), + model.configuration_fingerprint.as_str(), + if definition.hidden { + "hidden" + } else { + "visible" + }, + if readonly { "readonly" } else { "writable" }, + ] + .into_iter() + .chain(provenance.iter().map(String::as_str)) + .chain( + tools + .iter() + .flat_map(|tool| [tool.name.as_str(), tool.binding_fingerprint.as_str()]), + ) + .chain( + diagnostics + .iter() + .filter(|diagnostic| diagnostic.blocks_activation) + .map(|diagnostic| diagnostic.code.as_str()), + ), + ); + let approval_key = external_subagent_approval_key( + &definition.candidate_id, + &definition.behavior_version, + &activation_envelope, + ); + let conflict_behavior_version = stable_digest([ + definition.behavior_version.as_str(), + activation_envelope.as_str(), + ]); + let provider_id = definition + .provenance + .first() + .map(|item| &item.contribution_id.source.provider_id); + let provider_label = provider_id + .and_then(|provider_id| provider_labels.get(provider_id)) + .cloned() + .unwrap_or_else(|| "External AI app".to_string()); + let mut source_location_labels = Vec::new(); + let mut source_keys = Vec::new(); + let mut scope = ExternalSourceScope::UserGlobal; + for item in &definition.provenance { + if let Some(source) = sources.get(&item.contribution_id.source) { + if !source_keys.contains(&source.key) { + source_keys.push(source.key.clone()); + } + let location_label = + safe_external_source_location(source.scope, &source.location, workspace_root); + if !source_location_labels.contains(&location_label) { + source_location_labels.push(location_label); + } + if scope_rank(source.scope) >= scope_rank(scope) { + scope = source.scope; + } + } + } + ResolvedExternalCandidate { + definition: definition.clone(), + provider_label, + scope, + source_keys, + source_location_labels, + model_id: model.runtime_id, + model_label: model.display_label, + model_configuration_fingerprint: model.configuration_fingerprint, + tools, + readonly, + activation_envelope, + approval_key, + conflict_behavior_version, + diagnostics, + compatibility, + } +} + +fn reconcile_nonconflicting_candidate( + candidate: ResolvedExternalCandidate, + decisions: &ExternalSubagentDecisions<'_>, + state: &mut ExternalSubagentProductState, +) { + let activation = if decisions + .approved_envelopes + .contains(&candidate.approval_key) + { + install_active_candidate(&candidate, state); + ExternalSubagentActivationState::Active + } else if decisions + .declined_decisions + .contains_key(&candidate.approval_key) + { + ExternalSubagentActivationState::Declined + } else { + state + .pending_approvals + .push(candidate.definition.candidate_id.as_str().to_string()); + ExternalSubagentActivationState::ApprovalRequired + }; + state.summaries.push(summary_for(&candidate, activation)); +} + +fn reconcile_conflict_group( + execution_domain_id: &str, + workspace_scope: &str, + logical_id: &str, + local: Option<&LocalCandidateFact>, + external_candidates: Vec, + decisions: &ExternalSubagentDecisions<'_>, + state: &mut ExternalSubagentProductState, +) { + let mut key_participants = external_candidates + .iter() + .map(|candidate| { + ( + candidate.definition.candidate_id.as_str(), + candidate.conflict_behavior_version.as_str(), + ) + }) + .collect::>(); + if let Some(local) = local { + key_participants.push((&local.candidate_id, &local.behavior_version)); + } + let conflict_key = external_subagent_conflict_key( + execution_domain_id, + workspace_scope, + logical_id, + key_participants, + ); + let lineage = conflict_lineage(execution_domain_id, workspace_scope, logical_id); + state + .observed_conflict_lineage_current_keys + .insert(lineage, conflict_key.clone()); + let selected = decisions + .conflict_choices + .get(&conflict_key) + .filter(|selected| { + *selected == DISABLED_SUBAGENT_CONFLICT_CHOICE + || local.is_some_and(|local| &local.candidate_id == *selected) + || external_candidates.iter().any(|candidate| { + candidate.definition.candidate_id.as_str() == selected.as_str() + }) + }) + .cloned(); + + let mut conflict_candidates = Vec::new(); + if let Some(local) = local { + conflict_candidates.push(ExternalSubagentConflictCandidate { + candidate_id: local.candidate_id.clone(), + display_name: local.display_name.clone(), + source_label: local.source_label.clone(), + external: false, + }); + } + for candidate in &external_candidates { + conflict_candidates.push(ExternalSubagentConflictCandidate { + candidate_id: candidate.definition.candidate_id.as_str().to_string(), + display_name: candidate.definition.display_name.clone(), + source_label: candidate.provider_label.clone(), + external: true, + }); + } + conflict_candidates.sort_by(|left, right| left.candidate_id.cmp(&right.candidate_id)); + + match selected.as_deref() { + Some(selected_id) if selected_id == DISABLED_SUBAGENT_CONFLICT_CHOICE => { + state + .routes + .insert(logical_id.to_string(), ExternalSubagentRoute::Unavailable); + } + Some(selected_id) + if local.is_some_and(|candidate| candidate.candidate_id == selected_id) => + { + state + .routes + .insert(logical_id.to_string(), ExternalSubagentRoute::Local); + } + Some(selected_id) => { + if let Some(candidate) = external_candidates + .iter() + .find(|candidate| candidate.definition.candidate_id.as_str() == selected_id) + { + if decisions + .approved_envelopes + .contains(&candidate.approval_key) + { + install_active_candidate(candidate, state); + } else { + state + .routes + .insert(logical_id.to_string(), ExternalSubagentRoute::Unavailable); + } + } + } + None => { + state + .routes + .insert(logical_id.to_string(), ExternalSubagentRoute::Unavailable); + } + } + + for candidate in external_candidates { + let activation = if selected.as_deref() == Some(candidate.definition.candidate_id.as_str()) + && decisions + .approved_envelopes + .contains(&candidate.approval_key) + { + ExternalSubagentActivationState::Active + } else if selected.is_some() { + ExternalSubagentActivationState::Disabled + } else { + ExternalSubagentActivationState::Conflict + }; + state.summaries.push(summary_for(&candidate, activation)); + } + state.conflicts.push(ExternalSubagentConflict { + conflict_key, + logical_id: logical_id.to_string(), + candidates: conflict_candidates, + selected_candidate_id: selected, + }); +} + +fn install_active_candidate( + candidate: &ResolvedExternalCandidate, + state: &mut ExternalSubagentProductState, +) { + let runtime_key = external_subagent_runtime_key(&stable_digest([ + candidate.definition.candidate_id.as_str(), + candidate.definition.behavior_version.as_str(), + candidate.activation_envelope.as_str(), + ])); + let tools = candidate + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect::>(); + // Source-owned catalog copy is intentionally excluded from AgentInfo. The + // existing projection is model-visible, so allowing a + // description-only update there would mutate prompt context without a new + // behavior approval. Keep that projection host-owned and stable while the + // review surface continues to show the source description. + let runtime_description = format!( + "Approved external subagent from {}. Runs as a fresh single-run task.", + candidate.provider_label + ); + let agent = Arc::new(ExternalProvidedSubagent::new( + runtime_key.clone(), + candidate.definition.display_name.clone(), + runtime_description, + candidate.definition.prompt.expose().to_string(), + tools, + candidate.readonly, + candidate.definition.behavior_version.as_str().to_string(), + )); + state.registrations.push(ExternalSubagentRegistration { + runtime_key: runtime_key.clone(), + logical_id: candidate.definition.logical_id.clone(), + provider_label: candidate.provider_label.clone(), + model_binding: ExternalSubagentModelBinding { + model_id: candidate.model_id.clone(), + configuration_fingerprint: candidate.model_configuration_fingerprint.clone(), + }, + hidden: candidate.definition.hidden, + agent, + }); + state.routes.insert( + candidate.definition.logical_id.clone(), + ExternalSubagentRoute::External(runtime_key), + ); +} + +fn summary_for( + candidate: &ResolvedExternalCandidate, + activation_state: ExternalSubagentActivationState, +) -> ExternalSubagentSummary { + ExternalSubagentSummary { + candidate_id: candidate.definition.candidate_id.as_str().to_string(), + logical_id: candidate.definition.logical_id.clone(), + display_name: candidate.definition.display_name.clone(), + description: candidate.definition.description.clone(), + provider_label: candidate.provider_label.clone(), + scope: candidate.scope, + source_keys: candidate.source_keys.clone(), + source_location_labels: candidate.source_location_labels.clone(), + source_count: candidate.definition.provenance.len(), + effective_model_label: (candidate.model_id != "unavailable") + .then(|| candidate.model_label.clone()), + effective_tool_labels: candidate + .tools + .iter() + .map(|tool| tool.name.clone()) + .collect(), + supports_follow_up: false, + compatibility_state: candidate.compatibility, + diagnostics: candidate.diagnostics.clone(), + activation_state, + decision_key: candidate.approval_key.clone(), + } +} + +fn initial_activation_state( + candidate: &ResolvedExternalCandidate, +) -> ExternalSubagentActivationState { + if candidate.definition.disabled { + ExternalSubagentActivationState::Disabled + } else if matches!( + candidate.compatibility, + ExternalSubagentCompatibilityState::Blocked | ExternalSubagentCompatibilityState::Invalid + ) { + ExternalSubagentActivationState::Blocked + } else { + ExternalSubagentActivationState::ApprovalRequired + } +} + +fn workspace_scope_key(workspace_root: Option<&Path>) -> String { + workspace_root + .map(|path| { + path.to_string_lossy() + .replace('\\', "/") + .to_ascii_lowercase() + }) + .unwrap_or_else(|| "".to_string()) +} + +fn normalize_logical_id(value: &str) -> String { + value.to_ascii_lowercase() +} + +fn conflict_lineage(execution_domain_id: &str, workspace_scope: &str, logical_id: &str) -> String { + [ + execution_domain_id, + workspace_scope, + &normalize_logical_id(logical_id), + ] + .into_iter() + .map(|part| format!("{}:{part}", part.len())) + .collect() +} + +fn parse_conflict_lineage(value: &str) -> Option<(&str, &str, &str)> { + let (domain, rest) = take_length_prefixed(value)?; + let (scope, rest) = take_length_prefixed(rest)?; + let (logical_id, rest) = take_length_prefixed(rest)?; + rest.is_empty().then_some((domain, scope, logical_id)) +} + +fn take_length_prefixed(value: &str) -> Option<(&str, &str)> { + let colon = value.find(':')?; + let length = value[..colon].parse::().ok()?; + let rest = &value[colon + 1..]; + let part = rest.get(..length)?; + Some((part, rest.get(length..)?)) +} + +fn scope_rank(scope: ExternalSourceScope) -> u8 { + match scope { + ExternalSourceScope::UserGlobal => 0, + ExternalSourceScope::Project => 1, + ExternalSourceScope::WorkspaceLocal => 2, + ExternalSourceScope::RemoteUser => 3, + ExternalSourceScope::RemoteProject => 4, + _ => 0, + } +} + +fn stable_digest(parts: impl IntoIterator>) -> String { + let mut hasher = Sha256::new(); + for part in parts { + let value = part.as_ref(); + hasher.update(value.len().to_le_bytes()); + hasher.update(value.as_bytes()); + } + hex::encode(hasher.finalize()) +} + +#[cfg(test)] +mod tests { + use super::*; + use bitfun_product_domains::external_sources::{ + EcosystemId, ExecutionDomainId, ExternalSourceCatalogEntry, ExternalSourceHealth, + ExternalSourceLifecycleState, ExternalSourceRecord, + }; + use bitfun_product_domains::external_subagents::{ + ExternalSubagentBehaviorVersion, ExternalSubagentCandidateId, + ExternalSubagentContributionId, ExternalSubagentContributionRole, ExternalSubagentLocalId, + ExternalSubagentMode, ExternalSubagentProvenanceRef, ExternalSubagentToolRequest, + ExternalSubagentToolSelector, SecretText, + }; + + fn definition(behavior: &str, catalog: &str) -> (ExternalSubagentDefinition, SourceKey) { + let source = SourceKey::new("fake-provider", "agents/reviewer.md").unwrap(); + let provenance = vec![ExternalSubagentProvenanceRef { + contribution_id: ExternalSubagentContributionId::new( + source.clone(), + ExternalSubagentLocalId::new("reviewer").unwrap(), + ), + role: ExternalSubagentContributionRole::Definition, + }]; + ( + ExternalSubagentDefinition { + candidate_id: ExternalSubagentCandidateId::new( + "external_subagent:fake:reviewer:candidate", + ) + .unwrap(), + logical_id: "reviewer".to_string(), + provenance, + display_name: "Reviewer".to_string(), + description: catalog.to_string(), + prompt: SecretText::new("Review the requested changes."), + mode: ExternalSubagentMode::Subagent, + disabled: false, + hidden: false, + requested_model: ExternalSubagentModelRequest::Default, + requested_tools: ExternalSubagentToolRequest { + selectors: vec![ExternalSubagentToolSelector { + source_name: "read".to_string(), + canonical_host_name: Some("Read".to_string()), + allowed: true, + }], + uses_conservative_default: false, + }, + compatibility: ExternalSubagentCompatibilityState::Ready, + diagnostic_codes: Vec::new(), + behavior_version: ExternalSubagentBehaviorVersion::new(behavior).unwrap(), + }, + source, + ) + } + + fn snapshot(behavior: &str, catalog: &str) -> ExternalSubagentCoordinatorSnapshot { + let (definition, source) = definition(behavior, catalog); + ExternalSubagentCoordinatorSnapshot { + generation: 1, + discovery_pending: false, + provider_labels: BTreeMap::from([( + ProviderId::new("fake-provider").unwrap(), + "Fake AI".to_string(), + )]), + sources: vec![ExternalSourceCatalogEntry { + stable_key: "source".to_string(), + record: ExternalSourceRecord { + key: source, + ecosystem_id: EcosystemId::new("fake").unwrap(), + display_name: "Fake agents".to_string(), + source_kind: "markdown".to_string(), + scope: ExternalSourceScope::UserGlobal, + location: "agents/reviewer.md".to_string(), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + health: ExternalSourceHealth::Available, + content_version: "v1".to_string(), + diagnostics: Vec::new(), + }, + lifecycle: ExternalSourceLifecycleState::Available, + }], + definitions: vec![definition], + using_last_valid_provider_ids: Vec::new(), + diagnostics: Vec::new(), + next_refresh_deadline: None, + } + } + + fn facts() -> ProductFacts { + let mut ai_config = AIConfig { + models: vec![active_model( + "model_fast", + "Fast provider", + "fake", + "fast-model", + )], + ..AIConfig::default() + }; + ai_config.default_models.fast = Some("model_fast".to_string()); + ProductFacts { + ai_config, + tools: BTreeMap::from([( + "Read".to_string(), + ResolvedToolFact { + name: "Read".to_string(), + binding_fingerprint: "read-v1".to_string(), + readonly: true, + }, + )]), + locals: BTreeMap::new(), + } + } + + fn active_model( + id: &str, + provider_name: &str, + provider: &str, + model_name: &str, + ) -> AIModelConfig { + AIModelConfig { + id: id.to_string(), + name: provider_name.to_string(), + provider: provider.to_string(), + model_name: model_name.to_string(), + enabled: true, + ..AIModelConfig::default() + } + } + + #[test] + fn exact_external_model_maps_provider_and_model_to_internal_config_without_exposing_id() { + let config = AIConfig { + models: vec![active_model( + "model_internal_1", + "OpenRouter", + "openai", + "anthropic/claude-sonnet-4", + )], + ..AIConfig::default() + }; + + let resolved = + resolve_exact_external_model(Some("openrouter"), "anthropic/claude-sonnet-4", &config) + .expect("provider/model identity should resolve"); + + assert_eq!(resolved.runtime_id, "model_internal_1"); + assert_eq!( + resolved.display_label, + "OpenRouter · anthropic/claude-sonnet-4" + ); + assert!(!resolved.display_label.contains("model_internal_1")); + } + + #[test] + fn exact_external_model_fails_closed_when_provider_model_identity_is_ambiguous() { + let config = AIConfig { + models: vec![ + active_model("model_a", "Anthropic", "anthropic", "claude-sonnet-4"), + active_model("model_b", "Anthropic", "anthropic", "claude-sonnet-4"), + ], + ..AIConfig::default() + }; + + assert!(resolve_exact_external_model(None, "anthropic/claude-sonnet-4", &config).is_none()); + } + + #[test] + fn default_external_model_materializes_an_enabled_authoritative_selection() { + let mut config = AIConfig { + models: vec![active_model( + "model_review", + "Anthropic", + "anthropic", + "claude-sonnet-4", + )], + ..AIConfig::default() + }; + config.agent_model_defaults.subagents.default_selection = + SubagentModelSelection::fixed("model_review"); + + let resolved = resolve_bitfun_subagent_model("reviewer", &config) + .expect("configured subagent default should resolve"); + assert_eq!(resolved.runtime_id, "model_review"); + assert_eq!(resolved.display_label, "Anthropic · claude-sonnet-4"); + + config.agent_model_defaults.subagents.default_selection = SubagentModelSelection::Inherit; + assert!(resolve_bitfun_subagent_model("reviewer", &config).is_none()); + + config.agent_model_defaults.subagents.default_selection = + SubagentModelSelection::fixed("fast"); + assert!(resolve_bitfun_subagent_model("reviewer", &config).is_none()); + + config.agent_model_defaults.subagents.default_selection = + SubagentModelSelection::fixed("model_review"); + config.models[0].enabled = false; + assert!(resolve_bitfun_subagent_model("reviewer", &config).is_none()); + } + + #[test] + fn source_location_labels_hide_absolute_user_and_workspace_paths() { + assert_eq!( + safe_external_source_location( + ExternalSourceScope::Project, + "C:/repo/.opencode/agents/review.md", + Some(Path::new("C:/repo")), + ), + "/.opencode/agents/review.md" + ); + assert_eq!( + safe_external_source_location( + ExternalSourceScope::UserGlobal, + "C:/Users/alice/.future-ai/agents/review.md", + None, + ), + "~/.future-ai/agents/review.md" + ); + assert_eq!( + safe_external_source_location( + ExternalSourceScope::UserGlobal, + "C:/Users/alice/.config/opencode/agents/review.md", + None, + ), + "~/.config/opencode/agents/review.md" + ); + assert_eq!( + safe_external_source_location( + ExternalSourceScope::UserGlobal, + "/Users/alice/.config/opencode/agents/review.md", + None, + ), + "~/.config/opencode/agents/review.md" + ); + assert_eq!( + safe_external_source_location( + ExternalSourceScope::Project, + "C:\\repo\\.opencode\\agents\\review.md", + Some(Path::new("C:\\repo")), + ), + "/.opencode/agents/review.md" + ); + } + + #[test] + fn catalog_only_update_reuses_behavior_approval() { + let first = snapshot("behavior-v1", "catalog-v1"); + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let preview = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &first, + ExternalSubagentDecisions { + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &facts(), + ); + let approval = preview.summaries[0].decision_key.clone(); + let approved = BTreeSet::from([approval]); + let updated = snapshot("behavior-v1", "catalog-v2"); + let state = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &updated, + ExternalSubagentDecisions { + approved_envelopes: &approved, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &facts(), + ); + assert_eq!( + state.summaries[0].activation_state, + ExternalSubagentActivationState::Active + ); + assert_eq!(state.registrations.len(), 1); + assert!(!state.registrations[0] + .agent + .description() + .contains("catalog-v2")); + } + + #[test] + fn behavior_update_requires_a_new_approval() { + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let first = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &facts(), + ); + let approved = BTreeSet::from([first.summaries[0].decision_key.clone()]); + let updated = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v2", "catalog-v2"), + ExternalSubagentDecisions { + approved_envelopes: &approved, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &facts(), + ); + assert_eq!( + updated.summaries[0].activation_state, + ExternalSubagentActivationState::ApprovalRequired + ); + assert!(updated.registrations.is_empty()); + } + + #[test] + fn default_model_change_requires_a_new_approval_for_future_invocations() { + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let first_facts = facts(); + let first = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &first_facts, + ); + let approved = BTreeSet::from([first.summaries[0].decision_key.clone()]); + + let mut updated_facts = facts(); + updated_facts.ai_config.models = vec![active_model( + "model_new", + "New provider", + "fake", + "new-model", + )]; + updated_facts.ai_config.default_models.fast = Some("model_new".to_string()); + let updated = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &approved, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &updated_facts, + ); + + assert_ne!( + first.summaries[0].decision_key, + updated.summaries[0].decision_key + ); + assert_eq!( + updated.summaries[0].activation_state, + ExternalSubagentActivationState::ApprovalRequired + ); + assert!(updated.registrations.is_empty()); + } + + #[test] + fn same_model_id_runtime_identity_change_requires_a_new_approval() { + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let first_facts = facts(); + let first = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &first_facts, + ); + let approved = BTreeSet::from([first.summaries[0].decision_key.clone()]); + + let mut updated_facts = facts(); + updated_facts.ai_config.models[0].provider = "other-provider".to_string(); + updated_facts.ai_config.models[0].model_name = "replacement-model".to_string(); + updated_facts.ai_config.models[0].base_url = "https://models.example/v2".to_string(); + let updated = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &approved, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &updated_facts, + ); + + assert_ne!( + first.summaries[0].decision_key, + updated.summaries[0].decision_key + ); + assert_eq!( + updated.summaries[0].activation_state, + ExternalSubagentActivationState::ApprovalRequired + ); + assert!(updated.registrations.is_empty()); + } + + #[test] + fn unresolved_default_model_is_blocked_without_exposing_an_internal_placeholder_label() { + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let mut unavailable_facts = facts(); + unavailable_facts + .ai_config + .agent_model_defaults + .subagents + .default_selection = SubagentModelSelection::Inherit; + + let state = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &unavailable_facts, + ); + + assert_eq!( + state.summaries[0].activation_state, + ExternalSubagentActivationState::Blocked + ); + assert_eq!(state.summaries[0].effective_model_label, None); + assert!(state.registrations.is_empty()); + } + + #[test] + fn unresolved_conflict_is_fail_closed_and_atomic_choice_can_activate() { + let mut facts = facts(); + facts.locals.insert( + "reviewer".to_string(), + LocalCandidateFact { + logical_id: "reviewer".to_string(), + candidate_id: "local_subagent:reviewer".to_string(), + display_name: "Local reviewer".to_string(), + source_label: "BitFun project".to_string(), + behavior_version: "local-v1".to_string(), + }, + ); + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let preview = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &facts, + ); + assert_eq!( + preview.routes.get("reviewer"), + Some(&ExternalSubagentRoute::Unavailable) + ); + let external_id = preview.conflicts[0] + .candidates + .iter() + .find(|candidate| candidate.external) + .unwrap() + .candidate_id + .clone(); + let approval = preview.summaries[0].decision_key.clone(); + let choices = BTreeMap::from([(preview.conflicts[0].conflict_key.clone(), external_id)]); + let approved = BTreeSet::from([approval]); + let active = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &approved, + declined_decisions: &empty_map, + conflict_choices: &choices, + conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + }, + &facts, + ); + assert!(matches!( + active.routes.get("reviewer"), + Some(ExternalSubagentRoute::External(_)) + )); + } + + fn conflict_preview_with_local() -> (ExternalSubagentProductState, ProductFacts) { + let mut product_facts = facts(); + product_facts.locals.insert( + "reviewer".to_string(), + LocalCandidateFact { + logical_id: "reviewer".to_string(), + candidate_id: "local_subagent:reviewer".to_string(), + display_name: "Local reviewer".to_string(), + source_label: "BitFun project".to_string(), + behavior_version: "local-v1".to_string(), + }, + ); + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let preview = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &empty_map, + conflict_lineage_current_keys: &empty_map, + }, + &product_facts, + ); + (preview, product_facts) + } + + #[test] + fn selected_local_does_not_switch_to_external_when_local_participant_disappears() { + let (preview, _facts_with_local) = conflict_preview_with_local(); + let conflict = &preview.conflicts[0]; + let choices = BTreeMap::from([( + conflict.conflict_key.clone(), + "local_subagent:reviewer".to_string(), + )]); + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + + let shrunk = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &snapshot("behavior-v1", "catalog-v1"), + ExternalSubagentDecisions { + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &choices, + conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + }, + &facts(), + ); + + assert_eq!( + shrunk.routes.get("reviewer"), + Some(&ExternalSubagentRoute::Unavailable) + ); + assert_eq!(shrunk.conflicts[0].candidates.len(), 1); + assert!(shrunk.conflicts[0].selected_candidate_id.is_none()); + } + + #[test] + fn selected_external_does_not_switch_to_local_when_external_participant_disappears() { + let (preview, facts_with_local) = conflict_preview_with_local(); + let conflict = &preview.conflicts[0]; + let external_id = conflict + .candidates + .iter() + .find(|candidate| candidate.external) + .unwrap() + .candidate_id + .clone(); + let choices = BTreeMap::from([(conflict.conflict_key.clone(), external_id)]); + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let mut without_external = snapshot("behavior-v1", "catalog-v1"); + without_external.definitions.clear(); + + let shrunk = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &without_external, + ExternalSubagentDecisions { + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &choices, + conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + }, + &facts_with_local, + ); + + assert_eq!( + shrunk.routes.get("reviewer"), + Some(&ExternalSubagentRoute::Unavailable) + ); + assert_eq!(shrunk.conflicts[0].candidates.len(), 1); + assert!(shrunk.conflicts[0].selected_candidate_id.is_none()); + } + + #[test] + fn keep_unavailable_survives_participant_set_shrink() { + let (preview, facts_with_local) = conflict_preview_with_local(); + let choices = BTreeMap::from([( + preview.conflicts[0].conflict_key.clone(), + DISABLED_SUBAGENT_CONFLICT_CHOICE.to_string(), + )]); + let empty_set = BTreeSet::new(); + let empty_map = BTreeMap::new(); + let mut without_external = snapshot("behavior-v1", "catalog-v1"); + without_external.definitions.clear(); + + let shrunk = reconcile_with_facts( + Some(Path::new("C:/repo")), + "local-user", + &without_external, + ExternalSubagentDecisions { + approved_envelopes: &empty_set, + declined_decisions: &empty_map, + conflict_choices: &choices, + conflict_lineage_current_keys: &preview.observed_conflict_lineage_current_keys, + }, + &facts_with_local, + ); + + assert_eq!( + shrunk.routes.get("reviewer"), + Some(&ExternalSubagentRoute::Unavailable) + ); + assert!(shrunk.conflicts[0].selected_candidate_id.is_none()); + } +} diff --git a/src/crates/assembly/core/src/external_tools.rs b/src/crates/assembly/core/src/external_tools.rs index 949fcb16a7..aead0104b6 100644 --- a/src/crates/assembly/core/src/external_tools.rs +++ b/src/crates/assembly/core/src/external_tools.rs @@ -9,11 +9,11 @@ use async_trait::async_trait; use bitfun_external_sources::{ExternalToolCoordinator, ExternalToolCoordinatorSnapshot}; use bitfun_product_domains::external_sources::{ external_tool_approval_key, external_tool_conflict_key, external_tool_decision_key, - ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, ExternalSourceScope, - ExternalToolActivationState, ExternalToolApprovalRequest, ExternalToolCatalogEntry, - ExternalToolConflict, ExternalToolConflictCandidate, ExternalToolConflictCandidateKind, - ExternalToolDefinition, ExternalToolStaticStatus, PreparedExternalToolTarget, - SourceQualifiedToolTargetId, + ExternalSourceAssetKind, ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, + ExternalSourceScope, ExternalToolActivationState, ExternalToolApprovalRequest, + ExternalToolCatalogEntry, ExternalToolConflict, ExternalToolConflictCandidate, + ExternalToolConflictCandidateKind, ExternalToolDefinition, ExternalToolStaticStatus, + PreparedExternalToolTarget, SourceQualifiedToolTargetId, }; use bitfun_runtime_ports::{ PortErrorKind, ScriptToolDescriptor, ScriptToolExpectedExport, ScriptToolInvokeRequest, @@ -1643,7 +1643,7 @@ pub(super) async fn reconcile_external_tools( Err(error) => { state.diagnostics.push(tool_diagnostic( "external_tool.load_failed", - format!("Failed to load '{}': {error}", first.module_path), + format!("Failed to load the external tool module: {error}"), Some(target_id.source.clone()), )); for definition in definitions { @@ -1922,6 +1922,7 @@ fn tool_diagnostic( ) -> ExternalSourceDiagnostic { ExternalSourceDiagnostic { severity: ExternalSourceDiagnosticSeverity::Warning, + asset_kind: ExternalSourceAssetKind::Tool, code: code.into(), message: message.into(), source, diff --git a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs index fc6e48635e..fcdd52fefc 100644 --- a/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs +++ b/src/crates/assembly/core/src/infrastructure/ai/client_factory.rs @@ -10,19 +10,24 @@ use crate::infrastructure::ai::{build_stream_options_for_model, AIClient}; use crate::infrastructure::cli_credentials::{ self, codex::CodexResolver, gemini::GeminiResolver, CredentialResolver, }; -use crate::service::config::types::AuthConfig; +use crate::service::config::types::{model_runtime_binding_fingerprint, AuthConfig}; use crate::service::config::{get_global_config_service, ConfigService}; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::AIConfig; use anyhow::{anyhow, Result}; -use bitfun_ai_adapters::{resolve_cache_model_selector, resolve_required_model_selector}; +use bitfun_ai_adapters::resolve_required_model_selector; use log::{debug, info, warn}; use std::collections::HashMap; use std::sync::{Arc, OnceLock, RwLock}; pub struct AIClientFactory { config_service: Arc, - client_cache: RwLock>>, + client_cache: RwLock>, +} + +struct CachedAIClient { + configuration_fingerprint: String, + client: Arc, } fn functional_agent_model_selector<'a>( @@ -55,7 +60,18 @@ impl AIClientFactory { } pub async fn get_client_by_id(&self, model_id: &str) -> Result> { - self.get_or_create_client(model_id).await + self.get_or_create_client(model_id, None).await + } + + /// Resolves an immutable concrete model id only when its current runtime + /// identity still matches the user-approved binding. + pub async fn get_client_by_approved_binding( + &self, + model_id: &str, + configuration_fingerprint: &str, + ) -> Result> { + self.get_or_create_client(model_id, Some(configuration_fingerprint)) + .await } /// Get a client (supports resolving primary/fast) @@ -69,7 +85,7 @@ impl AIClientFactory { ) .map_err(|error| anyhow!(error.to_string()))?; - self.get_or_create_client(&resolved_model_id).await + self.get_or_create_client(&resolved_model_id, None).await } pub fn invalidate_cache(&self) { @@ -109,14 +125,17 @@ impl AIClientFactory { } } - async fn get_or_create_client(&self, model_id: &str) -> Result> { + async fn get_or_create_client( + &self, + model_id: &str, + expected_configuration_fingerprint: Option<&str>, + ) -> Result> { let global_config: crate::service::config::GlobalConfig = self.config_service.get_config(None).await?; - let normalized_model_id = resolve_cache_model_selector( - model_id, - |selector| global_config.ai.resolve_model_selection(selector), - |model_ref| global_config.ai.resolve_model_reference(model_ref), - ); + let normalized_model_id = model_id.trim().to_string(); + if normalized_model_id.is_empty() { + return Err(anyhow!("Model configuration id is empty")); + } if global_config .ai .models @@ -131,21 +150,6 @@ impl AIClientFactory { )); } - { - let cache = match self.client_cache.read() { - Ok(cache) => cache, - Err(poisoned) => { - warn!( - "AI client cache read lock poisoned during get_or_create_client, recovering" - ); - poisoned.into_inner() - } - }; - if let Some(client) = cache.get(&normalized_model_id) { - return Ok(client.clone()); - } - } - debug!("Creating new AI client: model_id={}", normalized_model_id); let mut matching_models = global_config .ai @@ -170,6 +174,33 @@ impl AIClientFactory { )); } + let configuration_fingerprint = model_runtime_binding_fingerprint(model_config); + if expected_configuration_fingerprint + .is_some_and(|expected| expected != configuration_fingerprint) + { + return Err(anyhow!( + "Approved model binding changed for configuration id: {}", + normalized_model_id + )); + } + + { + let cache = match self.client_cache.read() { + Ok(cache) => cache, + Err(poisoned) => { + warn!( + "AI client cache read lock poisoned during get_or_create_client, recovering" + ); + poisoned.into_inner() + } + }; + if let Some(cached) = cache.get(&normalized_model_id) { + if cached.configuration_fingerprint == configuration_fingerprint { + return Ok(cached.client.clone()); + } + } + } + let mut ai_config = AIConfig::try_from(model_config.clone()) .map_err(|e| anyhow!("AI configuration conversion failed: {}", e))?; apply_cli_credential(&model_config.auth, &mut ai_config).await?; @@ -197,7 +228,13 @@ impl AIClientFactory { poisoned.into_inner() } }; - cache.insert(model_config.id.clone(), client.clone()); + cache.insert( + model_config.id.clone(), + CachedAIClient { + configuration_fingerprint, + client: client.clone(), + }, + ); } debug!( @@ -326,7 +363,9 @@ pub async fn discover_cli_credentials() -> Vec( @@ -1239,6 +1240,44 @@ pub struct AIModelConfig { pub auth: AuthConfig, } +/// Stable identity of the runtime-affecting parts of a concrete model config. +/// +/// Credentials are deliberately excluded: rotating a secret must not require +/// the user to approve the same provider/model again. Endpoint, provider, +/// model, request options, and authentication source remain part of the +/// identity so an approved binding cannot silently drift to different runtime +/// behavior while retaining the same config id. +pub fn model_runtime_binding_fingerprint(model: &AIModelConfig) -> String { + let mut value = serde_json::to_value(model).unwrap_or(serde_json::Value::Null); + if let serde_json::Value::Object(fields) = &mut value { + fields.remove("api_key"); + } + + fn canonicalize(value: serde_json::Value) -> serde_json::Value { + match value { + serde_json::Value::Object(fields) => { + let mut entries = fields.into_iter().collect::>(); + entries.sort_by(|left, right| left.0.cmp(&right.0)); + serde_json::Value::Object( + entries + .into_iter() + .map(|(key, value)| (key, canonicalize(value))) + .collect(), + ) + } + serde_json::Value::Array(values) => { + serde_json::Value::Array(values.into_iter().map(canonicalize).collect()) + } + value => value, + } + } + + let canonical = serde_json::to_vec(&canonicalize(value)).unwrap_or_default(); + let mut hasher = Sha256::new(); + hasher.update(canonical); + hex::encode(hasher.finalize()) +} + /// Where to obtain the runtime auth material for an `AIModelConfig`. /// /// Stored on disk as `{"type":"api_key"}` / `{"type":"codex_cli"}` / diff --git a/src/crates/assembly/core/src/service/session_usage/service.rs b/src/crates/assembly/core/src/service/session_usage/service.rs index 8903cad48d..16ff8114c4 100644 --- a/src/crates/assembly/core/src/service/session_usage/service.rs +++ b/src/crates/assembly/core/src/service/session_usage/service.rs @@ -1727,6 +1727,7 @@ mod tests { parent_turn_index: Some(0), parent_tool_call_id: Some("tool-1".to_string()), subagent_type: Some("Explore".to_string()), + continuation_policy: None, }); let mut grandchild = SessionMetadata::new( "grandchild-session".to_string(), @@ -1742,6 +1743,7 @@ mod tests { parent_turn_index: Some(0), parent_tool_call_id: Some("child-tool".to_string()), subagent_type: Some("Explore".to_string()), + continuation_policy: None, }); let (session_ids, complete) = diff --git a/src/crates/assembly/external-sources/src/lib.rs b/src/crates/assembly/external-sources/src/lib.rs index 2545b81eea..8c3bb46f7a 100644 --- a/src/crates/assembly/external-sources/src/lib.rs +++ b/src/crates/assembly/external-sources/src/lib.rs @@ -4,8 +4,13 @@ //! branches on ecosystem identity. Concrete provider selection remains in the //! product composition root. +mod subagent; mod tool; +pub use subagent::{ + ExternalSubagentCoordinator, ExternalSubagentCoordinatorSnapshot, + ExternalSubagentDiscoveryRequest, ExternalSubagentDiscoveryResult, +}; pub use tool::{ ExternalToolCoordinator, ExternalToolCoordinatorSnapshot, ExternalToolDiscoveryRequest, ExternalToolDiscoveryResult, @@ -152,6 +157,11 @@ impl ExternalSourceCoordinator { tools: Vec::new(), tool_approval_requests: Vec::new(), tool_conflicts: Vec::new(), + subagent_generation: 0, + preference_revision: 0, + subagents: Vec::new(), + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), diagnostics: Vec::new(), }, }) @@ -641,6 +651,11 @@ impl ExternalSourceCoordinator { tools: Vec::new(), tool_approval_requests: Vec::new(), tool_conflicts: Vec::new(), + subagent_generation: 0, + preference_revision: 0, + subagents: Vec::new(), + subagent_conflicts: Vec::new(), + pending_subagent_approvals: Vec::new(), diagnostics, }; self.snapshot.clone() diff --git a/src/crates/assembly/external-sources/src/subagent.rs b/src/crates/assembly/external-sources/src/subagent.rs new file mode 100644 index 0000000000..37305022ef --- /dev/null +++ b/src/crates/assembly/external-sources/src/subagent.rs @@ -0,0 +1,451 @@ +use bitfun_product_domains::external_sources::{ + ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceDiagnostic, + ExternalSourceDiagnosticSeverity, ExternalSourceLifecycleState, ExternalSourceProviderError, + ExternalSourceRecord, ExternalWatchRoot, ProviderId, +}; +use bitfun_product_domains::external_subagents::{ + ExternalSubagentDefinition, ExternalSubagentDiscoveryInput, ExternalSubagentProviderIdentity, + ExternalSubagentProviderSnapshot, ExternalSubagentSourceProvider, +}; +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const EXTERNAL_SUBAGENT_LAST_VALID_MAX_AGE: Duration = Duration::from_secs(30); +const EXTERNAL_SUBAGENT_LAST_VALID_MAX_FAILURES: u32 = 3; + +struct SubagentProviderGeneration { + provider: Arc, + identity: ExternalSubagentProviderIdentity, + initial_result_received: bool, + last_success: Option, + transient_failure_since: Option, + consecutive_transient_failures: u32, + using_last_valid: bool, + last_error: Option, +} + +#[derive(Debug, Clone)] +pub struct ExternalSubagentCoordinatorSnapshot { + pub generation: u64, + pub discovery_pending: bool, + pub provider_labels: BTreeMap, + pub sources: Vec, + pub definitions: Vec, + pub using_last_valid_provider_ids: Vec, + pub diagnostics: Vec, + pub next_refresh_deadline: Option, +} + +pub struct ExternalSubagentDiscoveryRequest { + provider_id: ProviderId, + provider: Arc, + input: ExternalSubagentDiscoveryInput, +} + +impl ExternalSubagentDiscoveryRequest { + pub fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + pub fn input(&self) -> &ExternalSubagentDiscoveryInput { + &self.input + } + + pub fn execute(self) -> ExternalSubagentDiscoveryResult { + ExternalSubagentDiscoveryResult { + provider_id: self.provider_id, + candidate: self.provider.discover(&self.input), + } + } +} + +#[derive(Clone)] +pub struct ExternalSubagentDiscoveryResult { + provider_id: ProviderId, + candidate: Result, +} + +impl ExternalSubagentDiscoveryResult { + pub fn provider_id(&self) -> &ProviderId { + &self.provider_id + } + + pub fn succeeded(provider_id: ProviderId, snapshot: ExternalSubagentProviderSnapshot) -> Self { + Self { + provider_id, + candidate: Ok(snapshot), + } + } + + pub fn failed(provider_id: ProviderId, error: ExternalSourceProviderError) -> Self { + Self { + provider_id, + candidate: Err(error), + } + } +} + +pub struct ExternalSubagentCoordinator { + context: bitfun_product_domains::external_sources::ExternalSourceContext, + providers: Vec, + suppressed_sources: BTreeSet, + generation: u64, + snapshot: ExternalSubagentCoordinatorSnapshot, +} + +impl fmt::Debug for ExternalSubagentCoordinator { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExternalSubagentCoordinator") + .field("context", &self.context) + .field("providers", &self.providers.len()) + .field("suppressed_sources", &self.suppressed_sources) + .field("generation", &self.generation) + .finish() + } +} + +impl ExternalSubagentCoordinator { + pub fn new( + context: bitfun_product_domains::external_sources::ExternalSourceContext, + providers: Vec>, + ) -> Result { + let mut provider_ids = BTreeSet::new(); + let mut generations = Vec::with_capacity(providers.len()); + for provider in providers { + let identity = provider.identity(); + if !provider_ids.insert(identity.provider_id.clone()) { + return Err(format!( + "duplicate external subagent provider id: {}", + identity.provider_id + )); + } + generations.push(SubagentProviderGeneration { + provider, + identity, + initial_result_received: false, + last_success: None, + transient_failure_since: None, + consecutive_transient_failures: 0, + using_last_valid: false, + last_error: None, + }); + } + let discovery_pending = !generations.is_empty(); + Ok(Self { + context, + providers: generations, + suppressed_sources: BTreeSet::new(), + generation: 0, + snapshot: ExternalSubagentCoordinatorSnapshot { + generation: 0, + discovery_pending, + provider_labels: BTreeMap::new(), + sources: Vec::new(), + definitions: Vec::new(), + using_last_valid_provider_ids: Vec::new(), + diagnostics: Vec::new(), + next_refresh_deadline: None, + }, + }) + } + + pub fn refresh(&mut self) -> ExternalSubagentCoordinatorSnapshot { + let results = self + .discovery_requests() + .into_iter() + .map(ExternalSubagentDiscoveryRequest::execute) + .collect(); + self.apply_discovery_results_at(results, Instant::now()) + } + + pub fn discovery_requests(&self) -> Vec { + let suppressed_sources = self + .suppressed_sources + .iter() + .filter_map(|key| ExternalSourceRecord::source_key_from_preference_key(key)) + .collect::>(); + self.providers + .iter() + .map(|generation| ExternalSubagentDiscoveryRequest { + provider_id: generation.identity.provider_id.clone(), + provider: generation.provider.clone(), + input: ExternalSubagentDiscoveryInput { + context: self.context.clone(), + suppressed_sources: suppressed_sources.clone(), + }, + }) + .collect() + } + + pub fn apply_discovery_results( + &mut self, + results: Vec, + ) -> ExternalSubagentCoordinatorSnapshot { + self.apply_discovery_results_at(results, Instant::now()) + } + + pub fn apply_discovery_results_at( + &mut self, + results: Vec, + now: Instant, + ) -> ExternalSubagentCoordinatorSnapshot { + let mut results = results + .into_iter() + .map(|result| (result.provider_id, result.candidate)) + .collect::>(); + for generation in &mut self.providers { + let candidate = results + .remove(&generation.identity.provider_id) + .unwrap_or_else(|| { + Err(ExternalSourceProviderError::new( + "external_subagent.discovery_result_missing", + "subagent provider discovery did not return a result", + true, + )) + }); + apply_provider_candidate(generation, candidate, now); + } + self.rebuild_snapshot_at(now) + } + + pub fn apply_discovery_result( + &mut self, + result: ExternalSubagentDiscoveryResult, + ) -> ExternalSubagentCoordinatorSnapshot { + let now = Instant::now(); + if let Some(generation) = self + .providers + .iter_mut() + .find(|generation| generation.identity.provider_id == result.provider_id) + { + apply_provider_candidate(generation, result.candidate, now); + } + self.rebuild_snapshot_at(now) + } + + pub fn expire_last_valid(&mut self) -> ExternalSubagentCoordinatorSnapshot { + self.rebuild_snapshot_at(Instant::now()) + } + + pub fn snapshot(&self) -> ExternalSubagentCoordinatorSnapshot { + self.snapshot.clone() + } + + pub fn replace_suppressed_sources(&mut self, sources: BTreeSet) { + self.suppressed_sources = sources; + self.rebuild_snapshot_at(Instant::now()); + } + + pub fn suppressed_sources(&self) -> &BTreeSet { + &self.suppressed_sources + } + + pub fn set_source_enabled(&mut self, stable_key: &str, enabled: bool) -> Result<(), String> { + let known = self.providers.iter().any(|provider| { + provider.last_success.as_ref().is_some_and(|snapshot| { + snapshot + .sources + .iter() + .any(|source| source.preference_key() == stable_key) + }) + }); + if !known { + return Err(format!("unknown external subagent source: {stable_key}")); + } + if enabled { + self.suppressed_sources.remove(stable_key); + } else { + self.suppressed_sources.insert(stable_key.to_string()); + } + self.rebuild_snapshot_at(Instant::now()); + Ok(()) + } + + pub fn watch_roots(&self) -> Vec { + let mut roots = BTreeMap::new(); + for provider in &self.providers { + for root in provider.provider.watch_roots(&self.context) { + roots + .entry(root.path) + .and_modify(|recursive| *recursive |= root.recursive) + .or_insert(root.recursive); + } + } + roots + .into_iter() + .map(|(path, recursive)| ExternalWatchRoot { path, recursive }) + .collect() + } + + fn rebuild_snapshot_at(&mut self, now: Instant) -> ExternalSubagentCoordinatorSnapshot { + self.generation = self.generation.saturating_add(1); + let mut sources = Vec::new(); + let mut definitions = Vec::new(); + let mut provider_labels = BTreeMap::new(); + let mut using_last_valid_provider_ids = Vec::new(); + let mut diagnostics = Vec::new(); + let mut next_refresh_deadline: Option = None; + + for provider in &mut self.providers { + provider_labels.insert( + provider.identity.provider_id.clone(), + provider.identity.display_name.clone(), + ); + if provider.using_last_valid + && provider.transient_failure_since.is_some_and(|started| { + now.saturating_duration_since(started) >= EXTERNAL_SUBAGENT_LAST_VALID_MAX_AGE + }) + { + provider.using_last_valid = false; + } + let usable = provider.last_error.is_none() || provider.using_last_valid; + if provider.using_last_valid { + using_last_valid_provider_ids.push(provider.identity.provider_id.clone()); + if let Some(deadline) = provider + .transient_failure_since + .and_then(|started| started.checked_add(EXTERNAL_SUBAGENT_LAST_VALID_MAX_AGE)) + { + next_refresh_deadline = Some( + next_refresh_deadline.map_or(deadline, |current| current.min(deadline)), + ); + } + } + if let Some(snapshot) = &provider.last_success { + let suppressed_keys = snapshot + .sources + .iter() + .filter(|source| self.suppressed_sources.contains(&source.preference_key())) + .map(|source| source.key.clone()) + .collect::>(); + for source in &snapshot.sources { + let suppressed = suppressed_keys.contains(&source.key); + sources.push(ExternalSourceCatalogEntry { + stable_key: source.preference_key(), + record: source.clone(), + lifecycle: if suppressed { + ExternalSourceLifecycleState::Suppressed + } else if provider.using_last_valid { + ExternalSourceLifecycleState::UsingLastValidVersion + } else if !usable { + ExternalSourceLifecycleState::Unavailable + } else { + ExternalSourceLifecycleState::Available + }, + }); + } + if usable { + definitions.extend( + snapshot + .definitions + .iter() + .filter(|definition| { + !definition.provenance.iter().any(|item| { + suppressed_keys.contains(&item.contribution_id.source) + }) + }) + .cloned(), + ); + } + diagnostics.extend(snapshot.diagnostics.clone()); + } + if let Some(error) = &provider.last_error { + diagnostics.push(ExternalSourceDiagnostic { + severity: if error.transient { + ExternalSourceDiagnosticSeverity::Warning + } else { + ExternalSourceDiagnosticSeverity::Error + }, + asset_kind: ExternalSourceAssetKind::Subagent, + code: error.code.clone(), + message: error.message.clone(), + source: None, + }); + } + } + sources.sort_by(|left, right| left.stable_key.cmp(&right.stable_key)); + definitions.sort_by(|left, right| { + left.logical_id + .cmp(&right.logical_id) + .then(left.candidate_id.cmp(&right.candidate_id)) + }); + using_last_valid_provider_ids.sort(); + self.snapshot = ExternalSubagentCoordinatorSnapshot { + generation: self.generation, + discovery_pending: self + .providers + .iter() + .any(|provider| !provider.initial_result_received), + provider_labels, + sources, + definitions, + using_last_valid_provider_ids, + diagnostics, + next_refresh_deadline, + }; + self.snapshot.clone() + } +} + +fn apply_provider_candidate( + generation: &mut SubagentProviderGeneration, + candidate: Result, + now: Instant, +) { + generation.initial_result_received = true; + match candidate { + Ok(snapshot) => match snapshot.validate() { + Ok(()) if snapshot.provider == generation.identity => { + generation.last_success = Some(snapshot); + generation.transient_failure_since = None; + generation.consecutive_transient_failures = 0; + generation.using_last_valid = false; + generation.last_error = None; + } + Ok(()) => apply_failure( + generation, + ExternalSourceProviderError::new( + "external_subagent.provider_identity_mismatch", + "subagent provider returned a mismatched identity", + false, + ), + now, + ), + Err(error) => apply_failure( + generation, + ExternalSourceProviderError::new( + "external_subagent.snapshot_invalid", + error.to_string(), + false, + ), + now, + ), + }, + Err(error) => apply_failure(generation, error, now), + } +} + +fn apply_failure( + generation: &mut SubagentProviderGeneration, + error: ExternalSourceProviderError, + now: Instant, +) { + if error.transient { + generation.transient_failure_since.get_or_insert(now); + generation.consecutive_transient_failures = + generation.consecutive_transient_failures.saturating_add(1); + generation.using_last_valid = generation.last_success.is_some() + && generation.consecutive_transient_failures + < EXTERNAL_SUBAGENT_LAST_VALID_MAX_FAILURES + && generation.transient_failure_since.is_some_and(|started| { + now.saturating_duration_since(started) < EXTERNAL_SUBAGENT_LAST_VALID_MAX_AGE + }); + } else { + generation.transient_failure_since = None; + generation.consecutive_transient_failures = 0; + generation.using_last_valid = false; + } + generation.last_error = Some(error); +} diff --git a/src/crates/assembly/external-sources/src/tool.rs b/src/crates/assembly/external-sources/src/tool.rs index ae66f2873a..870f1c38c3 100644 --- a/src/crates/assembly/external-sources/src/tool.rs +++ b/src/crates/assembly/external-sources/src/tool.rs @@ -1,9 +1,9 @@ use bitfun_product_domains::external_sources::{ - ExternalSourceCatalogEntry, ExternalSourceContext, ExternalSourceDiagnostic, - ExternalSourceDiagnosticSeverity, ExternalSourceLifecycleState, ExternalSourceProviderError, - ExternalToolDefinition, ExternalToolProviderIdentity, ExternalToolProviderSnapshot, - ExternalToolSourceProvider, ExternalWatchRoot, PreparedExternalToolTarget, ProviderId, - SourceQualifiedToolTargetId, + ExternalSourceAssetKind, ExternalSourceCatalogEntry, ExternalSourceContext, + ExternalSourceDiagnostic, ExternalSourceDiagnosticSeverity, ExternalSourceLifecycleState, + ExternalSourceProviderError, ExternalToolDefinition, ExternalToolProviderIdentity, + ExternalToolProviderSnapshot, ExternalToolSourceProvider, ExternalWatchRoot, + PreparedExternalToolTarget, ProviderId, SourceQualifiedToolTargetId, }; use std::collections::{BTreeMap, BTreeSet}; use std::fmt; @@ -311,6 +311,7 @@ impl ExternalToolCoordinator { } else { ExternalSourceDiagnosticSeverity::Error }, + asset_kind: ExternalSourceAssetKind::Tool, code: error.code.clone(), message: error.message.clone(), source: None, diff --git a/src/crates/assembly/external-sources/tests/subagent_coordinator.rs b/src/crates/assembly/external-sources/tests/subagent_coordinator.rs new file mode 100644 index 0000000000..eed946e985 --- /dev/null +++ b/src/crates/assembly/external-sources/tests/subagent_coordinator.rs @@ -0,0 +1,249 @@ +use bitfun_external_sources::{ExternalSubagentCoordinator, ExternalSubagentDiscoveryResult}; +use bitfun_product_domains::external_sources::{ + EcosystemId, ExecutionDomainId, ExternalSourceContext, ExternalSourceHealth, + ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalWatchRoot, + SourceKey, +}; +use bitfun_product_domains::external_subagents::{ + external_subagent_candidate_id, ExternalSubagentBehaviorVersion, + ExternalSubagentCompatibilityState, ExternalSubagentContributionId, + ExternalSubagentContributionRole, ExternalSubagentDefinition, ExternalSubagentDiscoveryInput, + ExternalSubagentLocalId, ExternalSubagentMode, ExternalSubagentModelRequest, + ExternalSubagentProvenanceRef, ExternalSubagentProviderIdentity, + ExternalSubagentProviderSnapshot, ExternalSubagentSourceProvider, ExternalSubagentToolRequest, + SecretText, +}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +const EXPECTED_LAST_VALID_MAX_AGE: Duration = Duration::from_secs(30); +const EXPECTED_LAST_VALID_MAX_FAILURES: u32 = 3; + +struct FakeProvider { + identity: ExternalSubagentProviderIdentity, +} + +impl FakeProvider { + fn new(id: &str) -> Self { + Self { + identity: ExternalSubagentProviderIdentity::new(id, "fake.ecosystem", "Fake").unwrap(), + } + } +} + +impl ExternalSubagentSourceProvider for FakeProvider { + fn identity(&self) -> ExternalSubagentProviderIdentity { + self.identity.clone() + } + + fn discover( + &self, + _input: &ExternalSubagentDiscoveryInput, + ) -> Result { + Ok(snapshot(&self.identity, "v1", "catalog-v1")) + } + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec { + vec![ExternalWatchRoot { + path: context.workspace_root.clone().unwrap(), + recursive: true, + }] + } +} + +fn context() -> ExternalSourceContext { + ExternalSourceContext { + workspace_root: Some(PathBuf::from("C:/workspace")), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + } +} + +fn snapshot( + provider: &ExternalSubagentProviderIdentity, + behavior: &str, + catalog: &str, +) -> ExternalSubagentProviderSnapshot { + let source_key = SourceKey::new(provider.provider_id.as_str(), "project-agents").unwrap(); + let provenance = vec![ExternalSubagentProvenanceRef { + contribution_id: ExternalSubagentContributionId::new( + source_key.clone(), + ExternalSubagentLocalId::new("review").unwrap(), + ), + role: ExternalSubagentContributionRole::Definition, + }]; + ExternalSubagentProviderSnapshot { + provider: provider.clone(), + sources: vec![ExternalSourceRecord { + key: source_key, + ecosystem_id: EcosystemId::new("fake.ecosystem").unwrap(), + display_name: "Fake project agents".to_string(), + source_kind: "subagents".to_string(), + scope: ExternalSourceScope::Project, + location: "C:/workspace/.fake/agents".to_string(), + execution_domain_id: ExecutionDomainId::new("local-user").unwrap(), + health: ExternalSourceHealth::Available, + content_version: catalog.to_string(), + diagnostics: Vec::new(), + }], + definitions: vec![ExternalSubagentDefinition { + candidate_id: external_subagent_candidate_id( + &provider.provider_id, + "review", + &provenance, + ), + logical_id: "review".to_string(), + provenance, + display_name: "Review".to_string(), + description: catalog.to_string(), + prompt: SecretText::new(format!("prompt-{behavior}")), + mode: ExternalSubagentMode::Subagent, + disabled: false, + hidden: false, + requested_model: ExternalSubagentModelRequest::Default, + requested_tools: ExternalSubagentToolRequest { + selectors: Vec::new(), + uses_conservative_default: true, + }, + compatibility: ExternalSubagentCompatibilityState::ReadyWithDegradation, + diagnostic_codes: vec!["fake.default_tools".to_string()], + behavior_version: ExternalSubagentBehaviorVersion::new(behavior).unwrap(), + }], + diagnostics: Vec::new(), + } +} + +#[test] +fn provider_failures_are_isolated_and_transient_last_valid_is_bounded() { + let first = Arc::new(FakeProvider::new("fake.first")); + let second = Arc::new(FakeProvider::new("fake.second")); + let first_id = first.identity().provider_id; + let second_id = second.identity().provider_id; + let mut coordinator = ExternalSubagentCoordinator::new(context(), vec![first, second]).unwrap(); + let started = Instant::now(); + let initial = coordinator.apply_discovery_results_at( + vec![ + ExternalSubagentDiscoveryResult::succeeded( + first_id.clone(), + snapshot(&FakeProvider::new("fake.first").identity(), "v1", "c1"), + ), + ExternalSubagentDiscoveryResult::succeeded( + second_id.clone(), + snapshot(&FakeProvider::new("fake.second").identity(), "v1", "c1"), + ), + ], + started, + ); + assert_eq!(initial.definitions.len(), 2); + + for failure in 1..EXPECTED_LAST_VALID_MAX_FAILURES { + let current = coordinator.apply_discovery_results_at( + vec![ + ExternalSubagentDiscoveryResult::failed( + first_id.clone(), + ExternalSourceProviderError::new("fake.transient", "temporary", true), + ), + ExternalSubagentDiscoveryResult::succeeded( + second_id.clone(), + snapshot( + &FakeProvider::new("fake.second").identity(), + &format!("v{failure}"), + "c1", + ), + ), + ], + started + Duration::from_secs(u64::from(failure)), + ); + assert_eq!(current.definitions.len(), 2); + assert_eq!( + current.using_last_valid_provider_ids, + vec![first_id.clone()] + ); + } + + let expired = coordinator.apply_discovery_results_at( + vec![ + ExternalSubagentDiscoveryResult::failed( + first_id.clone(), + ExternalSourceProviderError::new("fake.transient", "temporary", true), + ), + ExternalSubagentDiscoveryResult::succeeded( + second_id, + snapshot(&FakeProvider::new("fake.second").identity(), "v3", "c1"), + ), + ], + started + Duration::from_secs(EXPECTED_LAST_VALID_MAX_AGE.as_secs() - 1), + ); + assert_eq!( + expired.definitions.len(), + 1, + "failure bound wins before age bound" + ); + assert!(expired.using_last_valid_provider_ids.is_empty()); +} + +#[test] +fn stable_deletion_and_non_transient_failure_never_reuse_last_valid() { + let provider = Arc::new(FakeProvider::new("fake.agents")); + let provider_id = provider.identity().provider_id; + let mut coordinator = ExternalSubagentCoordinator::new(context(), vec![provider]).unwrap(); + let started = Instant::now(); + coordinator.apply_discovery_results_at( + vec![ExternalSubagentDiscoveryResult::succeeded( + provider_id.clone(), + snapshot(&FakeProvider::new("fake.agents").identity(), "v1", "c1"), + )], + started, + ); + + let deleted = coordinator.apply_discovery_results_at( + vec![ExternalSubagentDiscoveryResult::succeeded( + provider_id.clone(), + ExternalSubagentProviderSnapshot { + provider: FakeProvider::new("fake.agents").identity(), + sources: Vec::new(), + definitions: Vec::new(), + diagnostics: Vec::new(), + }, + )], + started + Duration::from_secs(1), + ); + assert!(deleted.definitions.is_empty()); + + coordinator.apply_discovery_results_at( + vec![ExternalSubagentDiscoveryResult::succeeded( + provider_id.clone(), + snapshot(&FakeProvider::new("fake.agents").identity(), "v2", "c2"), + )], + started + Duration::from_secs(2), + ); + let unsafe_failure = coordinator.apply_discovery_results_at( + vec![ExternalSubagentDiscoveryResult::failed( + provider_id, + ExternalSourceProviderError::new("fake.unsafe", "unsafe update", false), + )], + started + Duration::from_secs(3), + ); + assert!(unsafe_failure.definitions.is_empty()); +} + +#[test] +fn suppression_is_forwarded_to_the_provider_and_removes_current_routes() { + let provider = Arc::new(FakeProvider::new("fake.agents")); + let provider_id = provider.identity().provider_id; + let mut coordinator = ExternalSubagentCoordinator::new(context(), vec![provider]).unwrap(); + let snapshot = coordinator.apply_discovery_results_at( + vec![ExternalSubagentDiscoveryResult::succeeded( + provider_id, + snapshot(&FakeProvider::new("fake.agents").identity(), "v1", "c1"), + )], + Instant::now(), + ); + let preference_key = snapshot.sources[0].stable_key.clone(); + coordinator + .set_source_enabled(&preference_key, false) + .unwrap(); + assert!(coordinator.snapshot().definitions.is_empty()); + let request = coordinator.discovery_requests().pop().unwrap(); + assert_eq!(request.input().suppressed_sources.len(), 1); +} diff --git a/src/crates/contracts/core-types/src/lib.rs b/src/crates/contracts/core-types/src/lib.rs index cab4210991..ea080e445e 100644 --- a/src/crates/contracts/core-types/src/lib.rs +++ b/src/crates/contracts/core-types/src/lib.rs @@ -16,7 +16,9 @@ pub use ai::{ ToolCallResponseInfo, ToolDefinition, }; pub use errors::{AiErrorDetail, ErrorCategory}; -pub use session::{validate_session_id, SessionKind}; +pub use session::{ + validate_session_id, SessionContinuationPolicy, SessionKind, SessionModelBindingPolicy, +}; pub use surface::{ ApprovalSource, CapabilityRequest, CapabilityRequestKind, PermissionDecision, PermissionScope, RuntimeArtifactKind, RuntimeArtifactRef, SurfaceKind, ThreadEnvironment, ThreadEnvironmentKind, diff --git a/src/crates/contracts/core-types/src/session.rs b/src/crates/contracts/core-types/src/session.rs index 87f2d28659..285b5c3997 100644 --- a/src/crates/contracts/core-types/src/session.rs +++ b/src/crates/contracts/core-types/src/session.rs @@ -9,6 +9,24 @@ pub enum SessionKind { EphemeralChild, } +/// Whether a persisted subagent session may accept another delegated turn. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SessionContinuationPolicy { + #[default] + Reusable, + FreshOnly, +} + +/// Whether model reconciliation may replace the session's selected model. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum SessionModelBindingPolicy { + #[default] + Mutable, + ApprovedImmutable, +} + pub fn validate_session_id(session_id: &str) -> Result<(), String> { if session_id.is_empty() { return Err("session_id cannot be empty".to_string()); diff --git a/src/crates/contracts/product-domains/Cargo.toml b/src/crates/contracts/product-domains/Cargo.toml index f91183e945..e6ce054fff 100644 --- a/src/crates/contracts/product-domains/Cargo.toml +++ b/src/crates/contracts/product-domains/Cargo.toml @@ -33,7 +33,7 @@ default = [] plugin-source = ["hex", "sha2"] miniapp = ["dirs", "sha2", "which"] function-agents = ["log"] -external-sources = [] +external-sources = ["hex", "sha2"] product-full = ["plugin-source", "miniapp", "function-agents", "external-sources"] [dev-dependencies] diff --git a/src/crates/contracts/product-domains/src/external_sources.rs b/src/crates/contracts/product-domains/src/external_sources.rs index c61163c245..cff4a472a3 100644 --- a/src/crates/contracts/product-domains/src/external_sources.rs +++ b/src/crates/contracts/product-domains/src/external_sources.rs @@ -110,6 +110,23 @@ impl SourceKey { self.source_id ) } + + pub fn from_stable_key(value: &str) -> Option { + let (provider, remainder) = take_length_prefixed(value)?; + let (source, remainder) = take_length_prefixed(remainder)?; + if !remainder.is_empty() { + return None; + } + Self::new(provider, source).ok() + } +} + +fn take_length_prefixed(value: &str) -> Option<(&str, &str)> { + let separator = value.find(':')?; + let length = value[..separator].parse::().ok()?; + let start = separator + 1; + let end = start.checked_add(length)?; + Some((value.get(start..end)?, value.get(end..)?)) } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] @@ -404,10 +421,23 @@ pub enum ExternalSourceDiagnosticSeverity { Error, } +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +#[non_exhaustive] +pub enum ExternalSourceAssetKind { + #[default] + Source, + Command, + Tool, + Subagent, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ExternalSourceDiagnostic { pub severity: ExternalSourceDiagnosticSeverity, + #[serde(default)] + pub asset_kind: ExternalSourceAssetKind, pub code: String, pub message: String, #[serde(skip_serializing_if = "Option::is_none")] @@ -422,6 +452,7 @@ impl ExternalSourceDiagnostic { ) -> Self { Self { severity: ExternalSourceDiagnosticSeverity::Warning, + asset_kind: ExternalSourceAssetKind::Source, code: code.into(), message: message.into(), source, @@ -435,11 +466,17 @@ impl ExternalSourceDiagnostic { ) -> Self { Self { severity: ExternalSourceDiagnosticSeverity::Error, + asset_kind: ExternalSourceAssetKind::Source, code: code.into(), message: message.into(), source, } } + + pub fn with_asset_kind(mut self, asset_kind: ExternalSourceAssetKind) -> Self { + self.asset_kind = asset_kind; + self + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -468,6 +505,11 @@ impl ExternalSourceRecord { ) } + pub fn source_key_from_preference_key(value: &str) -> Option { + let (_, stable_source_key) = take_length_prefixed(value)?; + SourceKey::from_stable_key(stable_source_key) + } + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { validate_id(&self.source_kind, "source kind")?; validate_text(&self.display_name, "source display name")?; @@ -961,6 +1003,18 @@ pub struct ExternalSourceCatalogSnapshot { pub tool_approval_requests: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub tool_conflicts: Vec, + /// Independent catalog generation for subagent row/action stability. + #[serde(default)] + pub subagent_generation: u64, + /// Monotonic persisted-preference revision used by subagent decisions. + #[serde(default)] + pub preference_revision: u64, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subagents: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub subagent_conflicts: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub pending_subagent_approvals: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub diagnostics: Vec, } diff --git a/src/crates/contracts/product-domains/src/external_subagents.rs b/src/crates/contracts/product-domains/src/external_subagents.rs new file mode 100644 index 0000000000..03920129c0 --- /dev/null +++ b/src/crates/contracts/product-domains/src/external_subagents.rs @@ -0,0 +1,610 @@ +//! Provider-neutral contracts for external subagent definitions. +//! +//! Ecosystem adapters own parsing and same-ecosystem composition. Product +//! assembly consumes only these typed definitions and never receives raw +//! configuration payloads. + +use crate::external_sources::{ + EcosystemId, ExternalSourceAssetKind, ExternalSourceContext, ExternalSourceContractError, + ExternalSourceDiagnostic, ExternalSourceProviderError, ExternalSourceRecord, + ExternalSourceScope, ExternalWatchRoot, ProviderId, SourceKey, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fmt; + +const MAX_ID_LENGTH: usize = 160; +const MAX_LABEL_LENGTH: usize = 4096; +const MAX_PROMPT_BYTES: usize = 256 * 1024; +const MAX_TOOL_SELECTORS: usize = 256; +const MAX_DIAGNOSTIC_CODES: usize = 256; +const MAX_PROVENANCE_REFS: usize = 256; +const MAX_PROVIDER_SOURCES: usize = 1024; +const MAX_PROVIDER_DEFINITIONS: usize = 1024; +const MAX_PROVIDER_DIAGNOSTICS: usize = 1024; + +fn validate_id(value: &str, label: &'static str) -> Result<(), ExternalSourceContractError> { + if value.is_empty() + || value.len() > MAX_ID_LENGTH + || value.trim() != value + || value.chars().any(char::is_control) + { + return Err(ExternalSourceContractError::InvalidIdentifier(label)); + } + Ok(()) +} + +fn is_stable_diagnostic_code(code: &str) -> bool { + !code.is_empty() + && code.len() <= MAX_ID_LENGTH + && code.trim() == code + && !code.chars().any(char::is_control) + && code + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'_' | b'-')) +} + +fn validate_provider_diagnostic( + diagnostic: &ExternalSourceDiagnostic, + provider_id: &ProviderId, + sources: &BTreeSet, +) -> Result<(), ExternalSourceContractError> { + if !is_stable_diagnostic_code(&diagnostic.code) { + return Err(ExternalSourceContractError::InvalidIdentifier( + "external subagent provider diagnostic code", + )); + } + if diagnostic.message.trim().is_empty() + || diagnostic.message.len() > MAX_LABEL_LENGTH + || diagnostic.message.chars().any(char::is_control) + { + return Err(ExternalSourceContractError::InvalidText( + "external subagent provider diagnostic message", + )); + } + if diagnostic.asset_kind != ExternalSourceAssetKind::Subagent + || diagnostic + .source + .as_ref() + .is_some_and(|source| &source.provider_id != provider_id || !sources.contains(source)) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "external subagent provider diagnostic source", + )); + } + Ok(()) +} + +macro_rules! external_subagent_id { + ($name:ident, $label:literal) => { + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] + #[serde(transparent)] + pub struct $name(String); + + impl $name { + pub fn new(value: impl Into) -> Result { + let value = value.into(); + validate_id(&value, $label)?; + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + } + + impl fmt::Display for $name { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.0) + } + } + }; +} + +external_subagent_id!(ExternalSubagentLocalId, "external subagent local"); +external_subagent_id!(ExternalSubagentCandidateId, "external subagent candidate"); +external_subagent_id!( + ExternalSubagentBehaviorVersion, + "external subagent behavior version" +); + +/// Sensitive prompt text that deliberately implements neither `Serialize` nor +/// a content-bearing `Debug` representation. +#[derive(Clone, PartialEq, Eq)] +pub struct SecretText(String); + +impl SecretText { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub fn expose(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for SecretText { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("SecretText([REDACTED])") + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSubagentContributionId { + pub source: SourceKey, + pub local_id: ExternalSubagentLocalId, +} + +impl ExternalSubagentContributionId { + pub fn new(source: SourceKey, local_id: ExternalSubagentLocalId) -> Self { + Self { source, local_id } + } + + pub fn stable_key(&self) -> String { + format!( + "{}{}:{}", + self.source.stable_key(), + self.local_id.as_str().len(), + self.local_id + ) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSubagentContributionRole { + Base, + Overlay, + Definition, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSubagentProvenanceRef { + pub contribution_id: ExternalSubagentContributionId, + pub role: ExternalSubagentContributionRole, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSubagentProviderIdentity { + pub provider_id: ProviderId, + pub ecosystem_id: EcosystemId, + pub display_name: String, +} + +impl ExternalSubagentProviderIdentity { + pub fn new( + provider_id: impl Into, + ecosystem_id: impl Into, + display_name: impl Into, + ) -> Result { + let display_name = display_name.into(); + if display_name.is_empty() + || display_name.len() > MAX_LABEL_LENGTH + || display_name.chars().any(char::is_control) + { + return Err(ExternalSourceContractError::InvalidText( + "external subagent provider display name", + )); + } + Ok(Self { + provider_id: ProviderId::new(provider_id)?, + ecosystem_id: EcosystemId::new(ecosystem_id)?, + display_name, + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSubagentMode { + Subagent, + All, + Primary, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum ExternalSubagentModelRequest { + Default, + Exact { + #[serde(default, skip_serializing_if = "Option::is_none")] + provider_hint: Option, + model_name: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSubagentToolSelector { + pub source_name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub canonical_host_name: Option, + pub allowed: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSubagentToolRequest { + pub selectors: Vec, + pub uses_conservative_default: bool, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ExternalSubagentCompatibilityState { + Ready, + ReadyWithDegradation, + Blocked, + Invalid, +} + +/// Complete backend-only definition. Prompt content is redacted from `Debug` +/// and the type is intentionally not serializable. +#[derive(Clone, PartialEq, Eq)] +pub struct ExternalSubagentDefinition { + pub candidate_id: ExternalSubagentCandidateId, + pub logical_id: String, + pub provenance: Vec, + pub display_name: String, + pub description: String, + pub prompt: SecretText, + pub mode: ExternalSubagentMode, + pub disabled: bool, + pub hidden: bool, + pub requested_model: ExternalSubagentModelRequest, + pub requested_tools: ExternalSubagentToolRequest, + pub compatibility: ExternalSubagentCompatibilityState, + pub diagnostic_codes: Vec, + pub behavior_version: ExternalSubagentBehaviorVersion, +} + +impl fmt::Debug for ExternalSubagentDefinition { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExternalSubagentDefinition") + .field("candidate_id", &self.candidate_id) + .field("logical_id", &self.logical_id) + .field("provenance", &self.provenance) + .field("display_name", &self.display_name) + .field("description", &self.description) + .field("prompt", &self.prompt) + .field("mode", &self.mode) + .field("disabled", &self.disabled) + .field("hidden", &self.hidden) + .field("requested_model", &self.requested_model) + .field("requested_tools", &self.requested_tools) + .field("compatibility", &self.compatibility) + .field("diagnostic_codes", &self.diagnostic_codes) + .field("behavior_version", &self.behavior_version) + .finish() + } +} + +impl ExternalSubagentDefinition { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + validate_id(&self.logical_id, "external subagent logical")?; + if self.provenance.is_empty() + || self.provenance.len() > MAX_PROVENANCE_REFS + || self + .provenance + .iter() + .any(|item| item.contribution_id.local_id.as_str() != self.logical_id) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "external subagent provenance", + )); + } + let mut provenance = BTreeSet::new(); + if self.provenance.iter().any(|item| !provenance.insert(item)) { + return Err(ExternalSourceContractError::InvalidIdentifier( + "external subagent provenance", + )); + } + for (value, label) in [ + (&self.display_name, "external subagent display name"), + (&self.description, "external subagent description"), + ] { + if value.len() > MAX_LABEL_LENGTH || value.chars().any(char::is_control) { + return Err(ExternalSourceContractError::InvalidText(label)); + } + } + if self.prompt.expose().len() > MAX_PROMPT_BYTES + || (self.prompt.expose().trim().is_empty() + && !matches!( + self.compatibility, + ExternalSubagentCompatibilityState::Blocked + | ExternalSubagentCompatibilityState::Invalid + )) + { + return Err(ExternalSourceContractError::InvalidText( + "external subagent prompt", + )); + } + if let ExternalSubagentModelRequest::Exact { + provider_hint, + model_name, + } = &self.requested_model + { + if provider_hint.as_ref().is_some_and(|provider| { + provider.is_empty() + || provider.len() > MAX_LABEL_LENGTH + || provider.trim() != provider + || provider.chars().any(char::is_control) + }) || model_name.is_empty() + || model_name.len() > MAX_LABEL_LENGTH + || model_name.trim() != model_name + || model_name.chars().any(char::is_control) + { + return Err(ExternalSourceContractError::InvalidText( + "external subagent model request", + )); + } + } + if self.requested_tools.selectors.len() > MAX_TOOL_SELECTORS { + return Err(ExternalSourceContractError::InvalidText( + "external subagent tool selectors", + )); + } + let mut tool_selectors = BTreeSet::new(); + for selector in &self.requested_tools.selectors { + let valid_name = |value: &str| { + !value.is_empty() + && value.len() <= MAX_LABEL_LENGTH + && value.trim() == value + && !value.chars().any(char::is_control) + }; + if !valid_name(&selector.source_name) + || selector + .canonical_host_name + .as_deref() + .is_some_and(|name| !valid_name(name)) + || !tool_selectors.insert(( + selector.source_name.as_str(), + selector.canonical_host_name.as_deref(), + )) + { + return Err(ExternalSourceContractError::InvalidText( + "external subagent tool selector", + )); + } + } + if self.diagnostic_codes.len() > MAX_DIAGNOSTIC_CODES { + return Err(ExternalSourceContractError::InvalidText( + "external subagent diagnostic codes", + )); + } + let mut diagnostic_codes = BTreeSet::new(); + if self + .diagnostic_codes + .iter() + .any(|code| !is_stable_diagnostic_code(code) || !diagnostic_codes.insert(code.as_str())) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "external subagent diagnostic code", + )); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExternalSubagentDiscoveryInput { + pub context: ExternalSourceContext, + pub suppressed_sources: BTreeSet, +} + +#[derive(Clone, PartialEq, Eq)] +pub struct ExternalSubagentProviderSnapshot { + pub provider: ExternalSubagentProviderIdentity, + pub sources: Vec, + pub definitions: Vec, + pub diagnostics: Vec, +} + +impl fmt::Debug for ExternalSubagentProviderSnapshot { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("ExternalSubagentProviderSnapshot") + .field("provider", &self.provider) + .field("sources", &self.sources) + .field("definitions", &self.definitions) + .field("diagnostics", &self.diagnostics) + .finish() + } +} + +impl ExternalSubagentProviderSnapshot { + pub fn validate(&self) -> Result<(), ExternalSourceContractError> { + let diagnostic_count = self + .sources + .iter() + .fold(self.diagnostics.len(), |total, source| { + total.saturating_add(source.diagnostics.len()) + }); + if self.sources.len() > MAX_PROVIDER_SOURCES + || self.definitions.len() > MAX_PROVIDER_DEFINITIONS + || diagnostic_count > MAX_PROVIDER_DIAGNOSTICS + { + return Err(ExternalSourceContractError::InvalidText( + "external subagent provider snapshot size", + )); + } + let mut sources = BTreeSet::new(); + for source in &self.sources { + source.validate()?; + if source.key.provider_id != self.provider.provider_id + || source.ecosystem_id != self.provider.ecosystem_id + || !sources.insert(source.key.clone()) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "external subagent provider-qualified source", + )); + } + } + for source in &self.sources { + for diagnostic in &source.diagnostics { + validate_provider_diagnostic(diagnostic, &self.provider.provider_id, &sources)?; + } + } + for diagnostic in &self.diagnostics { + validate_provider_diagnostic(diagnostic, &self.provider.provider_id, &sources)?; + } + let mut candidates = BTreeSet::new(); + for definition in &self.definitions { + definition.validate()?; + if !candidates.insert(definition.candidate_id.clone()) + || definition.provenance.iter().any(|item| { + item.contribution_id.source.provider_id != self.provider.provider_id + || !sources.contains(&item.contribution_id.source) + }) + { + return Err(ExternalSourceContractError::InvalidIdentifier( + "provider-qualified external subagent", + )); + } + } + Ok(()) + } +} + +pub trait ExternalSubagentSourceProvider: Send + Sync { + fn identity(&self) -> ExternalSubagentProviderIdentity; + + fn discover( + &self, + input: &ExternalSubagentDiscoveryInput, + ) -> Result; + + fn watch_roots(&self, context: &ExternalSourceContext) -> Vec; +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum ExternalSubagentActivationState { + ApprovalRequired, + Declined, + Disabled, + Active, + Conflict, + Blocked, + Unavailable, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSubagentDiagnosticSummary { + pub code: String, + pub blocks_activation: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSubagentSummary { + pub candidate_id: String, + pub logical_id: String, + pub display_name: String, + pub description: String, + pub provider_label: String, + pub scope: ExternalSourceScope, + pub source_keys: Vec, + pub source_location_labels: Vec, + pub source_count: usize, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub effective_model_label: Option, + pub effective_tool_labels: Vec, + pub supports_follow_up: bool, + pub compatibility_state: ExternalSubagentCompatibilityState, + pub diagnostics: Vec, + pub activation_state: ExternalSubagentActivationState, + pub decision_key: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSubagentConflictCandidate { + pub candidate_id: String, + pub display_name: String, + pub source_label: String, + pub external: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ExternalSubagentConflict { + pub conflict_key: String, + pub logical_id: String, + pub candidates: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub selected_candidate_id: Option, +} + +pub fn external_subagent_candidate_id( + provider_id: &ProviderId, + logical_id: &str, + provenance: &[ExternalSubagentProvenanceRef], +) -> ExternalSubagentCandidateId { + let parts = provenance + .iter() + .map(|item| format!("{}:{:?}", item.contribution_id.stable_key(), item.role)); + ExternalSubagentCandidateId::new(format!( + "external_subagent:{}:{}:{}", + provider_id, + logical_id.to_ascii_lowercase(), + stable_digest(parts) + )) + .expect("bounded digest-based candidate id") +} + +pub fn external_subagent_approval_key( + candidate_id: &ExternalSubagentCandidateId, + behavior_version: &ExternalSubagentBehaviorVersion, + activation_envelope: &str, +) -> String { + format!( + "external_subagent_approval:{}", + stable_digest([ + candidate_id.as_str(), + behavior_version.as_str(), + activation_envelope, + ]) + ) +} + +pub fn external_subagent_conflict_key<'a>( + execution_domain_id: &str, + workspace_scope: &str, + logical_id: &str, + candidates: impl IntoIterator, +) -> String { + let mut candidates = candidates + .into_iter() + .map(|(id, version)| format!("{}:{id}{}:{version}", id.len(), version.len())) + .collect::>(); + candidates.sort_unstable(); + format!( + "external_subagent_conflict:{}", + stable_digest( + [ + execution_domain_id.to_string(), + workspace_scope.to_string(), + logical_id.to_ascii_lowercase(), + ] + .into_iter() + .chain(candidates) + ) + ) +} + +fn stable_digest(parts: impl IntoIterator>) -> String { + let mut hasher = Sha256::new(); + for part in parts { + let part = part.as_ref().as_bytes(); + hasher.update((part.len() as u64).to_le_bytes()); + hasher.update(part); + } + hex::encode(hasher.finalize()) +} diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index c3e985eb9a..9967ce10ef 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -8,6 +8,9 @@ pub mod canvas; #[cfg(feature = "external-sources")] pub mod external_sources; +#[cfg(feature = "external-sources")] +pub mod external_subagents; + #[cfg(feature = "plugin-source")] pub mod plugin_source; diff --git a/src/crates/contracts/product-domains/tests/external_source_contracts.rs b/src/crates/contracts/product-domains/tests/external_source_contracts.rs index 55137766a4..f9d34ef0ee 100644 --- a/src/crates/contracts/product-domains/tests/external_source_contracts.rs +++ b/src/crates/contracts/product-domains/tests/external_source_contracts.rs @@ -1,13 +1,23 @@ use bitfun_product_domains::external_sources::{ external_tool_approval_key, external_tool_conflict_key, prompt_command_conflict_key, - EcosystemId, ExecutionDomainId, ExpandedPromptCommand, ExternalSourceContext, - ExternalSourceDiagnostic, ExternalSourceHealth, ExternalSourceProviderError, - ExternalSourceRecord, ExternalSourceScope, ExternalToolCapability, ExternalToolDefinition, - ExternalToolRuntimeKind, ExternalToolStaticStatus, ExternalWatchRoot, + EcosystemId, ExecutionDomainId, ExpandedPromptCommand, ExternalSourceAssetKind, + ExternalSourceContext, ExternalSourceDiagnostic, ExternalSourceHealth, + ExternalSourceProviderError, ExternalSourceRecord, ExternalSourceScope, ExternalToolCapability, + ExternalToolDefinition, ExternalToolRuntimeKind, ExternalToolStaticStatus, ExternalWatchRoot, PromptCommandAvailability, PromptCommandDefinition, PromptCommandProviderIdentity, PromptCommandProviderSnapshot, PromptCommandSourceProvider, SourceKey, SourceQualifiedCommandId, SourceQualifiedToolId, SourceQualifiedToolTargetId, }; +use bitfun_product_domains::external_subagents::{ + external_subagent_approval_key, external_subagent_candidate_id, external_subagent_conflict_key, + ExternalSubagentBehaviorVersion, ExternalSubagentCandidateId, + ExternalSubagentCompatibilityState, ExternalSubagentContributionId, + ExternalSubagentContributionRole, ExternalSubagentDefinition, ExternalSubagentDiscoveryInput, + ExternalSubagentLocalId, ExternalSubagentMode, ExternalSubagentModelRequest, + ExternalSubagentProvenanceRef, ExternalSubagentProviderIdentity, + ExternalSubagentProviderSnapshot, ExternalSubagentToolRequest, ExternalSubagentToolSelector, + SecretText, +}; use std::path::PathBuf; fn source(provider_id: &str, ecosystem_id: &str, source_id: &str) -> ExternalSourceRecord { @@ -182,6 +192,260 @@ fn capability_provider_contract_does_not_require_core_or_an_ecosystem_enum() { assert_eq!(provider.watch_roots(&context()).len(), 1); } +#[test] +fn persisted_source_preference_keys_round_trip_without_path_guessing() { + let record = source( + "provider.with.dots", + "fake.ecosystem", + "project/source:agents", + ); + assert_eq!( + ExternalSourceRecord::source_key_from_preference_key(&record.preference_key()), + Some(record.key) + ); + assert!(ExternalSourceRecord::source_key_from_preference_key("malformed").is_none()); +} + +#[test] +fn external_subagent_identity_preserves_ordered_provenance_and_separate_revisions() { + let provider = + ExternalSubagentProviderIdentity::new("fake.agents", "fake.ecosystem", "Fake Agents") + .unwrap(); + let first = ExternalSubagentContributionId::new( + SourceKey::new("fake.agents", "global-config").unwrap(), + ExternalSubagentLocalId::new("review").unwrap(), + ); + let second = ExternalSubagentContributionId::new( + SourceKey::new("fake.agents", "project-config").unwrap(), + ExternalSubagentLocalId::new("review").unwrap(), + ); + let provenance = vec![ + ExternalSubagentProvenanceRef { + contribution_id: first, + role: ExternalSubagentContributionRole::Base, + }, + ExternalSubagentProvenanceRef { + contribution_id: second, + role: ExternalSubagentContributionRole::Overlay, + }, + ]; + let candidate_id = external_subagent_candidate_id(&provider.provider_id, "review", &provenance); + let reversed = external_subagent_candidate_id( + &provider.provider_id, + "review", + &provenance.iter().cloned().rev().collect::>(), + ); + assert_ne!( + candidate_id, reversed, + "provenance order changes behavior identity" + ); + + let definition = ExternalSubagentDefinition { + candidate_id, + logical_id: "review".to_string(), + provenance, + display_name: "Review".to_string(), + description: "Reviews a change".to_string(), + prompt: SecretText::new("Review carefully"), + mode: ExternalSubagentMode::Subagent, + disabled: false, + hidden: false, + requested_model: ExternalSubagentModelRequest::Default, + requested_tools: ExternalSubagentToolRequest { + selectors: vec![ExternalSubagentToolSelector { + source_name: "read".to_string(), + canonical_host_name: Some("Read".to_string()), + allowed: true, + }], + uses_conservative_default: false, + }, + compatibility: ExternalSubagentCompatibilityState::Ready, + diagnostic_codes: Vec::new(), + behavior_version: ExternalSubagentBehaviorVersion::new("behavior-v1").unwrap(), + }; + assert_eq!(definition.prompt.expose(), "Review carefully"); + assert!(!format!("{definition:?}").contains("Review carefully")); + + let mut invalid_model = definition.clone(); + invalid_model.requested_model = ExternalSubagentModelRequest::Exact { + provider_hint: Some("fake\nprovider".to_string()), + model_name: "model".to_string(), + }; + assert!(invalid_model.validate().is_err()); + + let mut invalid_tool = definition.clone(); + invalid_tool.requested_tools.selectors[0].source_name = "read\nsecret".to_string(); + assert!(invalid_tool.validate().is_err()); + + let mut invalid_diagnostic = definition.clone(); + invalid_diagnostic.diagnostic_codes = vec!["provider.invalid:raw-source-key".to_string()]; + assert!(invalid_diagnostic.validate().is_err()); + + let mut excessive_tools = definition.clone(); + excessive_tools.requested_tools.selectors = (0..257) + .map(|index| ExternalSubagentToolSelector { + source_name: format!("tool-{index}"), + canonical_host_name: None, + allowed: true, + }) + .collect(); + assert!(excessive_tools.validate().is_err()); + + let snapshot = ExternalSubagentProviderSnapshot { + provider, + sources: vec![ + source("fake.agents", "fake.ecosystem", "global-config"), + source("fake.agents", "fake.ecosystem", "project-config"), + ], + definitions: vec![definition], + diagnostics: Vec::new(), + }; + snapshot + .validate() + .expect("valid external subagent provider snapshot"); + + let source_key = snapshot.sources[0].key.clone(); + let mut valid_diagnostic = snapshot.clone(); + valid_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.degraded", + "An optional field is not supported", + Some(source_key), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + valid_diagnostic + .validate() + .expect("bounded provider diagnostics with a known source are valid"); + + let mut valid_source_diagnostic = snapshot.clone(); + let valid_source_key = valid_source_diagnostic.sources[0].key.clone(); + valid_source_diagnostic.sources[0].diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.source_degraded", + "This source has a recoverable warning", + Some(valid_source_key), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + valid_source_diagnostic + .validate() + .expect("source-owned diagnostics use the same provider contract"); + + let mut invalid_provider_diagnostic = snapshot.clone(); + invalid_provider_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent:raw-source", + "Invalid diagnostic code", + Some(SourceKey::new("other.agents", "project").unwrap()), + ) + .with_asset_kind(ExternalSourceAssetKind::Command), + ); + assert!(invalid_provider_diagnostic.validate().is_err()); + + let mut wrong_provider_diagnostic = snapshot.clone(); + wrong_provider_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.invalid_source", + "Unknown provider source", + Some(SourceKey::new("other.agents", "project").unwrap()), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + assert!(wrong_provider_diagnostic.validate().is_err()); + + let mut unknown_source_diagnostic = snapshot.clone(); + unknown_source_diagnostic.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.unknown_source", + "Unknown source", + Some(SourceKey::new("fake.agents", "missing").unwrap()), + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + assert!(unknown_source_diagnostic.validate().is_err()); + + let mut invalid_diagnostic_message = snapshot.clone(); + invalid_diagnostic_message.diagnostics.push( + ExternalSourceDiagnostic::warning("fake.agent.invalid_message", "invalid\nmessage", None) + .with_asset_kind(ExternalSourceAssetKind::Subagent), + ); + assert!(invalid_diagnostic_message.validate().is_err()); + + let mut wrong_asset_kind = snapshot.clone(); + wrong_asset_kind.diagnostics.push( + ExternalSourceDiagnostic::warning( + "fake.agent.wrong_kind", + "Diagnostic belongs to another asset kind", + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Tool), + ); + assert!(wrong_asset_kind.validate().is_err()); + + let mut excessive_sources = snapshot.clone(); + excessive_sources.sources = vec![snapshot.sources[0].clone(); 1025]; + assert!(excessive_sources.validate().is_err()); + + let mut excessive_definitions = snapshot.clone(); + excessive_definitions.definitions = vec![snapshot.definitions[0].clone(); 1025]; + assert!(excessive_definitions.validate().is_err()); + + let mut excessive_diagnostics = snapshot.clone(); + excessive_diagnostics.diagnostics = vec![ + ExternalSourceDiagnostic::warning( + "fake.agent.degraded", + "An optional field is not supported", + None, + ) + .with_asset_kind(ExternalSourceAssetKind::Subagent); + 1025 + ]; + assert!(excessive_diagnostics.validate().is_err()); + + let mut excessive_provenance = snapshot.clone(); + excessive_provenance.definitions[0].provenance = + vec![snapshot.definitions[0].provenance[0].clone(); 257]; + assert!(excessive_provenance.validate().is_err()); + + let input = ExternalSubagentDiscoveryInput { + context: context(), + suppressed_sources: [SourceKey::new("fake.agents", "suppressed").unwrap()] + .into_iter() + .collect(), + }; + assert_eq!(input.suppressed_sources.len(), 1); +} + +#[test] +fn external_subagent_decision_keys_bind_behavior_but_not_catalog_copy() { + let candidate = ExternalSubagentCandidateId::new("candidate-v1").unwrap(); + let behavior = ExternalSubagentBehaviorVersion::new("behavior-v1").unwrap(); + let approval = external_subagent_approval_key(&candidate, &behavior, "envelope-v1"); + let same = external_subagent_approval_key(&candidate, &behavior, "envelope-v1"); + let changed = external_subagent_approval_key( + &candidate, + &ExternalSubagentBehaviorVersion::new("behavior-v2").unwrap(), + "envelope-v1", + ); + assert_eq!(approval, same); + assert_ne!(approval, changed); + + let first = external_subagent_conflict_key( + "local-user", + "/workspace", + "review", + [("local", "v1"), (candidate.as_str(), behavior.as_str())], + ); + let reordered = external_subagent_conflict_key( + "local-user", + "/workspace", + "REVIEW", + [(candidate.as_str(), behavior.as_str()), ("local", "v1")], + ); + assert_eq!(first, reordered); +} + #[test] fn diagnostics_remain_source_qualified() { let diagnostic = ExternalSourceDiagnostic::warning( diff --git a/src/crates/execution/agent-runtime/src/agents.rs b/src/crates/execution/agent-runtime/src/agents.rs index ba41536fb6..68cd444d3e 100644 --- a/src/crates/execution/agent-runtime/src/agents.rs +++ b/src/crates/execution/agent-runtime/src/agents.rs @@ -250,6 +250,10 @@ pub struct SubagentQueryContext<'a> { pub workspace_root: Option<&'a Path>, pub list_scope: SubagentListScope, pub include_disabled: bool, + /// False for remote workspaces until an explicit remote source provider is + /// available. This prevents a matching path string from selecting local + /// external-source routes. + pub external_sources_supported: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -378,6 +382,7 @@ pub enum SubagentSourceKind { Builtin, Project, User, + External, Unspecified, } @@ -388,6 +393,7 @@ pub enum SubAgentSource { Builtin, Project, User, + External, } pub const fn subagent_source_kind(source: Option) -> SubagentSourceKind { @@ -395,6 +401,7 @@ pub const fn subagent_source_kind(source: Option) -> SubagentSou Some(SubAgentSource::Builtin) => SubagentSourceKind::Builtin, Some(SubAgentSource::Project) => SubagentSourceKind::Project, Some(SubAgentSource::User) => SubagentSourceKind::User, + Some(SubAgentSource::External) => SubagentSourceKind::External, None => SubagentSourceKind::Unspecified, } } @@ -404,7 +411,8 @@ pub const fn subagent_source_presentation_rank(source: Option) - Some(SubAgentSource::Builtin) => 0, Some(SubAgentSource::Project) => 1, Some(SubAgentSource::User) => 2, - None => 3, + Some(SubAgentSource::External) => 3, + None => 4, } } @@ -450,6 +458,7 @@ pub fn resolve_subagent_default_enabled( SubagentSourceKind::Builtin => visibility.can_access_from_parent(parent_agent_type), SubagentSourceKind::Project | SubagentSourceKind::User + | SubagentSourceKind::External | SubagentSourceKind::Unspecified => true, } } @@ -503,7 +512,7 @@ const fn default_reason( SubagentSourceKind::Project | SubagentSourceKind::User => { Some(SubagentStateReason::CustomDefaultEnabled) } - SubagentSourceKind::Unspecified => None, + SubagentSourceKind::External | SubagentSourceKind::Unspecified => None, } } diff --git a/src/crates/execution/agent-runtime/src/session.rs b/src/crates/execution/agent-runtime/src/session.rs index cdea253c7d..b599a4ae45 100644 --- a/src/crates/execution/agent-runtime/src/session.rs +++ b/src/crates/execution/agent-runtime/src/session.rs @@ -1,5 +1,6 @@ use crate::session_state::SessionState; pub use bitfun_core_types::SessionKind; +pub use bitfun_core_types::{SessionContinuationPolicy, SessionModelBindingPolicy}; use serde::{Deserialize, Serialize}; use std::time::SystemTime; use uuid::Uuid; @@ -160,6 +161,24 @@ pub struct SessionConfig { /// Model config ID used by this session (for token usage tracking) #[serde(default, skip_serializing_if = "Option::is_none")] pub model_id: Option, + /// Whether this child session accepts another delegated turn. + #[serde(default, skip_serializing_if = "is_reusable_continuation_policy")] + pub continuation_policy: SessionContinuationPolicy, + /// Whether config reconciliation may replace this session's model. + #[serde(default, skip_serializing_if = "is_mutable_model_binding_policy")] + pub model_binding_policy: SessionModelBindingPolicy, + /// Runtime identity approved for an immutable concrete model binding. + /// Mutable sessions leave this unset and continue to resolve selectors. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model_binding_fingerprint: Option, +} + +fn is_reusable_continuation_policy(policy: &SessionContinuationPolicy) -> bool { + *policy == SessionContinuationPolicy::Reusable +} + +fn is_mutable_model_binding_policy(policy: &SessionModelBindingPolicy) -> bool { + *policy == SessionModelBindingPolicy::Mutable } impl Default for SessionConfig { @@ -176,6 +195,9 @@ impl Default for SessionConfig { remote_connection_id: None, remote_ssh_host: None, model_id: None, + continuation_policy: SessionContinuationPolicy::default(), + model_binding_policy: SessionModelBindingPolicy::default(), + model_binding_fingerprint: None, } } } @@ -241,7 +263,7 @@ pub fn sanitize_persisted_session_state(state: &SessionState) -> SessionState { mod tests { use super::{ sanitize_persisted_session_state, CompressionState, PersistedSessionStateFile, Session, - SessionConfig, + SessionConfig, SessionContinuationPolicy, SessionModelBindingPolicy, }; use crate::session_state::{ProcessingPhase, SessionState}; use serde_json::json; @@ -261,6 +283,46 @@ mod tests { assert!(config.remote_connection_id.is_none()); assert!(config.remote_ssh_host.is_none()); assert!(config.model_id.is_none()); + assert_eq!( + config.continuation_policy, + SessionContinuationPolicy::Reusable + ); + assert_eq!( + config.model_binding_policy, + SessionModelBindingPolicy::Mutable + ); + } + + #[test] + fn non_default_subagent_session_policies_are_persisted_and_legacy_defaults_remain_compatible() { + let config = SessionConfig { + continuation_policy: SessionContinuationPolicy::FreshOnly, + model_binding_policy: SessionModelBindingPolicy::ApprovedImmutable, + ..SessionConfig::default() + }; + let serialized = serde_json::to_value(&config).expect("session config should serialize"); + assert_eq!(serialized["continuation_policy"], "fresh_only"); + assert_eq!(serialized["model_binding_policy"], "approved_immutable"); + + let mut legacy = serialized; + legacy + .as_object_mut() + .expect("session config should be an object") + .remove("continuation_policy"); + legacy + .as_object_mut() + .expect("session config should be an object") + .remove("model_binding_policy"); + let restored: SessionConfig = + serde_json::from_value(legacy).expect("legacy session config should deserialize"); + assert_eq!( + restored.continuation_policy, + SessionContinuationPolicy::Reusable + ); + assert_eq!( + restored.model_binding_policy, + SessionModelBindingPolicy::Mutable + ); } #[test] diff --git a/src/crates/execution/agent-runtime/tests/agent_registry_contracts.rs b/src/crates/execution/agent-runtime/tests/agent_registry_contracts.rs index c343ec27ae..f156dc1f9e 100644 --- a/src/crates/execution/agent-runtime/tests/agent_registry_contracts.rs +++ b/src/crates/execution/agent-runtime/tests/agent_registry_contracts.rs @@ -136,6 +136,10 @@ fn subagent_source_contract_preserves_runtime_kind_and_presentation_order() { subagent_source_kind(Some(SubAgentSource::User)), SubagentSourceKind::User ); + assert_eq!( + subagent_source_kind(Some(SubAgentSource::External)), + SubagentSourceKind::External + ); assert_eq!(subagent_source_kind(None), SubagentSourceKind::Unspecified); assert_eq!( @@ -150,7 +154,11 @@ fn subagent_source_contract_preserves_runtime_kind_and_presentation_order() { subagent_source_presentation_rank(Some(SubAgentSource::User)), 2 ); - assert_eq!(subagent_source_presentation_rank(None), 3); + assert_eq!( + subagent_source_presentation_rank(Some(SubAgentSource::External)), + 3 + ); + assert_eq!(subagent_source_presentation_rank(None), 4); } #[test] diff --git a/src/crates/services/services-core/src/session/lineage.rs b/src/crates/services/services-core/src/session/lineage.rs index 36bdd0601e..5df91a5f31 100644 --- a/src/crates/services/services-core/src/session/lineage.rs +++ b/src/crates/services/services-core/src/session/lineage.rs @@ -413,6 +413,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: None, subagent_type: None, + continuation_policy: None, }, ); @@ -443,6 +444,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: None, subagent_type: None, + continuation_policy: None, }); let mut grandchild = metadata("grandchild"); @@ -492,6 +494,7 @@ mod tests { parent_turn_index: Some(1), parent_tool_call_id: None, subagent_type: None, + continuation_policy: None, }); source.todos = Some(json!([{ "id": "todo" }])); source.deep_review_run_manifest = Some(json!({ "run": "manifest" })); diff --git a/src/crates/services/services-core/src/session/metadata.rs b/src/crates/services/services-core/src/session/metadata.rs index d6a32a4ce9..d1b9df929e 100644 --- a/src/crates/services/services-core/src/session/metadata.rs +++ b/src/crates/services/services-core/src/session/metadata.rs @@ -126,6 +126,9 @@ fn build_session_relationship( .as_ref() .and_then(|value| value.subagent_type.clone()) .or_else(|| legacy_custom_metadata_string(existing_custom_metadata, "subagentType")); + let continuation_policy = existing_relationship + .as_ref() + .and_then(|value| value.continuation_policy); if kind.is_none() && parent_session_id.is_none() @@ -134,6 +137,7 @@ fn build_session_relationship( && parent_turn_index.is_none() && parent_tool_call_id.is_none() && subagent_type.is_none() + && continuation_policy.is_none() { return None; } @@ -146,6 +150,7 @@ fn build_session_relationship( parent_turn_index, parent_tool_call_id, subagent_type, + continuation_policy, }) } @@ -362,6 +367,7 @@ fn fill_workspace_path_if_missing(metadata: &mut SessionMetadata, workspace_path mod tests { use super::*; use crate::session::{SessionRelationship, SessionRelationshipKind}; + use bitfun_core_types::SessionContinuationPolicy; use serde_json::json; fn metadata() -> SessionMetadata { @@ -421,6 +427,7 @@ mod tests { parent_turn_index: Some(3), parent_tool_call_id: Some("tool".to_string()), subagent_type: Some("ReviewSecurity".to_string()), + continuation_policy: Some(SessionContinuationPolicy::FreshOnly), }; set_session_relationship(&mut metadata, relationship.clone()); @@ -535,6 +542,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: Some("tool-1".to_string()), subagent_type: Some("review".to_string()), + continuation_policy: None, }) ); } diff --git a/src/crates/services/services-core/src/session/types.rs b/src/crates/services/services-core/src/session/types.rs index 8d28f4c7f0..cebc7f32b1 100644 --- a/src/crates/services/services-core/src/session/types.rs +++ b/src/crates/services/services-core/src/session/types.rs @@ -1,6 +1,6 @@ //! Types for session persistence -use bitfun_core_types::SessionKind; +use bitfun_core_types::{SessionContinuationPolicy, SessionKind}; use serde::{Deserialize, Serialize}; pub const SESSION_STORAGE_SCHEMA_VERSION: u32 = 2; @@ -56,6 +56,8 @@ pub struct SessionRelationship { alias = "subagent_type" )] pub subagent_type: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub continuation_policy: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] @@ -1019,7 +1021,7 @@ mod tests { SessionRelationship, SessionRelationshipKind, TextItemData, ThinkingItemData, ToolItemData, UserMessageData, }; - use bitfun_core_types::SessionKind; + use bitfun_core_types::{SessionContinuationPolicy, SessionKind}; #[test] fn dialog_turn_kind_defaults_to_user_dialog_for_legacy_payloads() { @@ -1216,6 +1218,7 @@ mod tests { parent_turn_index: Some(2), parent_tool_call_id: None, subagent_type: None, + continuation_policy: Some(SessionContinuationPolicy::FreshOnly), }); let json = serde_json::to_value(&metadata).expect("metadata should serialize"); diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx index b1d5a13562..ac71824c34 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.test.tsx @@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createRoot, type Root } from 'react-dom/client'; import { useAgentsStore } from './agentsStore'; +import { isLocallyManageableSubagent } from './agentVisibility'; vi.mock('react-i18next', () => ({ initReactI18next: { @@ -109,6 +110,14 @@ try { const describeWithJsdom = JSDOMCtor ? describe : describe.skip; +describe('agent editability', () => { + it('keeps external subagents visible but outside local mutations', () => { + expect(isLocallyManageableSubagent({ source: 'external' })).toBe(false); + expect(isLocallyManageableSubagent({ subagentSource: 'external', source: 'user' })).toBe(false); + expect(isLocallyManageableSubagent({ source: 'builtin' })).toBe(true); + }); +}); + describeWithJsdom('AgentsScene', () => { let dom: { window: Window & typeof globalThis }; let container: HTMLDivElement; diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index 49192fdc09..936ff58ca8 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -38,7 +38,11 @@ import { getAgentBadge, getAgentDescription, getCapabilityLabel } from './utils' import './AgentsView.scss'; import './AgentsScene.scss'; import { useGallerySceneAutoRefresh } from '@/app/hooks/useGallerySceneAutoRefresh'; -import { CORE_AGENT_IDS, isAgentInOverviewZone } from './agentVisibility'; +import { + CORE_AGENT_IDS, + isAgentInOverviewZone, + isLocallyManageableSubagent, +} from './agentVisibility'; import { CustomAgentAPI } from '@/infrastructure/api/service-api/CustomAgentAPI'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; import type { ModeSkillInfo, SubagentModelSelection } from '@/infrastructure/config/types'; @@ -48,6 +52,8 @@ import { type ModelSelectOption, useModelSelectPresentation, } from '@/infrastructure/config/components/ModelSelectPresentation'; +import { useSceneManager } from '@/app/hooks/useSceneManager'; +import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; const UNGROUPED_SKILL_GROUP = '__ungrouped__'; const DEFAULT_SUBAGENT_MODEL_OVERRIDE_VALUE = '__default_subagent_model__'; @@ -203,6 +209,8 @@ function buildSkillGroups( const AgentsHomeView: React.FC = () => { const { t } = useTranslation('scenes/agents'); const notification = useNotification(); + const { openScene } = useSceneManager(); + const setSettingsTab = useSettingsStore((state) => state.setActiveTab); const [deletingAgent, setDeletingAgent] = useState(false); const { searchQuery, @@ -230,6 +238,7 @@ const AgentsHomeView: React.FC = () => { const { buildModelOption, renderModelOption, renderModelValue } = useModelSelectPresentation(); const { + workspacePath, allAgents, filteredAgents, loading, @@ -309,6 +318,7 @@ const AgentsHomeView: React.FC = () => { { key: 'builtin', label: t('filters.builtin'), count: counts.builtin }, { key: 'user', label: t('filters.user'), count: counts.user }, { key: 'project', label: t('filters.project'), count: counts.project }, + { key: 'external', label: t('filters.external'), count: counts.external }, ] as const; const typeFilters = [ @@ -324,6 +334,9 @@ const AgentsHomeView: React.FC = () => { () => allAgents.find((agent) => agent.id === selectedAgentId) ?? null, [allAgents, selectedAgentId], ); + const selectedAgentIsExternal = ( + selectedAgent?.source ?? selectedAgent?.subagentSource + ) === 'external'; const selectedAgentModeConfig = useMemo( () => (selectedAgent?.agentKind === 'mode' ? getModeConfig(selectedAgent.id) : null), [getModeConfig, selectedAgent], @@ -340,6 +353,10 @@ const AgentsHomeView: React.FC = () => { () => (selectedAgent?.agentKind === 'mode' ? getModeManageableSubagents(selectedAgent.id) : []), [getModeManageableSubagents, selectedAgent], ); + const selectedAgentEditableSubagents = useMemo( + () => selectedAgentManageableSubagents.filter(isLocallyManageableSubagent), + [selectedAgentManageableSubagents], + ); const selectedAgentConfiguredTools = useMemo(() => ( selectedAgent?.agentKind === 'mode' ? (selectedAgentModeConfig?.enabled_tools ?? selectedAgent.defaultTools ?? []) @@ -437,7 +454,12 @@ const AgentsHomeView: React.FC = () => { const handleSubagentModelChange = useCallback(async ( value: string | number | (string | number)[], ) => { - if (!selectedAgent || selectedAgent.agentKind !== 'subagent' || savingSubagentModel) { + if ( + !selectedAgent + || selectedAgent.agentKind !== 'subagent' + || selectedAgentIsExternal + || savingSubagentModel + ) { return; } @@ -450,7 +472,7 @@ const AgentsHomeView: React.FC = () => { } finally { setSavingSubagentModel(false); } - }, [handleSetSubagentModel, savingSubagentModel, selectedAgent]); + }, [handleSetSubagentModel, savingSubagentModel, selectedAgent, selectedAgentIsExternal]); const selectedAgentCapabilityTabs = useMemo(() => { const tabs: Array<{ key: CapabilityTab; @@ -459,7 +481,7 @@ const AgentsHomeView: React.FC = () => { count?: string; }> = []; - if (selectedAgent?.agentKind === 'subagent') { + if (selectedAgent?.agentKind === 'subagent' && !selectedAgentIsExternal) { tabs.push({ key: 'model', icon: Cpu, @@ -515,6 +537,7 @@ const AgentsHomeView: React.FC = () => { pendingSubagentIds, pendingTools, selectedAgent, + selectedAgentIsExternal, selectedAgentConfiguredTools, selectedAgentEnabledSubagentIds, selectedAgentHasSkillTool, @@ -601,7 +624,9 @@ const AgentsHomeView: React.FC = () => { const handleDeleteCustomAgent = useCallback(async () => { if (!selectedAgent) return; - if ((selectedAgent.source ?? selectedAgent.subagentSource ?? 'builtin') === 'builtin') { + if (['builtin', 'external'].includes( + selectedAgent.source ?? selectedAgent.subagentSource ?? 'builtin', + )) { return; } const id = selectedAgent.id; @@ -613,7 +638,7 @@ const AgentsHomeView: React.FC = () => { if (!ok) return; setDeletingAgent(true); try { - await CustomAgentAPI.deleteCustomAgent(id); + await CustomAgentAPI.deleteCustomAgent(id, workspacePath || undefined); notification.success(t('agentsOverview.deleteSuccess', { name })); closeAgentDetails(); await loadAgents(); @@ -624,11 +649,13 @@ const AgentsHomeView: React.FC = () => { } finally { setDeletingAgent(false); } - }, [selectedAgent, closeAgentDetails, loadAgents, notification, t]); + }, [selectedAgent, closeAgentDetails, loadAgents, notification, t, workspacePath]); const canManageCustomAgent = Boolean( selectedAgent - && (selectedAgent.source ?? selectedAgent.subagentSource ?? 'builtin') !== 'builtin', + && !['builtin', 'external'].includes( + selectedAgent.source ?? selectedAgent.subagentSource ?? 'builtin', + ), ); return ( @@ -862,6 +889,12 @@ const AgentsHomeView: React.FC = () => { meta={selectedAgent ? ( <> {t('agentCard.meta.tools', { count: selectedAgentToolCount })} + {selectedAgent.externalProviderLabel ? ( + {t('agentCard.meta.externalProvider', { provider: selectedAgent.externalProviderLabel })} + ) : null} + {selectedAgent.supportsFollowUp === false ? ( + {t('agentCard.meta.singleRun')} + ) : null} {selectedAgent.agentKind === 'mode' && selectedAgentHasSkillTool ? ( {t('agentCard.meta.skills', { count: selectedAgentSkills.length })} ) : null} @@ -972,7 +1005,7 @@ const AgentsHomeView: React.FC = () => { try { const currentEnabledIds = new Set(selectedAgentEnabledSubagentIds); const defaultEnabledIds = new Set(selectedAgentDefaultEnabledSubagentIds); - const changedSubagents = selectedAgentManageableSubagents.filter((subagent) => + const changedSubagents = selectedAgentEditableSubagents.filter((subagent) => currentEnabledIds.has(subagent.id) !== defaultEnabledIds.has(subagent.id)); if (changedSubagents.length === 0) { @@ -1062,7 +1095,7 @@ const AgentsHomeView: React.FC = () => { const nextEnabledIds = new Set(pendingSubagentIds ?? selectedAgentEnabledSubagentIds); const currentEnabledIds = new Set(selectedAgentEnabledSubagentIds); - const changedSubagents = selectedAgentManageableSubagents.filter((subagent) => + const changedSubagents = selectedAgentEditableSubagents.filter((subagent) => currentEnabledIds.has(subagent.id) !== nextEnabledIds.has(subagent.id)); if (changedSubagents.length === 0) { @@ -1116,7 +1149,9 @@ const AgentsHomeView: React.FC = () => { ) : null} - {currentCapabilityTab === 'model' && selectedAgent.agentKind === 'subagent' ? ( + {currentCapabilityTab === 'model' + && selectedAgent.agentKind === 'subagent' + && !selectedAgentIsExternal ? ( + +
    + + +
    @@ -164,6 +168,16 @@

    Create or edit by prompt

    -
    - - 100% - -
    - HTML and PPTX export ready after generation.
    diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json index 8021a497bb..02bbb6f948 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/meta.json @@ -10,7 +10,7 @@ "ppt", "ai" ], - "version": 232, + "version": 247, "created_at": 0, "updated_at": 0, "permissions": { @@ -32,7 +32,7 @@ "enabled": false }, "ai": { - "enabled": false, + "enabled": true, "allowed_models": [], "max_tokens_per_request": 16000, "rate_limit_per_minute": 0 @@ -42,7 +42,7 @@ "rate_limit_per_minute": 120 } }, - "permission_rationale": "PPT Live stores only its own deck draft, fetches user-provided URLs only when generating source-grounded decks, runs hidden BitFun agent turns (ppt-design skill plus research tools such as WebSearch/WebFetch) only when the user asks it to generate or refine content, and exports PPTX/PDF/PNG/HTML entirely inside the desktop WebView.", + "permission_rationale": "PPT Live stores only its own deck draft, lists available AI models so the user can choose which Cowork model generates decks, fetches user-provided URLs only when generating source-grounded decks, runs hidden BitFun agent turns (ppt-design skill plus research tools such as WebSearch/WebFetch) only when the user asks it to generate or refine content, and exports PPTX/PDF/PNG/HTML entirely inside the desktop WebView.", "ai_context": { "original_prompt": "A built-in Live App for AI-assisted PPT generation, preview, and visual editing.", "conversation_id": null, diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/agent-prompt.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/agent-prompt.js index 61217812fe..ec24807f54 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/agent-prompt.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/agent-prompt.js @@ -49,18 +49,28 @@ export function buildAgentPrompt(input) { prompt += ` +## 节奏(必须,影响用户等待时间) + +硬性禁令(违反会白白烧掉数分钟模型时间): + +1. Skill 返回后**下一轮工具调用必须是 Write \`project.json\`**(\`status: "planning"\` + 完整 \`outline\` / \`slide_order\`)。禁止先 Read seed 的 \`project.json\`、禁止先 Read style-presets、禁止长篇思考规划。 +2. Input JSON 已含 \`style.palette\` / \`style.stylePreset\` 时:**禁止** \`Read references/style-presets/*\`;直接用 palette + Skill 表里的一句话 DNA。 +3. **禁止**为审计反复 Read / Grep / Glob 已写页面;每页一次 Write 写对。 +4. **按需研究**:仅当用户提供 URL、明确要求事实核验,或主题依赖外部时效数据时才 WebSearch / WebFetch;否则跳过。 +5. 写页节奏:每轮并行 Write **2 页** HTML(payload 过大时才降到 1 页)。最后一轮写完剩余页时,**同轮**再 Write/Edit \`project.json\` 把 \`status\` 设为 \`"complete"\` 并结束——不要单独开一轮只做 Glob/LS/Edit。 +6. 详细设计规则以 Skill 与下方 Authoring subset 为准;不要加载无关 reference;不要做「先打样 2 页再批量」的额外 showcase 轮次。 + ## 生成文件协议 - 当前 agent 工作区根目录就是 deck 根目录;所有路径均相对该工作区根目录。 - 先写工作区根目录下的 \`project.json\`,再写工作区根目录下的 \`slides/slide-NN.html\`。 - 只有在 \`slide_order\` 引用的每一页都已有完整 HTML 后,才将 \`project.json\` 的 \`status\` 设为 \`"complete"\`。 -- 完成前做一次有界检查:核对 \`outline[].slide_id\`、\`slide_order\` 和对应页面文件;缺什么只补什么,检查后立即结束。 +- 完成检查只在最后一轮工具批内完成(写完最后一页的同时改 status);缺什么只补什么,然后立即结束。 ## 约束 - 用户只能看到 PPT Live UI,无法回答提问。如有歧义自行判断最优方案并记录假设。 - 不要调用 AskUserQuestion、ControlHub、GenerativeUI、ComputerUse 等交互工具。 -- 研究用 WebSearch / WebFetch 即可。 ## Authoring subset(生成规则) diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/bitfun-backend-adapter.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/bitfun-backend-adapter.js index 7d521d7a0f..c800657905 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/bitfun-backend-adapter.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/bitfun-backend-adapter.js @@ -45,6 +45,7 @@ function installAgentBackend(app) { sessionName: 'PPT Live', sessionId: options.sessionId, appDataWorkspace: options.appDataWorkspace, + model: options.model || undefined, }); if (!result?.sessionId || !result?.turnId) { throw new Error('PPT Live agent backend did not return sessionId/turnId'); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/deck-project-contract.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/deck-project-contract.js index 87b799024e..b5a8b96934 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/deck-project-contract.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/deck-project-contract.js @@ -126,16 +126,29 @@ function seedPersistenceError(code, phase, missingPaths) { }); } +function shouldPersistSeedProjectJson(seed) { + const slideFiles = Array.isArray(seed?.slideFiles) ? seed.slideFiles : []; + if (slideFiles.length > 0) return true; + const outline = seed?.plan?.outline; + if (Array.isArray(outline) && outline.length > 0) return true; + if (String(seed?.plan?.status || '') === 'complete') return true; + // Empty new-deck skeleton: skip project.json so the agent can Write it + // fresh without a Read-before-Write round trip on a host-seeded file. + return false; +} + export async function persistDeckProjectSeed(fs, projectDir, seed) { try { await fs.mkdir(`${projectDir}/slides`, { recursive: true }); } catch { throw seedPersistenceError('seed_fs_mkdir_failed', 'mkdir', ['slides']); } - try { - await fs.writeFile(`${projectDir}/project.json`, `${JSON.stringify(seed.plan, null, 2)}\n`); - } catch { - throw seedPersistenceError('seed_fs_write_failed', 'project-write', ['project.json']); + if (shouldPersistSeedProjectJson(seed)) { + try { + await fs.writeFile(`${projectDir}/project.json`, `${JSON.stringify(seed.plan, null, 2)}\n`); + } catch { + throw seedPersistenceError('seed_fs_write_failed', 'project-write', ['project.json']); + } } for (const slideFile of seed.slideFiles || []) { try { @@ -456,7 +469,50 @@ async function readCompleteSlideWithRetry(readFile, relPath, options) { return typeof result === 'string' ? result.trim() : null; } +/** + * If outline/slide files are already complete but status is still "planning", + * host-side mark complete so the agent need not spend an extra Glob/Edit round. + * Returns true when project.json was updated (or already complete). + */ +export async function finalizeDeckProjectIfReady(readFile, writeFile, options = {}) { + if (typeof writeFile !== 'function') return false; + let plan; + try { + plan = await readProjectPlanWithRetry(readFile, { ...options, requireComplete: false }); + } catch { + return false; + } + if (!plan) return false; + if (plan.status === 'complete') return true; + + let slideOrder; + try { + slideOrder = validateCompletedPlan({ ...plan, status: 'complete' }); + } catch { + return false; + } + + for (const slideId of slideOrder) { + const html = await readCompleteSlideWithRetry(readFile, `slides/${slideId}.html`, options); + if (!html) return false; + } + + const nextPlan = { ...plan, status: 'complete' }; + try { + await writeFile('project.json', `${JSON.stringify(nextPlan, null, 2)}\n`); + } catch { + return false; + } + return true; +} + export async function readDeckProjectContract(readFile, options = {}) { + const { writeFile } = options; + // Prefer host-side complete marking before the requireComplete retry loop, + // so a finished deck with status still "planning" does not sit in delays. + if (typeof writeFile === 'function') { + await finalizeDeckProjectIfReady(readFile, writeFile, options); + } const plan = await readProjectPlanWithRetry(readFile, { ...options, requireComplete: true }); const slideOrder = validateCompletedPlan(plan); const outlineById = new Map(plan.outline.map((item) => [String(item.slide_id), item])); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-scene.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-scene.js index e52685c77a..0e1824aa0e 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-scene.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/editable-slide-scene.js @@ -106,11 +106,11 @@ function isPercentage(value) { const TEXT_STYLE_FIELDS = new Set([ 'fontSize', 'fontFace', 'color', 'bold', 'italic', 'underline', 'align', 'valign', 'lineSpacing', 'paraSpaceBefore', 'paraSpaceAfter', - 'margin', 'rotate', 'vert', 'transparency', 'bulletColor', + 'charSpacing', 'margin', 'rotate', 'vert', 'transparency', 'bulletColor', ]); const TEXT_RUN_OPTION_FIELDS = new Set([ 'fontSize', 'fontFace', 'color', 'bold', 'italic', 'underline', - 'breakLine', 'bullet', 'transparency', + 'breakLine', 'bullet', 'transparency', 'charSpacing', ]); const BULLET_FIELDS = new Set(['type', 'indent']); const LINE_STYLE_FIELDS = new Set([ @@ -169,6 +169,7 @@ function hasValidTextStyle(style) { && ['lineSpacing', 'paraSpaceBefore', 'paraSpaceAfter'].every((field) => ( style[field] === undefined || isNonNegativeFinite(style[field]) )) + && (style.charSpacing === undefined || Number.isFinite(style.charSpacing)) && hasValidMargin(style.margin) && (style.rotate === undefined || Number.isFinite(style.rotate)) && (style.vert === undefined || TEXT_VERTS.has(style.vert)) @@ -190,6 +191,7 @@ function hasValidTextRunOptions(options) { || typeof options.underline === 'boolean' || typeof options.underline === 'string') && (options.transparency === undefined || isPercentage(options.transparency)) + && (options.charSpacing === undefined || Number.isFinite(options.charSpacing)) && (options.bullet === undefined || ( hasOnlyFields(options.bullet, BULLET_FIELDS) && (options.bullet.type === undefined || options.bullet.type === 'bullet') diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/html2pptx-dom-core.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/html2pptx-dom-core.js index adec55a362..42b7a13396 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/html2pptx-dom-core.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/html2pptx-dom-core.js @@ -454,6 +454,38 @@ export function extractSlideDataFromDocument(doc = document) { const outerShadowOf = (effect) => (effect?.mode === 'outer' ? effect.shadow : null); + // Inline tags keep per-run style. Block wrappers (especially
  • …

  • + // authored by the deck skill / element-model path) must also be flattened — + // skipping them yields empty list items, scene validation fails, and degrade + // removes the entire UL/OL (dropping all dense body copy). + const INLINE_FORMAT_TAGS = new Set([ + 'SPAN', 'B', 'STRONG', 'I', 'EM', 'U', 'SMALL', 'LABEL', 'A', 'CODE', 'MARK', 'SUB', 'SUP', + ]); + const BLOCK_TEXT_WRAPPER_TAGS = new Set(['P', 'H1', 'H2', 'H3', 'H4', 'H5', 'H6']); + + const applyTextRunComputedStyle = (node, options, textTransform) => { + const computed = view.getComputedStyle(node); + const nextOptions = { ...options }; + let nextTransform = textTransform; + const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight, 10) >= 600; + if (isBold && !shouldSkipBold(computed.fontFamily)) nextOptions.bold = true; + if (computed.fontStyle === 'italic') nextOptions.italic = true; + if (computed.textDecoration && computed.textDecoration.includes('underline')) { + nextOptions.underline = true; + } + if (computed.color && computed.color !== 'rgb(0, 0, 0)') { + nextOptions.color = rgbToHex(computed.color); + const transparency = extractAlpha(computed.color); + if (transparency !== null) nextOptions.transparency = transparency; + } + if (computed.fontSize) nextOptions.fontSize = pxToPoints(computed.fontSize); + if (computed.textTransform && computed.textTransform !== 'none') { + const transformStr = computed.textTransform; + nextTransform = (text) => applyTextTransform(text, transformStr); + } + return { options: nextOptions, textTransform: nextTransform, computed }; + }; + // Parse inline formatting tags (, , , , , ) into text runs const parseInlineFormatting = (element, baseOptions = {}, runs = [], baseTextTransform = (x) => x) => { let prevNodeIsText = false; @@ -472,43 +504,30 @@ export function extractSlideDataFromDocument(doc = document) { } } else if (node.nodeType === view.Node.ELEMENT_NODE && node.textContent.trim()) { - const options = { ...baseOptions }; - const computed = view.getComputedStyle(node); - - // Handle inline elements with computed styles - if (['SPAN', 'B', 'STRONG', 'I', 'EM', 'U', 'SMALL', 'LABEL', 'A', 'CODE', 'MARK', 'SUB', 'SUP'].includes(node.tagName)) { - const isBold = computed.fontWeight === 'bold' || parseInt(computed.fontWeight) >= 600; - if (isBold && !shouldSkipBold(computed.fontFamily)) options.bold = true; - if (computed.fontStyle === 'italic') options.italic = true; - if (computed.textDecoration && computed.textDecoration.includes('underline')) options.underline = true; - if (computed.color && computed.color !== 'rgb(0, 0, 0)') { - options.color = rgbToHex(computed.color); - const transparency = extractAlpha(computed.color); - if (transparency !== null) options.transparency = transparency; - } - if (computed.fontSize) options.fontSize = pxToPoints(computed.fontSize); - - // Apply text-transform on the span element itself - if (computed.textTransform && computed.textTransform !== 'none') { - const transformStr = computed.textTransform; - textTransform = (text) => applyTextTransform(text, transformStr); - } + if (INLINE_FORMAT_TAGS.has(node.tagName) || BLOCK_TEXT_WRAPPER_TAGS.has(node.tagName)) { + const styled = applyTextRunComputedStyle(node, baseOptions, textTransform); + const options = styled.options; + textTransform = styled.textTransform; + const computed = styled.computed; // Validate: Check for margins on inline elements - if (computed.marginLeft && parseFloat(computed.marginLeft) > 0) { - addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-left.`, node); - } - if (computed.marginRight && parseFloat(computed.marginRight) > 0) { - addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-right.`, node); - } - if (computed.marginTop && parseFloat(computed.marginTop) > 0) { - addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-top.`, node); - } - if (computed.marginBottom && parseFloat(computed.marginBottom) > 0) { - addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-bottom.`, node); + if (INLINE_FORMAT_TAGS.has(node.tagName)) { + if (computed.marginLeft && parseFloat(computed.marginLeft) > 0) { + addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-left.`, node); + } + if (computed.marginRight && parseFloat(computed.marginRight) > 0) { + addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-right.`, node); + } + if (computed.marginTop && parseFloat(computed.marginTop) > 0) { + addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-top.`, node); + } + if (computed.marginBottom && parseFloat(computed.marginBottom) > 0) { + addDiagnostic('blocking', 'inline_margin', `Inline element <${node.tagName.toLowerCase()}> has unsupported margin-bottom.`, node); + } } - // Recursively process the child node. This will flatten nested spans into multiple runs. + // Recursively process the child node. This will flatten nested spans + // and block wrappers (li > p) into multiple runs. parseInlineFormatting(node, options, runs, textTransform); } } @@ -824,6 +843,17 @@ export function extractSlideDataFromDocument(doc = document) { return pxToPoints(lh) || fontSize * 1.2; }; + // CSS letter-spacing → PPTX charSpacing (pt). Negative tracking is common + // on titles; dropping it makes PowerPoint glyphs looser and can wrap a + // line that fit in the browser. + const resolveCharSpacing = (computed) => { + const raw = computed.letterSpacing; + if (!raw || raw === 'normal') return undefined; + const px = parseFloat(raw); + if (!Number.isFinite(px) || Math.abs(px) < 0.01) return undefined; + return px * PT_PER_PX; + }; + const emitTextElement = (el, type = el.tagName.toLowerCase(), exactFrame = false, rectOverride = null) => { const rect = rectOverride || rectFor(el); const text = el.textContent.replace(/\s+/g, ' ').trim(); @@ -875,6 +905,8 @@ export function extractSlideDataFromDocument(doc = document) { paraSpaceAfter: 0, margin: textInset, }; + const charSpacing = resolveCharSpacing(computed); + if (charSpacing !== undefined) baseStyle.charSpacing = charSpacing; const transparency = extractAlpha(computed.color); if (transparency !== null) baseStyle.transparency = transparency; @@ -1907,23 +1939,34 @@ export function extractSlideDataFromDocument(doc = document) { const rect = rectFor(el); if (rect.width === 0 || rect.height === 0) return; - const liElements = Array.from(el.querySelectorAll('li')); + // Prefer real
  • children. After repairNestedParagraphs unwraps + //
  • …

  • into sibling

    under the UL, fall back to those + // direct semantic blocks so dense list body text is not lost. + const liElements = Array.from(el.querySelectorAll(':scope > li')); + const listItemElements = liElements.length + ? liElements + : Array.from(el.querySelectorAll(':scope > p, :scope > h1, :scope > h2, :scope > h3, :scope > h4, :scope > h5, :scope > h6')); const items = []; const ulComputed = view.getComputedStyle(el); - const ulPaddingLeftPt = pxToPoints(ulComputed.paddingLeft); + // jsdom / stripped lists may report paddingLeft as "" → NaN. A non-finite + // indent makes scene validation reject the whole list payload. + const ulPaddingLeftPt = (() => { + const value = pxToPoints(ulComputed.paddingLeft); + return Number.isFinite(value) && value >= 0 ? value : 18; + })(); // Split: margin-left for bullet position, indent for text position // margin-left + indent = ul padding-left const marginLeft = ulPaddingLeftPt * 0.5; const textIndent = ulPaddingLeftPt * 0.5; - const computed = view.getComputedStyle(liElements[0] || el); + const computed = view.getComputedStyle(listItemElements[0] || el); const textHex = rgbToHex(computed.color); const bulletColor = resolveListBulletColor(el, liElements, textHex); - liElements.forEach((li, idx) => { - const isLast = idx === liElements.length - 1; - const runs = parseInlineFormatting(li, { breakLine: false }); + listItemElements.forEach((itemEl, idx) => { + const isLast = idx === listItemElements.length - 1; + const runs = parseInlineFormatting(itemEl, { breakLine: false }); // Clean manual bullets from first run if (runs.length > 0) { runs[0].text = runs[0].text.replace(/^[•\-\*▪▸]\s*/, ''); @@ -1952,9 +1995,22 @@ export function extractSlideDataFromDocument(doc = document) { // UL/OL padding: paddingLeft is already split into bullet margin + // text indent below. The remaining padding (top/right/bottom) must // be preserved as PPTX internal margin so text doesn't shift. - const ulPadR = pxToInch(parseFloat(ulComputed.paddingRight) || 0); - const ulPadT = pxToInch(parseFloat(ulComputed.paddingTop) || 0); - const ulPadB = pxToInch(parseFloat(ulComputed.paddingBottom) || 0); + const inchPad = (value) => { + const px = parseFloat(value); + return Number.isFinite(px) && px > 0 ? pxToInch(px) : 0; + }; + const ulPadR = inchPad(ulComputed.paddingRight); + const ulPadT = inchPad(ulComputed.paddingTop); + const ulPadB = inchPad(ulComputed.paddingBottom); + const listCharSpacing = resolveCharSpacing(computed); + + // Empty list payloads fail scene validation; degrade then deletes the + // whole UL/OL and every nested body line with it. Leave the list + // unmarked so nested semantic paragraphs can still export. + if (!items.length) { + listItemElements.forEach((itemEl) => processed.add(itemEl)); + return; + } pushElement({ type: 'list', @@ -1969,18 +2025,28 @@ export function extractSlideDataFromDocument(doc = document) { fontSize: pxToPoints(computed.fontSize) || 12, fontFace: computed.fontFamily.split(',')[0].replace(/['"]/g, '').trim() || 'Arial', color: textHex, - bulletColor, - transparency: extractAlpha(computed.color), + ...(bulletColor ? { bulletColor } : {}), + ...(extractAlpha(computed.color) != null + ? { transparency: extractAlpha(computed.color) } + : {}), align: resolveTextAlign(computed), lineSpacing: resolveLineSpacing(computed), paraSpaceBefore: 0, paraSpaceAfter: pxToPoints(computed.marginBottom) || 0, // PptxGenJS margin array is [top, right, bottom, left]. - margin: [ulPadT, ulPadR, ulPadB, marginLeft / 72] + margin: [ulPadT, ulPadR, ulPadB, marginLeft / 72], + ...(listCharSpacing !== undefined ? { charSpacing: listCharSpacing } : {}), } }, el); - liElements.forEach(li => processed.add(li)); + listItemElements.forEach((itemEl) => { + processed.add(itemEl); + // Nested semantic blocks were flattened into the list runs above. + itemEl.querySelectorAll('p, h1, h2, h3, h4, h5, h6').forEach((child) => processed.add(child)); + }); + // Any leftover LI wrappers (when items came from unwrapped paragraphs) + // must not emit a second empty list pass. + liElements.forEach((li) => processed.add(li)); processed.add(el); return; } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js index f4788ac771..c6a368729c 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/i18n.js @@ -78,7 +78,7 @@ export const STRINGS = { generationPlanningSlides: 'Planning slide content…', generationPlanProgress: 'Planned {{count}} slides so far…', generationPlanReady: '{{count}} slides planned.', - generationOutlineReady: 'Outline ready: {{count}} slides. Writing briefs…', + generationOutlineReady: 'Outline ready: {{count}} slides. Writing pages…', generationBriefsBatchReady: 'Briefs done for slides {{start}}-{{end}} ({{total}} total).', generationBriefsRetry: 'Briefs for slides {{start}}-{{end}} issue; retrying ({{attempt}}/{{max}})…', generationPlanRetry: 'Planning issue; retrying ({{attempt}}/{{max}})…', @@ -86,6 +86,10 @@ export const STRINGS = { generationSlidesPhase: 'Generating {{count}} slides…', generationAuditPhase: 'Reviewing the full deck…', generationRenderingSlide: 'Generating slide {{slide}}/{{total}}…', + generationWritingSlideProgress: 'Writing slides {{done}}/{{total}}…', + generationResearching: 'Looking up reference material…', + generationLoadingSkill: 'Loading design guidelines…', + generationVerifyingDeck: 'Checking slide files…', generationSlideReady: 'Slide {{slide}}/{{total}} done.', generationSlideRetry: 'Slide {{slide}} issue; retrying ({{attempt}}/{{max}})…', generationSlideRepair: 'Slide {{slide}} needs adjustment; retrying ({{attempt}}/{{max}})…', @@ -93,7 +97,7 @@ export const STRINGS = { generationPartialDeck: 'Slides {{missing}} did not finish. The finished slides are kept — send a follow-up prompt to fill the missing pages.', agentWorkingTitle: 'Building your deck', agentWorkingKicker: 'In progress', - agentWorkingClaim: 'Your slides will appear in the canvas when generation finishes.', + agentWorkingClaim: 'Pages appear here as soon as each slide file is written.', agentWorkingProof: 'Progress', agentWorkingDetail: 'This is a live preview area while your deck is being created.', agentWorkingSourceNote: 'Content is generated from your prompt.', @@ -155,20 +159,28 @@ export const STRINGS = { generationProgressPulse: 'Still generating…', generationPageProgress: 'Generating page {{current}}', generationSlideProgress: '{{count}} slides generated', - generationStepBrief: 'Assumptions', - generationStepBriefDetail: 'Read the prompt and plan the deck.', + generationStepSkill: 'Guidelines', + generationStepSkillDetail: 'Load the ppt-design skill and defaults.', + generationStepOutline: 'Outline', + generationStepOutlineDetail: 'Write project.json with the slide plan.', + generationStepSlides: 'Slides', + generationStepSlidesDetail: 'Write each slide HTML file.', + generationStepVerify: 'Load', + generationStepVerifyDetail: 'Verify files and open the deck.', + generationStepBrief: 'Guidelines', + generationStepBriefDetail: 'Load the ppt-design skill and defaults.', generationStepSpine: 'Outline', - generationStepSpineDetail: 'Create assertion-led slide titles from the request.', - generationStepProof: 'Slide copy', - generationStepProofDetail: 'Ground each page in source facts or clear assumptions.', - generationStepDesign: 'Visual design', - generationStepDesignDetail: 'Apply theme, layout, and visual hierarchy.', - generationStepCompile: 'Load slides', - generationStepCompileDetail: 'Load the generated deck into the editor.', - generationReadingBrief: 'Reading your prompt...', + generationStepSpineDetail: 'Write project.json with the slide plan.', + generationStepProof: 'Research', + generationStepProofDetail: 'Look up facts only when the topic needs them.', + generationStepDesign: 'Slides', + generationStepDesignDetail: 'Write each slide HTML file.', + generationStepCompile: 'Load', + generationStepCompileDetail: 'Verify files and open the deck.', + generationReadingBrief: 'Loading design guidelines...', generationWritingClaims: 'Building the outline...', - generationChoosingProof: 'Writing slide copy...', - generationDesigningLayouts: 'Designing slide layouts...', + generationChoosingProof: 'Looking up reference material...', + generationDesigningLayouts: 'Writing slide pages...', generationCompiled: 'Your deck is ready.', generationSpineReady: 'Claim spine ready.', generationLocalSpine: 'Generation is unavailable. Please try again later.', @@ -373,6 +385,10 @@ export const STRINGS = { propertiesFont: 'Font', propertiesColorMode: 'Slide colors', propertiesStylePreset: 'Style preset', + propertiesModel: 'Model', + modelOptionAuto: 'Auto (host default)', + modelOptionPrimary: 'Primary', + modelOptionFast: 'Fast', colorModeLight: 'Light', colorModeDark: 'Dark', fontSansSerif: 'Sans-serif', @@ -462,7 +478,7 @@ export const STRINGS = { generationPlanningSlides: '正在规划各页内容…', generationPlanProgress: '已规划 {{count}} 页内容…', generationPlanReady: '已规划 {{count}} 页内容', - generationOutlineReady: '大纲已完成:共 {{count}} 页,正在撰写各页要点…', + generationOutlineReady: '大纲已完成:共 {{count}} 页,开始写页面…', generationBriefsBatchReady: '第 {{start}}-{{end}} 页要点已完成(累计 {{total}} 页)。', generationBriefsRetry: '第 {{start}}-{{end}} 页要点遇到问题,正在重试({{attempt}}/{{max}})…', generationPlanRetry: '规划遇到问题,正在重试({{attempt}}/{{max}})…', @@ -470,6 +486,10 @@ export const STRINGS = { generationSlidesPhase: '开始生成 {{count}} 页 PPT', generationAuditPhase: '正在检查整套演示稿…', generationRenderingSlide: '正在生成第 {{slide}}/{{total}} 页…', + generationWritingSlideProgress: '正在写第 {{done}}/{{total}} 页…', + generationResearching: '正在检索参考资料…', + generationLoadingSkill: '正在加载设计规范…', + generationVerifyingDeck: '正在校验页面文件…', generationSlideReady: '第 {{slide}}/{{total}} 页已生成', generationSlideRetry: '第 {{slide}} 页生成遇到问题,正在重试({{attempt}}/{{max}})…', generationSlideRepair: '第 {{slide}} 页需要调整,正在重新生成({{attempt}}/{{max}})…', @@ -477,7 +497,7 @@ export const STRINGS = { generationPartialDeck: '第 {{missing}} 页未完成。已完成的页面已保留,可继续发送指令补全缺失页。', agentWorkingTitle: '正在生成演示稿', agentWorkingKicker: '生成中', - agentWorkingClaim: '完成后,最终页面会出现在中间画布。', + agentWorkingClaim: '每写完一页,就会立刻出现在中间画布。', agentWorkingProof: '进度', agentWorkingDetail: '这是生成过程中的预览区域。', agentWorkingSourceNote: '内容将根据你的 Prompt 自动生成。', @@ -539,20 +559,28 @@ export const STRINGS = { generationProgressPulse: '仍在生成中…', generationPageProgress: '正在生成第 {{current}} 页', generationSlideProgress: '已生成 {{count}} 页', - generationStepBrief: '发布假设', - generationStepBriefDetail: '阅读 Prompt 并规划演示稿。', - generationStepSpine: '生成大纲', - generationStepSpineDetail: '把需求转成断言式页面标题。', - generationStepProof: '页面文案', - generationStepProofDetail: '用素材事实或明确假设支撑每页。', - generationStepDesign: '设计排版', - generationStepDesignDetail: '应用主题、版式与视觉层次。', - generationStepCompile: '加载页面', - generationStepCompileDetail: '将生成结果加载为可编辑页面。', - generationReadingBrief: '正在理解你的 Prompt…', + generationStepSkill: '设计规范', + generationStepSkillDetail: '加载 ppt-design Skill 与默认假设。', + generationStepOutline: '大纲', + generationStepOutlineDetail: '写入 project.json 幻灯片计划。', + generationStepSlides: '写页面', + generationStepSlidesDetail: '逐页写入 slide HTML 文件。', + generationStepVerify: '加载', + generationStepVerifyDetail: '校验文件并打开演示稿。', + generationStepBrief: '设计规范', + generationStepBriefDetail: '加载 ppt-design Skill 与默认假设。', + generationStepSpine: '大纲', + generationStepSpineDetail: '写入 project.json 幻灯片计划。', + generationStepProof: '检索', + generationStepProofDetail: '仅在主题需要事实时检索资料。', + generationStepDesign: '写页面', + generationStepDesignDetail: '逐页写入 slide HTML 文件。', + generationStepCompile: '加载', + generationStepCompileDetail: '校验文件并打开演示稿。', + generationReadingBrief: '正在加载设计规范…', generationWritingClaims: '正在生成大纲…', - generationChoosingProof: '正在撰写页面文案…', - generationDesigningLayouts: '正在设计页面版式…', + generationChoosingProof: '正在检索参考资料…', + generationDesigningLayouts: '正在写页面…', generationCompiled: '演示稿已就绪。', generationSpineReady: '论点主线已生成。', generationLocalSpine: '生成服务暂不可用,请稍后重试。', @@ -757,6 +785,10 @@ export const STRINGS = { propertiesFont: '字体', propertiesColorMode: '幻灯片配色', propertiesStylePreset: '风格预设', + propertiesModel: '模型', + modelOptionAuto: '自动(跟随主机默认)', + modelOptionPrimary: '主模型', + modelOptionFast: '快速模型', colorModeLight: '浅色', colorModeDark: '深色', fontSansSerif: '非衬线', diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js index 0af0895090..73d66d2b3e 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/pptx-html-build.js @@ -4,12 +4,16 @@ // buildSlideFromScene() validates and serializes only scene.nodes. // // Key design decisions: -// WIDTH_SAFETY_IN — text boxes are widened by 0.15" to absorb cross-renderer -// font metric drift (PowerPoint renders CJK glyphs slightly wider than -// browsers). safeTextBoxGeometry() shifts the x coordinate for right/center -// aligned text so the extra width doesn't shift the visual anchor. +// width safety — text boxes are widened to absorb cross-renderer font metric +// drift (PowerPoint renders CJK glyphs slightly wider than browsers). The +// safety scales with font size (calibrated as ~0.36" at 14pt ≈ ~2.4 CJK em); +// a fixed margin under-compensates large titles and causes false wraps. +// safeTextBoxGeometry() shifts x for right/center align so the extra width +// does not move the visual anchor. // margin — set from the element's CSS padding so PPTX internal inset matches // the HTML box model (prevents text from shifting toward top-left). +// charSpacing — CSS letter-spacing is preserved so negative tracking in the +// preview cannot become looser (and wrap) in PowerPoint. // valign — resolved from CSS flex/grid align-items or line-height ratio. // ───────────────────────────────────────────────────────────────────────────── import pptxgen from 'pptxgenjs'; @@ -26,43 +30,66 @@ const OOXML_ROUND_RECT_ADJUSTMENTS = Symbol('ppt-live-round-rect-adjustments'); // if a line of CJK text *barely* fits on one line in the browser, PowerPoint's // slightly wider rendering pushes the last character to the next line. // -// 0.15 inch (~14.4px @ 96dpi) ≈ one CJK glyph at ~14pt body text. This is -// large enough to absorb the worst-case cross-renderer metric drift while -// capTextBoxWidth() prevents any overflow past the slide edge. +// Calibration: 0.36" ≈ ~2.4 CJK em at 14pt body text. Prefer a generous +// right-side slack over false wraps — empty padding rarely hurts layout. +// Safety must scale with font size — a 42pt title needs ~3× that margin. +// Boxes may extend past the slide edge; PowerPoint allows off-slide shapes, +// and clipping the safety margin would reintroduce wraps near the right edge. // // IMPORTANT: the safety width widens the text box to prevent wrapping, but // for right/center-aligned text this alone would shift the rendered glyphs // (right edge moves right, center moves right). The callers compensate by // adjusting the x coordinate so that the *original* text region is preserved. -const WIDTH_SAFETY_IN = 0.15; +const WIDTH_SAFETY_BASE_IN = 0.36; +const WIDTH_SAFETY_REF_PT = 14; +const WIDTH_SAFETY_MIN_IN = 0.28; +const WIDTH_SAFETY_MAX_IN = 1.6; -function capTextBoxWidth(x, w) { - return Math.min(w, Math.max(0.15, SLIDE_W_IN - x - 0.02)); +export function textBoxWidthSafetyInches(fontSizePt) { + const size = Math.max(8, Number(fontSizePt) || WIDTH_SAFETY_REF_PT); + const scaled = WIDTH_SAFETY_BASE_IN * (size / WIDTH_SAFETY_REF_PT); + return Math.min(WIDTH_SAFETY_MAX_IN, Math.max(WIDTH_SAFETY_MIN_IN, scaled)); +} + +function resolveTextFontSizePt(el) { + const base = Number(el?.style?.fontSize) || WIDTH_SAFETY_REF_PT; + if (!Array.isArray(el?.text)) return base; + const runSizes = el.text + .map((run) => Number(run?.options?.fontSize) || 0) + .filter((size) => size > 0); + return runSizes.length ? Math.max(base, ...runSizes) : base; +} + +function textUsesBold(el) { + if (el?.style?.bold) return true; + if (!Array.isArray(el?.text)) return false; + return el.text.some((run) => run?.options?.bold); } // Given an element's original x/w and its text-align, return {x, w} for the -// PPTX text box. The box is widened by WIDTH_SAFETY_IN to prevent wrapping, -// but the x coordinate is shifted so the original left/right/center anchor -// stays in the same visual position: +// PPTX text box. The box is widened by a font-size-scaled safety margin to +// prevent wrapping, but the x coordinate is shifted so the original +// left/right/center anchor stays in the same visual position: // left → extra width extends to the right (x unchanged) // right → extra width extends to the left (x shifts left by safety) // center → split equally (x shifts left by safety/2) -function safeTextBoxGeometry(origX, origW, align, isVerticalText) { - const safety = isVerticalText ? 0 : WIDTH_SAFETY_IN; - const rawW = origW + safety; - if (!safety || align === 'left' || !align) { - return { x: origX, w: capTextBoxWidth(origX, rawW) }; +export function safeTextBoxGeometry(origX, origW, align, isVerticalText, fontSizePt, options = {}) { + if (isVerticalText) return { x: origX, w: Math.max(0.15, origW) }; + let safety = textBoxWidthSafetyInches(fontSizePt); + // Faux / true bold can widen CJK runs by a few percent beyond the base drift. + if (options.bold) safety *= 1.12; + const w = Math.max(0.15, origW + safety); + if (align === 'left' || !align) { + return { x: origX, w }; } if (align === 'right') { - const x = Math.max(0, origX - safety); - return { x, w: capTextBoxWidth(x, rawW) }; + return { x: Math.max(0, origX - safety), w }; } if (align === 'center') { - const x = Math.max(0, origX - safety / 2); - return { x, w: capTextBoxWidth(x, rawW) }; + return { x: Math.max(0, origX - safety / 2), w }; } // justify behaves like left for anchoring - return { x: origX, w: capTextBoxWidth(origX, rawW) }; + return { x: origX, w }; } function toImagePayload(src) { @@ -301,7 +328,15 @@ function addSceneNodes(slideData, targetSlide, pres) { } } else if (el.type === 'text') { const isVerticalText = el.style.vert && el.style.vert !== 'horz'; - const { x: boxX, w: boxW } = safeTextBoxGeometry(el.x, el.w, el.style.align, isVerticalText); + const fontSizePt = resolveTextFontSizePt(el); + const { x: boxX, w: boxW } = safeTextBoxGeometry( + el.x, + el.w, + el.style.align, + isVerticalText, + fontSizePt, + { bold: textUsesBold(el) }, + ); const textOptions = { x: boxX, y: el.y, @@ -332,6 +367,9 @@ function addSceneNodes(slideData, targetSlide, pres) { if (el.style.transparency != null && el.style.transparency !== undefined) { textOptions.transparency = el.style.transparency; } + if (Number.isFinite(el.style.charSpacing)) { + textOptions.charSpacing = el.style.charSpacing; + } targetSlide.addText(pptxTextValue(el.text), textOptions); } } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/render.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/render.js index d078c933c1..e7b520d892 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/render.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/render.js @@ -570,6 +570,8 @@ export function renderGeneration(state) { document.querySelector('.ppt-live')?.classList.toggle('is-generating', isActive); document.querySelector('.ppt-live')?.classList.toggle('has-generation-error', hasError); + renderGenerationProgress(state, { isActive, hasError }); + if (!list) return; // Merge high-level phase events and granular agent-stream entries into a @@ -606,6 +608,44 @@ export function renderGeneration(state) { scrollGenerationListToLatest(list); } +function renderGenerationProgress(state, { isActive, hasError }) { + const panel = byId('generationProgress'); + const labelEl = byId('generationProgressLabel'); + const countEl = byId('generationProgressCount'); + const fillEl = byId('generationProgressFill'); + const phaseList = byId('generationPhaseList'); + if (!panel || !labelEl || !countEl || !fillEl || !phaseList) return; + + const steps = state.generation?.steps || []; + const show = Boolean(isActive || hasError || steps.some((step) => step.status === 'done')); + panel.hidden = !show; + if (!show) return; + + const drafted = Number(state.generation?.draftedCount) || 0; + const target = Number(state.generation?.slideTarget) || 0; + const running = steps.find((step) => step.status === 'running'); + const ratio = target > 0 + ? Math.max(0, Math.min(1, drafted / target)) + : (running ? Math.max(0.08, steps.filter((step) => step.status === 'done').length / Math.max(steps.length, 1)) : (isActive ? 0.08 : 1)); + + labelEl.textContent = running?.label + || (hasError ? t('eventTurnFailed') : t('processEventDone')); + countEl.textContent = target > 0 + ? `${Math.min(drafted, target)} / ${target}` + : (drafted > 0 ? String(drafted) : ''); + fillEl.style.width = `${Math.round(ratio * 100)}%`; + panel.classList.toggle('is-error', hasError); + panel.classList.toggle('is-active', isActive && !hasError); + + phaseList.innerHTML = ''; + for (const step of steps) { + const li = document.createElement('li'); + li.className = `generation-phase is-${step.status || 'pending'}`; + li.textContent = step.label || step.id; + phaseList.append(li); + } +} + function mergeTimeline(events, stream) { const eventItems = events.map((e) => ({ ...e, source: 'event' })); const streamItems = stream.map((s) => ({ ...s, source: 'stream' })); @@ -705,22 +745,35 @@ function renderLiveIndicator(state, merged) { } function currentActivityLabel(state, merged) { + const drafted = Number(state.generation?.draftedCount) || 0; + const target = Number(state.generation?.slideTarget) || 0; + const currentStep = (state.generation?.steps || []).find((s) => s.status === 'running'); + if (currentStep?.id === 'slides' && (drafted > 0 || target > 0)) { + return t('generationWritingSlideProgress', { + done: drafted, + total: target || Math.max(drafted, 1), + }); + } // Derive the most specific "what is happening right now" label. for (let i = merged.length - 1; i >= 0; i--) { const item = merged[i]; if (item.source === 'stream') { if (item.kind === 'tool-start') { + const tool = String(item.toolName || '').toLowerCase(); + const detail = truncateText(String(item.text || ''), 80); + if ((tool === 'write' || tool === 'edit') && detail) { + return `${friendlyStreamToolName(item.toolName)} ${detail}…`; + } return `${friendlyStreamToolName(item.toolName)}…`; } if (item.kind === 'text') { return t('processEventText'); } } - if (item.source === 'event' && item.title) { + if (item.source === 'event' && item.title && item.kind !== 'pulse') { return item.title; } } - const currentStep = (state.generation?.steps || []).find((s) => s.status === 'running'); if (currentStep?.label) return `${currentStep.label}…`; return t('generationProgressPulse'); } @@ -875,6 +928,9 @@ export function renderOutline(state, handlers) { export function renderThumbs(state, handlers) { const holder = byId('slideThumbs'); if (!holder) return; + // Full rebuild resets scrollTop; keep the filmstrip viewport stable when + // the user clicks a non-visible thumb or when selection only changes. + const previousScrollTop = holder.scrollTop; holder.innerHTML = ''; if (!state.slides.length) { const empty = document.createElement('div'); @@ -923,7 +979,9 @@ export function renderThumbs(state, handlers) { button.addEventListener('click', () => handlers.selectSlide(slide.id)); holder.append(button); }); + holder.scrollTop = previousScrollTop; requestAnimationFrame(() => { + holder.scrollTop = previousScrollTop; fitThumbPreviews(); observeThumbPreviews(); }); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/state.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/state.js index ed9173943b..7df55d3db8 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/state.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/state.js @@ -4,6 +4,13 @@ import { normalizeStylePresetKey } from './style-presets.js'; export const STORAGE_KEY = 'pptLiveStudioStateV6'; export const HISTORY_KEY = 'pptLiveDeckHistoryV1'; export const SCHEMA_VERSION = 6; +/** Default Cowork model selector when the user has not chosen one yet. */ +export const DEFAULT_PREFERRED_MODEL = 'primary'; + +export function normalizePreferredModel(value) { + const raw = String(value || '').trim(); + return raw || DEFAULT_PREFERRED_MODEL; +} export const ELEMENT_TYPES = ['text', 'list', 'shape', 'metric', 'chart', 'media']; export const THEME_PRESETS = { @@ -182,6 +189,7 @@ export function createInitialState() { runId: '', skillKey: '', }, + preferredModel: DEFAULT_PREFERRED_MODEL, style: defaultStyle(), outline: [], sources: { items: [], facts: [], warnings: [], summary: '', fetchedAt: 0 }, @@ -223,6 +231,7 @@ export function ensureState(value) { runId: String(state.agentSession?.runId || ''), skillKey: String(state.agentSession?.skillKey || ''), }; + state.preferredModel = normalizePreferredModel(state.preferredModel); state.style = { ...defaultStyle(), ...(state.style || {}) }; delete state.style.brandPrimary; delete state.style.brandAccent; @@ -276,13 +285,15 @@ export function normalizeSources(value = {}) { }; } +/** File-protocol phases shown in the Process panel (not the legacy planning spine). */ +export const GENERATION_PHASE_ORDER = ['skill', 'outline', 'slides', 'verify']; + export function generationSteps() { return [ - { id: 'brief', label: t('generationStepBrief'), detail: t('generationStepBriefDetail') }, - { id: 'spine', label: t('generationStepSpine'), detail: t('generationStepSpineDetail') }, - { id: 'proof', label: t('generationStepProof'), detail: t('generationStepProofDetail') }, - { id: 'design', label: t('generationStepDesign'), detail: t('generationStepDesignDetail') }, - { id: 'compile', label: t('generationStepCompile'), detail: t('generationStepCompileDetail') }, + { id: 'skill', label: t('generationStepSkill'), detail: t('generationStepSkillDetail') }, + { id: 'outline', label: t('generationStepOutline'), detail: t('generationStepOutlineDetail') }, + { id: 'slides', label: t('generationStepSlides'), detail: t('generationStepSlidesDetail') }, + { id: 'verify', label: t('generationStepVerify'), detail: t('generationStepVerifyDetail') }, ]; } diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/style.css b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/style.css index b2b2adf78d..ad0d34a592 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/style.css +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/style.css @@ -969,12 +969,12 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .generation-pipeline li.is-done { border-color: var(--studio-chip-done-border); } .generation-pipeline small { display: block; margin-top: 2px; color: var(--studio-muted); font-size: 10px; } -/* Agent sidebar */ +/* Agent sidebar — prompt / style-layout / process-outline each ~1/3 */ .agent-sidebar { min-width: 0; min-height: 0; display: grid; - grid-template-rows: auto auto minmax(120px, 1fr); + grid-template-rows: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr); border-left: 1px solid var(--studio-line); background: var(--studio-sidebar-bg); overflow: hidden; @@ -990,9 +990,10 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } position: relative; z-index: 3; display: grid; + grid-template-rows: auto minmax(0, 1fr) auto auto; gap: 10px; border-bottom: 1px solid var(--studio-line); - overflow: visible; + overflow: hidden; } .agent-card--tabs { @@ -1007,8 +1008,9 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } z-index: 1; display: grid; grid-template-rows: auto; + align-content: start; min-height: 0; - max-height: clamp(200px, 36vh, 400px); + overflow-x: hidden; overflow-y: auto; /* Reserve the scrollbar track so the content width never jumps when the scrollbar appears or disappears (e.g. while a dropdown is open). */ @@ -1042,13 +1044,20 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } border-color: var(--studio-warning); } +.onebox-label { + min-height: 0; + display: grid; + grid-template-rows: minmax(0, 1fr); +} + .onebox-input { width: 100%; - min-height: 120px; + min-height: 0; + height: 100%; border: 1px solid var(--studio-border); border-radius: 10px; padding: 10px 12px; - resize: vertical; + resize: none; color: var(--studio-text); background: var(--studio-input-bg); font-family: var(--app-font); @@ -1244,6 +1253,15 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } color: var(--studio-primary); } +.onebox-model { + display: grid; + gap: 4px; +} + +.onebox-model .property-label { + margin: 0; +} + .onebox-submit { min-height: 40px; width: 100%; } .prompt-actions { @@ -1296,13 +1314,107 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .sidebar-tab-panel { min-height: 0; display: none; - grid-template-rows: minmax(0, 1fr); + grid-template-rows: auto minmax(0, 1fr); gap: 8px; overflow: hidden; } +.sidebar-tab-panel[data-sidebar-panel="outline"] { + grid-template-rows: minmax(0, 1fr); +} + .sidebar-tab-panel.is-active { display: grid; } +.generation-progress { + display: grid; + gap: 8px; + padding: 8px 8px 10px; + border: 1px solid var(--studio-line); + border-radius: 8px; + background: var(--studio-control-muted); +} + +.generation-progress[hidden] { + display: none !important; +} + +.generation-progress.is-active { + border-color: var(--studio-chip-active-border); +} + +.generation-progress.is-error { + border-color: var(--studio-chip-error-border); +} + +.generation-progress__meta { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; +} + +.generation-progress__label { + color: var(--studio-text); + font-size: 11px; + font-weight: 650; +} + +.generation-progress__count { + color: var(--studio-muted); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.generation-progress__track { + height: 4px; + border-radius: 999px; + background: var(--studio-control-subtle); + overflow: hidden; +} + +.generation-progress__fill { + height: 100%; + width: 0; + border-radius: inherit; + background: var(--studio-accent); + transition: width 220ms ease; +} + +.generation-phase-list { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin: 0; + padding: 0; + list-style: none; +} + +.generation-phase { + padding: 2px 7px; + border-radius: 999px; + border: 1px solid var(--studio-line); + color: var(--studio-muted); + font-size: 10px; + line-height: 1.4; +} + +.generation-phase.is-running { + border-color: var(--studio-chip-active-border); + color: var(--studio-text); + background: color-mix(in srgb, var(--studio-accent) 10%, transparent); +} + +.generation-phase.is-done { + border-color: var(--studio-chip-done-border); + color: var(--studio-text); +} + +.generation-phase.is-error { + border-color: var(--studio-chip-error-border); + color: var(--studio-error, #c84b31); +} + .generation-steps, .outline-list { margin: 0; padding: 0; @@ -2890,10 +3002,6 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } padding: 10px; } - .agent-card--properties { - max-height: clamp(160px, 30vh, 300px); - } - .property-section__header { padding: 10px 12px; } @@ -2901,10 +3009,6 @@ button:disabled { opacity: 0.5; cursor: not-allowed; } .property-section__body { padding: 0 12px 12px; } - - .onebox-input { - min-height: 100px; - } } @media (max-width: 980px) { diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/dom-repair-diagnostics.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/dom-repair-diagnostics.test.mjs index 62d348bd19..328a9ddece 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/dom-repair-diagnostics.test.mjs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/dom-repair-diagnostics.test.mjs @@ -8,7 +8,7 @@ import { } from '../src/editable-slide-scene.js'; import { normalizeDocumentToEditableScene } from '../src/editable-slide-normalize.js'; import { extractSlideDataFromDocument } from '../src/html2pptx-dom-core.js'; -import { buildSlideFromScene } from '../src/pptx-html-build.js'; +import { buildSlideFromScene, createPptxDeck } from '../src/pptx-html-build.js'; import { sanitizeSlideDocumentRoot } from '../src/sanitize-slide-html.js'; import { HTML_ALLOWED_TAGS, @@ -1554,6 +1554,55 @@ test('removes a manual bullet split from its text by inline formatting', () => { assert.doesNotMatch(list.items.map((run) => run.text).join(''), /[•●]/u); }); +test('keeps dense list body text authored as li > p wrappers', async () => { + // Stress-test decks (and the element-model path) wrap every bullet in

    . + // sanitize unwraps

  • into

      ; empty list payloads previously + // failed scene validation and degrade deleted the whole UL (all body copy). + const doc = createDocument(` +

      +

      高影响 · 高紧迫

      +
        +
      • 支付链路抖动必须在 Q1 前根治。

      • +
      • 多语种架构改造可提升可用性。

      • +
      • 风控模型升级涉及监管报备。

      • +
      +
      + `); + sanitizeSlideDocumentRoot(doc); + installMeasurableLayout(doc); + + const slideData = extractSlideDataFromDocument(doc); + const list = slideData.elements.find((element) => element.type === 'list'); + assert.ok(list, 'post-sanitize ul>p lists must still extract as a list element'); + const listText = list.items.map((run) => run.text).join(''); + assert.match(listText, /支付链路抖动/); + assert.match(listText, /多语种架构/); + assert.match(listText, /风控模型升级/); + + const scene = normalizeDocumentToEditableScene(doc, { + slideNumber: 6, + width: 1280 / 96, + height: 720 / 96, + }); + const sceneText = scene.nodes + .filter((node) => node.type === 'text') + .map((node) => (Array.isArray(node.text) ? node.text.map((run) => run.text).join('') : node.text)) + .join('\n'); + assert.match(sceneText, /支付链路抖动/); + assert.match(sceneText, /多语种架构/); + + const pptx = createPptxDeck({ title: 'li-p-list' }); + await buildSlideFromScene(scene, pptx); + const base64 = String(await pptx.write({ outputType: 'base64' })).replace(/^data:.*;base64,/, ''); + const requireFromPptxGen = createRequire(import.meta.resolve('pptxgenjs')); + const JSZip = requireFromPptxGen('jszip'); + const zip = await JSZip.loadAsync(base64, { base64: true }); + const xml = await zip.file('ppt/slides/slide1.xml').async('string'); + assert.match(xml, /支付链路抖动/); + assert.match(xml, /多语种架构/); + assert.match(xml, /风控模型升级/); +}); + test('does not classify a dash without following whitespace as a manual bullet', () => { const doc = createDocument('

      –40°C remains a normal sentence

      '); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs index a192bd823e..65b6d8ce3d 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/generated-file-protocol.test.mjs @@ -171,6 +171,38 @@ test('prompt pins the stable skill key and workspace-relative delivery contract' assert.match(prompt, /工作区根目录下的 `slides\/slide-NN\.html`/); assert.match(prompt, /`project\.json` 的 `status` 设为 `"complete"`/); assert.match(prompt, /`slide_order`.*每一页.*完整 HTML/s); + assert.match(prompt, /节奏(必须,影响用户等待时间)/); + assert.match(prompt, /按需研究/); + assert.match(prompt, /硬性禁令/); + assert.match(prompt, /下一轮工具调用必须是 Write `project\.json`/); + assert.match(prompt, /禁止.*Read references\/style-presets/); +}); + +test('backend adapter forwards preferred model into agent.run options', async () => { + const { installBitFunBackendAdapter } = await import('../src/bitfun-backend-adapter.js'); + const calls = []; + const app = { + agent: { + run: async (_prompt, options) => { + calls.push(options); + return { sessionId: 's1', turnId: 't1', actionRunId: 't1' }; + }, + onEvent() {}, + cancel: async () => {}, + turnText: async () => ({ text: '' }), + cancelStaleRuns: async () => ({ cancelledRuns: 0 }), + }, + }; + installBitFunBackendAdapter(app); + await app.backend.call('ppt.generate', { instruction: 'hi' }, { + sessionId: 's1', + appDataWorkspace: 'decks/demo', + model: 'fast', + }); + assert.equal(calls.length, 1); + assert.equal(calls[0].model, 'fast'); + assert.equal(calls[0].sessionId, 's1'); + assert.equal(calls[0].appDataWorkspace, 'decks/demo'); }); test('prompt carries a targeted contract diagnostic into same-session continuation', () => { @@ -626,6 +658,72 @@ test('persistDeckProjectSeed creates slides directory before ordered writes', as assert.deepEqual(calls[0][2], { recursive: true }); }); +test('persistDeckProjectSeed skips empty new-deck project.json to avoid Read-before-Write', async () => { + const calls = []; + const fs = { + async mkdir(path, options) { + calls.push(['mkdir', path, options]); + }, + async writeFile(path) { + calls.push(['write', path]); + }, + }; + const seed = deckProjectContract.createDeckProjectSeed({ + hasExistingDeck: false, + title: '', + style: { stylePreset: 'clean-business' }, + }); + assert.equal(seed.plan.status, 'planning'); + assert.deepEqual(seed.plan.outline, []); + assert.deepEqual(seed.slideFiles, []); + + await persistDeckProjectSeed(fs, '/deck', seed); + + assert.deepEqual(calls.map(([operation, path]) => [operation, path]), [ + ['mkdir', '/deck/slides'], + ]); +}); + +test('finalizeDeckProjectIfReady marks planning decks complete when slides exist', async () => { + const { finalizeDeckProjectIfReady } = deckProjectContract; + const plan = { + status: 'planning', + title: 'Ready deck', + outline: [ + { id: 's1', title: 'One', bullets: [], slide_id: 'slide-01' }, + { id: 's2', title: 'Two', bullets: [], slide_id: 'slide-02' }, + ], + slide_order: ['slide-01', 'slide-02'], + }; + let written = null; + const files = new Map([ + ['project.json', JSON.stringify(plan)], + ['slides/slide-01.html', completeSlide('one')], + ['slides/slide-02.html', completeSlide('two')], + ]); + const ok = await finalizeDeckProjectIfReady( + async (relPath) => files.get(relPath), + async (relPath, content) => { + written = { relPath, content }; + files.set(relPath, content); + }, + { maxAttempts: 1 }, + ); + assert.equal(ok, true); + assert.equal(written?.relPath, 'project.json'); + assert.equal(JSON.parse(written.content).status, 'complete'); + + const deck = await readDeckProjectContract( + async (relPath) => files.get(relPath), + { + maxAttempts: 1, + writeFile: async (relPath, content) => files.set(relPath, content), + }, + ); + assert.equal(deck.plan.status, 'complete'); + assert.equal(deck.slides.length, 2); +}); + test('persistDeckProjectSeed reports mkdir and slide write failures for same-session continuation', async (t) => { await t.test('mkdir failure', async () => { await assert.rejects( @@ -714,9 +812,12 @@ test('skill defines the workspace root unambiguously and bounded plan-first comp assert.doesNotMatch(skill, /\{\{ppt_project_dir\}\}/); assert.match(skill, /当前工作区根目录就是当前 deck 根目录/); - assert.match(skill, /先.*project\.json.*再.*slides\/slide-NN\.html/s); - assert.match(skill, /有界完成检查/); - assert.match(skill, /仅检查一次/); + assert.match(skill, /下一轮工具必须是 Write `project\.json`/); + assert.match(skill, /马上 Write `project\.json`/); + assert.match(skill, /直接按 outline 写页/); + assert.match(skill, /同轮收尾/); + assert.match(skill, /禁止单独开一轮只做 Glob\/LS\/Edit/); + assert.match(skill, /禁止.*Read references\/style-presets/); }); function contractSection(source, startLabel, endLabel) { diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs index bfa0f8502a..8290f0593d 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/test/pptx-ooxml-artifact.test.mjs @@ -1881,7 +1881,9 @@ test('production fixtures lock OOXML under a WebKit border-box regression simula const secondText = second.nodes.filter((node) => node.type === 'text'); assert.deepEqual( { shapes: secondShapes.length, lines: secondLines.length, text: secondText.length }, - { shapes: 8, lines: 10, text: 25 }, + // Dense
    • lists (unwrapped to ul>p) now keep their body text runs + // instead of being deleted by empty-list degrade. Fixture text count rose. + { shapes: 8, lines: 10, text: 30 }, ); const secondMap = svgPointMapper({ leftPt: 600, topPt: 108, widthPt: 300, heightPt: 215, viewWidth: 300, viewHeight: 240, @@ -2135,3 +2137,94 @@ test('unsupported CSS filter is stripped instead of aborting the export', async assert.equal(renderPageCalls, 0); }); }); + +test('text box width safety scales with font size to prevent false wraps', async () => { + const { + textBoxWidthSafetyInches, + safeTextBoxGeometry, + buildSlideFromScene, + createPptxDeck, + } = await import('../src/pptx-html-build.js'); + + assertNear(textBoxWidthSafetyInches(14), 0.36, '14pt keeps the calibrated body-text safety'); + assertNear(textBoxWidthSafetyInches(28), 0.72, '28pt doubles the body-text safety'); + assertNear(textBoxWidthSafetyInches(42), 1.08, '42pt triples the body-text safety'); + assert.ok(textBoxWidthSafetyInches(8) >= 0.28, 'tiny text still keeps a floor'); + assert.ok(textBoxWidthSafetyInches(72) <= 1.6, 'huge titles are capped'); + + const body = safeTextBoxGeometry(1, 4, 'left', false, 14); + const title = safeTextBoxGeometry(1, 4, 'left', false, 42); + assertNear(body.w, 4.36, '14pt left text widens by 0.36"'); + assertNear(title.w, 5.08, '42pt left text widens by 1.08"'); + + const centered = safeTextBoxGeometry(2, 4, 'center', false, 28); + assertNear(centered.x, 2 - 0.36, 'center align shifts x by half the scaled safety'); + assertNear(centered.w, 4.72, 'center align keeps the full scaled width'); + + const boldTitle = safeTextBoxGeometry(1, 4, 'left', false, 42, { bold: true }); + assert.ok(boldTitle.w > title.w, 'bold titles get a little extra width safety'); + + // Near the slide's right edge: still apply the full safety margin even if the + // box extends past 13.333" — PowerPoint accepts off-slide shape extents. + const nearRight = safeTextBoxGeometry(12.5, 0.7, 'left', false, 14); + assertNear(nearRight.w, 0.7 + 0.36, 'right-edge boxes keep the full safety width'); + assert.ok(nearRight.x + nearRight.w > 13.333, 'safety may extend past the slide edge'); + + const pptx = createPptxDeck({ title: 'Width safety' }); + await buildSlideFromScene({ + slideNumber: 1, + width: 13.333, + height: 7.5, + nodes: [{ + type: 'text', + sourceId: 'title-42', + x: 1, + y: 1, + w: 4, + h: 1, + text: '大号标题防换行', + style: { + fontSize: 42, + fontFace: 'PingFang SC', + color: '111111', + align: 'left', + valign: 'top', + }, + }], + }, pptx); + const zip = await writeAndOpen(pptx); + const slideXml = await zipText(zip, 'ppt/slides/slide1.xml'); + const titleShape = [...slideXml.matchAll(/[\s\S]*?<\/p:sp>/g)] + .map((match) => match[0]) + .find((xml) => xml.includes('大号标题防换行')); + assert.ok(titleShape, 'title shape must serialize'); + const expectedCx = Math.round(5.08 * 914400); + assert.match(titleShape, new RegExp(` { + await withControllableExportDom(async () => { + const { prepareEditableSlides } = await import('../src/export-slide-browser.js'); + const { exportEditablePptx } = await import('../src/export-deck-browser.js'); + const html = ` +

      紧排标题

      + `; + const deck = { title: 'Letter spacing', slides: [{ id: 'tracked', html }] }; + const scenes = await prepareEditableSlides(deck.slides); + const title = scenes[0].nodes.find((node) => node.sourceId === 'tracked-title'); + assert.ok(title, 'tracked title must extract'); + assertNear(title.style.charSpacing, -0.8 * 0.75, 'letter-spacing px maps to charSpacing pt'); + + const exported = await exportEditablePptx(deck, scenes); + const zip = await JSZip.loadAsync(Buffer.from(exported.base64, 'base64')); + const slideXml = await zipText(zip, 'ppt/slides/slide1.xml'); + const titleShape = [...slideXml.matchAll(/[\s\S]*?<\/p:sp>/g)] + .map((match) => match[0]) + .find((xml) => xml.includes('紧排标题')); + assert.ok(titleShape, 'tracked title must serialize'); + const expectedSpc = Math.round((-0.8 * 0.75) * 100); + assert.match(titleShape, new RegExp(`spc="${expectedSpc}"`)); + }); +}); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js index b301df3052..ed21160e1b 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/ui.js @@ -18,6 +18,7 @@ import { defaultElement, ensureState, escapeHtml, + GENERATION_PHASE_ORDER, getActiveIndex, getActiveSlide, getSelectedElement, @@ -29,6 +30,8 @@ import { densityToIndex, indexToDensity, uid, + DEFAULT_PREFERRED_MODEL, + normalizePreferredModel, } from './src/state.js'; import { getAllStylePresets, getStylePreset, DEFAULT_STYLE_PRESET, resolveStylePalette } from './src/style-presets.js'; import { enhanceFlatSelect, refreshFlatSelect } from './src/flat-select.js'; @@ -73,7 +76,6 @@ let promptSubmitGuard = false; let backendRunInFlight = false; let historyItems = []; let lastHistoryWriteAt = 0; - const $ = (id) => document.getElementById(id); const runtime = () => window.app || {}; installBitFunBackendAdapter(runtime()); @@ -200,11 +202,14 @@ async function saveHistorySnapshot(reason = 'autosave') { function isRecoverableWorkingOnlyState(value) { const slides = Array.isArray(value?.slides) ? value.slides : []; + const title = String(value?.title || ''); + if (value?.generation?.active) return false; + if (title !== t('agentWorkingTitle')) return false; + // Legacy fake working slide, or the empty generating surface. + if (!slides.length) return true; return slides.length === 1 && !slides[0]?.html - && String(slides[0]?.id || '').startsWith('agent-working-slide') - && String(value?.title || '') === t('agentWorkingTitle') - && !value?.generation?.active; + && String(slides[0]?.id || '').startsWith('agent-working-slide'); } function normalizeHistoryItem(item) { @@ -310,12 +315,25 @@ function setBusy(nextBusy, message) { } function setGenerationStep(id, status, message) { + const targetIdx = GENERATION_PHASE_ORDER.indexOf(id); state.generation.current = id; - state.generation.steps = state.generation.steps.map((step) => ({ - ...step, - status: step.id === id ? status : step.status, - })); - state.generation.active = status === 'running' || state.generation.steps.some((step) => step.status === 'running'); + state.generation.steps = state.generation.steps.map((step) => { + const stepIdx = GENERATION_PHASE_ORDER.indexOf(step.id); + if (step.id === id) return { ...step, status }; + // Advancing to a later phase marks earlier phases done so the bar stays honest. + if ( + status === 'running' + && targetIdx >= 0 + && stepIdx >= 0 + && stepIdx < targetIdx + && step.status !== 'error' + ) { + return { ...step, status: 'done' }; + } + return step; + }); + state.generation.active = status === 'running' + || state.generation.steps.some((step) => step.status === 'running'); renderGeneration(state); renderGenerationOverlay(state); if (message) setStatus(message); @@ -370,7 +388,7 @@ function addGenerationEvent(event, detail = '', kind = 'info') { /** * Push a raw Agent stream entry (tool call, text chunk, or turn lifecycle) * into the live process panel so users can see exactly what the Cowork - * session is doing — not just the abstracted 5-step state machine. + * session is doing — not just the abstracted file-protocol phase bar. * Entries are capped (see GENERATION_STREAM_LIMIT) and rendered by * renderGeneration's agent-stream section. */ @@ -871,21 +889,46 @@ async function tryReadDeckPlanFile(project) { } } +/** True when HTML looks like a finished slide document. */ +function isCompleteSlideHtml(raw) { + return /<\/html>\s*$/i.test(String(raw || '').trim()); +} + +/** True when HTML is far enough along to preview during generation. */ +function isPreviewableSlideHtml(raw) { + const text = String(raw || '').trim(); + if (!text) return false; + if (isCompleteSlideHtml(text)) return true; + // Progressive reads may catch a flush before the final closing tag lands. + return text.length >= 240 && /]/i.test(text) && /]/i.test(text); +} + /** Complete slide HTML from disk, or null when missing or incomplete. */ -async function tryReadDeckSlideFile(project, slideNumber) { +async function tryReadDeckSlideFile(project, slideNumber, options = {}) { + const requireComplete = options.requireComplete !== false; try { const raw = String(await readDeckProjectFile(project, deckSlideFileName(slideNumber)) || '').trim(); - if (!raw || !/<\/html>\s*$/i.test(raw)) return null; + if (!raw) return null; + if (requireComplete ? !isCompleteSlideHtml(raw) : !isPreviewableSlideHtml(raw)) return null; return raw; } catch { return null; } } -/** Retry slide reads briefly after Write completes — disk/fs bridge may lag the tool event. */ -async function tryReadDeckSlideFileWithRetry(project, slideNumber, maxAttempts = 6, delayMs = 120) { +/** Retry slide reads after Write completes — disk/fs bridge may lag the tool event. */ +async function tryReadDeckSlideFileWithRetry( + project, + slideNumber, + maxAttempts = 8, + delayMs = 120, + options = {}, +) { for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - const html = await tryReadDeckSlideFile(project, slideNumber); + // Prefer a complete document; after a few misses accept a previewable body + // so the canvas can advance while the final bytes flush. + const requireComplete = options.requireComplete !== false && attempt <= Math.max(2, maxAttempts - 3); + const html = await tryReadDeckSlideFile(project, slideNumber, { requireComplete }); if (html) return html; if (attempt < maxAttempts) { await new Promise((resolve) => setTimeout(resolve, delayMs)); @@ -894,14 +937,32 @@ async function tryReadDeckSlideFileWithRetry(project, slideNumber, maxAttempts = return null; } +/** Extract 1-based slide number from a Write/Edit path, or 0 when unrelated. */ +function matchSlideNumberFromPath(filePath) { + const normalized = String(filePath || '').trim().replace(/\\/g, '/'); + if (!normalized) return 0; + const match = normalized.match(/(?:^|\/)slides\/slide-(\d{1,2})\.html$/i) + || normalized.match(/(?:^|\/)slide-(\d{1,2})\.html$/i); + if (!match) return 0; + const slideNumber = parseInt(match[1], 10); + return Number.isFinite(slideNumber) && slideNumber > 0 ? slideNumber : 0; +} + /** * Read every slide HTML file referenced by `project.json` from the deck * project directory. Returns `{ title, language, outline, researchReport, * design, slides }` shaped for `applyDeckPayload`. */ async function readDeckFromProjectFiles(project) { + const host = runtime(); + const writeFile = typeof host.fs?.writeFile === 'function' + ? async (relPath, content) => { + await host.fs.writeFile(`${project.dir}/${relPath}`, content); + } + : undefined; const result = await readDeckProjectContract( (relPath) => readDeckProjectFile(project, relPath), + writeFile ? { writeFile } : {}, ); const { plan } = result; const slides = result.slides.map((slide) => ({ @@ -1029,11 +1090,15 @@ async function executeBackendTurn(requestInput, hooks = {}, options = {}) { const activity = { lastEventAt: Date.now() }; try { + const preferredModel = normalizePreferredModel( + options.model || state.preferredModel || DEFAULT_PREFERRED_MODEL, + ); const result = await host.backend.call('ppt.generate', requestInput, { entityId: 'deck', idempotencyKey: `ppt-live-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, sessionId: options.sessionId || undefined, appDataWorkspace: options.appDataWorkspace || undefined, + model: preferredModel, }); sessionId = result?.sessionId || null; turnId = result?.turnId || result?.actionRunId || null; @@ -1118,7 +1183,8 @@ async function executeBackendTurn(requestInput, hooks = {}, options = {}) { text: paramSummary, }); } else if (eventType === 'Completed' && rawToolName) { - const resultSummary = summarizeToolResult(rawToolName, toolEvent.result || {}); + const donePath = resolveToolEventFilePath(toolEvent, toolTrace); + const resultSummary = summarizeToolResult(rawToolName, toolEvent.result || {}, donePath); if (resultSummary) { pushAgentStreamEntry({ kind: 'tool-done', @@ -1147,20 +1213,28 @@ async function executeBackendTurn(requestInput, hooks = {}, options = {}) { hooks.onToolPhase?.('completed'); if (toolName === 'skill') { progressTracker.note(t('eventToolSkillReady'), '', 'phase'); + hooks.onToolPhase?.('skill-ready'); } else if (toolName === 'websearch' || toolName === 'webfetch') { hooks.onToolPhase?.('research'); } - // Progressive preview: when the agent writes/edits a slide file, - // notify the caller so it can read the file from disk and render - // the completed page immediately instead of waiting for the whole - // turn to finish. - if ((toolName === 'write' || toolName === 'edit') && typeof hooks.onSlideFileWritten === 'function') { + // Progressive preview: when the agent writes project.json or a slide + // file, notify the caller so the UI can show outline/pages early. + if ( + toolName === 'write' + || toolName === 'edit' + || toolName === 'apply_patch' + || toolName === 'strreplace' + || toolName === 'search_replace' + ) { const filePath = resolveToolEventFilePath(toolEvent, toolTrace); - const slideMatch = filePath.match(/slides\/slide-(\d{2})\.html/i); - if (slideMatch) { - const slideNumber = parseInt(slideMatch[1], 10); - if (Number.isFinite(slideNumber) && slideNumber > 0) { - // Completed tool events carry file_path in result, not params. + if (/project\.json$/i.test(filePath) && typeof hooks.onPlanFileWritten === 'function') { + void Promise.resolve(hooks.onPlanFileWritten()).catch(() => { + // Outline preview is best-effort; never break the turn. + }); + } + if (typeof hooks.onSlideFileWritten === 'function') { + const slideNumber = matchSlideNumberFromPath(filePath); + if (slideNumber > 0) { void Promise.resolve(hooks.onSlideFileWritten(slideNumber)).catch(() => { // Progressive preview is best-effort; never break the turn. }); @@ -1232,10 +1306,29 @@ async function executeBackendTurn(requestInput, hooks = {}, options = {}) { const heartbeat = setInterval(() => { if (settled) return; const now = Date.now(); - if (now - progressTracker.lastProgressLogAt < 12000) return; + if (now - progressTracker.lastProgressLogAt < 3000) return; + const drafted = Number(state.generation?.draftedCount) || 0; + const target = Number(state.generation?.slideTarget) || 0; const current = (state.generation?.steps || []).find((step) => step.status === 'running'); - progressTracker.note(current?.label ? `${current.label}…` : t('generationProgressPulse'), current?.detail || '', 'pulse', 0); - }, 12000); + let title = t('generationProgressPulse'); + let detail = ''; + if (current?.id === 'slides' && (drafted > 0 || target > 0)) { + title = t('generationWritingSlideProgress', { + done: drafted, + total: target || Math.max(drafted, 1), + }); + } else if (current?.id === 'outline') { + title = t('generationWritingClaims'); + } else if (current?.id === 'skill') { + title = t('generationLoadingSkill'); + } else if (current?.id === 'verify') { + title = t('generationVerifyingDeck'); + } else if (current?.label) { + title = `${current.label}…`; + detail = current.detail || ''; + } + progressTracker.note(title, detail, 'pulse', 0); + }, 3000); cleanup.push(() => clearInterval(heartbeat)); }); @@ -1300,7 +1393,7 @@ async function runCoworkDeckGeneration(operation, instruction) { const runEpoch = deckEpoch; setBusy(true, t('working')); resetGeneration(); - setGenerationStep('brief', 'running', t('generationReadingBrief')); + setGenerationStep('skill', 'running', t('generationLoadingSkill')); addGenerationEvent({ title: t('processEventStarted'), detail: t('processEventWaiting'), kind: 'start' }); prepareAgentGenerationSurface(operation, instruction); let completed = false; @@ -1340,6 +1433,142 @@ async function runCoworkDeckGeneration(operation, instruction) { // on every slide write is wasteful IO. Refreshed lazily when a new slide // number appears that isn't in the cached plan's outline. let progressivePlan = null; + let progressivePublishChain = Promise.resolve(); + let progressivePollTimer = null; + const announcedSlideReady = new Set(); + + const applyProgressivePlan = (plan) => { + if (!plan) return null; + progressivePlan = plan; + const titles = planOutlineTitles(plan); + const total = titles.length + || (Array.isArray(plan.outline) ? plan.outline.length : 0) + || (Array.isArray(plan.slide_order) ? plan.slide_order.length : 0); + if (total > 0) state.generation.slideTarget = total; + const planTitle = resolveDeckTitle({ plan, state, instruction }); + if (!isEphemeralDeckTitle(planTitle)) state.title = planTitle; + if (titles.length) state.outline = titles; + return { titles, total }; + }; + + const publishProgressiveDeck = (focusSlideNumber = 0) => { + if (!progressiveSlides.size) return; + const plan = progressivePlan || {}; + const total = Number(state.generation.slideTarget) + || (Array.isArray(plan.outline) ? plan.outline.length : 0) + || progressiveSlides.size; + const knownSlides = [...progressiveSlides.entries()] + .sort((a, b) => a[0] - b[0]) + .map(([number, slideHtml]) => ({ + id: `ppt-live-slide-${number}`, + slideNumber: number, + title: typeof plan?.outline?.[number - 1] === 'string' + ? plan.outline[number - 1] + : (plan?.outline?.[number - 1]?.title || `${t('newSlideTitle')} ${number}`), + html: slideHtml, + })); + const deckTitle = resolveDeckTitle({ + plan, + state, + instruction, + slides: knownSlides, + }); + const activeNumber = focusSlideNumber > 0 + ? focusSlideNumber + : knownSlides[knownSlides.length - 1]?.slideNumber; + applyDeckPayload({ + title: deckTitle, + language: plan?.language || '', + outline: planOutlineTitles(plan || {}), + researchReport: plan?.researchReport || null, + design: plan?.design || {}, + slides: knownSlides, + }, { + instruction, + activeSlideId: activeNumber ? `ppt-live-slide-${activeNumber}` : '', + }); + state.selectedElementId = ''; + state.generation.draftedCount = progressiveSlides.size; + if (activeNumber > 0) { + setGenerationStep('slides', 'running', t('generationRenderingSlide', { + slide: activeNumber, + total, + })); + if (!announcedSlideReady.has(activeNumber)) { + announcedSlideReady.add(activeNumber); + addGenerationEvent({ + title: t('generationSlideReady', { + slide: activeNumber, + total, + }), + detail: '', + kind: 'slide', + }); + } + } + rerender(); + }; + + const ingestProgressiveSlide = (slideNumber, html) => { + if (!(slideNumber > 0) || !html) return false; + const previous = progressiveSlides.get(slideNumber); + if (previous === html) return false; + progressiveSlides.set(slideNumber, html); + return true; + }; + + const refreshProgressiveSlide = async (slideNumber) => { + if (!project || !(slideNumber > 0) || isDeckEpochStale(runEpoch)) return false; + const html = await tryReadDeckSlideFileWithRetry(project, slideNumber); + if (!html) { + runtime().log?.debug?.('PPT Live progressive slide read missed', { slideNumber }); + return false; + } + const planOutlineLen = Array.isArray(progressivePlan?.outline) ? progressivePlan.outline.length : 0; + if (!progressivePlan || slideNumber > planOutlineLen) { + applyProgressivePlan(await tryReadDeckPlanFile(project)); + } + if (!ingestProgressiveSlide(slideNumber, html)) return false; + publishProgressiveDeck(slideNumber); + return true; + }; + + const scanProgressiveSlides = async () => { + if (!project || isDeckEpochStale(runEpoch)) return; + if (!progressivePlan) { + applyProgressivePlan(await tryReadDeckPlanFile(project)); + } + const total = Number(state.generation.slideTarget) + || (Array.isArray(progressivePlan?.outline) ? progressivePlan.outline.length : 0) + || (Array.isArray(progressivePlan?.slide_order) ? progressivePlan.slide_order.length : 0) + || 0; + if (!(total > 0)) return; + let newest = 0; + let changed = false; + for (let slideNumber = 1; slideNumber <= total; slideNumber += 1) { + // Keep re-reading incomplete previews until a complete document lands. + const existing = progressiveSlides.get(slideNumber); + if (existing && isCompleteSlideHtml(existing)) continue; + const html = await tryReadDeckSlideFile(project, slideNumber, { + requireComplete: Boolean(existing), + }); + if (!html) continue; + if (ingestProgressiveSlide(slideNumber, html)) { + changed = true; + newest = slideNumber; + } + } + if (changed) publishProgressiveDeck(newest); + }; + + const enqueueProgressive = (work) => { + progressivePublishChain = progressivePublishChain + .then(work) + .catch((error) => { + runtime().log?.warn?.('PPT Live progressive preview failed', { error: String(error) }); + }); + return progressivePublishChain; + }; try { let lastError = null; @@ -1370,86 +1599,60 @@ async function runCoworkDeckGeneration(operation, instruction) { requestInput.currentSlideIndex = getActiveIndex(state); requestInput.currentDeck = buildCurrentDeckSnapshot(instruction); } + + if (project && !progressivePollTimer) { + // Tool events can miss a path/id; polling the deck directory keeps + // progressive preview honest even when Write completion metadata is thin. + progressivePollTimer = setInterval(() => { + void enqueueProgressive(() => scanProgressiveSlides()); + }, 900); + } + const { sessionId } = await executeBackendTurn(requestInput, { onToolPhase: (kind) => { if (kind === 'detected') { - setGenerationStep('brief', 'running', t('generationReadingBrief')); - } else if (kind === 'completed') { - setGenerationStep('brief', 'done'); + setGenerationStep('skill', 'running', t('generationLoadingSkill')); + } else if (kind === 'skill-ready') { + setGenerationStep('outline', 'running', t('generationWritingClaims')); } else if (kind === 'research') { - setGenerationStep('proof', 'running', t('generationChoosingProof')); + // Research is optional; keep the outline phase running with a clearer label. + setGenerationStep('outline', 'running', t('generationResearching')); + addGenerationEvent({ title: t('generationResearching'), detail: '', kind: 'phase' }); } // 'round' = new model round; don't override the current phase — // the stream entries already show what the agent is doing. }, onTextProgress: (buffer) => noteTextStreamProgress(buffer, progressShim, lastStreamPhase), + onPlanFileWritten: project + ? async () => { + const plan = await tryReadDeckPlanFile(project); + const applied = applyProgressivePlan(plan); + if (!applied?.total) return; + setGenerationStep( + 'slides', + 'running', + t('generationOutlineReady', { count: applied.total }), + ); + addGenerationEvent({ + title: t('generationOutlineReady', { count: applied.total }), + detail: applied.titles.slice(0, 4).join(' · '), + kind: 'phase', + }); + // Keep the canvas empty until the first real slide file lands. + if (!progressiveSlides.size && !state.slides?.some((slide) => slide?.html)) { + state.slides = []; + state.activeSlideId = ''; + } + rerender(); + void enqueueProgressive(() => scanProgressiveSlides()); + } + : undefined, // Progressive preview: when the agent writes a slide file during the // turn, read it from disk and show it immediately so the user sees // pages appearing one by one instead of all at once at the end. onSlideFileWritten: project ? async (slideNumber) => { - const html = await tryReadDeckSlideFileWithRetry(project, slideNumber); - if (!html) return; - progressiveSlides.set(slideNumber, html); - // Read project.json lazily; cache it so we don't re-read on - // every slide write. Re-read only if a new slide number beyond - // the cached plan's outline appears. - const planOutlineLen = Array.isArray(progressivePlan?.outline) ? progressivePlan.outline.length : 0; - if (!progressivePlan || slideNumber > planOutlineLen) { - const fresh = await tryReadDeckPlanFile(project); - if (fresh) { - progressivePlan = fresh; - const planTitle = resolveDeckTitle({ plan: fresh, state, instruction }); - if (!isEphemeralDeckTitle(planTitle)) { - state.title = planTitle; - } - } - } - const plan = progressivePlan || {}; - // Build an incremental payload from all slides known so far. - const knownSlides = [...progressiveSlides.entries()] - .sort((a, b) => a[0] - b[0]) - .map(([number, slideHtml]) => ({ - id: `ppt-live-slide-${number}`, - slideNumber: number, - title: typeof plan?.outline?.[number - 1] === 'string' - ? plan.outline[number - 1] - : (plan?.outline?.[number - 1]?.title || `${t('newSlideTitle')} ${number}`), - html: slideHtml, - })); - const deckTitle = resolveDeckTitle({ - plan, - state, - instruction, - slides: knownSlides, - }); - setGenerationStep('design', 'running', t('generationSlideReady', { - slide: slideNumber, - total: knownSlides.length, - })); - setStatus(t('generationRenderingSlide', { - slide: slideNumber, - total: plan?.outline?.length || knownSlides.length, - })); - addGenerationEvent({ - title: t('generationSlideReady', { - slide: slideNumber, - total: plan?.outline?.length || knownSlides.length, - }), - detail: '', - kind: 'slide', - }); - applyDeckPayload({ - title: deckTitle, - language: plan?.language || '', - outline: planOutlineTitles(plan || {}), - researchReport: plan?.researchReport || null, - design: plan?.design || {}, - slides: knownSlides, - }, { instruction }); - state.activeSlideId = `ppt-live-slide-${slideNumber}`; - state.selectedElementId = ''; - rerender(); + await enqueueProgressive(() => refreshProgressiveSlide(slideNumber)); } : undefined, }, { @@ -1464,11 +1667,12 @@ async function runCoworkDeckGeneration(operation, instruction) { runId: retrySession?.project?.runId || '', skillKey: PPT_DESIGN_SKILL_KEY, }; + state.preferredModel = normalizePreferredModel(state.preferredModel); // The agent delivered through files; read them back. addGenerationEvent({ title: t('generationParsingDeck'), detail: '', kind: 'parsing' }); - setStatus(t('generationParsingDeck')); - setGenerationStep('design', 'running', t('generationDesigningLayouts')); + setGenerationStep('verify', 'running', t('generationVerifyingDeck')); + await progressivePublishChain.catch(() => {}); const payload = project ? await readDeckFromProjectFiles(project) : null; @@ -1476,10 +1680,7 @@ async function runCoworkDeckGeneration(operation, instruction) { applyDeckPayload(payload, { instruction }); await saveHistorySnapshot(`agent:${operation}`); addGenerationEvent({ title: t('processEventDone'), detail: '', kind: 'done' }); - setGenerationStep('spine', 'done'); - setGenerationStep('proof', 'done'); - setGenerationStep('design', 'done'); - setGenerationStep('compile', 'done', t('generationCompiled')); + setGenerationStep('verify', 'done', t('generationCompiled')); finishGenerationUi(t('deckReady')); completed = true; rerender(); @@ -1501,6 +1702,10 @@ async function runCoworkDeckGeneration(operation, instruction) { } } } finally { + if (progressivePollTimer) { + clearInterval(progressivePollTimer); + progressivePollTimer = null; + } const ownsEpoch = !isDeckEpochStale(runEpoch); if (ownsEpoch) { if (state.generation.active && !completed) state.generation.active = false; @@ -1515,96 +1720,18 @@ function prepareAgentGenerationSurface(operation, instruction) { setStatus(t('generationAgentWorking')); addGenerationEvent({ title: t('generationAgentWorking'), detail: compactText(instruction || ''), kind: 'start' }); if (operation === 'auto' && (isDefaultDraft() || isStarterDeck())) { + // Do not inject a fake "working" slide. Clear the starter deck so the + // canvas stays on the generating empty-state until the first real page + // file is written and progressive preview can show it. state.title = t('agentWorkingTitle'); - } - rerender(); -} - -function showAgentWorkingCanvas(instruction) { - try { - const slide = normalizeSlide({ - id: uid('agent-working-slide'), - title: t('agentWorkingTitle'), - subtitle: '', - kicker: t('agentWorkingKicker'), - claim: t('agentWorkingClaim'), - proofObject: t('agentWorkingProof'), - supportNote: instruction || t('agentWorkingDetail'), - sourceNote: t('agentWorkingSourceNote'), - notes: t('agentWorkingSourceNote'), - layout: 'brief', - theme: { - background: '#fbfcff', - ink: '#111827', - muted: '#5b6575', - primary: '#ff4f46', - accent: '#14b8a6', - panel: '#ffffff', - }, - elements: [ - { - type: 'text', - text: t('agentWorkingTitle'), - x: 9, - y: 16, - w: 72, - h: 13, - style: { fontSize: 32, fontWeight: 820, color: 'ink', background: 'transparent', borderRadius: 0, opacity: 1, align: 'left' }, - }, - { - type: 'text', - text: t('agentWorkingDetail'), - x: 10, - y: 34, - w: 58, - h: 10, - style: { fontSize: 16, fontWeight: 650, color: 'muted', background: 'transparent', borderRadius: 0, opacity: 1, align: 'left' }, - }, - { - type: 'list', - items: [ - t('generationReadingBrief'), - t('generationWritingClaims'), - t('generationChoosingProof'), - t('generationDesigningLayouts'), - ], - x: 10, - y: 50, - w: 50, - h: 29, - style: { fontSize: 18, fontWeight: 650, color: 'ink', background: 'transparent', borderRadius: 0, opacity: 1, align: 'left' }, - }, - { - type: 'shape', - x: 67, - y: 20, - w: 22, - h: 52, - style: { fontSize: 18, fontWeight: 700, color: 'accent', background: 'primary', borderRadius: 24, opacity: 0.12, align: 'center' }, - }, - { - type: 'metric', - text: t('agentWorkingMetric'), - label: t('agentWorkingMetricLabel'), - x: 65, - y: 42, - w: 26, - h: 20, - style: { fontSize: 34, fontWeight: 830, color: 'primary', background: 'panel', borderRadius: 14, opacity: 1, align: 'left' }, - }, - ], - }, 0, { ...state, slides: [] }); - state.title = t('agentWorkingTitle'); - state.slides = [slide]; - state.outline = [slide.title]; - state.activeSlideId = slide.id; - state.selectedElementId = getActiveSlide(state)?.elements[0]?.id || ''; - setStatus(t('generationAgentWorking')); - addGenerationEvent(t('generationAgentWorking')); + state.slides = []; + state.outline = []; + state.activeSlideId = ''; + state.selectedElementId = ''; rerender(); - } catch (error) { - runtime().log?.warn?.('PPT Live working canvas failed', { instruction, error: String(error) }); + return; } + rerender(); } const SILENT_TOOL_EVENT_TYPES = new Set([ @@ -1741,7 +1868,7 @@ function summarizeToolParams(toolName, params = {}) { } /** Compact a tool's result into a human-readable single line for the live agent stream. */ -function summarizeToolResult(toolName, result = {}) { +function summarizeToolResult(toolName, result = {}, filePath = '') { const name = String(toolName || '').toLowerCase(); const r = result && typeof result === 'object' ? result : {}; if (name === 'websearch') { @@ -1754,13 +1881,18 @@ function summarizeToolResult(toolName, result = {}) { } if (name === 'read') { const lines = Number(r.lineCount || (Array.isArray(r.lines) ? r.lines.length : 0)); + const path = shortFilePath(filePath || String(r.file_path || r.path || '')); + if (path && lines) return `${path} · ${lines} 行`; + if (path) return path; return lines ? `${lines} 行` : ''; } - // write/edit completion is signaled by the slide-ready event; skip the - // raw "written" message to keep the timeline clean. - if (name === 'write' || name === 'edit') return ''; + // Keep write/edit completions visible so the timeline does not go quiet + // between slide-ready events (especially for project.json and non-slide files). + if (name === 'write' || name === 'edit') { + return shortFilePath(filePath || String(r.file_path || r.path || '')) || '✓'; + } if (name === 'grep' || name === 'glob') return ''; - if (name === 'skill') return ''; + if (name === 'skill') return t('eventToolSkillReady'); if (name === 'task') return compactText(String(r.result || r.message || ''), 160); return ''; } @@ -1926,23 +2058,40 @@ function userFacingToolDetail(eventType, toolEvent) { } function resolveToolEventFilePath(toolEvent, toolTrace = []) { + const pickPath = (value) => { + if (!value || typeof value !== 'object') return ''; + return String( + value.file_path + || value.filePath + || value.path + || value.target_file + || value.targetFile + || '', + ).trim(); + }; + const params = toolEvent?.params && typeof toolEvent.params === 'object' ? toolEvent.params : {}; - const directPath = String(params.file_path || params.path || '').trim(); + const directPath = pickPath(params); if (directPath) return directPath; const result = toolEvent?.result && typeof toolEvent.result === 'object' ? toolEvent.result : {}; - const resultPath = String(result.file_path || result.path || '').trim(); + const resultPath = pickPath(result); if (resultPath) return resultPath; const toolId = String(toolEvent?.tool_id || toolEvent?.toolId || '').trim(); - if (!toolId) return ''; + if (!toolId) { + // Fall back to the latest Started write-like event when Completed omits ids. + const started = [...toolTrace].reverse().find((entry) => ( + entry.eventType === 'Started' && pickPath(entry.params) + )); + return pickPath(started?.params); + } const started = [...toolTrace].reverse().find((entry) => ( entry.eventType === 'Started' && String(entry.toolId || entry.tool_id || '') === toolId )); - const startedParams = started?.params && typeof started.params === 'object' ? started.params : {}; - return String(startedParams.file_path || startedParams.path || '').trim(); + return pickPath(started?.params); } function normalizeToolEvent(toolEvent) { @@ -2040,6 +2189,7 @@ function applyDeckPayload(payload, options = {}) { instruction: options.instruction || '', slides: payload?.slides || [], }); + const preferredActiveId = String(options.activeSlideId || '').trim(); const htmlSlides = normalizeHtmlSlides(payload); if (htmlSlides.length) { state.title = resolvedTitle; @@ -2048,7 +2198,11 @@ function applyDeckPayload(payload, options = {}) { slides: htmlSlides, })); state.outline = state.slides.map((slide) => slide.title); - state.activeSlideId = state.slides[0]?.id || ''; + state.activeSlideId = ( + preferredActiveId && state.slides.some((slide) => slide.id === preferredActiveId) + ? preferredActiveId + : (state.slides[0]?.id || '') + ); state.selectedElementId = ''; } else if (!Array.isArray(payload?.slides) || payload.slides.length === 0) { throw new Error('PPT Live deck payload has no slides'); @@ -2062,7 +2216,11 @@ function applyDeckPayload(payload, options = {}) { slides: payload.slides, })); state.outline = state.slides.map((slide) => slide.title); - state.activeSlideId = state.slides[0]?.id || ''; + state.activeSlideId = ( + preferredActiveId && state.slides.some((slide) => slide.id === preferredActiveId) + ? preferredActiveId + : (state.slides[0]?.id || '') + ); state.selectedElementId = state.slides[0]?.elements[0]?.id || ''; } if (Array.isArray(payload.outline) && payload.outline.length) { @@ -3070,17 +3228,12 @@ function setCanvasZoom(zoom) { const stage = document.querySelector('.canvas-stage'); if (stage) stage.style.transform = currentZoom === 1 ? '' : `scale(${currentZoom})`; const zoomValue = $('zoomValue'); - const statusZoomValue = $('statusZoomValue'); - const pct = Math.round(currentZoom * 100) + '%'; - if (zoomValue) zoomValue.textContent = pct; - if (statusZoomValue) statusZoomValue.textContent = pct; + if (zoomValue) zoomValue.textContent = `${Math.round(currentZoom * 100)}%`; } function bindCanvasZoom() { $('zoomIn')?.addEventListener('click', () => setCanvasZoom(currentZoom + ZOOM_STEP)); $('zoomOut')?.addEventListener('click', () => setCanvasZoom(currentZoom - ZOOM_STEP)); - $('statusZoomIn')?.addEventListener('click', () => setCanvasZoom(currentZoom + ZOOM_STEP)); - $('statusZoomOut')?.addEventListener('click', () => setCanvasZoom(currentZoom - ZOOM_STEP)); document.querySelector('.canvas-area')?.addEventListener('wheel', (e) => { if (e.ctrlKey || e.metaKey) { e.preventDefault(); @@ -3228,6 +3381,27 @@ function bindPropertyPanels() { refreshFlatSelect(stylePresetSelect); }); } + + /* Cowork model selector */ + const modelSelect = $('modelSelect'); + if (modelSelect) { + enhanceFlatSelect(modelSelect); + modelSelect.addEventListener('change', () => { + const selected = normalizePreferredModel(modelSelect.value); + if (selected === state.preferredModel) return; + state.preferredModel = selected; + // Drop the reused session so the next turn starts with the newly chosen model + // context cleanly when host-side model update is unavailable. + if (state.agentSession?.id) { + state.agentSession = { + ...state.agentSession, + id: '', + }; + } + refreshFlatSelect(modelSelect); + void persist(true); + }); + } } /* ============================================ @@ -3398,12 +3572,16 @@ async function confirmExportFromModal() { const { filename, exportSummary } = await executeExport(format); const savedMessage = t('exportSavedTo', { path: filename }); const diagnosticMessage = formatExportDiagnostics(exportSummary); - const completedMessage = diagnosticMessage - ? `${savedMessage} ${diagnosticMessage}` - : savedMessage; + if (diagnosticMessage) { + runtime().log?.info?.(`PPT Live ${format} export completed with visual adjustments`, { + filename, + summary: diagnosticMessage, + counts: exportSummary?.counts || null, + }); + } $('exportOverlay')?.classList.remove('is-exporting'); - setExportModalFeedback('success', completedMessage); - setExportStatus(completedMessage); + setExportModalFeedback('success', savedMessage); + setExportStatus(savedMessage); revealDownloadFolder(); await new Promise((resolve) => setTimeout(resolve, 1600)); closeExportModal(); @@ -3535,10 +3713,67 @@ function renderStylePresetOptions() { refreshFlatSelect(stylePresetSelect); } +function appendModelOption(select, value, label) { + const option = document.createElement('option'); + option.value = value; + option.textContent = label; + select.append(option); +} + +/** Match host chat ModelSelector: concrete options use model_name. */ +function modelOptionLabel(model) { + const modelName = String(model?.modelName || model?.model_name || '').trim(); + if (modelName) return modelName; + const configName = String(model?.name || '').trim(); + if (configName) return configName; + return String(model?.id || '').trim(); +} + +function renderModelOptions(models = []) { + const modelSelect = $('modelSelect'); + if (!modelSelect) return; + const selected = normalizePreferredModel(state.preferredModel); + modelSelect.textContent = ''; + + // Same special entries as chat ModelSelector: auto / primary / fast, then concrete models. + appendModelOption(modelSelect, 'auto', t('modelOptionAuto')); + appendModelOption(modelSelect, 'primary', t('modelOptionPrimary')); + appendModelOption(modelSelect, 'fast', t('modelOptionFast')); + + for (const model of Array.isArray(models) ? models : []) { + const id = String(model?.id || '').trim(); + if (!id || id === 'auto' || id === 'primary' || id === 'fast') continue; + appendModelOption(modelSelect, id, modelOptionLabel(model)); + } + + if (![...modelSelect.options].some((option) => option.value === selected)) { + appendModelOption(modelSelect, selected, selected); + } + modelSelect.value = selected; + if (modelSelect.selectedIndex < 0) modelSelect.value = DEFAULT_PREFERRED_MODEL; + state.preferredModel = normalizePreferredModel(modelSelect.value); + refreshFlatSelect(modelSelect); +} + +async function loadModelOptions() { + renderModelOptions([]); + const getModels = runtime()?.ai?.getModels; + if (typeof getModels !== 'function') return; + try { + const models = await getModels(); + renderModelOptions(models); + } catch (error) { + runtime().log?.warn?.('PPT Live failed to list AI models', { error: String(error) }); + renderModelOptions([]); + } +} + function syncLocale() { state.generation = normalizeGeneration(state.generation); applyI18n(); renderStylePresetOptions(); + renderModelOptions([]); + void loadModelOptions(); const pill = $('aiStatusPill'); if (pill) pill.textContent = busy ? t('statusPillBusy') : t('statusPillReady'); rerender(); @@ -3551,6 +3786,7 @@ async function init() { await recoverFromRestart(); syncLocale(); syncStylePanelFromState(state); + await loadModelOptions(); await persist(true); } catch (error) { runtime().log?.error?.('PPT Live init failed', { error: String(error) }); diff --git a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts index 0c6c2bd3e3..eb6c69b6de 100644 --- a/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts +++ b/src/web-ui/src/app/scenes/miniapps/hooks/useMiniAppBridge.ts @@ -260,6 +260,7 @@ export function useMiniAppBridge( enableTools: params.enableTools as boolean | undefined, sessionId: params.sessionId as string | undefined, appDataWorkspace: params.appDataWorkspace as string | undefined, + model: typeof params.model === 'string' ? params.model : undefined, }, ); agentSessionIdsRef.current.add(result.sessionId); diff --git a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts index 511c65e0cb..a70982eecf 100644 --- a/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts +++ b/src/web-ui/src/infrastructure/api/service-api/MiniAppAPI.ts @@ -77,7 +77,10 @@ export interface AiChatStartedResult { export interface AiModelInfo { id: string; + /** User-defined configuration name. */ name: string; + /** Actual model identifier shown in the host chat model picker. */ + modelName: string; provider: string; isDefault: boolean; } @@ -96,6 +99,11 @@ export interface AgentRunOptions { * the agent workspace (file-protocol apps keep agent outputs there). */ appDataWorkspace?: string; + /** + * Model selector for the hidden Cowork session (`auto` / `primary` / `fast` + * or a concrete model config id). Applied on create and on session reuse. + */ + model?: string; } export interface AgentRunStartedResult { @@ -662,6 +670,7 @@ export class MiniAppAPI { enableTools: options?.enableTools, sessionId: options?.sessionId, appDataWorkspace: options?.appDataWorkspace, + model: options?.model, } }); } catch (error) { From 15e62d2d7915c617176824dafcdba9b0fa7a030d Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 19 Jul 2026 13:11:43 +0800 Subject: [PATCH 031/174] feat(permission): add auto approve ask runtime policy - inject auto approve interaction policy into tool execution options - reply to ask requests through the V2 permission manager - record once replies with the AutoApprove audit source - avoid remembered grants for automatically approved requests - preserve deny and cancellation enforcement - keep CLI auto approval invocation-scoped - remove duplicate CLI permission reply handling --- src/apps/cli/src/agent/core_adapter.rs | 48 ++++++++-- src/apps/cli/src/modes/exec.rs | 48 +++++----- .../src/agentic/coordination/coordinator.rs | 9 ++ .../src/agentic/execution/round_executor.rs | 36 ++++++++ .../agentic/tools/pipeline/tool_pipeline.rs | 87 +++++++++++++++++++ .../core/src/agentic/tools/pipeline/types.rs | 3 + .../agent-runtime/src/permission_v2.rs | 6 ++ src/crates/execution/agent-runtime/src/sdk.rs | 2 +- 8 files changed, 202 insertions(+), 37 deletions(-) diff --git a/src/apps/cli/src/agent/core_adapter.rs b/src/apps/cli/src/agent/core_adapter.rs index 22e1db8844..c38ab81549 100644 --- a/src/apps/cli/src/agent/core_adapter.rs +++ b/src/apps/cli/src/agent/core_adapter.rs @@ -13,7 +13,7 @@ use bitfun_agent_runtime::sdk::{ AgentDialogTurnRequest, AgentRuntime, AgentSessionCreateRequest, AgentSessionDeleteRequest, AgentSessionListRequest, AgentSessionModelUpdateRequest, AgentSessionRestoreRequest, AgentTurnCancellationRequest, AgentUserAnswersRequest, SessionTranscript, - SessionTranscriptRequest, + SessionTranscriptRequest, AUTO_APPROVE_ASK_CONTEXT_KEY, }; use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; use bitfun_core::agentic::persistence::session_branch::SessionBranchResult; @@ -43,6 +43,23 @@ fn validated_session_summary( }) } +fn cli_approval_metadata( + approval_policy: CliApprovalPolicy, +) -> serde_json::Map { + let mut metadata = serde_json::Map::new(); + if approval_policy != CliApprovalPolicy::Ask { + metadata.insert( + USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), + serde_json::Value::Bool(false), + ); + } + metadata.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + serde_json::Value::Bool(approval_policy == CliApprovalPolicy::Auto), + ); + metadata +} + /// Core-based Agent implementation. /// Stateless regarding agent_type — callers pass it per-call. pub(crate) struct CoreAgentAdapter { @@ -393,13 +410,7 @@ impl Agent for CoreAgentAdapter { } // Start the dialog turn; events arrive through the shared broadcast source. - let mut metadata = serde_json::Map::new(); - if self.approval_policy != CliApprovalPolicy::Ask { - metadata.insert( - USER_INPUT_AVAILABLE_CONTEXT_KEY.to_string(), - serde_json::Value::Bool(false), - ); - } + let metadata = cli_approval_metadata(self.approval_policy); let request = AgentDialogTurnRequest { session_id: session_id.clone(), message: message.clone(), @@ -514,7 +525,26 @@ mod tests { use bitfun_runtime_ports::AgentSessionSummary; - use super::validated_session_summary; + use bitfun_agent_runtime::sdk::AUTO_APPROVE_ASK_CONTEXT_KEY; + use bitfun_agent_runtime::user_questions::USER_INPUT_AVAILABLE_CONTEXT_KEY; + + use crate::runtime::approval::CliApprovalPolicy; + + use super::{cli_approval_metadata, validated_session_summary}; + + #[test] + fn cli_approval_metadata_keeps_auto_invocation_scoped() { + let auto = cli_approval_metadata(CliApprovalPolicy::Auto); + assert_eq!(auto[AUTO_APPROVE_ASK_CONTEXT_KEY], true); + assert_eq!(auto[USER_INPUT_AVAILABLE_CONTEXT_KEY], false); + + let reject = cli_approval_metadata(CliApprovalPolicy::Reject); + assert_eq!(reject[AUTO_APPROVE_ASK_CONTEXT_KEY], false); + + let ask = cli_approval_metadata(CliApprovalPolicy::Ask); + assert_eq!(ask[AUTO_APPROVE_ASK_CONTEXT_KEY], false); + assert!(!ask.contains_key(USER_INPUT_AVAILABLE_CONTEXT_KEY)); + } #[test] fn model_updates_use_the_runtime_sdk_without_the_core_compatibility_facade() { diff --git a/src/apps/cli/src/modes/exec.rs b/src/apps/cli/src/modes/exec.rs index b6a66a4bd2..abccbdb076 100644 --- a/src/apps/cli/src/modes/exec.rs +++ b/src/apps/cli/src/modes/exec.rs @@ -614,25 +614,21 @@ impl ExecMode { if !handled_request_ids.insert(request.request_id.clone()) { continue; } - let (reply, action_required, source) = match permission_mode { - ExecApprovalMode::Auto => ( - PermissionReply::Once, - false, - PermissionReplySource::AutoApprove, - ), - ExecApprovalMode::Reject => ( - PermissionReply::Reject { - feedback: Some( - "Non-interactive execution requires an explicit permission policy" - .to_string(), - ), - }, - true, - PermissionReplySource::System, + if permission_mode == ExecApprovalMode::Auto { + continue; + } + let reply = PermissionReply::Reject { + feedback: Some( + "Non-interactive execution requires an explicit permission policy" + .to_string(), ), }; if let Err(error) = permission_runtime - .respond_permission_with_source(&request.request_id, reply, source) + .respond_permission_with_source( + &request.request_id, + reply, + PermissionReplySource::System, + ) .await { let _ = permission_action_tx @@ -640,15 +636,13 @@ impl ExecMode { .await; break; } - if action_required { - let _ = permission_action_tx - .send(format!( - "action-required: permission needed for {} ({})", - request.action, request.request_id - )) - .await; - break; - } + let _ = permission_action_tx + .send(format!( + "action-required: permission needed for {} ({})", + request.action, request.request_id + )) + .await; + break; } }); @@ -1308,8 +1302,8 @@ mod patch_tests { use super::{ completed_turn_failure, effective_event_invocation, event_belongs_to_exec_turn, - event_turn_id, serialize_stream_envelope, write_patch_to_path, ExecApprovalMode, - ExecJsonResult, ExecMode, ExecTokenUsage, TOOL_START_INPUT_PREVIEW_CHARS, + event_turn_id, serialize_stream_envelope, write_patch_to_path, ExecJsonResult, ExecMode, + ExecTokenUsage, TOOL_START_INPUT_PREVIEW_CHARS, }; use bitfun_events::{ AgenticEvent, AgenticEventEnvelope, AgenticEventPriority, ToolEventIdentity, diff --git a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs index 5161dd815c..87fb341b9c 100644 --- a/src/crates/assembly/core/src/agentic/coordination/coordinator.rs +++ b/src/crates/assembly/core/src/agentic/coordination/coordinator.rs @@ -62,6 +62,7 @@ use crate::util::errors::{BitFunError, BitFunResult}; use bitfun_agent_runtime::output_surface::{ supports_inline_markdown_images_for_source, TOOL_CONTEXT_INLINE_MARKDOWN_IMAGE_DISPLAY_KEY, }; +use bitfun_agent_runtime::permission_v2::AUTO_APPROVE_ASK_CONTEXT_KEY; use bitfun_agent_runtime::remote_file_delivery::{ needs_computer_links_for_source, remote_file_delivery_reminder, TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY, @@ -3699,6 +3700,14 @@ Update the persona files and delete BOOTSTRAP.md as soon as bootstrap is complet user_input_available.to_string(), ); } + if let Some(auto_approve_ask) = + metadata_bool(user_message_metadata.as_ref(), AUTO_APPROVE_ASK_CONTEXT_KEY) + { + context_vars.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + auto_approve_ask.to_string(), + ); + } if needs_computer_links_for_source(submission_policy.trigger_source) { context_vars.insert( TOOL_CONTEXT_REMOTE_FILE_DELIVERY_KEY.to_string(), diff --git a/src/crates/assembly/core/src/agentic/execution/round_executor.rs b/src/crates/assembly/core/src/agentic/execution/round_executor.rs index 2a6d217632..2849db34e4 100644 --- a/src/crates/assembly/core/src/agentic/execution/round_executor.rs +++ b/src/crates/assembly/core/src/agentic/execution/round_executor.rs @@ -30,6 +30,7 @@ use crate::util::elapsed_ms_u64; use crate::util::errors::{BitFunError, BitFunResult}; use crate::util::types::Message as AIMessage; use crate::util::types::ToolDefinition; +use bitfun_agent_runtime::permission_v2::AUTO_APPROVE_ASK_CONTEXT_KEY; use bitfun_agent_runtime::turn_cancellation::DialogTurnCancellationTokenStore; use bitfun_ai_adapters::{ ModelExchangeRequestTraceHandle, ModelExchangeResponseTrace, ModelExchangeTraceConfig, @@ -105,6 +106,16 @@ impl RoundExecutor { }) } + fn resolve_auto_approve_ask( + global: &crate::service::config::types::GlobalConfig, + context_vars: &std::collections::HashMap, + ) -> bool { + context_vars + .get(AUTO_APPROVE_ASK_CONTEXT_KEY) + .and_then(|value| value.parse::().ok()) + .unwrap_or(global.tool_permissions.interaction.auto_approve_ask) + } + async fn sleep_with_cancellation( delay_ms: u64, cancel_token: &CancellationToken, @@ -765,6 +776,8 @@ impl RoundExecutor { let subagent_batch_execution_policy = Self::map_subagent_batch_execution_policy( global_config.ai.subagent_batch_execution_policy, ); + let auto_approve_ask = + Self::resolve_auto_approve_ask(&global_config, &context.context_vars); let project_rules = match context.workspace.as_ref() { Some(workspace) if workspace.is_remote() => { @@ -802,6 +815,7 @@ impl RoundExecutor { timeout_secs: tool_execution_timeout, subagent_batch_execution_policy, permission_rules, + auto_approve_ask, ..ToolExecutionOptions::default() }; @@ -1327,6 +1341,7 @@ mod tests { use crate::service::config::types::{AgentProfileConfig, GlobalConfig}; use crate::util::errors::BitFunError; use crate::util::types::ai::GeminiUsage; + use bitfun_agent_runtime::permission_v2::AUTO_APPROVE_ASK_CONTEXT_KEY; use bitfun_agent_runtime::turn_cancellation::DialogTurnCancellationTokenStore; use bitfun_runtime_ports::{ DelegationPolicy, PermissionEffect, PermissionEvaluator, PermissionPolicyPreset, @@ -1421,6 +1436,27 @@ mod tests { ); } + #[test] + fn auto_approve_context_overrides_persisted_interaction_preference() { + let mut global = GlobalConfig::default(); + global.tool_permissions.interaction.auto_approve_ask = true; + let mut context_vars = std::collections::HashMap::new(); + context_vars.insert( + AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), + "false".to_string(), + ); + + assert!(!RoundExecutor::resolve_auto_approve_ask( + &global, + &context_vars + )); + context_vars.insert(AUTO_APPROVE_ASK_CONTEXT_KEY.to_string(), "true".to_string()); + assert!(RoundExecutor::resolve_auto_approve_ask( + &global, + &context_vars + )); + } + #[tokio::test] async fn cancel_token_for_dialog_turn_returns_registered_token() { let executor = test_round_executor(); diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs index 7907c002b0..ddbf316962 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/tool_pipeline.rs @@ -624,6 +624,27 @@ impl ToolPipeline { .await .map_err(|error| BitFunError::service(error.to_string()))?; + if task.options.auto_approve_ask { + if cancellation_token.is_cancelled() { + manager + .cancel_request(&request_id, "Tool execution was cancelled") + .await + .map_err(|error| BitFunError::service(error.to_string()))?; + return Err(BitFunError::Cancelled( + "Tool execution was cancelled before automatic permission reply" + .to_string(), + )); + } + manager + .reply( + &request_id, + PermissionReply::Once, + bitfun_runtime_ports::PermissionReplySource::AutoApprove, + ) + .await + .map_err(|error| BitFunError::service(error.to_string()))?; + } + let outcome = tokio::select! { outcome = pending.wait() => outcome, _ = cancellation_token.cancelled() => { @@ -653,6 +674,12 @@ impl ToolPipeline { return Err(BitFunError::Cancelled(reason)); } } + + if cancellation_token.is_cancelled() { + return Err(BitFunError::Cancelled( + "Tool execution was cancelled after permission reply".to_string(), + )); + } } Ok(V2PermissionAuthorization::Allowed) @@ -2336,6 +2363,7 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 1); let mut deny_options = ToolExecutionOptions::default(); + deny_options.auto_approve_ask = true; deny_options.permission_rules = vec![ PermissionRule::new("edit", "src/*", PermissionEffect::Allow), PermissionRule::new("edit", "src/private/*", PermissionEffect::Deny), @@ -2542,6 +2570,65 @@ mod tests { assert_eq!(calls.load(Ordering::SeqCst), 3); } + #[tokio::test] + async fn v2_auto_approve_ask_uses_once_reply_and_writes_no_grant() { + let store = Arc::new(MemoryPermissionStore::default()); + let manager = permission_test_manager(Arc::clone(&store)); + let mut events = manager.subscribe(); + let pipeline = test_tool_pipeline().with_permission_request_manager(Arc::clone(&manager)); + let calls = Arc::new(AtomicUsize::new(0)); + register_v2_file_test_tool( + &pipeline, + vec![PermissionIntent::new( + "edit", + vec!["src/main.rs".to_string()], + )], + Arc::clone(&calls), + ) + .await; + + let mut options = ToolExecutionOptions::default(); + options.auto_approve_ask = true; + pipeline + .execute_tools( + vec![test_tool_call("auto", "Write")], + permission_test_context(), + options, + ) + .await + .expect("auto-approved tool should execute"); + + assert_eq!(calls.load(Ordering::SeqCst), 1); + assert!(store + .grants + .lock() + .expect("permission grant lock") + .is_empty()); + let audit = store.audit.lock().expect("permission audit lock"); + assert_eq!(audit.len(), 2); + assert!(matches!(audit[0].event, PermissionAuditEvent::Requested)); + assert!(matches!( + audit[1].event, + PermissionAuditEvent::Replied { + reply: PermissionReply::Once, + source: bitfun_runtime_ports::PermissionReplySource::AutoApprove, + } + )); + assert!(matches!( + events.try_recv().expect("asked event"), + bitfun_runtime_ports::PermissionRequestEvent::Asked { .. } + )); + assert!(matches!( + events.try_recv().expect("replied event"), + bitfun_runtime_ports::PermissionRequestEvent::Replied { + reply: PermissionReply::Once, + source: bitfun_runtime_ports::PermissionReplySource::AutoApprove, + .. + } + )); + assert!(manager.pending_requests().is_empty()); + } + #[tokio::test] async fn v2_cancellation_clears_pending_request_without_side_effect() { let store = Arc::new(MemoryPermissionStore::default()); diff --git a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs index 1e56469837..3181ec2971 100644 --- a/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs +++ b/src/crates/assembly/core/src/agentic/tools/pipeline/types.rs @@ -24,6 +24,8 @@ pub struct ToolExecutionOptions { pub timeout_secs: Option, /// Ordered V2 permission rules. An unmatched resource defaults to `ask`. pub permission_rules: Vec, + /// Automatically reply `once` to `ask` requests through the V2 manager. + pub auto_approve_ask: bool, } impl Default for ToolExecutionOptions { @@ -34,6 +36,7 @@ impl Default for ToolExecutionOptions { max_retries: 0, timeout_secs: None, // Default no timeout (infinite waiting) permission_rules: Vec::new(), + auto_approve_ask: false, } } } diff --git a/src/crates/execution/agent-runtime/src/permission_v2.rs b/src/crates/execution/agent-runtime/src/permission_v2.rs index 1f8cd5014b..3863d4469b 100644 --- a/src/crates/execution/agent-runtime/src/permission_v2.rs +++ b/src/crates/execution/agent-runtime/src/permission_v2.rs @@ -17,6 +17,12 @@ use tokio::sync::{broadcast, oneshot, Mutex}; const PERMISSION_EVENT_CAPACITY: usize = 128; +/// Per-submission override for the interactive ask handling policy. +/// +/// Product surfaces may set this in dialog metadata to keep invocation-scoped +/// policies (such as CLI `--auto`) separate from persisted user preferences. +pub const AUTO_APPROVE_ASK_CONTEXT_KEY: &str = "auto_approve_ask"; + pub type PermissionRequestEventReceiver = broadcast::Receiver; #[derive(Debug, Clone, PartialEq)] diff --git a/src/crates/execution/agent-runtime/src/sdk.rs b/src/crates/execution/agent-runtime/src/sdk.rs index 02e38ee35b..47b7b0d67e 100644 --- a/src/crates/execution/agent-runtime/src/sdk.rs +++ b/src/crates/execution/agent-runtime/src/sdk.rs @@ -38,7 +38,7 @@ pub use crate::context_profile::{ContextProfile, ContextProfilePolicy, ModelCapa pub use crate::event_source::{AgentEventReceiver, AgentEventSource, AgentSessionEventReceiver}; pub use crate::permission_v2::{ PermissionReplyResolution, PermissionRequestEventReceiver, PermissionRequestManager, - PermissionRequestManagerError, + PermissionRequestManagerError, AUTO_APPROVE_ASK_CONTEXT_KEY, }; pub use crate::post_call_hooks::{ RuntimeHookErrorPolicy, RuntimeHookKind, RuntimeHookPlan, RuntimeHookRegistry, From 96f01e9e2bac20c9d34a0bbe9ac4e4292f00e1ec Mon Sep 17 00:00:00 2001 From: wsp Date: Sun, 19 Jul 2026 13:33:16 +0800 Subject: [PATCH 032/174] feat(permission): add desktop permission modes - add shared web permission config service with safe defaults - expose ask and full_access modes in session permissions settings - require danger confirmation before enabling full access - add independent auto approve ask toggle - show dismissible Auto status in the ChatInput toolbar - keep ACP sessions outside native BitFun Auto mode - add localized settings and ChatInput labels - add permission config normalization and persistence tests --- .../src/flow_chat/components/ChatInput.scss | 39 ++++++++ .../src/flow_chat/components/ChatInput.tsx | 65 ++++++++++++- .../config/components/SessionConfig.tsx | 97 ++++++++++++++++++- src/web-ui/src/infrastructure/config/index.ts | 1 + .../services/PermissionConfigService.test.ts | 95 ++++++++++++++++++ .../services/PermissionConfigService.ts | 81 ++++++++++++++++ src/web-ui/src/locales/en-US/flow-chat.json | 3 + .../en-US/settings/session-config.json | 15 +++ src/web-ui/src/locales/zh-CN/flow-chat.json | 3 + .../zh-CN/settings/session-config.json | 15 +++ src/web-ui/src/locales/zh-TW/flow-chat.json | 3 + .../zh-TW/settings/session-config.json | 15 +++ 12 files changed, 429 insertions(+), 3 deletions(-) create mode 100644 src/web-ui/src/infrastructure/config/services/PermissionConfigService.test.ts create mode 100644 src/web-ui/src/infrastructure/config/services/PermissionConfigService.ts diff --git a/src/web-ui/src/flow_chat/components/ChatInput.scss b/src/web-ui/src/flow_chat/components/ChatInput.scss index 70c7c76b9b..e44e296050 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.scss +++ b/src/web-ui/src/flow_chat/components/ChatInput.scss @@ -1461,9 +1461,48 @@ display: flex; align-items: center; gap: $size-gap-1; + min-width: 0; animation: bitfun-stacked-reveal 0.28s cubic-bezier(0.4, 0, 0.2, 1) 0.17s both; } + &__auto-permission { + display: inline-flex; + align-items: center; + gap: 3px; + min-width: 0; + height: 20px; + padding: 0 6px; + border: 1px solid color-mix(in srgb, var(--color-warning) 38%, transparent); + border-radius: 999px; + background: color-mix(in srgb, var(--color-warning) 12%, transparent); + color: var(--color-warning); + font: inherit; + font-size: var(--flowchat-font-size-xxs); + line-height: 1; + cursor: pointer; + flex-shrink: 0; + transition: background 0.15s ease, border-color 0.15s ease; + + &:hover:not(:disabled) { + border-color: color-mix(in srgb, var(--color-warning) 56%, transparent); + background: color-mix(in srgb, var(--color-warning) 18%, transparent); + } + + &:focus-visible { + outline: 2px solid var(--color-warning); + outline-offset: 2px; + } + + &:disabled { + cursor: default; + opacity: 0.55; + } + + svg { + flex-shrink: 0; + } + } + &__model-usage-group { display: inline-flex; align-items: center; diff --git a/src/web-ui/src/flow_chat/components/ChatInput.tsx b/src/web-ui/src/flow_chat/components/ChatInput.tsx index cde3433974..ef103152ba 100644 --- a/src/web-ui/src/flow_chat/components/ChatInput.tsx +++ b/src/web-ui/src/flow_chat/components/ChatInput.tsx @@ -6,7 +6,7 @@ import React, { useRef, useCallback, useEffect, useReducer, useState, useMemo, useSyncExternalStore } from 'react'; import path from 'path-browserify'; import { useTranslation } from 'react-i18next'; -import { ArrowUp, BotMessageSquare, Image, RotateCcw, Plus, X, Sparkles, Loader2, ChevronRight, Files, MessageSquarePlus, Star } from 'lucide-react'; +import { ArrowUp, BotMessageSquare, Image, RotateCcw, Plus, X, Sparkles, Loader2, ChevronRight, Files, MessageSquarePlus, Star, ShieldCheck } from 'lucide-react'; import { ContextDropZone, useContextStore } from '../../shared/context-system'; import { useActiveSessionState } from '@/flow_chat/hooks'; import { RichTextInput, type MentionState, type InlineTriggerState } from './RichTextInput'; @@ -84,7 +84,8 @@ import { useSceneStore } from '@/app/stores/sceneStore'; import type { SceneTabId } from '@/app/components/SceneBar/types'; import { useAgentsStore } from '@/app/scenes/agents/agentsStore'; import { configAPI } from '@/infrastructure/api/service-api/ConfigAPI'; -import { configManager } from '@/infrastructure/config'; +import { configManager, normalizeToolPermissionConfig, permissionConfigService } from '@/infrastructure/config'; +import type { ToolPermissionConfig } from '@/infrastructure/config/types'; import type { ModeSkillInfo } from '@/infrastructure/config/types'; import { SubagentAPI, type SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI'; import MCPAPI, { type MCPPrompt, type MCPPromptMessage, type MCPServerInfo } from '@/infrastructure/api/service-api/MCPAPI'; @@ -287,6 +288,8 @@ export const ChatInput: React.FC = ({ const [historyIndex, setHistoryIndex] = useState(-1); const [savedDraft, setSavedDraft] = useState(''); const [inputTarget, setInputTarget] = useState('main'); + const [autoApproveAskEnabled, setAutoApproveAskEnabled] = useState(false); + const [autoApproveAskSaving, setAutoApproveAskSaving] = useState(false); const { addMessage: addToHistory, getSessionHistory } = useInputHistoryStore(); const contexts = useContextStore(state => state.contexts); @@ -1359,6 +1362,48 @@ export const ChatInput: React.FC = ({ }; }, []); + React.useEffect(() => { + let cancelled = false; + const applyConfig = (config: ToolPermissionConfig) => { + if (!cancelled) { + setAutoApproveAskEnabled(config.interaction.auto_approve_ask); + } + }; + const loadConfig = async () => { + applyConfig(await permissionConfigService.getConfig()); + }; + const handlePermissionConfigUpdated = (value?: ToolPermissionConfig) => { + if (value) { + applyConfig(normalizeToolPermissionConfig(value)); + } else { + void loadConfig(); + } + }; + + void loadConfig(); + globalEventBus.on('permission:config:updated', handlePermissionConfigUpdated); + return () => { + cancelled = true; + globalEventBus.off('permission:config:updated', handlePermissionConfigUpdated); + }; + }, []); + + const handleDisableAutoApproveAsk = useCallback(async (event: React.MouseEvent) => { + event.stopPropagation(); + if (autoApproveAskSaving) return; + setAutoApproveAskEnabled(false); + setAutoApproveAskSaving(true); + try { + await permissionConfigService.setAutoApproveAsk(false); + } catch (error) { + log.error('Failed to disable auto approve ask', error); + setAutoApproveAskEnabled(true); + notificationService.error(t('chatInput.autoPermissionDisableFailed')); + } finally { + setAutoApproveAskSaving(false); + } + }, [autoApproveAskSaving, t]); + React.useEffect(() => { if (!slashCommandState.isActive || slashCommandState.kind !== 'all' || derivedState?.isProcessing) { return; @@ -4236,6 +4281,22 @@ export const ChatInput: React.FC = ({ )} + {autoApproveAskEnabled && !isAcpTargetSession && ( + + + + )}
      diff --git a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx index 57d01262bd..9d1b88fedf 100644 --- a/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/SessionConfig.tsx @@ -14,6 +14,7 @@ import { Modal, Select, Tooltip, + confirmDanger, type SelectOption, } from '@/component-library'; import { ConfigPageHeader, ConfigPageLayout, ConfigPageContent, ConfigPageSection, ConfigPageRow } from './common'; @@ -27,9 +28,14 @@ import { type AgentCompanionPetPackage, } from '../services/AgentCompanionPetService'; import { configManager } from '../services/ConfigManager'; +import { + DEFAULT_TOOL_PERMISSION_CONFIG, + normalizeToolPermissionConfig, + permissionConfigService, +} from '../services/PermissionConfigService'; import { systemAPI } from '@/infrastructure/api/service-api/SystemAPI'; import { useNotification, notificationService } from '@/shared/notification-system'; -import type { AIModelConfig, DebugModeConfig, LanguageDebugTemplate } from '../types'; +import type { AIModelConfig, DebugModeConfig, LanguageDebugTemplate, ToolPermissionConfig } from '../types'; import { LANGUAGE_TEMPLATE_LABELS, DEFAULT_DEBUG_MODE_CONFIG, @@ -112,6 +118,8 @@ const SessionSettingsPanels: React.FC = ({ variant } useState(DEFAULT_SUBAGENT_BATCH_EXECUTION_POLICY); const [toolExecConfigLoading, setToolExecConfigLoading] = useState(false); const [deferredToolLoadingConfigSaving, setDeferredToolLoadingConfigSaving] = useState(false); + const [toolPermissionConfig, setToolPermissionConfig] = useState(DEFAULT_TOOL_PERMISSION_CONFIG); + const [permissionConfigSaving, setPermissionConfigSaving] = useState(false); const [computerUseEnabled, setComputerUseEnabled] = useState(false); const [computerUseAccess, setComputerUseAccess] = useState(false); @@ -216,6 +224,7 @@ const SessionSettingsPanels: React.FC = ({ variant } debugConfigData, computerUseCfg, browserControlPreferredBrowser, + loadedToolPermissionConfig, loadedCompanionPets, ] = await Promise.all([ aiExperienceConfigService.getSettingsAsync(), @@ -228,6 +237,7 @@ const SessionSettingsPanels: React.FC = ({ variant } configManager.getConfig('ai.debug_mode_config'), configManager.getConfig('ai.computer_use_enabled'), configManager.getConfig('ai.browser_control_preferred_browser'), + permissionConfigService.getConfig(), listAgentCompanionPets(), ]); @@ -243,6 +253,7 @@ const SessionSettingsPanels: React.FC = ({ variant } setSubagentBatchExecutionPolicy(normalizeSubagentBatchExecutionPolicy(loadedSubagentBatchExecutionPolicy)); if (debugConfigData) setDebugConfig(debugConfigData); setPreferredBrowser(browserControlPreferredBrowser || DEFAULT_BROWSER_CONTROL_BROWSER); + setToolPermissionConfig(normalizeToolPermissionConfig(loadedToolPermissionConfig)); refreshDesktopStatus(computerUseCfg); } catch (error) { @@ -253,6 +264,50 @@ const SessionSettingsPanels: React.FC = ({ variant } } }, [refreshDesktopStatus]); + const saveToolPermissionConfig = async (nextConfig: ToolPermissionConfig, previousConfig: ToolPermissionConfig) => { + setToolPermissionConfig(nextConfig); + setPermissionConfigSaving(true); + try { + await permissionConfigService.saveConfig(nextConfig); + notificationService.success(t('messages.saveSuccess'), { duration: 2000 }); + } catch (error) { + log.error('Failed to save tool permission config', error); + setToolPermissionConfig(previousConfig); + notificationService.error(t('messages.saveFailed')); + } finally { + setPermissionConfigSaving(false); + } + }; + + const handlePermissionPresetChange = async (value: string | number | (string | number)[]) => { + const nextPreset = String(Array.isArray(value) ? value[0] : value) === 'full_access' ? 'full_access' : 'ask'; + if (nextPreset === toolPermissionConfig.policy.preset) return; + const previousConfig = toolPermissionConfig; + if (nextPreset === 'full_access') { + const confirmed = await confirmDanger( + t('permissionPolicy.fullAccessWarningTitle'), + t('permissionPolicy.fullAccessWarningMessage'), + { + confirmText: t('permissionPolicy.fullAccessConfirm'), + cancelText: t('permissionPolicy.cancel'), + }, + ); + if (!confirmed) return; + } + await saveToolPermissionConfig( + { ...previousConfig, policy: { ...previousConfig.policy, preset: nextPreset } }, + previousConfig, + ); + }; + + const handleAutoApproveAskChange = async (enabled: boolean) => { + const previousConfig = toolPermissionConfig; + await saveToolPermissionConfig( + { ...previousConfig, interaction: { ...previousConfig.interaction, auto_approve_ask: enabled } }, + previousConfig, + ); + }; + useEffect(() => { loadAllData(); }, [loadAllData]); @@ -1066,6 +1121,46 @@ const SessionSettingsPanels: React.FC = ({ variant } + + +
      + , + Select: () =>
      , Switch: () => , confirmDanger: vi.fn(async () => false), })); @@ -54,13 +86,17 @@ vi.mock('@/app/components', () => ({ })); vi.mock('./hooks/useAgentsList', () => ({ - useAgentsList: () => ({ + useAgentsList: () => useAgentsListMock(), +})); + +function mockAgentsList(overrides: Record = {}) { + useAgentsListMock.mockReturnValue({ allAgents: [], filteredAgents: [], loading: false, availableTools: [], getModeProfile: () => null, - getModeSkills: () => [], + getAgentSkills: () => [], getModeManageableSubagents: () => [], counts: { builtin: 0, user: 0, project: 0, mode: 0, subagent: 0 }, loadAgents: vi.fn(), @@ -70,8 +106,10 @@ vi.mock('./hooks/useAgentsList', () => ({ handleSetSkills: vi.fn(), handleResetSkills: vi.fn(), handleSetSubagentEnabled: vi.fn(), - }), -})); + handleSetSubagentModel: vi.fn(), + ...overrides, + }); +} vi.mock('@/app/hooks/useGallerySceneAutoRefresh', () => ({ useGallerySceneAutoRefresh: vi.fn(), @@ -81,6 +119,13 @@ vi.mock('@/infrastructure/contexts/WorkspaceContext', () => ({ useCurrentWorkspace: () => ({ workspacePath: 'D:/workspace/project' }), })); +vi.mock('@/infrastructure/config/services/ConfigManager', () => ({ + configManager: { + getConfig: vi.fn(async () => false), + onConfigChange: vi.fn(() => () => {}), + }, +})); + vi.mock('@/shared/notification-system', () => ({ useNotification: () => ({ success: vi.fn(), @@ -148,6 +193,7 @@ describeWithJsdom('AgentsScene', () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); useAgentsStore.getState().openHome(); + mockAgentsList(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -185,4 +231,56 @@ describeWithJsdom('AgentsScene', () => { expect(stylesheet).toContain('flex: 1 1 auto;'); expect(stylesheet).toContain('min-width: 0;'); }); + + it('shows skill grouping and editing for a custom subagent with the Skill tool', async () => { + const subagent = { + key: 'user::skill-worker', + id: 'skill-worker', + name: 'Skill worker', + description: 'Uses specialized workflows.', + isReadonly: false, + isReview: false, + toolCount: 1, + defaultTools: ['Skill'], + defaultEnabled: true, + effectiveEnabled: true, + source: 'user', + agentKind: 'subagent' as const, + capabilities: [], + }; + mockAgentsList({ + allAgents: [subagent], + filteredAgents: [subagent], + getAgentSkills: (agentId: string) => agentId === subagent.id + ? [{ key: 'user::custom::workflow', effectiveEnabled: true }] + : [], + }); + const { default: AgentsScene } = await import('./AgentsScene'); + + await act(async () => { + root.render(); + }); + await act(async () => { + Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === subagent.name) + ?.click(); + }); + + const skillsTab = Array.from(container.querySelectorAll('[role="tab"]')) + .find((tab) => tab.textContent?.includes('agentsOverview.skills')); + expect(skillsTab).toBeTruthy(); + + await act(async () => { + skillsTab?.click(); + }); + expect(container.querySelector('[data-testid="agent-detail-skill-summary"]')).toBeTruthy(); + + const manageButton = Array.from(container.querySelectorAll('button')) + .find((button) => button.textContent === 'manage'); + expect(manageButton).toBeTruthy(); + await act(async () => { + manageButton?.click(); + }); + expect(container.querySelector('[data-testid="agent-detail-skill-groups"]')).toBeTruthy(); + }); }); diff --git a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx index d26d503a64..8c60078cb1 100644 --- a/src/web-ui/src/app/scenes/agents/AgentsScene.tsx +++ b/src/web-ui/src/app/scenes/agents/AgentsScene.tsx @@ -12,7 +12,7 @@ import { Wrench, } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { Badge, Button, IconButton, Search, Select, Switch, confirmDanger } from '@/component-library'; +import { Badge, Button, IconButton, Search, Select, confirmDanger } from '@/component-library'; import { GalleryDetailModal, GalleryEmpty, @@ -25,6 +25,15 @@ import { import AgentCard from './components/AgentCard'; import CoreAgentCard, { type CoreAgentMeta } from './components/CoreAgentCard'; import CreateAgentPage from './components/CreateAgentPage'; +import { + AgentCapabilityTooltip, + capabilityTooltipAriaLabel, + type AgentCapabilityTooltipField, +} from './components/AgentCapabilityTooltip'; +import { SkillGroupPicker, SkillGroupSummary } from './components/SkillGroupPicker'; +import { ToolGroupPicker, ToolGroupSummary } from './components/ToolGroupPicker'; +import { useUserSkillGroups } from './components/useUserSkillGroups'; +import { useUserToolGroups } from './components/useUserToolGroups'; import { type AgentWithCapabilities, useAgentsStore, @@ -48,7 +57,6 @@ import { configManager } from '@/infrastructure/config/services/ConfigManager'; import type { ModeSkillInfo, SubagentModelSelection } from '@/infrastructure/config/types'; import { buildSkillCoverageSourceMap, - formatSkillOrigin, getModeSkillRuntimeStatus, } from '@/infrastructure/config/skillSourcePresentation'; import type { SubagentInfo } from '@/infrastructure/api/service-api/SubagentAPI'; @@ -60,27 +68,8 @@ import { import { useSceneManager } from '@/app/hooks/useSceneManager'; import { useSettingsStore } from '@/app/scenes/settings/settingsStore'; -const UNGROUPED_SKILL_GROUP = '__ungrouped__'; const DEFAULT_SUBAGENT_MODEL_OVERRIDE_VALUE = '__default_subagent_model__'; -const SKILL_GROUP_ORDER: Record = { - office: 0, - meta: 1, - miniapp: 2, - gstack: 3, - team: 4, - [UNGROUPED_SKILL_GROUP]: 99, -}; - -interface SkillGroup { - key: string; - label: string; - skills: ModeSkillInfo[]; - enabledCount: number; - selectedCount: number; - totalCount: number; -} - type CapabilityTab = 'model' | 'tools' | 'skills' | 'subagents'; function normalizeSelectValue(value: string | number | (string | number)[]): string { @@ -107,82 +96,19 @@ function getConfiguredEnabledSkillKeys(skills: ModeSkillInfo[]): string[] { return skills.filter((skill) => skill.effectiveEnabled).map((skill) => skill.key); } -function modeHasSkillTool(enabledTools: string[]): boolean { +function hasSkillTool(enabledTools: string[]): boolean { return enabledTools.includes('Skill'); } -function modeHasTaskTool(enabledTools: string[]): boolean { +function hasTaskTool(enabledTools: string[]): boolean { return enabledTools.includes('Task'); } -function buildDuplicateSkillNameSet(skills: ModeSkillInfo[]): Set { - const counts = new Map(); - for (const skill of skills) { - counts.set(skill.name, (counts.get(skill.name) ?? 0) + 1); - } - return new Set( - [...counts.entries()] - .filter(([, count]) => count > 1) - .map(([name]) => name), - ); -} - -function formatLocalizedSkillOrigin( - skill: ModeSkillInfo, - t: TFunction<'scenes/agents'>, - workspaceIsRemote: boolean, -): string { - return formatSkillOrigin(skill, { - fallbackSourceLabel: t('agentsOverview.unknownSkillSource'), - userLabel: workspaceIsRemote - ? t('agentsOverview.skillScopeLocalUser') - : t('agentsOverview.skillScopeUser'), - projectLabel: workspaceIsRemote - ? t('agentsOverview.skillScopeRemoteProject') - : t('agentsOverview.skillScopeProject'), - }); -} - -function formatSkillDisplayName( - skill: ModeSkillInfo, - duplicateNames: Set, - t: TFunction<'scenes/agents'>, - workspaceIsRemote: boolean, -): string { - if (!duplicateNames.has(skill.name)) { - return skill.name; - } - return `${skill.name} [${formatLocalizedSkillOrigin(skill, t, workspaceIsRemote)}]`; -} - -function getSkillGroupKey(skill: ModeSkillInfo): string { - return skill.groupKey?.trim() || UNGROUPED_SKILL_GROUP; -} - -function getSkillGroupLabel(groupKey: string, t: TFunction<'scenes/agents'>): string { - switch (groupKey) { - case 'office': - return t('agentsOverview.skillGroups.office'); - case 'computer-use': - return t('agentsOverview.skillGroups.computerUse'); - case 'meta': - return t('agentsOverview.skillGroups.meta'); - case 'miniapp': - return t('agentsOverview.skillGroups.miniapp'); - case 'gstack': - return t('agentsOverview.skillGroups.gstack'); - case 'team': - return t('agentsOverview.skillGroups.team'); - default: - return t('agentsOverview.skillGroups.other'); - } -} - -function getSkillRuntimeStatusLabel( +function skillRuntimeStatusLabel( skill: ModeSkillInfo, coverageSourceBySkillKey: ReadonlyMap, t: TFunction<'scenes/agents'>, -): string | null { +): string | undefined { const status = getModeSkillRuntimeStatus( skill, coverageSourceBySkillKey, @@ -196,63 +122,51 @@ function getSkillRuntimeStatusLabel( case 'enabled': return t('agentsOverview.skillRuntimeEnabled'); case 'disabled': - return null; + return undefined; } } -function getSkillTitle( - skill: ModeSkillInfo, - coverageSourceBySkillKey: ReadonlyMap, +function subagentSourceLabel( + source: SubagentInfo['source'] | undefined, t: TFunction<'scenes/agents'>, - workspaceIsRemote: boolean, ): string { - return [ - skill.description || skill.name, - formatLocalizedSkillOrigin(skill, t, workspaceIsRemote), - getSkillRuntimeStatusLabel(skill, coverageSourceBySkillKey, t), - ].filter(Boolean).join('\n'); + switch (source) { + case 'project': + return t('filters.project'); + case 'user': + return t('filters.user'); + case 'external': + return t('filters.external'); + default: + return t('filters.builtin'); + } } -function buildSkillGroups( - skills: ModeSkillInfo[], - enabledSkillKeys: string[], +function subagentTooltipFields( + subagent: SubagentInfo, t: TFunction<'scenes/agents'>, -): SkillGroup[] { - const enabledSkillKeySet = new Set(enabledSkillKeys); - const groups = new Map(); - - for (const skill of skills) { - const groupKey = getSkillGroupKey(skill); - const items = groups.get(groupKey); - if (items) { - items.push(skill); - } else { - groups.set(groupKey, [skill]); - } - } - - return [...groups.entries()] - .map(([groupKey, groupSkills]) => ({ - key: groupKey, - label: getSkillGroupLabel(groupKey, t), - skills: [...groupSkills].sort((a, b) => { - const aEnabled = enabledSkillKeySet.has(a.key); - const bEnabled = enabledSkillKeySet.has(b.key); - if (aEnabled && !bEnabled) return -1; - if (!aEnabled && bEnabled) return 1; - return a.name.localeCompare(b.name) || a.key.localeCompare(b.key); - }), - enabledCount: groupSkills.filter((skill) => enabledSkillKeySet.has(skill.key)).length, - selectedCount: groupSkills.filter((skill) => skill.selectedForRuntime).length, - totalCount: groupSkills.length, - })) - .sort((a, b) => { - const orderDiff = (SKILL_GROUP_ORDER[a.key] ?? 50) - (SKILL_GROUP_ORDER[b.key] ?? 50); - if (orderDiff !== 0) { - return orderDiff; - } - return a.label.localeCompare(b.label); - }); + isExternal: boolean, +): AgentCapabilityTooltipField[] { + const source = subagent.subagentSource ?? subagent.source; + return [ + { + label: t('agentsOverview.capabilityTooltip.subagentId'), + value: subagent.id, + monospace: true, + }, + { + label: t('agentsOverview.capabilityTooltip.source'), + value: subagentSourceLabel(source, t), + }, + { + label: t('agentsOverview.capabilityTooltip.toolCount'), + value: String(subagent.toolCount), + }, + ...(isExternal ? [{ + label: t('agentsOverview.capabilityTooltip.status'), + value: t('agentsOverview.capabilityTooltip.externalManaged'), + }] : []), + ]; } const AgentsHomeView: React.FC = () => { @@ -285,17 +199,24 @@ const AgentsHomeView: React.FC = () => { const [savingSubagentModel, setSavingSubagentModel] = React.useState(false); const [computerUseEnabled, setComputerUseEnabled] = useState(true); const { buildModelOption, renderModelOption, renderModelValue } = useModelSelectPresentation(); + const { + groups: userToolGroups, + saveGroups: saveUserToolGroups, + } = useUserToolGroups(); + const { + groups: userSkillGroups, + saveGroups: saveUserSkillGroups, + } = useUserSkillGroups(); const { workspacePath, - workspaceIsRemote = false, allAgents, filteredAgents, loading, availableTools, configuredModels = [], getModeProfile, - getModeSkills, + getAgentSkills, getModeManageableSubagents, counts, hiddenAgentIds, @@ -395,9 +316,9 @@ const AgentsHomeView: React.FC = () => { () => (selectedAgent?.agentKind === 'mode' ? getModeProfile(selectedAgent.id) : null), [getModeProfile, selectedAgent], ); - const selectedAgentModeSkills = useMemo( - () => (selectedAgent?.agentKind === 'mode' ? getModeSkills(selectedAgent.id) : []), - [getModeSkills, selectedAgent], + const selectedAgentSkillConfigs = useMemo( + () => (selectedAgent ? getAgentSkills(selectedAgent.id) : []), + [getAgentSkills, selectedAgent], ); const selectedAgentManageableSubagents = useMemo( () => (selectedAgent?.agentKind === 'mode' ? getModeManageableSubagents(selectedAgent.id) : []), @@ -420,11 +341,9 @@ const AgentsHomeView: React.FC = () => { () => availableTools.filter((tool) => isUserSelectableToolName(tool.name)), [availableTools], ); - const selectedAgentHasSkillTool = selectedAgent?.agentKind === 'mode' - ? modeHasSkillTool(selectedAgentConfiguredTools) - : false; + const selectedAgentHasSkillTool = hasSkillTool(selectedAgentConfiguredTools); const selectedAgentHasTaskTool = selectedAgent?.agentKind === 'mode' - ? modeHasTaskTool(selectedAgentConfiguredTools) + ? hasTaskTool(selectedAgentConfiguredTools) : false; const selectedAgentEnabledSubagents = useMemo( () => selectedAgentManageableSubagents.filter((subagent) => subagent.effectiveEnabled), @@ -441,35 +360,26 @@ const AgentsHomeView: React.FC = () => { [selectedAgentEnabledSubagents], ); const selectedAgentSkills = useMemo( - () => getConfiguredEnabledSkillKeys(selectedAgentModeSkills), - [selectedAgentModeSkills], - ); - const selectedAgentSkillItems = useMemo( - () => selectedAgentModeSkills.filter((skill) => skill.effectiveEnabled), - [selectedAgentModeSkills], - ); - const selectedAgentSkillGroups = useMemo( - () => buildSkillGroups(selectedAgentModeSkills, selectedAgentSkills, t), - [selectedAgentModeSkills, selectedAgentSkills, t], - ); - const editableSkillGroups = useMemo( - () => buildSkillGroups(selectedAgentModeSkills, pendingSkills ?? selectedAgentSkills, t), - [pendingSkills, selectedAgentModeSkills, selectedAgentSkills, t], - ); - const selectedAgentDuplicateSkillNames = useMemo( - () => buildDuplicateSkillNameSet(selectedAgentModeSkills), - [selectedAgentModeSkills], + () => getConfiguredEnabledSkillKeys(selectedAgentSkillConfigs), + [selectedAgentSkillConfigs], ); const selectedAgentCoverageSourceBySkillKey = useMemo( () => buildSkillCoverageSourceMap( - selectedAgentModeSkills, + selectedAgentSkillConfigs, t('agentsOverview.unknownSkillSource'), ), - [selectedAgentModeSkills, t], + [selectedAgentSkillConfigs, t], + ); + const selectedAgentSkillItems = useMemo( + () => selectedAgentSkillConfigs.map((skill) => ({ + ...skill, + runtimeStatus: skillRuntimeStatusLabel(skill, selectedAgentCoverageSourceBySkillKey, t), + })), + [selectedAgentCoverageSourceBySkillKey, selectedAgentSkillConfigs, t], ); const selectedAgentRuntimeSkillCount = useMemo( - () => selectedAgentModeSkills.filter((skill) => skill.selectedForRuntime).length, - [selectedAgentModeSkills], + () => selectedAgentSkillConfigs.filter((skill) => skill.selectedForRuntime).length, + [selectedAgentSkillConfigs], ); const selectedAgentProfileMemberNames = useMemo(() => { if (!selectedAgentModeProfile) { @@ -570,12 +480,17 @@ const AgentsHomeView: React.FC = () => { }); } - if (selectedAgent?.agentKind === 'mode' && selectedAgentHasSkillTool && selectedAgentModeSkills.length > 0) { + if (selectedAgentHasSkillTool && selectedAgentSkillConfigs.length > 0) { + const currentSkillCount = skillsEditing + ? (pendingSkills ?? selectedAgentSkills).length + : selectedAgent?.agentKind === 'mode' + ? selectedAgentRuntimeSkillCount + : selectedAgentSkills.length; tabs.push({ key: 'skills', icon: Puzzle, label: t('agentsOverview.skills'), - count: `${selectedAgentRuntimeSkillCount}/${selectedAgentModeSkills.length}`, + count: `${currentSkillCount}/${selectedAgentSkillConfigs.length}`, }); } @@ -594,6 +509,7 @@ const AgentsHomeView: React.FC = () => { return tabs; }, [ userSelectableAvailableTools.length, + pendingSkills, pendingSubagentIds, pendingTools, selectedAgent, @@ -603,9 +519,11 @@ const AgentsHomeView: React.FC = () => { selectedAgentHasSkillTool, selectedAgentHasTaskTool, selectedAgentManageableSubagents.length, - selectedAgentModeSkills.length, + selectedAgentSkillConfigs.length, + selectedAgentSkills, selectedAgentRuntimeSkillCount, selectedAgentTools, + skillsEditing, subagentsEditing, t, toolsEditing, @@ -616,6 +534,12 @@ const AgentsHomeView: React.FC = () => { } return selectedAgentCapabilityTabs[0]?.key ?? 'tools'; }, [activeCapabilityTab, selectedAgentCapabilityTabs]); + const canManageCurrentCapability = selectedAgent?.agentKind === 'mode' + || ( + currentCapabilityTab === 'skills' + && selectedAgent?.agentKind === 'subagent' + && !selectedAgentIsExternal + ); const isCurrentTabEditing = currentCapabilityTab === 'tools' ? toolsEditing : currentCapabilityTab === 'skills' @@ -635,34 +559,6 @@ const AgentsHomeView: React.FC = () => { setSavingSubagents(false); }, []); - const togglePendingSkill = useCallback((skillKey: string) => { - setPendingSkills((prev) => { - const current = prev ?? selectedAgentSkills; - return current.includes(skillKey) - ? current.filter((key) => key !== skillKey) - : [...current, skillKey]; - }); - }, [selectedAgentSkills]); - - const setPendingSkillGroupEnabled = useCallback((skills: ModeSkillInfo[], enabled: boolean) => { - setPendingSkills((prev) => { - const current = prev ?? selectedAgentSkills; - const groupKeys = new Set(skills.map((skill) => skill.key)); - - if (!enabled) { - return current.filter((key) => !groupKeys.has(key)); - } - - const next = [...current]; - for (const skill of skills) { - if (!next.includes(skill.key)) { - next.push(skill.key); - } - } - return next; - }); - }, [selectedAgentSkills]); - const openAgentDetails = useCallback((agent: AgentWithCapabilities) => { setSelectedAgentId(agent.id); setActiveCapabilityTab(null); @@ -793,10 +689,14 @@ const AgentsHomeView: React.FC = () => { index={index} meta={coreAgentMeta[agent.id] ?? { role: agent.name, ...DEFAULT_CORE_AGENT_ACCENT }} toolCount={getDisplayedToolCount(agent)} - skillCount={agent.agentKind === 'mode' && modeHasSkillTool(getModeConfig(agent.id)?.enabled_tools ?? agent.defaultTools ?? []) - ? getConfiguredEnabledSkillKeys(getModeSkills(agent.id)).length + skillCount={hasSkillTool( + agent.agentKind === 'mode' + ? (getModeConfig(agent.id)?.enabled_tools ?? agent.defaultTools ?? []) + : (agent.defaultTools ?? []), + ) + ? getConfiguredEnabledSkillKeys(getAgentSkills(agent.id)).length : 0} - subagentCount={agent.agentKind === 'mode' && modeHasTaskTool(getModeConfig(agent.id)?.enabled_tools ?? agent.defaultTools ?? []) + subagentCount={agent.agentKind === 'mode' && hasTaskTool(getModeConfig(agent.id)?.enabled_tools ?? agent.defaultTools ?? []) ? (agent.visibleSubagentCount ?? 0) : 0} onOpenDetails={openAgentDetails} @@ -893,10 +793,14 @@ const AgentsHomeView: React.FC = () => { agent={agent} index={index} toolCount={getDisplayedToolCount(agent)} - skillCount={agent.agentKind === 'mode' && modeHasSkillTool(getModeConfig(agent.id)?.enabled_tools ?? agent.defaultTools ?? []) - ? getConfiguredEnabledSkillKeys(getModeSkills(agent.id)).length + skillCount={hasSkillTool( + agent.agentKind === 'mode' + ? (getModeConfig(agent.id)?.enabled_tools ?? agent.defaultTools ?? []) + : (agent.defaultTools ?? []), + ) + ? getConfiguredEnabledSkillKeys(getAgentSkills(agent.id)).length : 0} - subagentCount={agent.agentKind === 'mode' && modeHasTaskTool(getModeConfig(agent.id)?.enabled_tools ?? agent.defaultTools ?? []) + subagentCount={agent.agentKind === 'mode' && hasTaskTool(getModeConfig(agent.id)?.enabled_tools ?? agent.defaultTools ?? []) ? (agent.visibleSubagentCount ?? 0) : 0} onOpenDetails={openAgentDetails} @@ -954,8 +858,12 @@ const AgentsHomeView: React.FC = () => { {selectedAgent.supportsFollowUp === false ? ( {t('agentCard.meta.singleRun')} ) : null} - {selectedAgent.agentKind === 'mode' && selectedAgentHasSkillTool ? ( - {t('agentCard.meta.skills', { count: selectedAgentRuntimeSkillCount })} + {selectedAgentHasSkillTool ? ( + {t('agentCard.meta.skills', { + count: selectedAgent.agentKind === 'mode' + ? selectedAgentRuntimeSkillCount + : selectedAgentSkills.length, + })} ) : null} {selectedAgent.agentKind === 'mode' && selectedAgentHasTaskTool ? ( {t('agentCard.meta.subagents', { count: selectedAgentManageableSubagents.filter((subagent) => subagent.effectiveEnabled).length })} @@ -1033,7 +941,7 @@ const AgentsHomeView: React.FC = () => { ); })}
      - {selectedAgent.agentKind === 'mode' ? ( + {canManageCurrentCapability ? (
      {isCurrentTabEditing ? ( <> @@ -1227,193 +1135,43 @@ const AgentsHomeView: React.FC = () => { {currentCapabilityTab === 'tools' ? ( selectedAgent.agentKind === 'mode' && toolsEditing ? ( -
      - {[...userSelectableAvailableTools] - .sort((a, b) => { - const draft = pendingTools ?? selectedAgentConfiguredTools; - const aOn = draft.includes(a.name); - const bOn = draft.includes(b.name); - if (aOn && !bOn) return -1; - if (!aOn && bOn) return 1; - return 0; - }) - .map((tool) => { - const draft = pendingTools ?? selectedAgentConfiguredTools; - const isOn = draft.includes(tool.name); - return ( - - ); - })} -
      + ) : ( -
      - {selectedAgentTools.map((tool) => ( - - {tool.replace(/_/g, ' ')} - - ))} -
      + ) ) : null} {currentCapabilityTab === 'skills' - && selectedAgent.agentKind === 'mode' && selectedAgentHasSkillTool - && selectedAgentModeSkills.length > 0 ? ( + && selectedAgentSkillConfigs.length > 0 ? ( skillsEditing ? ( -
      - {editableSkillGroups.map((group) => { - const allEnabled = group.enabledCount === group.totalCount; - const someEnabled = group.enabledCount > 0; - - return ( -
      -
      -
      - {group.label} - - {`${group.enabledCount}/${group.totalCount}`} - -
      -
      e.stopPropagation()} - > - - setPendingSkillGroupEnabled(group.skills, e.target.checked) - } - aria-label={ - allEnabled - ? t('agentsOverview.disableGroup') - : t('agentsOverview.enableGroup') - } - /> - {someEnabled && !allEnabled ? ( - - ) : null} -
      -
      -
      - {group.skills.map((skill) => { - const isOn = (pendingSkills ?? selectedAgentSkills).includes(skill.key); - const displayName = formatSkillDisplayName( - skill, - selectedAgentDuplicateSkillNames, - t, - workspaceIsRemote, - ); - const runtimeStatus = getModeSkillRuntimeStatus( - skill, - selectedAgentCoverageSourceBySkillKey, - t('agentsOverview.unknownSkillSource'), - ); - const runtimeStatusLabel = getSkillRuntimeStatusLabel( - skill, - selectedAgentCoverageSourceBySkillKey, - t, - ); - - return ( - - ); - })} -
      -
      - ); - })} -
      + ) : ( -
      - {selectedAgentSkillItems.length === 0 ? ( - - {t('agentsOverview.noSkills')} - - ) : ( - selectedAgentSkillGroups - .filter((group) => group.enabledCount > 0) - .map((group) => ( -
      -
      -
      - {group.label} - - {group.selectedCount} - -
      -
      -
      - {group.skills - .filter((skill) => skill.effectiveEnabled) - .map((skill) => { - const runtimeStatus = getModeSkillRuntimeStatus( - skill, - selectedAgentCoverageSourceBySkillKey, - t('agentsOverview.unknownSkillSource'), - ); - const runtimeStatusLabel = getSkillRuntimeStatusLabel( - skill, - selectedAgentCoverageSourceBySkillKey, - t, - ); - return ( - - {formatSkillDisplayName(skill, selectedAgentDuplicateSkillNames, t, workspaceIsRemote)} - {runtimeStatusLabel ? ( - {runtimeStatusLabel} - ) : null} - - ); - })} -
      -
      - )) - )} -
      + ) ) : null} @@ -1429,28 +1187,39 @@ const AgentsHomeView: React.FC = () => { {selectedAgentManageableSubagents.map((subagent: SubagentInfo) => { const isOn = (pendingSubagentIds ?? selectedAgentEnabledSubagentIds).includes(subagent.id); const isExternal = !isLocallyManageableSubagent(subagent); + const tooltipFields = subagentTooltipFields(subagent, t, isExternal); return ( - - + ); })}
      @@ -1461,15 +1230,23 @@ const AgentsHomeView: React.FC = () => { {t('agentsOverview.noSubagents')} ) : ( - selectedAgentEnabledSubagents.map((subagent: SubagentInfo) => ( - - {subagent.name} - - )) + selectedAgentEnabledSubagents.map((subagent: SubagentInfo) => { + const tooltipFields = subagentTooltipFields( + subagent, + t, + !isLocallyManageableSubagent(subagent), + ); + return ( + + {subagent.name} + + ); + }) )}
      ) diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.scss b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.scss new file mode 100644 index 0000000000..8c84f6d32f --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.scss @@ -0,0 +1,60 @@ +@use '../../../../component-library/styles/tokens' as tokens; + +.agent-capability-tooltip { + .bitfun-tooltip__content { + width: min(360px, calc(100vw - 16px)); + max-width: min(360px, calc(100vw - 16px)); + padding: tokens.$size-gap-2 tokens.$size-gap-3; + } + + &__title { + color: var(--color-text-primary); + font-size: var(--font-size-xs); + font-weight: var(--font-weight-semibold); + line-height: 1.4; + overflow-wrap: anywhere; + } + + &__description { + margin-top: tokens.$size-gap-1; + color: var(--color-text-secondary); + line-height: 1.45; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + + &__fields { + display: grid; + gap: tokens.$size-gap-1; + margin: tokens.$size-gap-2 0 0; + padding-top: tokens.$size-gap-2; + border-top: 1px solid var(--border-subtle); + } + + &__field { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: tokens.$size-gap-3; + align-items: baseline; + + dt { + color: var(--color-text-muted); + font-size: var(--font-size-xs); + line-height: 1.4; + } + + dd { + min-width: 0; + margin: 0; + color: var(--color-text-primary); + font-size: var(--font-size-xs); + line-height: 1.4; + overflow-wrap: anywhere; + text-align: right; + } + } + + .is-monospace { + font-family: var(--font-family-mono); + } +} diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.tsx b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.tsx new file mode 100644 index 0000000000..c6bccabf77 --- /dev/null +++ b/src/web-ui/src/app/scenes/agents/components/AgentCapabilityTooltip.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { Tooltip } from '@/component-library'; +import './AgentCapabilityTooltip.scss'; + +type TooltipPlacement = React.ComponentProps['placement']; + +export interface AgentCapabilityTooltipField { + label: string; + value: React.ReactNode; + monospace?: boolean; +} + +interface AgentCapabilityTooltipProps { + title: string; + description?: string; + fields: AgentCapabilityTooltipField[]; + children: React.ReactElement; + placement?: TooltipPlacement; + titleMonospace?: boolean; +} + +export function capabilityTooltipAriaLabel( + title: string, + description: string | undefined, + fields: AgentCapabilityTooltipField[], +): string { + const fieldText = fields.flatMap((field) => ( + typeof field.value === 'string' && field.value.trim() + ? [`${field.label}: ${field.value}`] + : [] + )); + return [title, description, ...fieldText].filter(Boolean).join('. '); +} + +export const AgentCapabilityTooltip: React.FC = ({ + title, + description, + fields, + children, + placement = 'top', + titleMonospace = false, +}) => { + const visibleFields = fields.filter((field) => field.value !== null && field.value !== undefined && field.value !== ''); + + return ( + +
      + {title} +
      + {description ?
      {description}
      : null} + {visibleFields.length > 0 ? ( +
      + {visibleFields.map((field) => ( +
      +
      {field.label}
      +
      {field.value}
      +
      + ))} +
      + ) : null} +
      + )} + placement={placement} + className="agent-capability-tooltip" + interactive + > + {children} + + ); +}; diff --git a/src/web-ui/src/app/scenes/agents/components/AgentCard.scss b/src/web-ui/src/app/scenes/agents/components/AgentCard.scss index a9aadbac4d..d2d482edb3 100644 --- a/src/web-ui/src/app/scenes/agents/components/AgentCard.scss +++ b/src/web-ui/src/app/scenes/agents/components/AgentCard.scss @@ -500,6 +500,11 @@ gap: $size-gap-1; } + &__tooltip-trigger { + display: inline-flex; + max-width: 100%; + } + &__token { display: inline-flex; align-items: center; diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss index 38e4a0dca2..8b585641a7 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.scss @@ -15,7 +15,7 @@ } .th__list-inner { - width: min(100%, 600px); + width: min(100%, 1200px); margin-inline: auto; display: flex; flex-direction: column; @@ -38,14 +38,45 @@ } .th-create-page__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: $size-gap-4; margin-bottom: $size-gap-5; } +.th-create-page__heading { + min-width: 0; +} + .th-create-page__form { display: flex; flex-direction: column; gap: $size-gap-4; - width: min(100%, 560px); + width: 100%; +} + +.th-create-page__columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + column-gap: 0; + align-items: start; +} + +.th-create-page__column { + display: flex; + flex-direction: column; + min-width: 0; + gap: $size-gap-4; + + &--definition { + padding-right: clamp($size-gap-3, 1.5vw, $size-gap-4); + border-right: 1px solid var(--border-subtle); + } + + &--capabilities { + padding-left: clamp($size-gap-3, 1.5vw, $size-gap-4); + } } .th-create-page__actions { @@ -53,9 +84,7 @@ align-items: center; justify-content: flex-end; gap: $size-gap-2; - padding-top: $size-gap-2; - border-top: 1px solid var(--border-subtle); - margin-top: $size-gap-2; + flex-shrink: 0; } .th-create-panel__field { @@ -72,6 +101,40 @@ } } +.th-create-panel__field-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: $size-gap-2; +} + +.th-create-panel__tool-edit-actions { + display: flex; + justify-content: flex-end; + gap: $size-gap-2; +} + +.th-create-panel__tool-summary { + box-sizing: border-box; + padding: $size-gap-3; + border-radius: $size-radius-base; + background: var(--element-bg-subtle); + max-height: 360px; + min-height: 96px; + overflow-y: auto; + overscroll-behavior: contain; +} + +.th-create-panel__prompt-textarea .bitfun-textarea__field { + min-height: 460px; +} + +.th-create-panel__identity-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: $size-gap-3; +} + .th-create-panel__label { font-size: var(--font-size-xs); font-weight: $font-weight-medium; @@ -185,8 +248,43 @@ } } -@container (max-width: 480px) { +@media (max-width: 960px) { + .th__list-inner { + width: min(100%, 760px); + } + + .th-create-page__columns { + grid-template-columns: minmax(0, 1fr); + gap: $size-gap-4; + } + + .th-create-page__column--definition { + padding-right: 0; + padding-bottom: $size-gap-4; + border-right: 0; + border-bottom: 1px solid var(--border-subtle); + } + + .th-create-page__column--capabilities { + padding-left: 0; + } + +} + +@media (max-width: 480px) { .th__list-body { padding: $size-gap-4; } + + .th-create-panel__identity-fields { + grid-template-columns: minmax(0, 1fr); + } + + .th-create-page__head { + flex-wrap: wrap; + } + + .th-create-page__actions { + width: 100%; + } } diff --git a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx index 9eb50bb0e9..34292c85c4 100644 --- a/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx +++ b/src/web-ui/src/app/scenes/agents/components/CreateAgentPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ArrowLeft } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import { Button, Input, Switch, Textarea, Tooltip } from '@/component-library'; @@ -18,6 +18,8 @@ import { normalizeReviewModeState, type SubagentEditorToolInfo, } from './subagentEditorUtils'; +import { ToolGroupPicker, ToolGroupSummary } from './ToolGroupPicker'; +import { useUserToolGroups } from './useUserToolGroups'; import '../AgentsView.scss'; import './CreateAgentPage.scss'; @@ -53,6 +55,8 @@ const VISIBLE_CONTEXT_SECTIONS: UserContextSection[] = [ 'workspace_instructions', 'project_layout', ]; +const TOOL_SUMMARY_MIN_HEIGHT = 96; +const TOOL_SUMMARY_MAX_HEIGHT = 360; function defaultReadonlyForKind(kind: CustomAgentKind): boolean { return kind === 'subagent'; @@ -83,6 +87,10 @@ const CreateAgentPage: React.FC = () => { const notification = useNotification(); const { hasWorkspace, workspacePath } = useCurrentWorkspace(); const { openHome, agentEditorMode, editingAgentId } = useAgentsStore(); + const { + groups: userToolGroups, + saveGroups: saveUserToolGroups, + } = useUserToolGroups(); const isEdit = agentEditorMode === 'edit' && Boolean(editingAgentId); @@ -98,12 +106,18 @@ const CreateAgentPage: React.FC = () => { const [review, setReview] = useState(false); const [toolInfos, setToolInfos] = useState([]); const [selectedTools, setSelectedTools] = useState>(new Set()); + const [toolsEditing, setToolsEditing] = useState(false); + const [pendingTools, setPendingTools] = useState | null>(null); + const [toolSummaryHeight, setToolSummaryHeight] = useState(null); const [userContextPolicy, setUserContextPolicy] = useState>( () => new Set(defaultPolicyForKind('mode')), ); const [submitting, setSubmitting] = useState(false); const [detailLoading, setDetailLoading] = useState(false); const [detailError, setDetailError] = useState(null); + const definitionColumnRef = useRef(null); + const capabilitiesColumnRef = useRef(null); + const toolSummaryRef = useRef(null); useEffect(() => { toolAPI @@ -119,6 +133,8 @@ const CreateAgentPage: React.FC = () => { name: toolName, description: typeof tool?.description === 'string' ? tool.description : '', isReadonly: Boolean(tool?.is_readonly), + needsPermissions: Boolean(tool?.needs_permissions), + dynamicInfo: tool?.dynamic_info, }; }) .filter((tool): tool is SubagentEditorToolInfo => Boolean(tool)); @@ -142,6 +158,8 @@ const CreateAgentPage: React.FC = () => { setReview(false); setUserContextPolicy(new Set(defaultPolicyForKind(kind))); setSelectedTools(defaultSelectedTools(toolInfos, kind, false)); + setToolsEditing(false); + setPendingTools(null); }, [isEdit, kind, toolInfos]); useEffect(() => { @@ -186,6 +204,8 @@ const CreateAgentPage: React.FC = () => { setReadonly(detail.readonly); setReview(detail.review); setSelectedTools(new Set(detail.tools ?? [])); + setToolsEditing(false); + setPendingTools(null); setUserContextPolicy(new Set(detail.userContextPolicy)); setAgentIdError(null); setNameError(null); @@ -229,19 +249,6 @@ const CreateAgentPage: React.FC = () => { [t], ); - const toggleTool = useCallback((toolName: string) => { - if (!isUserSelectableToolName(toolName)) return; - setSelectedTools((prev) => { - const next = new Set(prev); - if (next.has(toolName)) { - next.delete(toolName); - } else { - next.add(toolName); - } - return next; - }); - }, []); - const toggleContextSection = useCallback((section: UserContextSection) => { setUserContextPolicy((prev) => { const next = new Set(prev); @@ -265,6 +272,7 @@ const CreateAgentPage: React.FC = () => { }); setReadonly(next.readonly); setSelectedTools(next.selectedTools); + setPendingTools((current) => current ? new Set(next.selectedTools) : null); }, [readonly, selectedTools, toolInfos], ); @@ -284,6 +292,69 @@ const CreateAgentPage: React.FC = () => { () => filterToolsForReviewMode(toolInfos, kind === 'subagent' && review), [kind, review, toolInfos], ); + const selectableGroupTools = useMemo(() => selectableTools.map((tool) => ({ + name: tool.name, + description: tool.description, + is_readonly: tool.isReadonly, + needs_permissions: tool.needsPermissions, + dynamic_info: tool.dynamicInfo, + })), [selectableTools]); + const managementGroupTools = useMemo(() => toolInfos.map((tool) => ({ + name: tool.name, + description: tool.description, + is_readonly: tool.isReadonly, + needs_permissions: tool.needsPermissions, + dynamic_info: tool.dynamicInfo, + })), [toolInfos]); + + useLayoutEffect(() => { + const desktopMediaQuery = window.matchMedia('(min-width: 961px)'); + + const updateToolSummaryHeight = () => { + if (toolsEditing || !desktopMediaQuery.matches) { + setToolSummaryHeight(null); + return; + } + + const definitionColumn = definitionColumnRef.current; + const capabilitiesColumn = capabilitiesColumnRef.current; + const toolSummary = toolSummaryRef.current; + if (!definitionColumn || !capabilitiesColumn || !toolSummary) { + setToolSummaryHeight(null); + return; + } + + const availableHeight = + capabilitiesColumn.getBoundingClientRect().bottom + - toolSummary.getBoundingClientRect().top; + const nextHeight = Math.min( + TOOL_SUMMARY_MAX_HEIGHT, + Math.max(TOOL_SUMMARY_MIN_HEIGHT, Math.floor(availableHeight)), + ); + setToolSummaryHeight((currentHeight) => ( + currentHeight === nextHeight ? currentHeight : nextHeight + )); + }; + + updateToolSummaryHeight(); + const resizeObserver = typeof ResizeObserver === 'undefined' + ? null + : new ResizeObserver(updateToolSummaryHeight); + if (definitionColumnRef.current) { + resizeObserver?.observe(definitionColumnRef.current); + } + if (capabilitiesColumnRef.current) { + resizeObserver?.observe(capabilitiesColumnRef.current); + } + window.addEventListener('resize', updateToolSummaryHeight); + desktopMediaQuery.addEventListener('change', updateToolSummaryHeight); + + return () => { + resizeObserver?.disconnect(); + window.removeEventListener('resize', updateToolSummaryHeight); + desktopMediaQuery.removeEventListener('change', updateToolSummaryHeight); + }; + }, [selectableTools.length, toolsEditing]); const contextSectionLabels: Record = { workspace_context: t('agentsOverview.form.contextWorkspaceContext'), @@ -454,231 +525,300 @@ const CreateAgentPage: React.FC = () => {
      -

      {formTitle}

      -

      {formSubtitle}

      -
      - -
      -
      - -
      - {(['mode', 'subagent'] as CustomAgentKind[]).map((candidateKind) => ( - - ))} -
      -
      - -
      - - { - setAgentId(event.target.value); - setAgentIdError(validateAgentId(event.target.value)); - }} - onBlur={() => setAgentIdError(validateAgentId(agentId))} - placeholder={t('agentsOverview.form.idPlaceholder')} - inputSize="small" - error={!!agentIdError} - disabled={isEdit} - /> - {agentIdError ? ( - {agentIdError} - ) : null} -
      - -
      - - { - setName(event.target.value); - setNameError(validateName(event.target.value)); - }} - onBlur={() => setNameError(validateName(name))} - placeholder={t('agentsOverview.form.namePlaceholder')} - inputSize="small" - error={!!nameError} - /> - {nameError ? ( - {nameError} - ) : null} -
      - -
      - - setDescription(event.target.value)} - placeholder={t('agentsOverview.form.descPlaceholder')} - inputSize="small" - /> +
      +

      {formTitle}

      +

      {formSubtitle}

      - - {kind === 'subagent' ? ( -
      - -
      - {(['user', 'project'] as CustomAgentLevel[]).map((candidateLevel) => { - const disabled = - (candidateLevel === 'project' && !hasWorkspace) || isEdit; - return ( - - ); - })} -
      -
      - ) : null} - -
      -
      - - handleReadonlyChange(event.target.checked)} - size="small" - /> -
      - {kind === 'subagent' ? ( -
      - - handleReviewChange(event.target.checked)} - size="small" - /> -
      - ) : null} +
      + +
      +
      - {selectableTools.length > 0 ? ( -
      - -
      - {selectableTools.map((tool) => { - const tooltipContent = tool.description.trim() || tool.name; - return ( +
      +
      +
      +
      + +
      + {(['mode', 'subagent'] as CustomAgentKind[]).map((candidateKind) => ( - ); - })} + ))} +
      -
      - ) : null} - -
      - -
      - {VISIBLE_CONTEXT_SECTIONS.map((section) => { - const label = contextSectionLabels[section]; - const tooltipContent = contextSectionTooltips[section]; - return ( - - + ); + })} +
      +
      + ) : null} + +
      +
      + + handleReadonlyChange(event.target.checked)} + size="small" + /> +
      + {kind === 'subagent' ? ( +
      + + handleReviewChange(event.target.checked)} + size="small" + /> +
      + ) : null} +
      + + {selectableTools.length > 0 ? ( +
      +
      +
      -
      + + {toolsEditing ? ( +
      + + +
      + ) : ( + + )} +
      + {toolsEditing ? ( + setPendingTools(new Set(toolNames))} + onSaveUserGroups={saveUserToolGroups} + disabled={submitting} + testId="custom-agent-tool-groups" + /> + ) : ( +
      + +
      + )} +
      + ) : null} -
      - -