From 7371141fe2b877e15452e4837053d91b8015346c Mon Sep 17 00:00:00 2001 From: Bob Lee Date: Thu, 6 Aug 2026 05:31:55 -0700 Subject: [PATCH] fix(ppt-live): inherit model and preserve PDF text --- .../desktop/src/api/miniapp_export_api.rs | 41 +++- .../product-domains/src/miniapp/builtin.rs | 18 +- .../builtin/assets/ppt-live/bundle.json | 2 +- .../builtin/assets/ppt-live/dist/ui.bundle.js | 99 +-------- .../builtin/assets/ppt-live/index.html | 4 - .../miniapp/builtin/assets/ppt-live/meta.json | 10 +- .../ppt-live/src/bitfun-backend-adapter.js | 2 - .../builtin/assets/ppt-live/src/i18n.js | 8 - .../builtin/assets/ppt-live/src/state.js | 14 +- .../test/generated-file-protocol.test.mjs | 27 ++- .../src/miniapp/builtin/assets/ppt-live/ui.js | 88 -------- .../flow_chat/components/ModelSelector.tsx | 12 +- .../components/ModelSelectorExternal.test.tsx | 206 +++++++++++++++++- .../flow_chat/utils/modelSelectionTarget.ts | 19 ++ 14 files changed, 313 insertions(+), 237 deletions(-) create mode 100644 src/web-ui/src/flow_chat/utils/modelSelectionTarget.ts diff --git a/src/apps/desktop/src/api/miniapp_export_api.rs b/src/apps/desktop/src/api/miniapp_export_api.rs index 2dab358524..91457c8b3e 100644 --- a/src/apps/desktop/src/api/miniapp_export_api.rs +++ b/src/apps/desktop/src/api/miniapp_export_api.rs @@ -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")] @@ -41,6 +42,13 @@ fn wrap_slide_html(html: &str, width: u32, height: u32) -> String { ) } +fn utf8_html_bytes(html: &str) -> Vec { + 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( app: &AppHandle, @@ -54,7 +62,10 @@ fn file_url_for_export_html( 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())?; @@ -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( + "架构说明中文 · café", + 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("
中文
", 1280, 720); + assert!(document.contains("")); + } +} diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin.rs b/src/crates/contracts/product-domains/src/miniapp/builtin.rs index cc7d09437a..693aec8c4d 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: 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"), @@ -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 @@ -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 @@ -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}"))); @@ -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"); 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 3d0eb3133d..ce2581b985 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": 258 + "version": 259 } 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 3eefde7b47..d7d7c672b8 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 @@ -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", @@ -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", @@ -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: { @@ -7906,7 +7893,6 @@ function createInitialState() { runId: "", skillKey: "" }, - preferredModel: DEFAULT_PREFERRED_MODEL, style: defaultStyle(), outline: [], sources: { items: [], facts: [], warnings: [], summary: "", fetchedAt: 0 }, @@ -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; @@ -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 = {}) { @@ -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"); @@ -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 || ""); @@ -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; @@ -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(() => { @@ -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() { @@ -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(); } @@ -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) }); diff --git a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/index.html b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/index.html index cc46936d2a..dd116f35aa 100644 --- a/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/index.html +++ b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/index.html @@ -111,10 +111,6 @@

PPT Live

