Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion src/apps/desktop/src/api/miniapp_export_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const RENDER_TIMEOUT_MS: u64 = 30_000;
const RENDER_SETTLE_MS: u64 = 900;
/// Reused hidden host — one window, navigate per slide (avoids create/close flash per page).
const EXPORT_HOST_LABEL: &str = "miniapp-slide-export-host";
const UTF8_BOM: &[u8] = b"\xEF\xBB\xBF";

#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
Expand All @@ -41,6 +42,13 @@ fn wrap_slide_html(html: &str, width: u32, height: u32) -> String {
)
}

fn utf8_html_bytes(html: &str) -> Vec<u8> {
let mut bytes = Vec::with_capacity(UTF8_BOM.len() + html.len());
bytes.extend_from_slice(UTF8_BOM);
bytes.extend_from_slice(html.as_bytes());
bytes
}

/// Write slide HTML to app cache and return a `file://` URL for the export webview.
fn file_url_for_export_html<R: tauri::Runtime>(
app: &AppHandle<R>,
Expand All @@ -54,7 +62,10 @@ fn file_url_for_export_html<R: tauri::Runtime>(
std::fs::create_dir_all(&export_dir)
.map_err(|error| format!("Failed to create export cache dir: {error}"))?;
let file_path = export_dir.join(format!("slide-{}.html", Uuid::new_v4()));
std::fs::write(&file_path, html)
// Sanitized slide documents may intentionally omit author-provided meta
// tags. The BOM makes the file encoding unambiguous before a hidden
// WebView renders it to PDF or PNG.
std::fs::write(&file_path, utf8_html_bytes(html))
.map_err(|error| format!("Failed to write export HTML: {error}"))?;
let url = tauri::Url::from_file_path(&file_path)
.map_err(|_| "Failed to build file URL for export webview".to_string())?;
Expand Down Expand Up @@ -164,3 +175,31 @@ pub async fn miniapp_render_slide_page(
other => Err(format!("Unsupported slide render format: {other}")),
}
}

#[cfg(test)]
mod tests {
use super::{utf8_html_bytes, wrap_slide_html, UTF8_BOM};

#[test]
fn export_html_bytes_are_utf8_even_when_full_document_has_no_charset_meta() {
let document = wrap_slide_html(
"<!doctype html><html><head><title>架构说明</title></head><body>中文 · café</body></html>",
1280,
720,
);
assert!(!document.to_ascii_lowercase().contains("charset="));

let bytes = utf8_html_bytes(&document);
assert!(bytes.starts_with(UTF8_BOM));
assert_eq!(
std::str::from_utf8(&bytes[UTF8_BOM.len()..]).expect("HTML should remain valid UTF-8"),
document
);
}

#[test]
fn fragment_wrapper_keeps_its_explicit_utf8_charset() {
let document = wrap_slide_html("<main>中文</main>", 1280, 720);
assert!(document.contains("<meta charset=\"UTF-8\">"));
}
}
18 changes: 8 additions & 10 deletions src/crates/contracts/product-domains/src/miniapp/builtin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ pub const BUILTIN_APPS: &[BuiltinMiniAppBundle] = &[
},
BuiltinMiniAppBundle {
id: "builtin-ppt-live",
version: 258,
version: 259,
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"),
Expand Down Expand Up @@ -549,9 +549,9 @@ mod tests {
assert_eq!(meta["version"].as_u64(), Some(u64::from(app.version)));
assert_eq!(bundle["version"].as_u64(), Some(u64::from(app.version)));
assert_eq!(meta["permissions"]["node"]["enabled"], false);
// AI permission is enabled so the UI can list models for Cowork selection
// via app.ai.getModels(); generation still goes through agent.run.
assert_eq!(meta["permissions"]["ai"]["enabled"], true);
// Model selection belongs to the host's shared ChatInput; PPT Live no
// longer needs raw AI access merely to duplicate the model catalog.
assert!(meta["permissions"].get("ai").is_none());
assert_eq!(meta["permissions"]["agent"]["enabled"], true);
assert_eq!(meta["permissions"]["agent"]["rate_limit_per_minute"], 120);
// Research happens inside hidden agent turns (WebSearch/WebFetch via
Expand Down Expand Up @@ -585,7 +585,7 @@ mod tests {
// reads the files back instead of parsing giant JSON text.
assert!(adapter_source.contains("protocol: 'files'"));
assert!(adapter_source.contains("appDataWorkspace: options.appDataWorkspace"));
assert!(adapter_source.contains("model: options.model"));
assert!(!adapter_source.contains("model: options.model"));
assert!(adapter_source.contains("displayText: options.displayText"));
assert!(app.ui_js.contains("payload?.displayText"));
assert!(app
Expand All @@ -596,8 +596,9 @@ mod tests {
let ui_source = include_str!("builtin/assets/ppt-live/ui.js");
assert!(ui_source.contains("backendUsesFileProtocol"));
assert!(ui_source.contains("tryReadDeckSlideFile"));
assert!(ui_source.contains("preferredModel"));
assert!(ui_source.contains("modelSelect"));
assert!(!ui_source.contains("preferredModel"));
assert!(!ui_source.contains("modelSelect"));
assert!(!app.html.contains("modelSelect"));
assert!(meta["permissions"]["fs"]["read"]
.as_array()
.is_some_and(|scopes| scopes.iter().any(|scope| scope == "{appdata}")));
Expand All @@ -608,9 +609,6 @@ mod tests {
assert!(
include_str!("builtin/assets/ppt-live/ui.js").contains("installBitFunBackendAdapter")
);
assert!(meta["permissions"]["ai"]["enabled"]
.as_bool()
.unwrap_or(false));
// The single cowork agent turn loads the stable ppt-design skill key.
assert!(prompt_source.contains("user::bitfun-system::ppt-design"));
let ppt_live_source = include_str!("builtin/assets/ppt-live/ui.js");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"schemaVersion": 1,
"id": "builtin-ppt-live",
"version": 258
"version": 259
}
Original file line number Diff line number Diff line change
Expand Up @@ -7031,10 +7031,6 @@ var 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",
Expand Down Expand Up @@ -7443,10 +7439,6 @@ var STRINGS = {
propertiesFont: "\u5B57\u4F53",
propertiesColorMode: "\u5E7B\u706F\u7247\u914D\u8272",
propertiesStylePreset: "\u98CE\u683C\u9884\u8BBE",
propertiesModel: "\u6A21\u578B",
modelOptionAuto: "\u81EA\u52A8\uFF08\u8DDF\u968F\u4E3B\u673A\u9ED8\u8BA4\uFF09",
modelOptionPrimary: "\u4E3B\u6A21\u578B",
modelOptionFast: "\u5FEB\u901F\u6A21\u578B",
colorModeLight: "\u6D45\u8272",
colorModeDark: "\u6DF1\u8272",
fontSansSerif: "\u975E\u886C\u7EBF",
Expand Down Expand Up @@ -7743,12 +7735,7 @@ function getAllStylePresets(locale) {
// src/state.js
var STORAGE_KEY = "pptLiveStudioStateV6";
var HISTORY_KEY = "pptLiveDeckHistoryV1";
var SCHEMA_VERSION = 6;
var DEFAULT_PREFERRED_MODEL = "primary";
function normalizePreferredModel(value) {
const raw = String(value || "").trim();
return raw || DEFAULT_PREFERRED_MODEL;
}
var SCHEMA_VERSION = 7;
var ELEMENT_TYPES = ["text", "list", "shape", "metric", "chart", "media"];
var THEME_PRESETS = {
executive: {
Expand Down Expand Up @@ -7906,7 +7893,6 @@ function createInitialState() {
runId: "",
skillKey: ""
},
preferredModel: DEFAULT_PREFERRED_MODEL,
style: defaultStyle(),
outline: [],
sources: { items: [], facts: [], warnings: [], summary: "", fetchedAt: 0 },
Expand Down Expand Up @@ -7947,7 +7933,7 @@ function ensureState(value) {
runId: String(state2.agentSession?.runId || ""),
skillKey: String(state2.agentSession?.skillKey || "")
};
state2.preferredModel = normalizePreferredModel(state2.preferredModel);
delete state2.preferredModel;
state2.style = { ...defaultStyle(), ...state2.style || {} };
delete state2.style.brandPrimary;
delete state2.style.brandAccent;
Expand Down Expand Up @@ -37466,8 +37452,7 @@ function installAgentBackend(app) {
return app.agent.ensureSession({
sessionName: "PPT Live",
sessionId: options.sessionId,
appDataWorkspace: options.appDataWorkspace,
model: options.model || void 0
appDataWorkspace: options.appDataWorkspace
});
},
async call(action, input, options = {}) {
Expand All @@ -37480,8 +37465,7 @@ function installAgentBackend(app) {
sessionName: "PPT Live",
displayText: options.displayText || input.instruction,
sessionId: options.sessionId,
appDataWorkspace: options.appDataWorkspace,
model: options.model || void 0
appDataWorkspace: options.appDataWorkspace
});
if (!result?.sessionId || !result?.turnId) {
throw new Error("PPT Live agent backend did not return sessionId/turnId");
Expand Down Expand Up @@ -38745,8 +38729,7 @@ async function ensureDeckAgentSession() {
const project = currentDeckProject() || newDeckProject();
const requestSession = async (sessionId2) => host.backend.ensureSession({
sessionId: sessionId2 || void 0,
appDataWorkspace: project.workspaceSubdir,
model: normalizePreferredModel(state.preferredModel)
appDataWorkspace: project.workspaceSubdir
});
let result;
const persistedSessionId = String(state.agentSession?.id || "");
Expand Down Expand Up @@ -38982,15 +38965,11 @@ async function executeBackendTurn(requestInput, hooks = {}, options = {}) {
const progressTracker = createGenerationProgressTracker();
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 || void 0,
appDataWorkspace: options.appDataWorkspace || void 0,
model: preferredModel,
displayText: options.displayText || requestInput.instruction
});
sessionId = result?.sessionId || null;
Expand Down Expand Up @@ -39477,7 +39456,6 @@ async function runCoworkDeckGeneration(operation, instruction, options = {}) {
runId: retrySession?.project?.runId || "",
skillKey: PPT_DESIGN_SKILL_KEY
};
state.preferredModel = normalizePreferredModel(state.preferredModel);
addGenerationEvent({ title: translate("generationParsingDeck"), detail: "", kind: "parsing" });
setGenerationStep("verify", "running", translate("generationVerifyingDeck"));
await progressivePublishChain.catch(() => {
Expand Down Expand Up @@ -40827,24 +40805,6 @@ function bindPropertyPanels() {
refreshFlatSelect(stylePresetSelect);
});
}
const modelSelect = $("modelSelect");
if (modelSelect) {
enhanceFlatSelect(modelSelect);
modelSelect.addEventListener("change", () => {
const selected = normalizePreferredModel(modelSelect.value);
if (selected === state.preferredModel) return;
state.preferredModel = selected;
refreshFlatSelect(modelSelect);
void (async () => {
await ensureDeckAgentSession();
await persist(true);
})().catch((error2) => {
runtime().log?.warn?.("PPT Live failed to prepare the updated model session", {
error: String(error2)
});
});
});
}
}
var exportPreviewIndex = 0;
function getSelectedExportFormat() {
Expand Down Expand Up @@ -41124,58 +41084,10 @@ function renderStylePresetOptions() {
if (stylePresetSelect.selectedIndex < 0) stylePresetSelect.value = DEFAULT_STYLE_PRESET;
refreshFlatSelect(stylePresetSelect);
}
function appendModelOption(select, value, label) {
const option = document.createElement("option");
option.value = value;
option.textContent = label;
select.append(option);
}
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 = "";
appendModelOption(modelSelect, "auto", translate("modelOptionAuto"));
appendModelOption(modelSelect, "primary", translate("modelOptionPrimary"));
appendModelOption(modelSelect, "fast", translate("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 (error2) {
runtime().log?.warn?.("PPT Live failed to list AI models", { error: String(error2) });
renderModelOptions([]);
}
}
function syncLocale() {
state.generation = normalizeGeneration(state.generation);
applyI18n();
renderStylePresetOptions();
renderModelOptions([]);
void loadModelOptions();
syncComposerClaim();
rerender();
}
Expand Down Expand Up @@ -41207,7 +41119,6 @@ async function init() {
syncLocale();
await ensureDeckAgentSession();
syncStylePanelFromState(state);
await loadModelOptions();
await persist(true);
} catch (error2) {
runtime().log?.error?.("PPT Live init failed", { error: String(error2) });
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,10 +111,6 @@ <h1 data-i18n="title">PPT Live</h1>
<button type="button" class="font-toggle__btn" data-color-mode="dark" data-i18n="colorModeDark">Dark</button>
</div>
</div>
<div class="property-row is-stack">
<span class="property-label" data-i18n="propertiesModel">Model</span>
<select id="modelSelect" data-i18n-aria="propertiesModel"></select>
</div>
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"ppt",
"ai"
],
"version": 258,
"version": 259,
"created_at": 0,
"updated_at": 0,
"permissions": {
Expand All @@ -31,18 +31,12 @@
"node": {
"enabled": false
},
"ai": {
"enabled": true,
"allowed_models": [],
"max_tokens_per_request": 16000,
"rate_limit_per_minute": 0
},
"agent": {
"enabled": true,
"rate_limit_per_minute": 120
}
},
"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.",
"permission_rationale": "PPT Live stores only its own deck draft, 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, uses the model selected in the host's shared floating chat, 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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ function installAgentBackend(app) {
sessionName: 'PPT Live',
sessionId: options.sessionId,
appDataWorkspace: options.appDataWorkspace,
model: options.model || undefined,
});
},
async call(action, input, options = {}) {
Expand All @@ -57,7 +56,6 @@ function installAgentBackend(app) {
displayText: options.displayText || input.instruction,
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');
Expand Down
Loading