-
- Model - -
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 3699ed6821..1cf93d3823 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": 258, + "version": 259, "created_at": 0, "updated_at": 0, "permissions": { @@ -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, 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 6717f50ed4..a072b2b783 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 @@ -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 = {}) { @@ -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'); 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 eef63e2e24..17bdfc6ef4 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 @@ -397,10 +397,6 @@ 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', @@ -809,10 +805,6 @@ 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/state.js b/src/crates/contracts/product-domains/src/miniapp/builtin/assets/ppt-live/src/state.js index 7df55d3db8..297cfc3b27 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 @@ -3,14 +3,7 @@ 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 SCHEMA_VERSION = 7; export const ELEMENT_TYPES = ['text', 'list', 'shape', 'metric', 'chart', 'media']; export const THEME_PRESETS = { @@ -189,7 +182,6 @@ export function createInitialState() { runId: '', skillKey: '', }, - preferredModel: DEFAULT_PREFERRED_MODEL, style: defaultStyle(), outline: [], sources: { items: [], facts: [], warnings: [], summary: '', fetchedAt: 0 }, @@ -231,7 +223,9 @@ export function ensureState(value) { runId: String(state.agentSession?.runId || ''), skillKey: String(state.agentSession?.skillKey || ''), }; - state.preferredModel = normalizePreferredModel(state.preferredModel); + // Model selection belongs to the host ChatInput. Remove the legacy field so + // restored decks cannot overwrite the model selected in the floating chat. + delete state.preferredModel; state.style = { ...defaultStyle(), ...(state.style || {}) }; delete state.style.brandPrimary; delete state.style.brandAccent; 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 9f628e8186..88cab8f367 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 @@ -178,7 +178,7 @@ test('prompt pins the stable skill key and workspace-relative delivery contract' assert.match(prompt, /禁止.*Read references\/style-presets/); }); -test('backend adapter ensures a topic session and forwards preferred model into agent.run options', async () => { +test('backend adapter reuses the topic session without overriding the host-selected model', async () => { const { installBitFunBackendAdapter } = await import('../src/bitfun-backend-adapter.js'); const ensureCalls = []; const calls = []; @@ -219,15 +219,36 @@ test('backend adapter ensures a topic session and forwards preferred model into sessionName: 'PPT Live', sessionId: 's1', appDataWorkspace: 'decks/demo', - model: 'fast', }]); assert.equal(calls.length, 1); - assert.equal(calls[0].model, 'fast'); + assert.equal(Object.hasOwn(calls[0], 'model'), false); assert.equal(calls[0].sessionId, 's1'); assert.equal(calls[0].appDataWorkspace, 'decks/demo'); assert.equal(calls[0].displayText, '随便做几页测试页'); }); +test('legacy model state is discarded and PPT Live no longer renders its own selector', async () => { + const html = await readFile(new URL('../index.html', import.meta.url), 'utf8'); + const previousWindow = globalThis.window; + const previousDocument = globalThis.document; + globalThis.window = { app: { locale: 'en-US' } }; + globalThis.document = { documentElement: { lang: 'en-US' } }; + + let restored; + try { + const { ensureState } = await import('../src/state.js'); + restored = ensureState({ preferredModel: 'fast' }); + } finally { + if (previousWindow === undefined) delete globalThis.window; + else globalThis.window = previousWindow; + if (previousDocument === undefined) delete globalThis.document; + else globalThis.document = previousDocument; + } + + assert.equal(Object.hasOwn(restored, 'preferredModel'), false); + assert.doesNotMatch(html, /modelSelect|propertiesModel/); +}); + test('PPT topic lifecycle eagerly creates or rebinds its dedicated session', async () => { const uiSource = await readFile(new URL('../ui.js', import.meta.url), 'utf8'); 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 38c80bdcee..af877fbb11 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 @@ -30,8 +30,6 @@ 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'; @@ -835,7 +833,6 @@ async function ensureDeckAgentSession() { const requestSession = async (sessionId) => host.backend.ensureSession({ sessionId: sessionId || undefined, appDataWorkspace: project.workspaceSubdir, - model: normalizePreferredModel(state.preferredModel), }); let result; @@ -1149,15 +1146,11 @@ 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, displayText: options.displayText || requestInput.instruction, }); sessionId = result?.sessionId || null; @@ -1731,8 +1724,6 @@ async function runCoworkDeckGeneration(operation, instruction, options = {}) { 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' }); setGenerationStep('verify', 'running', t('generationVerifyingDeck')); @@ -3437,27 +3428,6 @@ function bindPropertyPanels() { }); } - /* 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; - refreshFlatSelect(modelSelect); - void (async () => { - // Keep the topic's conversation intact; ensureSession updates the - // persisted session's model in place. - await ensureDeckAgentSession(); - await persist(true); - })().catch((error) => { - runtime().log?.warn?.('PPT Live failed to prepare the updated model session', { - error: String(error), - }); - }); - }); - } } /* ============================================ @@ -3760,67 +3730,10 @@ 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(); syncComposerClaim(); rerender(); } @@ -3859,7 +3772,6 @@ async function init() { syncLocale(); await ensureDeckAgentSession(); 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/flow_chat/components/ModelSelector.tsx b/src/web-ui/src/flow_chat/components/ModelSelector.tsx index 6016c4666d..23ac5aee3e 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelector.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelector.tsx @@ -47,6 +47,10 @@ import { getRecentReasoningPreset, setRecentReasoningPreset, } from '../utils/reasoningPresets'; +import { + shouldIncludeInternalModelSession, + shouldSyncSessionModelSelection, +} from '../utils/modelSelectionTarget'; import './ModelSelector.scss'; const log = createLogger('ModelSelector'); @@ -708,7 +712,7 @@ export const ModelSelector: React.FC = ({ const maxContextTokens = await getModelMaxTokens(modelId, currentMode); store.updateSessionMaxContextTokens(sessionId, maxContextTokens); const session = store.getState().sessions.get(sessionId); - if (session && !session.isTransient) { + if (shouldSyncSessionModelSelection(session)) { await agentAPI.updateSessionModel({ sessionId, modelName: modelId, @@ -716,7 +720,7 @@ export const ModelSelector: React.FC = ({ workspacePath: sessionProjectWorkspacePath(session), remoteConnectionId: session.remoteConnectionId, remoteSshHost: session.remoteSshHost, - includeInternal: session.sessionKind === 'subagent', + includeInternal: shouldIncludeInternalModelSession(session), }); } }; @@ -799,7 +803,7 @@ export const ModelSelector: React.FC = ({ setReasoningLoading(true); store.updateSessionReasoningPreset(sessionId, normalizedPreset); try { - if (!session.isTransient) { + if (shouldSyncSessionModelSelection(session)) { await agentAPI.updateSessionModel({ sessionId, modelName: currentNativeModelId, @@ -807,7 +811,7 @@ export const ModelSelector: React.FC = ({ workspacePath: sessionProjectWorkspacePath(session), remoteConnectionId: session.remoteConnectionId, remoteSshHost: session.remoteSshHost, - includeInternal: session.sessionKind === 'subagent', + includeInternal: shouldIncludeInternalModelSession(session), }); } if (!targetIsSubagent) { diff --git a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx index 3d56e93b84..563ecfa1f6 100644 --- a/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx +++ b/src/web-ui/src/flow_chat/components/ModelSelectorExternal.test.tsx @@ -7,6 +7,12 @@ import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ModelSelector } from './ModelSelector'; import { configManager } from '@/infrastructure/config/services/ConfigManager'; +import { agentAPI } from '@/infrastructure/api/service-api/AgentAPI'; +import { setRecentReasoningPreset } from '../utils/reasoningPresets'; +import { + shouldIncludeInternalModelSession, + shouldSyncSessionModelSelection, +} from '../utils/modelSelectionTarget'; globalThis.IS_REACT_ACT_ENVIRONMENT = true; @@ -15,6 +21,52 @@ const aiApiMocks = vi.hoisted(() => ({ onModelCatalogUpdated: vi.fn(), })); +const flowChatStoreMocks = vi.hoisted(() => { + type TestSession = { + sessionKind?: string; + isTransient?: boolean; + agentBackedTransient?: boolean; + workspacePath?: string; + projectWorkspacePath?: string; + remoteConnectionId?: string; + remoteSshHost?: string; + maxContextTokens?: number; + config: { + agentType?: string; + modelName?: string; + reasoningPreset?: string; + workspacePath?: string; + projectWorkspacePath?: string; + }; + }; + const sessions = new Map(); + const subscribers = new Set<() => void>(); + const emit = () => subscribers.forEach(callback => callback()); + const store = { + getState: () => ({ sessions }), + subscribe: vi.fn((callback: () => void) => { + subscribers.add(callback); + return () => subscribers.delete(callback); + }), + updateSessionModelName: vi.fn((sessionId: string, modelName: string) => { + const session = sessions.get(sessionId); + if (session) session.config.modelName = modelName; + emit(); + }), + updateSessionReasoningPreset: vi.fn((sessionId: string, reasoningPreset?: string) => { + const session = sessions.get(sessionId); + if (session) session.config.reasoningPreset = reasoningPreset; + emit(); + }), + updateSessionMaxContextTokens: vi.fn((sessionId: string, maxContextTokens: number) => { + const session = sessions.get(sessionId); + if (session) session.maxContextTokens = maxContextTokens; + }), + updateAcpContextUsage: vi.fn(), + }; + return { sessions, subscribers, store }; +}); + vi.mock('@/infrastructure/api/service-api/AIApi', () => ({ aiApi: aiApiMocks, })); @@ -65,6 +117,10 @@ vi.mock('@/infrastructure/api/service-api/ACPClientAPI', () => ({ }, })); +vi.mock('../services/flow-chat-manager/SessionModule', () => ({ + getModelMaxTokens: vi.fn(async () => 128_000), +})); + vi.mock('@/infrastructure/event-bus', () => ({ globalEventBus: { emit: vi.fn(), @@ -75,10 +131,7 @@ vi.mock('@/infrastructure/event-bus', () => ({ vi.mock('../store/FlowChatStore', () => ({ FlowChatStore: { - getInstance: () => ({ - getState: () => ({ sessions: new Map() }), - subscribe: () => () => undefined, - }), + getInstance: () => flowChatStoreMocks.store, }, })); @@ -89,6 +142,18 @@ describe('ModelSelector external transport reuse', () => { beforeEach(() => { catalogUpdated = undefined; + flowChatStoreMocks.sessions.clear(); + flowChatStoreMocks.subscribers.clear(); + const storage = new Map(); + vi.stubGlobal('localStorage', { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => storage.set(key, String(value)), + removeItem: (key: string) => storage.delete(key), + clear: () => storage.clear(), + key: (index: number) => [...storage.keys()][index] ?? null, + get length() { return storage.size; }, + }); + window.localStorage.clear(); aiApiMocks.getModelCatalog.mockResolvedValue({ version: 1, default_models: { primary: 'model-a' }, @@ -117,6 +182,139 @@ describe('ModelSelector external transport reuse', () => { vi.clearAllMocks(); }); + it('syncs agent-backed transient selections to their hidden runtime session', () => { + const miniAppSession = { + sessionKind: 'miniapp', + isTransient: true, + agentBackedTransient: true, + }; + + expect(shouldSyncSessionModelSelection(miniAppSession)).toBe(true); + expect(shouldIncludeInternalModelSession(miniAppSession)).toBe(true); + expect(shouldSyncSessionModelSelection({ isTransient: true })).toBe(false); + }); + + it('updates an agent-backed transient session when its model and reasoning change', async () => { + flowChatStoreMocks.sessions.set('miniapp-session', { + sessionKind: 'miniapp', + isTransient: true, + agentBackedTransient: true, + workspacePath: '/tmp/miniapp-runtime', + projectWorkspacePath: '/tmp/project', + config: { + agentType: 'agentic', + modelName: 'model-a', + reasoningPreset: 'medium', + }, + }); + vi.mocked(configManager.getConfigs).mockResolvedValueOnce({ + 'ai.models': [ + { + id: 'model-a', + name: 'Model A', + model_name: 'model-a-native', + provider: 'openai', + base_url: 'https://example.test/v1', + enabled: true, + category: 'text', + capabilities: ['text_chat'], + }, + { + id: 'model-b', + name: 'Model B', + model_name: 'model-b-native', + provider: 'openai', + base_url: 'https://example.test/v1', + enabled: true, + category: 'text', + capabilities: ['text_chat'], + }, + ], + 'ai.default_models': { primary: 'model-a' }, + 'ai.agent_model_defaults': { mode: 'model-a' }, + }); + aiApiMocks.getModelCatalog.mockResolvedValueOnce({ + version: 1, + default_models: { primary: 'model-a' }, + models: ['model-a', 'model-b'].map(id => ({ + id, + name: id, + provider: 'openai', + base_url: 'https://example.test/v1', + model_name: `${id}-native`, + enabled: true, + capabilities: ['text_chat'], + reasoning: { + status: 'known', + default_preset: 'medium', + presets: [ + { + id: 'medium', + label: 'Medium', + order: 10, + source: 'models_dev', + actions: [{ type: 'effort', value: 'medium' }], + }, + { + id: 'high', + label: 'High', + order: 20, + source: 'models_dev', + actions: [{ type: 'effort', value: 'high' }], + }, + ], + }, + })), + }); + setRecentReasoningPreset('model-b', 'medium'); + + await act(async () => { + root.render( + , + ); + await Promise.resolve(); + }); + await act(async () => { + container.querySelector('[data-testid="chat-model-selector-btn"]')?.click(); + }); + await act(async () => { + document.body.querySelector( + '[data-testid="chat-model-selector-option"][data-model-id="model-b"]', + )?.click(); + await Promise.resolve(); + }); + + expect(agentAPI.updateSessionModel).toHaveBeenLastCalledWith(expect.objectContaining({ + sessionId: 'miniapp-session', + modelName: 'model-b', + reasoningPreset: 'medium', + workspacePath: '/tmp/project', + includeInternal: true, + })); + + await act(async () => { + container.querySelector( + '[data-testid="chat-reasoning-preset-selector-btn"]', + )?.click(); + }); + await act(async () => { + document.body.querySelector('[data-preset-id="high"]')?.click(); + await Promise.resolve(); + }); + + expect(agentAPI.updateSessionModel).toHaveBeenLastCalledWith(expect.objectContaining({ + sessionId: 'miniapp-session', + modelName: 'model-b', + reasoningPreset: 'high', + workspacePath: '/tmp/project', + includeInternal: true, + })); + }); + it('reloads the local catalog when the backend reports a snapshot update', async () => { const updatedCatalog = { version: 2, diff --git a/src/web-ui/src/flow_chat/utils/modelSelectionTarget.ts b/src/web-ui/src/flow_chat/utils/modelSelectionTarget.ts new file mode 100644 index 0000000000..f6c937c259 --- /dev/null +++ b/src/web-ui/src/flow_chat/utils/modelSelectionTarget.ts @@ -0,0 +1,19 @@ +export type SessionModelSelectionTarget = { + isTransient?: boolean; + agentBackedTransient?: boolean; + sessionKind?: string; +}; + +/** Whether the visible selector has a real runtime session to update. */ +export function shouldSyncSessionModelSelection( + session: T | undefined, +): session is T { + return Boolean(session && (!session.isTransient || session.agentBackedTransient)); +} + +/** Whether restoring the target requires access to internal runtime sessions. */ +export function shouldIncludeInternalModelSession( + session: SessionModelSelectionTarget | undefined, +): boolean { + return Boolean(session?.sessionKind === 'subagent' || session?.agentBackedTransient); +}