From b98578c7a45b4ab1b7dd4f9fa1b659699a75738b Mon Sep 17 00:00:00 2001 From: coso Date: Fri, 17 Apr 2026 08:28:15 +0800 Subject: [PATCH] feat: enrich sceneapp context surfaces --- docs/exec-plans/limenext-progress.md | 15 + src-tauri/src/commands/sceneapp_cmd.rs | 3 +- src-tauri/src/sceneapp/application.rs | 53 +- src-tauri/src/sceneapp/context/compiler.rs | 473 ++++++++++++++++++ src-tauri/src/sceneapp/context/dto.rs | 65 +++ src-tauri/src/sceneapp/context/mod.rs | 3 + src-tauri/src/sceneapp/context/store.rs | 241 +++++++++ src-tauri/src/sceneapp/dto.rs | 16 + src-tauri/src/sceneapp/mod.rs | 1 + src-tauri/src/sceneapp/runtime.rs | 231 ++++++++- .../clawWorkspaceProviderSelection.test.ts | 14 + .../sceneapps/SceneAppDetailPanel.tsx | 205 ++++++++ .../sceneapps/SceneAppRunDetailPanel.tsx | 73 +++ .../sceneapps/SceneAppScorecardPanel.tsx | 31 ++ .../sceneapps/SceneAppsPage.test.tsx | 84 +++- src/components/sceneapps/SceneAppsPage.tsx | 2 + .../sceneapps/useSceneAppsPageRuntime.ts | 93 +++- src/lib/api/sceneapp.test.ts | 29 ++ src/lib/api/sceneapp.ts | 6 + src/lib/context-layer/index.ts | 1 + src/lib/context-layer/types.ts | 50 ++ src/lib/sceneapp/launch.test.ts | 33 ++ src/lib/sceneapp/launch.ts | 2 + src/lib/sceneapp/product.test.ts | 175 +++++++ src/lib/sceneapp/product.ts | 240 ++++++++- src/lib/sceneapp/types.ts | 22 + src/lib/tauri-mock/core.test.ts | 69 +++ src/lib/tauri-mock/core.ts | 456 ++++++++++++++++- 28 files changed, 2652 insertions(+), 34 deletions(-) create mode 100644 src-tauri/src/sceneapp/context/compiler.rs create mode 100644 src-tauri/src/sceneapp/context/dto.rs create mode 100644 src-tauri/src/sceneapp/context/mod.rs create mode 100644 src-tauri/src/sceneapp/context/store.rs create mode 100644 src/lib/context-layer/index.ts create mode 100644 src/lib/context-layer/types.ts diff --git a/docs/exec-plans/limenext-progress.md b/docs/exec-plans/limenext-progress.md index 067141935..39db31fd6 100644 --- a/docs/exec-plans/limenext-progress.md +++ b/docs/exec-plans/limenext-progress.md @@ -4,6 +4,21 @@ ### 已完成 +- 收口 `SceneApp / Context Layer` 这轮尾部校验,把最终阻塞从“实现错误”纠偏为“测试契约与 smoke 串联稳定性”: + - `src/components/agent/chat/utils/clawWorkspaceProviderSelection.test.ts` 已补齐 `normalizeFetchProviderModelsSource` mock,避免 provider API fallback 分支被旧测试契约静默吞掉 + - 之前 `verify:local` 首次失败里的两类红灯现已分别确认: + - `clawWorkspaceProviderSelection` 两条失败是测试 mock 落后,不是当前 provider 选择实现回退 + - Rust `media_task_cmd` 两条 image task 失败是受限环境里的端口绑定权限问题;在当前无沙箱环境下定向 `cargo test --manifest-path "src-tauri/Cargo.toml" execute_image_generation_task_should_` 已通过 + - GUI smoke 首次卡在 `smoke:agent-runtime-tool-surface-page` 的 `browser_execute_action / close_cdp_session / close_chrome_profile_session -> fetch failed`,但单独复跑该 smoke 与随后复跑整套 `verify:gui-smoke -- --reuse-running --timeout-ms 600000` 已全部通过,说明更像一次串联波动,而不是 SceneApp 主链回归 + - 当前已确认通过: + - `npm exec vitest run "src/components/agent/chat/utils/clawWorkspaceProviderSelection.test.ts"` + - `cargo test --manifest-path "src-tauri/Cargo.toml" execute_image_generation_task_should_` + - `npm run verify:gui-smoke -- --reuse-running --timeout-ms 600000` + - `npm run verify:local` + - 本轮额外沉淀: + - `SceneApp / Context Layer` 主线已经达到 Lime 当前本地可交付门槛,剩余重点不再是补兼容,而是继续把 page smoke 的串联稳定性保持住 + - 遇到 `fetch failed` 一类 GUI smoke 假红时,先拆成“单条 smoke / DevBridge 健康 / 定向 Rust 用例”三段验证,再决定是不是实现回退 + - 修掉 `SceneAppsPage` 点击“继续最近”后因同页重复导航导致的持续闪屏: - 根因不是纯样式问题,而是 `useSceneAppsPageRuntime` 在显式恢复最近参数后,`search / prefillIntent` 的 debounced 同步又拿旧值触发了一轮同页 `onNavigate("sceneapps", ...)` - 当前已改成: diff --git a/src-tauri/src/commands/sceneapp_cmd.rs b/src-tauri/src/commands/sceneapp_cmd.rs index d211b06b2..cbaa4741e 100644 --- a/src-tauri/src/commands/sceneapp_cmd.rs +++ b/src-tauri/src/commands/sceneapp_cmd.rs @@ -25,9 +25,10 @@ pub async fn sceneapp_get_descriptor(id: String) -> Result, intent: SceneAppLaunchIntent, ) -> Result { - SceneAppService::plan_launch(intent) + SceneAppService::plan_launch(db.inner(), intent) } #[tauri::command] diff --git a/src-tauri/src/sceneapp/application.rs b/src-tauri/src/sceneapp/application.rs index 7d4265753..b8e005978 100644 --- a/src-tauri/src/sceneapp/application.rs +++ b/src-tauri/src/sceneapp/application.rs @@ -5,6 +5,10 @@ use super::adapters::{ extract_sceneapp_id_from_run_metadata, prepare_sceneapp_run_governance_artifact, }; use super::catalog::{get_sceneapp_descriptor, seeded_sceneapp_catalog}; +use super::context::store::{ + build_persisted_sceneapp_context, load_persisted_sceneapp_context, + save_persisted_sceneapp_context, +}; use super::dto::*; use super::governance::build_sceneapp_scorecard_from_runs; use super::runtime::build_launch_plan; @@ -35,10 +39,51 @@ impl SceneAppService { get_sceneapp_descriptor(id) } - pub fn plan_launch(intent: SceneAppLaunchIntent) -> Result { - let descriptor = get_sceneapp_descriptor(intent.sceneapp_id.as_str()) - .ok_or_else(|| format!("未找到 SceneApp: {}", intent.sceneapp_id))?; - Ok(build_launch_plan(descriptor, intent)) + pub fn plan_launch( + db: &DbConnection, + intent: SceneAppLaunchIntent, + ) -> Result { + let sceneapp_id = intent.sceneapp_id.clone(); + let workspace_id = intent.workspace_id.clone(); + let project_id = intent.project_id.clone(); + let descriptor = get_sceneapp_descriptor(sceneapp_id.as_str()) + .ok_or_else(|| format!("未找到 SceneApp: {sceneapp_id}"))?; + + let persisted_context = match load_persisted_sceneapp_context( + db, + sceneapp_id.as_str(), + workspace_id.as_deref(), + project_id.as_deref(), + ) { + Ok(context) => context, + Err(error) => { + let mut result = build_launch_plan(descriptor, intent, None); + result.plan.warnings.push(format!( + "读取项目级 Context Snapshot 失败,本次先按最新输入继续 planning:{error}" + )); + return Ok(result); + } + }; + + let mut result = build_launch_plan(descriptor, intent, persisted_context.as_ref()); + let Some(context_overlay) = result.context_overlay.as_ref() else { + return Ok(result); + }; + let persisted_context = + build_persisted_sceneapp_context(sceneapp_id.as_str(), &context_overlay.snapshot); + + match save_persisted_sceneapp_context(db, &persisted_context) { + Ok(Some(_)) => {} + Ok(None) => result + .plan + .warnings + .push("当前未解析到项目目录,暂未写入项目级 Context Snapshot。".to_string()), + Err(error) => result.plan.warnings.push(format!( + "写入项目级 Context Snapshot 失败,本次 planning 结果仍已返回:{error}" + )), + } + + Ok(result) } fn seeded_runs() -> Vec { diff --git a/src-tauri/src/sceneapp/context/compiler.rs b/src-tauri/src/sceneapp/context/compiler.rs new file mode 100644 index 000000000..eddbb74dc --- /dev/null +++ b/src-tauri/src/sceneapp/context/compiler.rs @@ -0,0 +1,473 @@ +use super::dto::{ + ContextCompilerPlan, ContextLayerSnapshot, ContextLayerSourceKind, ReferenceItem, + SceneAppContextOverlay, TasteProfile, +}; +use super::store::PersistedSceneAppContext; +use crate::sceneapp::dto::{SceneAppDescriptor, SceneAppLaunchIntent, SceneAppPattern}; +use std::collections::{hash_map::DefaultHasher, BTreeSet}; +use std::hash::{Hash, Hasher}; + +fn push_unique(values: &mut Vec, value: Option) { + let Some(value) = value.map(|item| item.trim().to_string()) else { + return; + }; + if value.is_empty() || values.iter().any(|item| item == &value) { + return; + } + values.push(value); +} + +fn truncate_summary(value: &str, max_chars: usize) -> String { + let trimmed = value.trim(); + if trimmed.chars().count() <= max_chars { + return trimmed.to_string(); + } + let mut summary = trimmed.chars().take(max_chars).collect::(); + summary.push('…'); + summary +} + +fn extract_url_candidate(text: &str) -> Option { + text.split_whitespace() + .find(|segment| segment.starts_with("http://") || segment.starts_with("https://")) + .map(|segment| { + segment.trim_end_matches(|char: char| { + matches!( + char, + '"' | '\'' | ')' | ']' | '}' | ',' | '.' | '>' | ',' | '。' | ')' + ) + }) + }) + .map(str::to_string) + .filter(|segment| !segment.is_empty()) +} + +fn stable_reference_item_id(prefix: &str, key: &str, value: &str) -> String { + let mut hasher = DefaultHasher::new(); + prefix.hash(&mut hasher); + key.hash(&mut hasher); + value.trim().hash(&mut hasher); + format!("{prefix}-{key}-{:x}", hasher.finish()) +} + +fn build_reference_items(intent: &SceneAppLaunchIntent) -> Vec { + let mut items = Vec::new(); + + if let Some(user_input) = intent.user_input.as_deref().map(str::trim) { + if !user_input.is_empty() { + items.push(ReferenceItem { + id: stable_reference_item_id("user-input", "input", user_input), + label: "用户输入".to_string(), + source_kind: ContextLayerSourceKind::UserInput, + content_type: if extract_url_candidate(user_input).is_some() { + "url".to_string() + } else { + "text".to_string() + }, + uri: extract_url_candidate(user_input), + summary: Some(truncate_summary(user_input, 80)), + selected: true, + }); + } + } + + items.extend(intent.slots.iter().filter_map(|(key, value)| { + let normalized = value.trim(); + if normalized.is_empty() { + return None; + } + Some(ReferenceItem { + id: stable_reference_item_id("slot", key.as_str(), normalized), + label: key.clone(), + source_kind: ContextLayerSourceKind::Slot, + content_type: if extract_url_candidate(normalized).is_some() { + "url".to_string() + } else { + "slot".to_string() + }, + uri: extract_url_candidate(normalized), + summary: Some(truncate_summary(normalized, 80)), + selected: true, + }) + })); + + items +} + +fn merge_reference_items( + input_items: Vec, + persisted_context: Option<&PersistedSceneAppContext>, +) -> Vec { + let mut seen = BTreeSet::new(); + let mut merged = Vec::new(); + + for item in input_items { + if seen.insert(item.id.clone()) { + merged.push(item); + } + } + + if let Some(context) = persisted_context { + for item in context.reference_items.iter() { + if !seen.insert(item.id.clone()) { + continue; + } + let mut restored = item.clone(); + restored.source_kind = ContextLayerSourceKind::ReferenceLibrary; + merged.push(restored); + } + } + + merged +} + +fn build_memory_refs(intent: &SceneAppLaunchIntent) -> Vec { + let mut refs = Vec::new(); + + push_unique( + &mut refs, + intent + .workspace_id + .as_ref() + .map(|workspace_id| format!("workspace:{workspace_id}")), + ); + push_unique( + &mut refs, + intent + .project_id + .as_ref() + .map(|project_id| format!("project:{project_id}")), + ); + if intent + .user_input + .as_deref() + .is_some_and(|value| !value.trim().is_empty()) + { + push_unique(&mut refs, Some("memory_profile:user_input".to_string())); + } + + refs +} + +fn build_tool_refs(descriptor: &SceneAppDescriptor, intent: &SceneAppLaunchIntent) -> Vec { + let mut refs = descriptor.capability_refs.clone(); + + if let Some(runtime_context) = intent.runtime_context.as_ref() { + if runtime_context.browser_session_attached { + push_unique(&mut refs, Some("browser_session".to_string())); + } + if runtime_context.cloud_session_ready { + push_unique(&mut refs, Some("cloud_session".to_string())); + } + if runtime_context.automation_enabled { + push_unique(&mut refs, Some("automation".to_string())); + } + } + + refs +} + +fn build_skill_refs(descriptor: &SceneAppDescriptor) -> Vec { + let mut refs = Vec::new(); + + push_unique(&mut refs, Some(descriptor.id.clone())); + push_unique(&mut refs, descriptor.linked_service_skill_id.clone()); + push_unique(&mut refs, descriptor.linked_scene_key.clone()); + push_unique( + &mut refs, + descriptor + .composition_profile + .as_ref() + .and_then(|profile| profile.blueprint_ref.clone()), + ); + + refs +} + +fn build_taste_profile( + descriptor: &SceneAppDescriptor, + intent: &SceneAppLaunchIntent, + reference_items: &[ReferenceItem], + persisted_context: Option<&PersistedSceneAppContext>, +) -> Option { + let has_user_input = intent + .user_input + .as_deref() + .is_some_and(|value| !value.trim().is_empty()); + let persisted_taste_profile = + persisted_context.and_then(|context| context.taste_profile.as_ref()); + if reference_items.is_empty() && !has_user_input { + return persisted_taste_profile.cloned(); + } + + let mut keywords = Vec::new(); + if let Some(profile) = persisted_taste_profile { + for keyword in profile.keywords.iter() { + push_unique(&mut keywords, Some(keyword.clone())); + } + } + for alias in descriptor.aliases.iter().take(2) { + push_unique(&mut keywords, Some(alias.clone())); + } + for (key, value) in intent.slots.iter() { + let normalized_key = key.trim().to_lowercase(); + if !matches!( + normalized_key.as_str(), + "style" | "tone" | "mood" | "platform" | "target_language" | "duration" + ) { + continue; + } + push_unique( + &mut keywords, + Some(format!("{key}:{}", truncate_summary(value, 24))), + ); + } + if matches!(descriptor.pattern_primary, SceneAppPattern::Reviewer) { + push_unique(&mut keywords, Some("review-first".to_string())); + } + push_unique(&mut keywords, Some(descriptor.output_hint.clone())); + + let mut avoid_keywords = Vec::new(); + if let Some(profile) = persisted_taste_profile { + for keyword in profile.avoid_keywords.iter() { + push_unique(&mut avoid_keywords, Some(keyword.clone())); + } + } + if !reference_items.is_empty() { + push_unique(&mut avoid_keywords, Some("偏离参考素材".to_string())); + } + + Some(TasteProfile { + profile_id: format!("taste-{}", descriptor.id), + summary: if persisted_taste_profile.is_some() && !reference_items.is_empty() { + format!( + "当前 TasteProfile 已在项目沉淀基础上,结合 {} 条参考输入更新启发式摘要。", + reference_items.len() + ) + } else if persisted_taste_profile.is_some() { + "当前 TasteProfile 已从项目上下文恢复。".to_string() + } else if reference_items.is_empty() { + "当前 TasteProfile 先基于用户输入与场景画像生成启发式摘要。".to_string() + } else { + format!( + "当前 TasteProfile 先基于 {} 条参考输入与场景画像生成启发式摘要。", + reference_items.len() + ) + }, + keywords, + avoid_keywords, + derived_from_reference_ids: reference_items.iter().map(|item| item.id.clone()).collect(), + confidence: Some( + if persisted_taste_profile.is_some() && !reference_items.is_empty() { + 0.72 + } else if persisted_taste_profile.is_some() { + 0.64 + } else if reference_items.is_empty() { + 0.38 + } else { + 0.56 + }, + ), + }) +} + +pub fn build_sceneapp_context_overlay( + descriptor: &SceneAppDescriptor, + intent: &SceneAppLaunchIntent, + persisted_context: Option<&PersistedSceneAppContext>, +) -> SceneAppContextOverlay { + let input_reference_items = build_reference_items(intent); + let reference_items = merge_reference_items(input_reference_items.clone(), persisted_context); + let memory_refs = build_memory_refs(intent); + let tool_refs = build_tool_refs(descriptor, intent); + let skill_refs = build_skill_refs(descriptor); + let taste_profile = + build_taste_profile(descriptor, intent, &reference_items, persisted_context); + + let mut active_layers = vec!["skill".to_string(), "tool".to_string()]; + if !memory_refs.is_empty() { + active_layers.push("memory".to_string()); + } + if !reference_items.is_empty() { + active_layers.push("reference".to_string()); + } + if taste_profile.is_some() { + active_layers.push("taste".to_string()); + } + + let mut notes = Vec::new(); + if !memory_refs.is_empty() { + notes.push(format!("已装配 {} 条 memory 引用。", memory_refs.len())); + } + let restored_reference_count = persisted_context + .map(|context| context.reference_items.len()) + .unwrap_or(0); + if restored_reference_count > 0 { + notes.push(format!( + "已从项目上下文恢复 {} 条历史参考。", + restored_reference_count + )); + } + if input_reference_items.is_empty() && reference_items.is_empty() { + notes.push("当前尚未选中显式参考素材,将主要依赖用户输入与场景画像。".to_string()); + } else if !input_reference_items.is_empty() { + notes.push(format!( + "本次新增 {} 条参考输入,当前 planning 共带上 {} 条参考。", + input_reference_items.len(), + reference_items.len() + )); + } else if !reference_items.is_empty() { + notes.push(format!( + "当前 planning 直接复用了 {} 条项目级参考。", + reference_items.len() + )); + } + if persisted_context + .and_then(|context| context.taste_profile.as_ref()) + .is_some() + { + notes.push("当前已复用项目级 TasteProfile,并按最新输入继续更新。".to_string()); + } else if taste_profile.is_some() { + notes.push("当前 TasteProfile 为启发式摘要,后续可继续接入持久化反馈回写。".to_string()); + } + + SceneAppContextOverlay { + compiler_plan: ContextCompilerPlan { + active_layers, + memory_refs: memory_refs.clone(), + tool_refs: tool_refs.clone(), + reference_count: reference_items.len(), + notes, + }, + snapshot: ContextLayerSnapshot { + workspace_id: intent.workspace_id.clone(), + project_id: intent.project_id.clone(), + skill_refs, + memory_refs, + tool_refs, + reference_items, + taste_profile, + }, + } +} + +#[cfg(test)] +mod tests { + use super::build_sceneapp_context_overlay; + use crate::sceneapp::catalog::get_sceneapp_descriptor; + use crate::sceneapp::context::dto::{ContextLayerSourceKind, ReferenceItem, TasteProfile}; + use crate::sceneapp::context::store::PersistedSceneAppContext; + use crate::sceneapp::dto::{SceneAppLaunchIntent, SceneAppRuntimeContext}; + use std::collections::BTreeMap; + + #[test] + fn should_build_reference_and_taste_overlay_for_sceneapp_launch() { + let descriptor = get_sceneapp_descriptor("x-article-export") + .expect("x-article-export descriptor should exist"); + let mut slots = BTreeMap::new(); + slots.insert( + "article_url".to_string(), + "https://x.com/openai/article/123".to_string(), + ); + slots.insert("target_language".to_string(), "中文".to_string()); + + let overlay = build_sceneapp_context_overlay( + &descriptor, + &SceneAppLaunchIntent { + sceneapp_id: "x-article-export".to_string(), + entry_source: Some("sceneapp_card".to_string()), + workspace_id: Some("workspace-default".to_string()), + project_id: Some("project-export".to_string()), + user_input: Some("请导出这篇文章并保持原始语气".to_string()), + slots, + runtime_context: Some(SceneAppRuntimeContext { + browser_session_attached: true, + ..SceneAppRuntimeContext::default() + }), + }, + None, + ); + + assert!(overlay + .compiler_plan + .active_layers + .contains(&"reference".to_string())); + assert!(overlay + .compiler_plan + .active_layers + .contains(&"taste".to_string())); + assert_eq!(overlay.compiler_plan.reference_count, 3); + assert!(overlay + .snapshot + .tool_refs + .contains(&"browser_session".to_string())); + assert!(overlay.snapshot.taste_profile.is_some()); + } + + #[test] + fn should_merge_persisted_sceneapp_context_into_overlay() { + let descriptor = get_sceneapp_descriptor("story-video-suite") + .expect("story-video-suite descriptor should exist"); + let mut slots = BTreeMap::new(); + slots.insert("style".to_string(), "科技感、快节奏".to_string()); + let persisted_context = PersistedSceneAppContext { + sceneapp_id: "story-video-suite".to_string(), + workspace_id: Some("workspace-default".to_string()), + project_id: Some("project-video".to_string()), + reference_items: vec![ReferenceItem { + id: "saved-reference-1".to_string(), + label: "竞品拆解".to_string(), + source_kind: ContextLayerSourceKind::ReferenceLibrary, + content_type: "text".to_string(), + uri: None, + summary: Some("保留结论前置和对比镜头。".to_string()), + selected: true, + }], + taste_profile: Some(TasteProfile { + profile_id: "taste-story-video-suite".to_string(), + summary: "偏好快节奏科技感表达。".to_string(), + keywords: vec!["快节奏".to_string()], + avoid_keywords: vec!["冗长铺垫".to_string()], + derived_from_reference_ids: vec!["saved-reference-1".to_string()], + confidence: Some(0.66), + }), + }; + + let overlay = build_sceneapp_context_overlay( + &descriptor, + &SceneAppLaunchIntent { + sceneapp_id: "story-video-suite".to_string(), + entry_source: Some("sceneapp_detail_preview".to_string()), + workspace_id: Some("workspace-default".to_string()), + project_id: Some("project-video".to_string()), + user_input: Some("做一个 30 秒新品短视频".to_string()), + slots, + runtime_context: None, + }, + Some(&persisted_context), + ); + + assert_eq!(overlay.compiler_plan.reference_count, 3); + assert!(overlay + .compiler_plan + .notes + .iter() + .any(|note| note.contains("恢复 1 条历史参考"))); + assert!(overlay + .compiler_plan + .notes + .iter() + .any(|note| note.contains("复用项目级 TasteProfile"))); + assert!(overlay + .snapshot + .reference_items + .iter() + .any(|item| item.label == "竞品拆解" + && matches!(item.source_kind, ContextLayerSourceKind::ReferenceLibrary))); + assert!(overlay + .snapshot + .taste_profile + .as_ref() + .is_some_and(|profile| profile.summary.contains("项目沉淀基础"))); + } +} diff --git a/src-tauri/src/sceneapp/context/dto.rs b/src-tauri/src/sceneapp/context/dto.rs new file mode 100644 index 000000000..03c53cc1c --- /dev/null +++ b/src-tauri/src/sceneapp/context/dto.rs @@ -0,0 +1,65 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ContextLayerSourceKind { + UserInput, + Slot, + Project, + Workspace, + MemoryProfile, + ReferenceLibrary, + ToolReadiness, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReferenceItem { + pub id: String, + pub label: String, + pub source_kind: ContextLayerSourceKind, + pub content_type: String, + pub uri: Option, + pub summary: Option, + pub selected: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct TasteProfile { + pub profile_id: String, + pub summary: String, + pub keywords: Vec, + pub avoid_keywords: Vec, + pub derived_from_reference_ids: Vec, + pub confidence: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ContextCompilerPlan { + pub active_layers: Vec, + pub memory_refs: Vec, + pub tool_refs: Vec, + pub reference_count: usize, + pub notes: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct ContextLayerSnapshot { + pub workspace_id: Option, + pub project_id: Option, + pub skill_refs: Vec, + pub memory_refs: Vec, + pub tool_refs: Vec, + pub reference_items: Vec, + pub taste_profile: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SceneAppContextOverlay { + pub compiler_plan: ContextCompilerPlan, + pub snapshot: ContextLayerSnapshot, +} diff --git a/src-tauri/src/sceneapp/context/mod.rs b/src-tauri/src/sceneapp/context/mod.rs new file mode 100644 index 000000000..1364267df --- /dev/null +++ b/src-tauri/src/sceneapp/context/mod.rs @@ -0,0 +1,3 @@ +pub mod compiler; +pub mod dto; +pub mod store; diff --git a/src-tauri/src/sceneapp/context/store.rs b/src-tauri/src/sceneapp/context/store.rs new file mode 100644 index 000000000..a448562ae --- /dev/null +++ b/src-tauri/src/sceneapp/context/store.rs @@ -0,0 +1,241 @@ +use super::dto::{ContextLayerSnapshot, ReferenceItem, TasteProfile}; +use crate::database::DbConnection; +use crate::workspace::WorkspaceManager; +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::PathBuf; + +const SCENEAPP_CONTEXT_RELATIVE_ROOT: &str = ".lime/sceneapp/context"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PersistedSceneAppContext { + pub sceneapp_id: String, + pub workspace_id: Option, + pub project_id: Option, + #[serde(default)] + pub reference_items: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub taste_profile: Option, +} + +fn normalize_optional_id(value: Option<&str>) -> Option { + value + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn sanitize_sceneapp_file_stem(sceneapp_id: &str) -> String { + let mut sanitized = String::new(); + let mut last_was_dash = false; + + for ch in sceneapp_id.trim().chars() { + let normalized = if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_') { + ch + } else { + '-' + }; + if normalized == '-' { + if last_was_dash { + continue; + } + last_was_dash = true; + } else { + last_was_dash = false; + } + sanitized.push(normalized); + } + + let trimmed = sanitized.trim_matches('-'); + if trimmed.is_empty() { + "sceneapp-context".to_string() + } else { + trimmed.to_string() + } +} + +fn resolve_workspace_root(db: &DbConnection, workspace_id: &str) -> Option { + WorkspaceManager::new(db.clone()) + .get(&workspace_id.to_string()) + .ok() + .flatten() + .map(|workspace| workspace.root_path) +} + +pub fn resolve_sceneapp_context_root( + db: &DbConnection, + workspace_id: Option<&str>, + project_id: Option<&str>, +) -> Option { + normalize_optional_id(project_id) + .and_then(|workspace_id| resolve_workspace_root(db, workspace_id.as_str())) + .or_else(|| { + normalize_optional_id(workspace_id) + .and_then(|workspace_id| resolve_workspace_root(db, workspace_id.as_str())) + }) +} + +pub fn resolve_sceneapp_context_path( + db: &DbConnection, + sceneapp_id: &str, + workspace_id: Option<&str>, + project_id: Option<&str>, +) -> Option { + let root = resolve_sceneapp_context_root(db, workspace_id, project_id)?; + let file_name = format!("{}.json", sanitize_sceneapp_file_stem(sceneapp_id)); + Some(root.join(SCENEAPP_CONTEXT_RELATIVE_ROOT).join(file_name)) +} + +pub fn build_persisted_sceneapp_context( + sceneapp_id: &str, + snapshot: &ContextLayerSnapshot, +) -> PersistedSceneAppContext { + PersistedSceneAppContext { + sceneapp_id: sceneapp_id.trim().to_string(), + workspace_id: snapshot.workspace_id.clone(), + project_id: snapshot.project_id.clone(), + reference_items: snapshot.reference_items.clone(), + taste_profile: snapshot.taste_profile.clone(), + } +} + +pub fn load_persisted_sceneapp_context( + db: &DbConnection, + sceneapp_id: &str, + workspace_id: Option<&str>, + project_id: Option<&str>, +) -> Result, String> { + let Some(path) = resolve_sceneapp_context_path(db, sceneapp_id, workspace_id, project_id) + else { + return Ok(None); + }; + if !path.exists() { + return Ok(None); + } + + let raw = fs::read_to_string(&path).map_err(|error| { + format!( + "读取 SceneApp Context Snapshot 失败({}): {error}", + path.display() + ) + })?; + let document = + serde_json::from_str::(raw.as_str()).map_err(|error| { + format!( + "解析 SceneApp Context Snapshot 失败({}): {error}", + path.display() + ) + })?; + Ok(Some(document)) +} + +pub fn save_persisted_sceneapp_context( + db: &DbConnection, + context: &PersistedSceneAppContext, +) -> Result, String> { + let Some(path) = resolve_sceneapp_context_path( + db, + context.sceneapp_id.as_str(), + context.workspace_id.as_deref(), + context.project_id.as_deref(), + ) else { + return Ok(None); + }; + + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).map_err(|error| { + format!( + "创建 SceneApp Context Snapshot 目录失败({}): {error}", + parent.display() + ) + })?; + } + + let serialized = serde_json::to_string_pretty(context) + .map_err(|error| format!("序列化 SceneApp Context Snapshot 失败: {error}"))?; + fs::write(&path, serialized.as_bytes()).map_err(|error| { + format!( + "写入 SceneApp Context Snapshot 失败({}): {error}", + path.display() + ) + })?; + + Ok(Some(path)) +} + +#[cfg(test)] +mod tests { + use super::{ + build_persisted_sceneapp_context, load_persisted_sceneapp_context, + save_persisted_sceneapp_context, + }; + use crate::database::schema::create_tables; + use crate::database::DbConnection; + use crate::workspace::{WorkspaceManager, WorkspaceType}; + use rusqlite::Connection; + use std::sync::{Arc, Mutex}; + use tempfile::tempdir; + + fn setup_test_db() -> DbConnection { + let conn = Connection::open_in_memory().expect("创建内存数据库失败"); + create_tables(&conn).expect("初始化表结构失败"); + Arc::new(Mutex::new(conn)) + } + + #[test] + fn should_save_and_load_sceneapp_context_snapshot_under_workspace_root() { + let db = setup_test_db(); + let temp_dir = tempdir().expect("创建临时目录失败"); + let workspace_root = temp_dir.path().join("sceneapp-context-workspace"); + let workspace = WorkspaceManager::new(db.clone()) + .create_with_type( + "SceneApp Context".to_string(), + workspace_root.clone(), + WorkspaceType::General, + ) + .expect("创建 workspace 失败"); + + let snapshot = crate::sceneapp::context::dto::ContextLayerSnapshot { + workspace_id: Some(workspace.id.clone()), + project_id: Some(workspace.id.clone()), + skill_refs: vec!["story-video-suite".to_string()], + memory_refs: vec![format!("workspace:{}", workspace.id)], + tool_refs: vec!["workspace_storage".to_string()], + reference_items: vec![crate::sceneapp::context::dto::ReferenceItem { + id: "slot-style-a1b2".to_string(), + label: "style".to_string(), + source_kind: + crate::sceneapp::context::dto::ContextLayerSourceKind::ReferenceLibrary, + content_type: "slot".to_string(), + uri: None, + summary: Some("科技感,快节奏".to_string()), + selected: true, + }], + taste_profile: Some(crate::sceneapp::context::dto::TasteProfile { + profile_id: "taste-story-video-suite".to_string(), + summary: "偏好快节奏科技感表达。".to_string(), + keywords: vec!["快节奏".to_string(), "科技感".to_string()], + avoid_keywords: vec!["冗长铺垫".to_string()], + derived_from_reference_ids: vec!["slot-style-a1b2".to_string()], + confidence: Some(0.72), + }), + }; + let persisted = build_persisted_sceneapp_context("story-video-suite", &snapshot); + + let path = save_persisted_sceneapp_context(&db, &persisted) + .expect("写入 SceneApp Context Snapshot 失败") + .expect("应返回写入路径"); + assert!(path.exists()); + + let loaded = load_persisted_sceneapp_context( + &db, + "story-video-suite", + Some(workspace.id.as_str()), + Some(workspace.id.as_str()), + ) + .expect("读取 SceneApp Context Snapshot 失败"); + + assert_eq!(loaded, Some(persisted)); + } +} diff --git a/src-tauri/src/sceneapp/dto.rs b/src-tauri/src/sceneapp/dto.rs index 130012a29..6bc0eecd8 100644 --- a/src-tauri/src/sceneapp/dto.rs +++ b/src-tauri/src/sceneapp/dto.rs @@ -1,3 +1,4 @@ +use super::context::dto::SceneAppContextOverlay; use lime_core::config::{AutomationExecutionMode, DeliveryConfig, TaskSchedule}; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -230,12 +231,27 @@ pub struct SceneAppReadiness { pub unmet_requirements: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SceneAppProjectPackPlan { + pub pack_kind: SceneAppDeliveryContract, + pub primary_part: Option, + pub required_parts: Vec, + pub viewer_kind: Option, + pub completion_strategy: String, + pub notes: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SceneAppPlanResult { pub descriptor: SceneAppDescriptor, pub readiness: SceneAppReadiness, pub plan: SceneAppExecutionPlan, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub context_overlay: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub project_pack_plan: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/sceneapp/mod.rs b/src-tauri/src/sceneapp/mod.rs index 875c05276..3b2c77b7e 100644 --- a/src-tauri/src/sceneapp/mod.rs +++ b/src-tauri/src/sceneapp/mod.rs @@ -1,6 +1,7 @@ pub mod adapters; pub mod application; pub mod catalog; +pub mod context; pub mod dto; pub mod governance; pub mod runtime; diff --git a/src-tauri/src/sceneapp/runtime.rs b/src-tauri/src/sceneapp/runtime.rs index c018b0f9c..54b9493d3 100644 --- a/src-tauri/src/sceneapp/runtime.rs +++ b/src-tauri/src/sceneapp/runtime.rs @@ -1,5 +1,9 @@ use super::adapters::build_sceneapp_runtime_adapter_plan; +use super::context::compiler::build_sceneapp_context_overlay; +use super::context::dto::SceneAppContextOverlay; +use super::context::store::PersistedSceneAppContext; use super::dto::*; +use std::collections::BTreeSet; fn pattern_title(pattern: &SceneAppPattern) -> &'static str { match pattern { @@ -107,10 +111,134 @@ fn build_step_plan(descriptor: &SceneAppDescriptor) -> Vec(values: I) -> Vec +where + I: IntoIterator, +{ + let mut seen = BTreeSet::new(); + values + .into_iter() + .filter_map(|value| { + let normalized = value.trim().to_string(); + if normalized.is_empty() || !seen.insert(normalized.clone()) { + return None; + } + Some(normalized) + }) + .collect() +} + +fn build_project_pack_required_parts(descriptor: &SceneAppDescriptor) -> Vec { + let declared_parts = descriptor + .delivery_profile + .as_ref() + .map(|profile| profile.required_parts.clone()) + .unwrap_or_default(); + if !declared_parts.is_empty() { + return dedupe_strings(declared_parts); + } + + descriptor + .composition_profile + .as_ref() + .map(|profile| { + dedupe_strings( + profile + .steps + .iter() + .map(|step| step.id.clone()) + .collect::>(), + ) + }) + .unwrap_or_default() +} + +fn build_project_pack_completion_strategy( + descriptor: &SceneAppDescriptor, + required_parts: &[String], +) -> String { + if !required_parts.is_empty() { + return "required_parts_complete".to_string(); + } + if descriptor + .infra_profile + .iter() + .any(|item| item == "workspace_storage" || item == "artifact_bundle") + { + return "workspace_artifact_writeback".to_string(); + } + "artifact_writeback".to_string() +} + +fn build_project_pack_plan( + descriptor: &SceneAppDescriptor, + intent: &SceneAppLaunchIntent, + context_overlay: &SceneAppContextOverlay, +) -> SceneAppProjectPackPlan { + let required_parts = build_project_pack_required_parts(descriptor); + let viewer_kind = descriptor + .delivery_profile + .as_ref() + .and_then(|profile| profile.viewer_kind.clone()); + let primary_part = descriptor + .delivery_profile + .as_ref() + .and_then(|profile| profile.primary_part.clone()) + .or_else(|| required_parts.first().cloned()); + let completion_strategy = + build_project_pack_completion_strategy(descriptor, required_parts.as_slice()); + let mut notes = Vec::new(); + + if matches!( + descriptor.delivery_contract, + SceneAppDeliveryContract::ProjectPack + ) { + notes.push("当前 SceneApp 以结果包作为默认交付单位。".to_string()); + } + if !required_parts.is_empty() { + notes.push(format!( + "完整度将按 {} 个必含部件判断。", + required_parts.len() + )); + } + if let Some(project_id) = intent + .project_id + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + { + notes.push(format!( + "结果会优先回写到项目 {project_id},便于继续编辑与复盘。" + )); + } else { + notes.push("当前还没有绑定项目,结果包只能先按运行时结果临时回流。".to_string()); + } + if context_overlay.compiler_plan.reference_count > 0 + || context_overlay.snapshot.taste_profile.is_some() + { + notes.push("结果包会连同参考与风格快照一起进入后续治理链。".to_string()); + } + if viewer_kind.is_some() { + notes.push("结果包可继续复用当前 viewer 主链打开与检查。".to_string()); + } + + SceneAppProjectPackPlan { + pack_kind: descriptor.delivery_contract.clone(), + primary_part, + required_parts, + viewer_kind, + completion_strategy, + notes, + } +} + pub fn build_launch_plan( descriptor: SceneAppDescriptor, intent: SceneAppLaunchIntent, + persisted_context: Option<&PersistedSceneAppContext>, ) -> SceneAppPlanResult { + let context_overlay = build_sceneapp_context_overlay(&descriptor, &intent, persisted_context); + let project_pack_plan = build_project_pack_plan(&descriptor, &intent, &context_overlay); let sceneapp_id = descriptor.id.clone(); let binding_family = descriptor .entry_bindings @@ -138,6 +266,8 @@ pub fn build_launch_plan( SceneAppPlanResult { descriptor: descriptor.clone(), readiness, + context_overlay: Some(context_overlay), + project_pack_plan: Some(project_pack_plan), plan: SceneAppExecutionPlan { sceneapp_id, executor_kind: binding_family.clone(), @@ -156,8 +286,11 @@ pub fn build_launch_plan( mod tests { use super::build_launch_plan; use crate::sceneapp::catalog::get_sceneapp_descriptor; + use crate::sceneapp::context::dto::{ContextLayerSourceKind, ReferenceItem, TasteProfile}; + use crate::sceneapp::context::store::PersistedSceneAppContext; use crate::sceneapp::dto::{ - SceneAppLaunchIntent, SceneAppRuntimeAction, SceneAppRuntimeContext, + SceneAppDeliveryContract, SceneAppLaunchIntent, SceneAppRuntimeAction, + SceneAppRuntimeContext, }; use std::collections::BTreeMap; @@ -179,12 +312,32 @@ mod tests { ..SceneAppRuntimeContext::default() }), }, + None, ); assert_eq!( plan.plan.adapter_plan.runtime_action, SceneAppRuntimeAction::LaunchCloudScene ); + assert!(plan.context_overlay.is_some()); + assert_eq!( + plan.context_overlay + .as_ref() + .map(|overlay| overlay.compiler_plan.reference_count), + Some(1) + ); + assert_eq!( + plan.project_pack_plan + .as_ref() + .map(|pack| pack.pack_kind.clone()), + Some(SceneAppDeliveryContract::ProjectPack) + ); + assert_eq!( + plan.project_pack_plan + .as_ref() + .map(|pack| pack.completion_strategy.as_str()), + Some("required_parts_complete") + ); assert_eq!( plan.plan.adapter_plan.target_ref, "sceneapp-service-story-video" @@ -222,12 +375,23 @@ mod tests { ..SceneAppRuntimeContext::default() }), }, + None, ); assert_eq!( plan.plan.adapter_plan.runtime_action, SceneAppRuntimeAction::LaunchBrowserAssist ); + assert!(plan.context_overlay.as_ref().is_some_and(|overlay| overlay + .compiler_plan + .active_layers + .contains(&"reference".to_string()))); + assert_eq!( + plan.project_pack_plan + .as_ref() + .map(|pack| pack.required_parts.len()), + Some(2) + ); assert_eq!(plan.plan.adapter_plan.target_ref, "x/article-export"); assert_eq!( plan.plan.adapter_plan.preferred_profile_key.as_deref(), @@ -261,12 +425,17 @@ mod tests { ..SceneAppRuntimeContext::default() }), }, + None, ); assert_eq!( plan.plan.adapter_plan.runtime_action, SceneAppRuntimeAction::CreateAutomationJob ); + assert!(plan.context_overlay.as_ref().is_some_and(|overlay| overlay + .compiler_plan + .active_layers + .contains(&"memory".to_string()))); assert_eq!( plan.plan.adapter_plan.target_ref, "sceneapp-service-daily-trend" @@ -280,4 +449,64 @@ mod tests { Some("every") ); } + + #[test] + fn should_merge_persisted_context_into_launch_plan() { + let descriptor = get_sceneapp_descriptor("story-video-suite") + .expect("story-video-suite descriptor should exist"); + let persisted_context = PersistedSceneAppContext { + sceneapp_id: "story-video-suite".to_string(), + workspace_id: Some("workspace-default".to_string()), + project_id: Some("project-video".to_string()), + reference_items: vec![ReferenceItem { + id: "saved-reference-1".to_string(), + label: "历史拆解".to_string(), + source_kind: ContextLayerSourceKind::ReferenceLibrary, + content_type: "text".to_string(), + uri: None, + summary: Some("保留强开头与多镜头切换。".to_string()), + selected: true, + }], + taste_profile: Some(TasteProfile { + profile_id: "taste-story-video-suite".to_string(), + summary: "偏好科技感、快节奏。".to_string(), + keywords: vec!["科技感".to_string(), "快节奏".to_string()], + avoid_keywords: vec!["铺垫过长".to_string()], + derived_from_reference_ids: vec!["saved-reference-1".to_string()], + confidence: Some(0.74), + }), + }; + let mut slots = BTreeMap::new(); + slots.insert("style".to_string(), "科技感".to_string()); + + let plan = build_launch_plan( + descriptor, + SceneAppLaunchIntent { + sceneapp_id: "story-video-suite".to_string(), + entry_source: Some("sceneapp_card".to_string()), + workspace_id: Some("workspace-default".to_string()), + project_id: Some("project-video".to_string()), + user_input: Some("根据这次发布会做 30 秒短视频".to_string()), + slots, + runtime_context: Some(SceneAppRuntimeContext { + cloud_session_ready: true, + ..SceneAppRuntimeContext::default() + }), + }, + Some(&persisted_context), + ); + + let overlay = plan.context_overlay.expect("context overlay should exist"); + assert_eq!(overlay.compiler_plan.reference_count, 3); + assert!(overlay + .compiler_plan + .notes + .iter() + .any(|note| note.contains("已从项目上下文恢复 1 条历史参考"))); + assert!(overlay + .compiler_plan + .notes + .iter() + .any(|note| note.contains("当前已复用项目级 TasteProfile"))); + } } diff --git a/src/components/agent/chat/utils/clawWorkspaceProviderSelection.test.ts b/src/components/agent/chat/utils/clawWorkspaceProviderSelection.test.ts index ce402d27d..a38b9c7b8 100644 --- a/src/components/agent/chat/utils/clawWorkspaceProviderSelection.test.ts +++ b/src/components/agent/chat/utils/clawWorkspaceProviderSelection.test.ts @@ -8,12 +8,25 @@ const { mockGetModelRegistry, mockGetProviderAliasConfig, mockFetchProviderModelsAuto, + mockNormalizeFetchProviderModelsSource, mockFilterModelsByTheme, } = vi.hoisted(() => ({ mockLoadConfiguredProviders: vi.fn(), mockGetModelRegistry: vi.fn(), mockGetProviderAliasConfig: vi.fn(), mockFetchProviderModelsAuto: vi.fn(), + mockNormalizeFetchProviderModelsSource: vi.fn((result) => { + if ( + result?.source === "LocalFallback" && + Array.isArray(result?.models) && + result.models.length > 0 && + typeof result?.error === "string" && + result.error.includes("已保留当前 Provider 的自定义模型") + ) { + return "CustomModels"; + } + return result?.source ?? "LocalFallback"; + }), mockFilterModelsByTheme: vi.fn(), })); @@ -46,6 +59,7 @@ vi.mock("@/hooks/useConfiguredProviders", () => ({ })); vi.mock("@/lib/api/modelRegistry", () => ({ + normalizeFetchProviderModelsSource: mockNormalizeFetchProviderModelsSource, modelRegistryApi: { getModelRegistry: mockGetModelRegistry, getProviderAliasConfig: mockGetProviderAliasConfig, diff --git a/src/components/sceneapps/SceneAppDetailPanel.tsx b/src/components/sceneapps/SceneAppDetailPanel.tsx index 4d68a57f8..a4464b62e 100644 --- a/src/components/sceneapps/SceneAppDetailPanel.tsx +++ b/src/components/sceneapps/SceneAppDetailPanel.tsx @@ -10,6 +10,8 @@ interface SceneAppDetailPanelProps { detailView: SceneAppDetailViewModel | null; projectId: string | null; launchInput: string; + planLoading: boolean; + planError?: string | null; launchDisabledReason?: string; launching: boolean; onProjectChange: (projectId: string) => void; @@ -21,6 +23,8 @@ export function SceneAppDetailPanel({ detailView, projectId, launchInput, + planLoading, + planError, launchDisabledReason, launching, onProjectChange, @@ -267,6 +271,207 @@ export function SceneAppDetailPanel({ +
+
+
+
执行预规划
+ + {detailView.planning.statusLabel} + +
+

+ {detailView.planning.summary} +

+ {planLoading ? ( +

+ 正在根据当前项目与输入刷新预规划… +

+ ) : null} + {planError ? ( +

{planError}

+ ) : null} + {detailView.planning.unmetRequirements.length ? ( +
+
仍待补齐
+
+ {detailView.planning.unmetRequirements.map((message, index) => ( +
+ {message} +
+ ))} +
+
+ ) : null} + {detailView.contextPlan ? ( +
+ {detailView.contextPlan.activeLayers.length ? ( +
+
活跃层
+
+ {detailView.contextPlan.activeLayers.map((layer) => ( + + {layer.label} + + ))} +
+
+ ) : null} +
+ 参考注入: + {detailView.contextPlan.referenceCount} 条 +
+ {detailView.contextPlan.memoryRefs.length ? ( +
+
记忆引用
+
+ {detailView.contextPlan.memoryRefs.map((memoryRef) => ( + + {memoryRef.label} + + ))} +
+
+ ) : null} + {detailView.contextPlan.toolRefs.length ? ( +
+
工具开放面
+
+ {detailView.contextPlan.toolRefs.map((toolRef) => ( + + {toolRef.label} + + ))} +
+
+ ) : null} + {detailView.contextPlan.tasteSummary ? ( +
+ 风格摘要: + {detailView.contextPlan.tasteSummary} +
+ ) : null} + {detailView.contextPlan.notes.length ? ( +
+ {detailView.contextPlan.notes.map((note, index) => ( +
+ {note} +
+ ))} +
+ ) : null} +
+ ) : ( +

+ 当前还没有生成上下文覆盖层,继续补项目或启动输入后会自动刷新。 +

+ )} + {detailView.planning.warnings.length ? ( +
+ {detailView.planning.warnings.map((warning, index) => ( +
+ {warning} +
+ ))} +
+ ) : null} +
+ +
+
Project Pack 规划
+ {detailView.projectPackPlan ? ( + <> +
+
+ 交付形态: + {detailView.projectPackPlan.packKindLabel} +
+
+ 完成策略: + {detailView.projectPackPlan.completionStrategyLabel} +
+ {detailView.projectPackPlan.primaryPart ? ( +
+ 主件: + {detailView.projectPackPlan.primaryPart} +
+ ) : null} + {detailView.projectPackPlan.viewerLabel ? ( +
+ 查看方式: + {detailView.projectPackPlan.viewerLabel} +
+ ) : null} +
+ {detailView.projectPackPlan.requiredParts.length ? ( +
+ {detailView.projectPackPlan.requiredParts.map((part) => ( + + {part.label} + + ))} +
+ ) : ( +

+ 当前规划还没有显式声明整包部件,继续沿现有结果回流主链执行。 +

+ )} + {detailView.projectPackPlan.notes.length ? ( +
+ {detailView.projectPackPlan.notes.map((note, index) => ( +
+ {note} +
+ ))} +
+ ) : null} + + ) : ( +

+ 当前规划还没有显式暴露 Project Pack 结果,继续沿现有交付合同运行。 +

+ )} +
+
+
启动输入

diff --git a/src/components/sceneapps/SceneAppRunDetailPanel.tsx b/src/components/sceneapps/SceneAppRunDetailPanel.tsx index 8c1a3c329..cf3e2be7b 100644 --- a/src/components/sceneapps/SceneAppRunDetailPanel.tsx +++ b/src/components/sceneapps/SceneAppRunDetailPanel.tsx @@ -277,6 +277,79 @@ export function SceneAppRunDetailPanel({

) : null} + + {runDetailView.packCompletionStrategyLabel || + runDetailView.packViewerLabel || + runDetailView.plannedDeliveryRequiredParts.length || + runDetailView.packPlanNotes.length ? ( +
+
+ 规划结果包基线 +
+

+ 这部分展示预规划阶段承诺的结果包口径,方便和当前真实回流的交付覆盖率对照。 +

+ + {runDetailView.packCompletionStrategyLabel ? ( +
+ 完成口径: + {runDetailView.packCompletionStrategyLabel} +
+ ) : null} + + {runDetailView.packViewerLabel ? ( +
+ 规划入口: + {runDetailView.packViewerLabel} +
+ ) : null} + + {runDetailView.plannedDeliveryRequiredParts.length ? ( +
+
+ 规划必含部件 +
+
+ {runDetailView.plannedDeliveryRequiredParts.map((part) => ( + + {part.label} + + ))} +
+
+ ) : null} + + {runDetailView.packPlanNotes.length ? ( +
+
+ 规划备注 +
+
+ {runDetailView.packPlanNotes.map((note) => ( + + {note} + + ))} +
+
+ ) : null} +
+ ) : null}
diff --git a/src/components/sceneapps/SceneAppScorecardPanel.tsx b/src/components/sceneapps/SceneAppScorecardPanel.tsx index 60c6935d5..2bbcf73e1 100644 --- a/src/components/sceneapps/SceneAppScorecardPanel.tsx +++ b/src/components/sceneapps/SceneAppScorecardPanel.tsx @@ -65,7 +65,9 @@ export function SceneAppScorecardPanel({

{scorecardView.deliveryContractLabel || scorecardView.viewerLabel || + scorecardView.completionStrategyLabel || scorecardView.deliveryRequiredParts.length || + scorecardView.packPlanNotes.length || scorecardView.profileRef || scorecardView.metricKeys.length || scorecardView.failureSignals.length ? ( @@ -88,6 +90,15 @@ export function SceneAppScorecardPanel({ {scorecardView.viewerLabel}
) : null} + {scorecardView.completionStrategyLabel ? ( +
+ 完成口径: + {scorecardView.completionStrategyLabel} +
+ ) : null} {scorecardView.deliveryRequiredParts.length ? (
) : null} + {scorecardView.packPlanNotes.length ? ( +
+
+ 规划备注 +
+
+ {scorecardView.packPlanNotes.map((note) => ( + + {note} + + ))} +
+
+ ) : null} {scorecardView.observedFailureSignals.length ? (
; readiness?: Partial; + contextOverlay?: Partial>; + projectPackPlan?: Partial>; plan?: Partial> & { adapterPlan?: Partial; }; @@ -376,6 +378,38 @@ function createPlanResult( unmetRequirements: [], ...(overrides.readiness ?? {}), } as SceneAppPlanResult["readiness"], + contextOverlay: { + compilerPlan: { + activeLayers: ["skill", "memory", "tool"], + memoryRefs: ["workspace:project-1"], + toolRefs: ["workspace_storage", "cloud_scene"], + referenceCount: 1, + notes: ["已装配 1 条参考素材和 1 条 memory 引用。"], + ...(overrides.contextOverlay?.compilerPlan ?? {}), + }, + snapshot: { + workspaceId: "project-1", + projectId: "project-1", + skillRefs: ["sceneapp-demo"], + memoryRefs: ["workspace:project-1"], + toolRefs: ["workspace_storage", "cloud_scene"], + referenceItems: [], + tasteProfile: null, + ...(overrides.contextOverlay?.snapshot ?? {}), + }, + }, + projectPackPlan: { + packKind: "project_pack", + primaryPart: "brief", + requiredParts: ["brief", "video_draft", "review_note"], + viewerKind: "artifact_bundle", + completionStrategy: "required_parts_complete", + notes: [ + "当前 SceneApp 以结果包作为默认交付单位。", + "完整度将按 3 个必含部件判断。", + ], + ...(overrides.projectPackPlan ?? {}), + }, plan: { sceneappId: planOverrides.sceneappId ?? "sceneapp-demo", executorKind: planOverrides.executorKind ?? "agent_turn", @@ -1185,6 +1219,23 @@ describe("SceneAppsPage", () => { container.querySelector('[data-testid="sceneapp-detail-scorecard-ref"]') ?.textContent, ).toContain("story-video-scorecard"); + expect( + container.querySelector('[data-testid="sceneapp-detail-context-layers"]') + ?.textContent, + ).toContain("Skill"); + expect( + container.querySelector( + '[data-testid="sceneapp-detail-context-reference-count"]', + )?.textContent, + ).toContain("1 条"); + expect( + container.querySelector('[data-testid="sceneapp-detail-pack-strategy"]') + ?.textContent, + ).toContain("整包完成度"); + expect( + container.querySelector('[data-testid="sceneapp-detail-pack-required-parts"]') + ?.textContent, + ).toContain("短视频草稿"); expect( container.querySelector('[data-testid="sceneapp-scorecard-profile-ref"]') ?.textContent, @@ -1196,7 +1247,16 @@ describe("SceneAppsPage", () => { expect( container.querySelector('[data-testid="sceneapp-scorecard-delivery-parts"]') ?.textContent, - ).toContain("配乐建议"); + ).toContain("复核意见"); + expect( + container.querySelector( + '[data-testid="sceneapp-scorecard-completion-strategy"]', + )?.textContent, + ).toContain("整包完成度"); + expect( + container.querySelector('[data-testid="sceneapp-scorecard-pack-notes"]') + ?.textContent, + ).toContain("结果包作为默认交付单位"); expect( container.querySelector( '[data-testid="sceneapp-scorecard-failure-signals"]', @@ -1219,6 +1279,19 @@ describe("SceneAppsPage", () => { container.querySelector('[data-testid="sceneapp-run-detail-summary"]') ?.textContent, ).toContain("短视频编排"); + expect( + container.querySelector('[data-testid="sceneapp-run-detail-pack-strategy"]') + ?.textContent, + ).toContain("整包完成度"); + expect( + container.querySelector( + '[data-testid="sceneapp-run-detail-pack-required-parts"]', + )?.textContent, + ).toContain("复核意见"); + expect( + container.querySelector('[data-testid="sceneapp-run-detail-pack-notes"]') + ?.textContent, + ).toContain("3 个必含部件"); expect(container.textContent).toContain("当前主要阻塞:复核阻塞"); expect(mockGetSceneAppRunSummary).toHaveBeenCalledWith( "story-video-suite-run-2", @@ -1413,6 +1486,15 @@ describe("SceneAppsPage", () => { container.querySelector('[data-testid="sceneapp-scorecard-profile-ref"]') ?.textContent, ).toContain("story-video-scorecard"); + expect( + container.querySelector( + '[data-testid="sceneapp-scorecard-completion-strategy"]', + )?.textContent, + ).toContain("整包完成度"); + expect( + container.querySelector('[data-testid="sceneapp-scorecard-pack-notes"]') + ?.textContent, + ).toContain("3 个必含部件"); expect( container.querySelector('[data-testid="sceneapp-scorecard-metric-keys"]') ?.textContent, diff --git a/src/components/sceneapps/SceneAppsPage.tsx b/src/components/sceneapps/SceneAppsPage.tsx index 93654e22f..2e0ffa30f 100644 --- a/src/components/sceneapps/SceneAppsPage.tsx +++ b/src/components/sceneapps/SceneAppsPage.tsx @@ -199,6 +199,8 @@ export function SceneAppsPage({ detailView={runtime.selectedDetailView} projectId={runtime.selectedProjectId} launchInput={runtime.launchInput} + planLoading={runtime.selectedPlanLoading} + planError={runtime.selectedPlanError} launchDisabledReason={runtime.launchDisabledReason} launching={ runtime.launchingSceneAppId === runtime.selectedDescriptor?.id diff --git a/src/components/sceneapps/useSceneAppsPageRuntime.ts b/src/components/sceneapps/useSceneAppsPageRuntime.ts index d4606ecb2..0338af655 100644 --- a/src/components/sceneapps/useSceneAppsPageRuntime.ts +++ b/src/components/sceneapps/useSceneAppsPageRuntime.ts @@ -5,6 +5,7 @@ import { getSceneAppScorecard, listSceneAppCatalog, listSceneAppRuns, + planSceneAppLaunch, prepareSceneAppRunGovernanceArtifact, prepareSceneAppRunGovernanceArtifacts, type SceneAppCatalog, @@ -12,6 +13,7 @@ import { type SceneAppDescriptor, type SceneAppNativeSkillRuntimeRef, type SceneAppPattern, + type SceneAppPlanResult, type SceneAppRunSummary, type SceneAppScorecard, type SceneAppType, @@ -290,6 +292,12 @@ export function useSceneAppsPageRuntime({ useState(null); const [selectedRunLoading, setSelectedRunLoading] = useState(false); const [selectedRunError, setSelectedRunError] = useState(null); + const [selectedPlanResult, setSelectedPlanResult] = + useState(null); + const [selectedPlanLoading, setSelectedPlanLoading] = useState(false); + const [selectedPlanError, setSelectedPlanError] = useState( + null, + ); const [recentVisitRecords, setRecentVisitRecords] = useState< SceneAppRecentVisitRecord[] >(() => listSceneAppRecentVisits()); @@ -527,6 +535,9 @@ export function useSceneAppsPageRuntime({ setRuns([]); setScorecardError(null); setRunsError(null); + setSelectedPlanResult(null); + setSelectedPlanLoading(false); + setSelectedPlanError(null); setSelectedRunId(null); setSelectedRunSummary(null); setSelectedRunLoading(false); @@ -710,6 +721,10 @@ export function useSceneAppsPageRuntime({ () => extractExplicitUrlFromText(launchInput), [launchInput], ); + const previewUrlCandidate = useMemo( + () => extractExplicitUrlFromText(effectivePrefillIntent), + [effectivePrefillIntent], + ); const selectedEntryCard = useMemo(() => { if (!selectedDescriptor) { @@ -723,14 +738,77 @@ export function useSceneAppsPageRuntime({ urlCandidate, }); }, [launchInput, selectedDescriptor, selectedProjectId, urlCandidate]); + const previewLaunchSeed = useMemo(() => { + if (!selectedDescriptor) { + return null; + } + + return resolveSceneAppSeed({ + descriptor: selectedDescriptor, + input: effectivePrefillIntent, + urlCandidate: previewUrlCandidate, + }); + }, [ + effectivePrefillIntent, + previewUrlCandidate, + selectedDescriptor, + ]); + + useEffect(() => { + if (!selectedDescriptor) { + setSelectedPlanResult(null); + setSelectedPlanLoading(false); + setSelectedPlanError(null); + return; + } + + let cancelled = false; + const trimmedProjectId = selectedProjectId?.trim() || undefined; + setSelectedPlanResult((current) => + current?.descriptor.id === selectedDescriptor.id ? current : null, + ); + setSelectedPlanLoading(true); + setSelectedPlanError(null); + + void planSceneAppLaunch({ + sceneappId: selectedDescriptor.id, + entrySource: "sceneapp_detail_preview", + workspaceId: trimmedProjectId, + projectId: trimmedProjectId, + userInput: previewLaunchSeed?.userInput, + slots: previewLaunchSeed?.slots, + }) + .then((nextPlanResult) => { + if (cancelled) { + return; + } + setSelectedPlanResult(nextPlanResult); + }) + .catch((error) => { + if (cancelled) { + return; + } + setSelectedPlanError(formatSceneAppErrorMessage(error)); + }) + .finally(() => { + if (!cancelled) { + setSelectedPlanLoading(false); + } + }); + + return () => { + cancelled = true; + }; + }, [previewLaunchSeed, selectedDescriptor, selectedProjectId]); const scorecardView = useMemo( () => buildSceneAppScorecardViewModel({ descriptor: selectedDescriptor, scorecard, + planResult: selectedPlanResult, }), - [scorecard, selectedDescriptor], + [scorecard, selectedDescriptor, selectedPlanResult], ); const governanceView = useMemo( () => @@ -739,9 +817,10 @@ export function useSceneAppsPageRuntime({ descriptor: selectedDescriptor, scorecard, run: selectedRunSummary, + projectPackPlan: selectedPlanResult?.projectPackPlan, }) : null, - [scorecard, selectedDescriptor, selectedRunSummary], + [scorecard, selectedDescriptor, selectedPlanResult, selectedRunSummary], ); const runListItems = useMemo( @@ -757,8 +836,9 @@ export function useSceneAppsPageRuntime({ return buildSceneAppRunDetailViewModel({ descriptor: selectedDescriptor, run: selectedRunSummary, + projectPackPlan: selectedPlanResult?.projectPackPlan, }); - }, [selectedDescriptor, selectedRunSummary]); + }, [selectedDescriptor, selectedPlanResult, selectedRunSummary]); const selectedRunEntryAction = selectedRunDetailView?.entryAction; @@ -1011,6 +1091,7 @@ export function useSceneAppsPageRuntime({ const refreshedDetailView = buildSceneAppRunDetailViewModel({ descriptor: selectedDescriptor, run: refreshed, + projectPackPlan: selectedPlanResult?.projectPackPlan, }); const targetEntry = refreshedDetailView.governanceArtifactEntries.find( (entry) => entry.artifactRef.kind === action.primaryArtifactKind, @@ -1027,6 +1108,7 @@ export function useSceneAppsPageRuntime({ [ openSelectedRunFileEntry, selectedDescriptor, + selectedPlanResult?.projectPackPlan, selectedRunSummary?.runId, ], ); @@ -1052,8 +1134,9 @@ export function useSceneAppsPageRuntime({ descriptor: selectedDescriptor, entryCard: selectedEntryCard, launchSeed, + planResult: selectedPlanResult, }); - }, [launchSeed, selectedDescriptor, selectedEntryCard]); + }, [launchSeed, selectedDescriptor, selectedEntryCard, selectedPlanResult]); const launchDisabledReason = useMemo(() => { if (!selectedDescriptor) { @@ -1211,6 +1294,8 @@ export function useSceneAppsPageRuntime({ selectedEntryCard, launchSeed, selectedDetailView, + selectedPlanLoading, + selectedPlanError, launchDisabledReason, launchingSceneAppId: launchRuntime.sceneAppLaunchingId, recentVisits, diff --git a/src/lib/api/sceneapp.test.ts b/src/lib/api/sceneapp.test.ts index 936282fd2..746cceacd 100644 --- a/src/lib/api/sceneapp.test.ts +++ b/src/lib/api/sceneapp.test.ts @@ -47,6 +47,32 @@ describe("sceneapp API", () => { id: "story-video-suite", title: "短视频编排", }, + contextOverlay: { + compilerPlan: { + activeLayers: ["skill", "memory", "tool"], + memoryRefs: ["workspace:workspace-default"], + toolRefs: ["workspace_storage"], + referenceCount: 0, + notes: ["已装配 1 条 memory 引用。"], + }, + snapshot: { + workspaceId: "workspace-default", + projectId: null, + skillRefs: ["story-video-suite"], + memoryRefs: ["workspace:workspace-default"], + toolRefs: ["workspace_storage"], + referenceItems: [], + tasteProfile: null, + }, + }, + projectPackPlan: { + packKind: "project_pack", + primaryPart: "brief", + requiredParts: ["brief", "video_draft"], + viewerKind: "artifact_bundle", + completionStrategy: "required_parts_complete", + notes: ["完整度将按 2 个必含部件判断。"], + }, readiness: { ready: false, unmetRequirements: [{ kind: "project" }], @@ -92,6 +118,9 @@ describe("sceneapp API", () => { runtimeAction: "launch_cloud_scene", }), }), + projectPackPlan: expect.objectContaining({ + completionStrategy: "required_parts_complete", + }), }), ); diff --git a/src/lib/api/sceneapp.ts b/src/lib/api/sceneapp.ts index e629bea1e..82109526f 100644 --- a/src/lib/api/sceneapp.ts +++ b/src/lib/api/sceneapp.ts @@ -13,9 +13,13 @@ import type { export type { SceneAppBindingFamily, + ContextCompilerPlan, + ContextLayerSnapshot, + ReferenceItem, SceneAppBrowserRuntimeRef, SceneAppCatalog, SceneAppCloudSceneRuntimeRef, + SceneAppContextOverlay, SceneAppAutomationIntent, SceneAppDeliveryContract, SceneAppDescriptor, @@ -26,6 +30,7 @@ export type { SceneAppLaunchRequirement, SceneAppPattern, SceneAppPlanResult, + SceneAppProjectPackPlan, SceneAppReadiness, SceneAppNativeSkillRuntimeRef, SceneAppRunSummary, @@ -35,6 +40,7 @@ export type { SceneAppScorecard, SceneAppScorecardMetric, SceneAppType, + TasteProfile, } from "@/lib/sceneapp"; export async function listSceneAppCatalog(): Promise { diff --git a/src/lib/context-layer/index.ts b/src/lib/context-layer/index.ts new file mode 100644 index 000000000..eea524d65 --- /dev/null +++ b/src/lib/context-layer/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/src/lib/context-layer/types.ts b/src/lib/context-layer/types.ts new file mode 100644 index 000000000..7a7bb76da --- /dev/null +++ b/src/lib/context-layer/types.ts @@ -0,0 +1,50 @@ +export type ContextLayerSourceKind = + | "user_input" + | "slot" + | "project" + | "workspace" + | "memory_profile" + | "reference_library" + | "tool_readiness"; + +export interface ReferenceItem { + id: string; + label: string; + sourceKind: ContextLayerSourceKind; + contentType: string; + uri?: string | null; + summary?: string | null; + selected: boolean; +} + +export interface TasteProfile { + profileId: string; + summary: string; + keywords: string[]; + avoidKeywords: string[]; + derivedFromReferenceIds: string[]; + confidence?: number | null; +} + +export interface ContextCompilerPlan { + activeLayers: string[]; + memoryRefs: string[]; + toolRefs: string[]; + referenceCount: number; + notes: string[]; +} + +export interface ContextLayerSnapshot { + workspaceId?: string | null; + projectId?: string | null; + skillRefs: string[]; + memoryRefs: string[]; + toolRefs: string[]; + referenceItems: ReferenceItem[]; + tasteProfile?: TasteProfile | null; +} + +export interface SceneAppContextOverlay { + compilerPlan: ContextCompilerPlan; + snapshot: ContextLayerSnapshot; +} diff --git a/src/lib/sceneapp/launch.test.ts b/src/lib/sceneapp/launch.test.ts index 3e3f0dced..e2e348b83 100644 --- a/src/lib/sceneapp/launch.test.ts +++ b/src/lib/sceneapp/launch.test.ts @@ -5,6 +5,8 @@ import type { SceneAppPlanResult } from "./types"; type SceneAppPlanResultOverrides = { descriptor?: Partial; readiness?: Partial; + contextOverlay?: Partial>; + projectPackPlan?: Partial>; plan?: Partial> & { adapterPlan?: Partial; }; @@ -45,6 +47,35 @@ function createPlanResult( unmetRequirements: [], ...(overrides.readiness ?? {}), } as SceneAppPlanResult["readiness"], + contextOverlay: { + compilerPlan: { + activeLayers: ["skill", "memory", "tool"], + memoryRefs: ["workspace:workspace-demo"], + toolRefs: ["workspace_storage"], + referenceCount: 0, + notes: ["已装配 1 条 memory 引用。"], + ...(overrides.contextOverlay?.compilerPlan ?? {}), + }, + snapshot: { + workspaceId: "workspace-demo", + projectId: "project-demo", + skillRefs: ["sceneapp-demo"], + memoryRefs: ["workspace:workspace-demo"], + toolRefs: ["workspace_storage"], + referenceItems: [], + tasteProfile: null, + ...(overrides.contextOverlay?.snapshot ?? {}), + }, + }, + projectPackPlan: { + packKind: "artifact_bundle", + primaryPart: "brief", + requiredParts: ["brief", "review_note"], + viewerKind: "artifact_bundle", + completionStrategy: "required_parts_complete", + notes: ["完整度将按 2 个必含部件判断。"], + ...(overrides.projectPackPlan ?? {}), + }, plan: { sceneappId: planOverrides.sceneappId ?? "sceneapp-demo", executorKind: planOverrides.executorKind ?? "agent_turn", @@ -161,6 +192,8 @@ describe("sceneapp launch facade", () => { }), }), ); + expect(draft.notes).toContain("已装配 1 条 memory 引用。"); + expect(draft.notes).toContain("完整度将按 2 个必含部件判断。"); expect(draft.notes).toContain("当前 SceneApp 规划先映射到 cloud_scene 主链。"); }); diff --git a/src/lib/sceneapp/launch.ts b/src/lib/sceneapp/launch.ts index 0ee00edf8..4f3bdf3d8 100644 --- a/src/lib/sceneapp/launch.ts +++ b/src/lib/sceneapp/launch.ts @@ -165,6 +165,8 @@ function readRequestMetadata(result: SceneAppPlanResult): Record; + readiness?: Partial; + contextOverlay?: Partial>; + projectPackPlan?: Partial< + NonNullable + >; + plan?: Partial> & { + adapterPlan?: Partial; + }; +}; + function createDescriptor( overrides: Partial = {}, ): SceneAppDescriptor { @@ -221,6 +234,106 @@ function createRun( }; } +function createPlanResult( + overrides: SceneAppPlanResultOverrides = {}, +): SceneAppPlanResult { + const planOverrides = overrides.plan ?? {}; + const adapterPlanOverrides = planOverrides.adapterPlan ?? {}; + + return { + descriptor: createDescriptor(overrides.descriptor ?? {}), + readiness: { + ready: true, + unmetRequirements: [], + ...(overrides.readiness ?? {}), + }, + contextOverlay: { + compilerPlan: { + activeLayers: ["skill", "memory", "taste", "tool"], + memoryRefs: ["workspace:project-story-video"], + toolRefs: ["cloud_scene", "workspace_storage"], + referenceCount: 2, + notes: ["已装配 2 条参考素材和 1 条 memory 引用。"], + }, + snapshot: { + workspaceId: "project-story-video", + projectId: "project-story-video", + skillRefs: ["sceneapp-service-story-video"], + memoryRefs: ["workspace:project-story-video"], + toolRefs: ["cloud_scene", "workspace_storage"], + referenceItems: [ + { + id: "ref-1", + label: "竞品视频 1", + sourceKind: "user_input", + contentType: "video", + selected: true, + }, + { + id: "ref-2", + label: "脚本参考 1", + sourceKind: "reference_library", + contentType: "text", + selected: true, + }, + ], + tasteProfile: { + profileId: "taste-story-video", + summary: "偏好快节奏、科技感强、开头三秒直接给出结论。", + keywords: ["快节奏", "科技感", "直接结论"], + avoidKeywords: ["冗长铺垫"], + derivedFromReferenceIds: ["ref-1", "ref-2"], + confidence: 0.76, + }, + }, + }, + projectPackPlan: { + packKind: "project_pack", + primaryPart: "brief", + requiredParts: [ + "brief", + "storyboard", + "script", + "music_refs", + "video_draft", + "review_note", + ], + viewerKind: "artifact_bundle", + completionStrategy: "required_parts_complete", + notes: [ + "当前 SceneApp 以结果包作为默认交付单位。", + "完整度将按 6 个必含部件判断。", + ], + ...(overrides.projectPackPlan ?? {}), + }, + plan: { + sceneappId: planOverrides.sceneappId ?? "story-video-suite", + executorKind: planOverrides.executorKind ?? "cloud_scene", + bindingFamily: planOverrides.bindingFamily ?? "cloud_scene", + stepPlan: planOverrides.stepPlan ?? [], + adapterPlan: { + adapterKind: adapterPlanOverrides.adapterKind ?? "cloud_scene", + runtimeAction: + adapterPlanOverrides.runtimeAction ?? "launch_cloud_scene", + targetRef: + adapterPlanOverrides.targetRef ?? "sceneapp-service-story-video", + targetLabel: adapterPlanOverrides.targetLabel ?? "短视频编排", + requestMetadata: adapterPlanOverrides.requestMetadata ?? {}, + launchPayload: adapterPlanOverrides.launchPayload ?? {}, + notes: adapterPlanOverrides.notes ?? [], + linkedServiceSkillId: adapterPlanOverrides.linkedServiceSkillId, + linkedSceneKey: adapterPlanOverrides.linkedSceneKey, + preferredProfileKey: adapterPlanOverrides.preferredProfileKey, + }, + storageStrategy: planOverrides.storageStrategy ?? "workspace_bundle", + artifactContract: planOverrides.artifactContract ?? "project_pack", + governanceHooks: + planOverrides.governanceHooks ?? ["evidence_pack", "scorecard"], + warnings: planOverrides.warnings ?? [], + }, + }; +} + function createScorecard( overrides: Partial = {}, ): SceneAppScorecard { @@ -295,6 +408,7 @@ describe("sceneapp product", () => { sourceLabel: "将基于当前输入启动", sourcePreview: "生成一个 30 秒短视频方案", }, + planResult: createPlanResult(), }); expect(detailView).toEqual( @@ -348,12 +462,25 @@ describe("sceneapp product", () => { ); expect(detailView.deliveryNarrative).toContain("项目资料包"); expect(detailView.scorecardNarrative).toContain("整包"); + expect(detailView.planning.statusLabel).toBe("已就绪"); + expect(detailView.contextPlan?.referenceCount).toBe(2); + expect(detailView.contextPlan?.tasteSummary).toContain("快节奏"); + expect(detailView.projectPackPlan?.completionStrategyLabel).toContain( + "整包完成度", + ); }); it("应把 descriptor 与 scorecard 装配成统一评分模型", () => { + const planResult = createPlanResult({ + projectPackPlan: { + completionStrategy: "workspace_artifact_writeback", + notes: ["运行完成后需要回写工作区结果。"], + }, + }); const scorecardView = buildSceneAppScorecardViewModel({ descriptor: createDescriptor(), scorecard: createScorecard(), + planResult, }); expect(scorecardView).toEqual( @@ -390,6 +517,10 @@ describe("sceneapp product", () => { }), ]), ); + expect(scorecardView?.completionStrategyLabel).toContain("工作区结果回写"); + expect(scorecardView?.packPlanNotes).toContain( + "运行完成后需要回写工作区结果。", + ); expect(scorecardView?.metrics).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -437,6 +568,7 @@ describe("sceneapp product", () => { const detailView = buildSceneAppRunDetailViewModel({ descriptor: createDescriptor(), run: createRun(), + projectPackPlan: createPlanResult().projectPackPlan, }); expect(detailView).toEqual( @@ -471,6 +603,16 @@ describe("sceneapp product", () => { ]), ); expect(detailView.deliveryViewerLabel).toBe("结果包查看"); + expect(detailView.packCompletionStrategyLabel).toContain("整包完成度"); + expect(detailView.plannedDeliveryRequiredParts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: "review_note", + label: "复核意见", + }), + ]), + ); + expect(detailView.packPlanNotes).toContain("完整度将按 6 个必含部件判断。"); expect(detailView.deliveryArtifactEntries).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -525,6 +667,39 @@ describe("sceneapp product", () => { ); }); + it("运行回流缺少部件覆盖率时也应保留规划结果包基线", () => { + const planResult = createPlanResult({ + projectPackPlan: { + requiredParts: ["brief", "video_draft", "review_note"], + completionStrategy: "artifact_writeback", + notes: ["当前按结果文件回流判断整包交付。"], + }, + }); + const detailView = buildSceneAppRunDetailViewModel({ + descriptor: createDescriptor(), + run: createRun({ + deliveryRequiredParts: [], + deliveryCompletedParts: [], + deliveryMissingParts: [], + deliveryPartCoverageKnown: false, + }), + projectPackPlan: planResult.projectPackPlan, + }); + + expect(detailView.deliveryRequiredParts).toEqual([]); + expect(detailView.deliveryPartCoverageKnown).toBe(false); + expect(detailView.packCompletionStrategyLabel).toContain("结果文件回流"); + expect(detailView.plannedDeliveryRequiredParts).toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: "brief", label: "任务简报" }), + expect.objectContaining({ key: "review_note", label: "复核意见" }), + ]), + ); + expect(detailView.packPlanNotes).toContain( + "当前按结果文件回流判断整包交付。", + ); + }); + it("应把运行、证据和 scorecard 装配成页面级治理看板", () => { const governanceView = buildSceneAppGovernancePanelViewModel({ descriptor: createDescriptor(), diff --git a/src/lib/sceneapp/product.ts b/src/lib/sceneapp/product.ts index 7f8ef822f..ab2e31c8a 100644 --- a/src/lib/sceneapp/product.ts +++ b/src/lib/sceneapp/product.ts @@ -6,6 +6,7 @@ import type { SceneAppGovernanceArtifactKind, SceneAppGovernanceArtifactRef, SceneAppNativeSkillRuntimeRef, + SceneAppPlanResult, SceneAppRunSummary, SceneAppScorecard, } from "./types"; @@ -76,6 +77,20 @@ const SCORECARD_SIGNAL_LABELS: Record = { runtime_failure: "运行链稳定性", }; +const CONTEXT_LAYER_LABELS: Record = { + skill: "Skill", + memory: "Memory", + taste: "Taste", + tool: "Tool", + reference: "Reference", +}; + +const PROJECT_PACK_COMPLETION_LABELS: Record = { + required_parts_complete: "按必含部件判断整包完成度", + workspace_artifact_writeback: "按工作区结果回写判断交付", + artifact_writeback: "按结果文件回流判断交付", +}; + export interface SceneAppWorkbenchStatItem { key: string; label: string; @@ -107,6 +122,31 @@ export interface SceneAppCompositionStepViewModel { bindingLabel?: string; } +export interface SceneAppPlanningViewModel { + statusLabel: string; + summary: string; + warnings: string[]; + unmetRequirements: string[]; +} + +export interface SceneAppContextPlanViewModel { + activeLayers: SceneAppDeliveryPartViewModel[]; + referenceCount: number; + memoryRefs: SceneAppDeliveryPartViewModel[]; + toolRefs: SceneAppDeliveryPartViewModel[]; + tasteSummary?: string; + notes: string[]; +} + +export interface SceneAppProjectPackPlanViewModel { + packKindLabel: string; + completionStrategyLabel: string; + viewerLabel?: string; + primaryPart?: string; + requiredParts: SceneAppDeliveryPartViewModel[]; + notes: string[]; +} + export interface SceneAppDetailViewModel { id: string; title: string; @@ -133,6 +173,9 @@ export interface SceneAppDetailViewModel { compositionBlueprintRef?: string; compositionStepCount: number; compositionSteps: SceneAppCompositionStepViewModel[]; + planning: SceneAppPlanningViewModel; + contextPlan: SceneAppContextPlanViewModel | null; + projectPackPlan: SceneAppProjectPackPlanViewModel | null; scorecardProfileRef?: string; scorecardMetricKeys: SceneAppDeliveryPartViewModel[]; scorecardFailureSignals: SceneAppDeliveryPartViewModel[]; @@ -155,7 +198,9 @@ export interface SceneAppScorecardViewModel { topFailureSignalLabel?: string; deliveryContractLabel?: string; viewerLabel?: string; + completionStrategyLabel?: string; deliveryRequiredParts: SceneAppDeliveryPartViewModel[]; + packPlanNotes: string[]; operatingNarrative: string; actionLabel?: string; summary: string; @@ -241,6 +286,10 @@ export interface SceneAppRunDetailViewModel { deliveryMissingParts: SceneAppDeliveryPartViewModel[]; deliveryPartCoverageKnown: boolean; deliveryViewerLabel?: string; + packCompletionStrategyLabel?: string; + packViewerLabel?: string; + plannedDeliveryRequiredParts: SceneAppDeliveryPartViewModel[]; + packPlanNotes: string[]; deliveryArtifactEntries: SceneAppRunDeliveryArtifactEntryViewModel[]; governanceActionEntries: SceneAppRunGovernanceActionViewModel[]; governanceArtifactEntries: SceneAppRunGovernanceArtifactEntryViewModel[]; @@ -352,6 +401,73 @@ function buildLabeledItems( })); } +function buildContextRefItems( + values: string[] | undefined, +): SceneAppDeliveryPartViewModel[] { + return Array.from(new Set(values ?? [])) + .map((value) => value.trim()) + .filter(Boolean) + .map((value) => { + const separatorIndex = value.indexOf(":"); + if (separatorIndex < 0) { + return { + key: value, + label: humanizeTokenLabel(value), + }; + } + + const prefix = value.slice(0, separatorIndex); + const suffix = value.slice(separatorIndex + 1); + return { + key: value, + label: `${humanizeTokenLabel(prefix)} · ${suffix}`, + }; + }); +} + +function buildProjectPackPlanPresentation(params: { + descriptor?: + | Pick + | null; + projectPackPlan?: SceneAppPlanResult["projectPackPlan"] | null; +}): { + packKindLabel?: string; + completionStrategyLabel?: string; + viewerLabel?: string; + primaryPart?: string; + requiredParts: SceneAppDeliveryPartViewModel[]; + notes: string[]; +} { + const { descriptor, projectPackPlan } = params; + const resolvedContract = + projectPackPlan?.packKind ?? descriptor?.deliveryContract; + const primaryPart = + projectPackPlan?.primaryPart ?? descriptor?.deliveryProfile?.primaryPart; + + return { + packKindLabel: resolvedContract + ? getSceneAppDeliveryContractLabel(resolvedContract) + : undefined, + completionStrategyLabel: projectPackPlan?.completionStrategy + ? PROJECT_PACK_COMPLETION_LABELS[projectPackPlan.completionStrategy] ?? + humanizeTokenLabel(projectPackPlan.completionStrategy) + : undefined, + viewerLabel: getSceneAppViewerKindLabel( + projectPackPlan?.viewerKind ?? descriptor?.deliveryProfile?.viewerKind, + ), + primaryPart: + primaryPart && + (DELIVERY_PART_LABELS[primaryPart] ?? humanizeTokenLabel(primaryPart)), + requiredParts: buildLabeledItems( + projectPackPlan?.requiredParts?.length + ? projectPackPlan.requiredParts + : descriptor?.deliveryProfile?.requiredParts, + DELIVERY_PART_LABELS, + ), + notes: dedupeNonEmptyLines(projectPackPlan?.notes), + }; +} + function getFailureSignalLabel(signal?: string | null): string | undefined { if (!signal) { return undefined; @@ -1136,8 +1252,9 @@ export function buildSceneAppDetailViewModel(params: { descriptor: SceneAppDescriptor; entryCard: SceneAppEntryCardItem | null; launchSeed: SceneAppSeed | null; + planResult?: SceneAppPlanResult | null; }): SceneAppDetailViewModel { - const { descriptor, entryCard, launchSeed } = params; + const { descriptor, entryCard, launchSeed, planResult } = params; const copy = getSceneAppPresentationCopy(descriptor); const executionChainLabel = Array.from( new Set( @@ -1160,6 +1277,77 @@ export function buildSceneAppDetailViewModel(params: { SCORECARD_SIGNAL_LABELS, ); const compositionSteps = buildCompositionStepViewModels(descriptor); + const projectPackPlanPresentation = buildProjectPackPlanPresentation({ + descriptor, + projectPackPlan: planResult?.projectPackPlan, + }); + const planning: SceneAppPlanningViewModel = planResult + ? { + statusLabel: planResult.readiness.ready + ? "已就绪" + : `待补 ${planResult.readiness.unmetRequirements.length} 项`, + summary: planResult.readiness.ready + ? `当前这次输入已经能进入 ${ + BINDING_FAMILY_LABELS[planResult.plan.executorKind] ?? + humanizeTokenLabel(planResult.plan.executorKind) + },并沿 ${getSceneAppDeliveryContractLabel( + planResult.plan.artifactContract, + )} 主链继续交付。` + : `当前预规划已生成,但还有 ${planResult.readiness.unmetRequirements.length} 项前置待补齐;补齐后会继续沿 ${ + BINDING_FAMILY_LABELS[planResult.plan.executorKind] ?? + humanizeTokenLabel(planResult.plan.executorKind) + } 执行。`, + warnings: dedupeNonEmptyLines(planResult.plan.warnings), + unmetRequirements: planResult.readiness.unmetRequirements.map( + (requirement) => requirement.message, + ), + } + : { + statusLabel: "待生成", + summary: + "当前还没有根据项目与启动输入生成执行预规划;补齐输入后会自动刷新。", + warnings: [], + unmetRequirements: [], + }; + const contextPlan = planResult?.contextOverlay + ? { + activeLayers: buildLabeledItems( + planResult.contextOverlay.compilerPlan.activeLayers, + CONTEXT_LAYER_LABELS, + ), + referenceCount: planResult.contextOverlay.compilerPlan.referenceCount, + memoryRefs: buildContextRefItems( + planResult.contextOverlay.snapshot.memoryRefs.length > 0 + ? planResult.contextOverlay.snapshot.memoryRefs + : planResult.contextOverlay.compilerPlan.memoryRefs, + ), + toolRefs: buildContextRefItems( + planResult.contextOverlay.snapshot.toolRefs.length > 0 + ? planResult.contextOverlay.snapshot.toolRefs + : planResult.contextOverlay.compilerPlan.toolRefs, + ), + tasteSummary: + planResult.contextOverlay.snapshot.tasteProfile?.summary?.trim() || + undefined, + notes: dedupeNonEmptyLines( + planResult.contextOverlay.compilerPlan.notes, + ), + } + : null; + const projectPackPlan = planResult?.projectPackPlan + ? { + ...projectPackPlanPresentation, + packKindLabel: + projectPackPlanPresentation.packKindLabel ?? + getSceneAppDeliveryContractLabel(planResult.projectPackPlan.packKind), + completionStrategyLabel: + projectPackPlanPresentation.completionStrategyLabel ?? + (PROJECT_PACK_COMPLETION_LABELS[ + planResult.projectPackPlan.completionStrategy + ] ?? + humanizeTokenLabel(planResult.projectPackPlan.completionStrategy)), + } + : null; return { id: descriptor.id, @@ -1199,6 +1387,9 @@ export function buildSceneAppDetailViewModel(params: { compositionBlueprintRef: descriptor.compositionProfile?.blueprintRef, compositionStepCount: descriptor.compositionProfile?.stepCount ?? 0, compositionSteps, + planning, + contextPlan, + projectPackPlan, scorecardProfileRef: descriptor.scorecardProfile?.profileRef, scorecardMetricKeys, scorecardFailureSignals, @@ -1210,6 +1401,7 @@ export function buildSceneAppScorecardViewModel( params: { descriptor: SceneAppDescriptor | null; scorecard: SceneAppScorecard | null; + planResult?: Pick | null; }, ): SceneAppScorecardViewModel | null { const { descriptor, scorecard } = params; @@ -1217,6 +1409,11 @@ export function buildSceneAppScorecardViewModel( return null; } + const projectPackPlan = buildProjectPackPlanPresentation({ + descriptor, + projectPackPlan: params.planResult?.projectPackPlan, + }); + if (!scorecard) { return { hasRuntimeScorecard: false, @@ -1234,13 +1431,10 @@ export function buildSceneAppScorecardViewModel( deliveryContractLabel: descriptor ? getSceneAppDeliveryContractLabel(descriptor.deliveryContract) : undefined, - viewerLabel: getSceneAppViewerKindLabel( - descriptor?.deliveryProfile?.viewerKind, - ), - deliveryRequiredParts: buildLabeledItems( - descriptor?.deliveryProfile?.requiredParts, - DELIVERY_PART_LABELS, - ), + viewerLabel: projectPackPlan.viewerLabel, + completionStrategyLabel: projectPackPlan.completionStrategyLabel, + deliveryRequiredParts: projectPackPlan.requiredParts, + packPlanNotes: projectPackPlan.notes, operatingNarrative: descriptor ? buildSceneAppScorecardNarrative(descriptor) : "当前还没有明确的经营口径。", @@ -1270,13 +1464,10 @@ export function buildSceneAppScorecardViewModel( deliveryContractLabel: descriptor ? getSceneAppDeliveryContractLabel(descriptor.deliveryContract) : undefined, - viewerLabel: getSceneAppViewerKindLabel( - descriptor?.deliveryProfile?.viewerKind, - ), - deliveryRequiredParts: buildLabeledItems( - descriptor?.deliveryProfile?.requiredParts, - DELIVERY_PART_LABELS, - ), + viewerLabel: projectPackPlan.viewerLabel, + completionStrategyLabel: projectPackPlan.completionStrategyLabel, + deliveryRequiredParts: projectPackPlan.requiredParts, + packPlanNotes: projectPackPlan.notes, operatingNarrative: descriptor ? buildSceneAppScorecardNarrative(descriptor) : "当前先按真实运行数据判断这个场景是否值得继续投入。", @@ -1314,10 +1505,18 @@ export function buildSceneAppRunListItemViewModel( } export function buildSceneAppRunDetailViewModel(params: { - descriptor: Pick; + descriptor: Pick< + SceneAppDescriptor, + "title" | "deliveryContract" | "deliveryProfile" + >; run: SceneAppRunSummary; + projectPackPlan?: SceneAppPlanResult["projectPackPlan"] | null; }): SceneAppRunDetailViewModel { const delivery = buildRunDeliveryPresentation(params.run); + const projectPackPlan = buildProjectPackPlanPresentation({ + descriptor: params.descriptor, + projectPackPlan: params.projectPackPlan, + }); const deliveryArtifactEntries = buildRunDeliveryArtifactEntries(params); const governanceActionEntries = buildRunGovernanceActionEntries(params.run); const governanceArtifactEntries = buildRunGovernanceArtifactEntries( @@ -1349,6 +1548,10 @@ export function buildSceneAppRunDetailViewModel(params: { deliveryMissingParts: delivery.missingParts, deliveryPartCoverageKnown: delivery.partCoverageKnown, deliveryViewerLabel: evidence.deliveryViewerLabel, + packCompletionStrategyLabel: projectPackPlan.completionStrategyLabel, + packViewerLabel: projectPackPlan.viewerLabel, + plannedDeliveryRequiredParts: projectPackPlan.requiredParts, + packPlanNotes: projectPackPlan.notes, deliveryArtifactEntries, governanceActionEntries, governanceArtifactEntries, @@ -1543,12 +1746,14 @@ export function buildSceneAppGovernancePanelViewModel(params: { descriptor: SceneAppDescriptor; scorecard: SceneAppScorecard | null; run: SceneAppRunSummary | null; + projectPackPlan?: SceneAppPlanResult["projectPackPlan"] | null; }): SceneAppGovernancePanelViewModel { const { descriptor, scorecard, run } = params; const detailView = run ? buildSceneAppRunDetailViewModel({ descriptor, run, + projectPackPlan: params.projectPackPlan, }) : null; const statusItems = buildSceneAppGovernanceStatusItems({ @@ -1559,6 +1764,7 @@ export function buildSceneAppGovernancePanelViewModel(params: { descriptor, scorecard, run, + projectPackPlan: params.projectPackPlan, }); return { @@ -1577,12 +1783,14 @@ export function buildSceneAppOperatingSummaryViewModel(params: { descriptor: SceneAppDescriptor; scorecard: SceneAppScorecard | null; run: SceneAppRunSummary | null; + projectPackPlan?: SceneAppPlanResult["projectPackPlan"] | null; }): SceneAppOperatingSummaryViewModel { const { descriptor, scorecard, run } = params; const detailView = run ? buildSceneAppRunDetailViewModel({ descriptor, run, + projectPackPlan: params.projectPackPlan, }) : null; const destinations = buildSceneAppGovernanceDestinations({ diff --git a/src/lib/sceneapp/types.ts b/src/lib/sceneapp/types.ts index e03f434cb..ae3003343 100644 --- a/src/lib/sceneapp/types.ts +++ b/src/lib/sceneapp/types.ts @@ -5,6 +5,17 @@ import type { TaskSchedule, } from "@/lib/api/automation"; import type { BaseSetupViewerKind } from "@/lib/base-setup/types"; +import type { + SceneAppContextOverlay, +} from "@/lib/context-layer"; + +export type { + ContextCompilerPlan, + ContextLayerSnapshot, + ReferenceItem, + SceneAppContextOverlay, + TasteProfile, +} from "@/lib/context-layer"; export type SceneAppType = | "local_instant" @@ -171,10 +182,21 @@ export interface SceneAppReadiness { unmetRequirements: SceneAppLaunchRequirement[]; } +export interface SceneAppProjectPackPlan { + packKind: SceneAppDeliveryContract; + primaryPart?: string; + requiredParts: string[]; + viewerKind?: BaseSetupViewerKind; + completionStrategy: string; + notes: string[]; +} + export interface SceneAppPlanResult { descriptor: SceneAppDescriptor; readiness: SceneAppReadiness; plan: SceneAppExecutionPlan; + contextOverlay?: SceneAppContextOverlay; + projectPackPlan?: SceneAppProjectPackPlan; } export interface SceneAppAutomationIntent { diff --git a/src/lib/tauri-mock/core.test.ts b/src/lib/tauri-mock/core.test.ts index 159c234db..585c36626 100644 --- a/src/lib/tauri-mock/core.test.ts +++ b/src/lib/tauri-mock/core.test.ts @@ -101,6 +101,15 @@ describe("tauri-mock/core invoke", () => { }), ).resolves.toEqual( expect.objectContaining({ + contextOverlay: expect.objectContaining({ + compilerPlan: expect.objectContaining({ + referenceCount: 2, + }), + }), + projectPackPlan: expect.objectContaining({ + completionStrategy: "required_parts_complete", + requiredParts: ["index.md", "meta.json"], + }), plan: expect.objectContaining({ adapterPlan: expect.objectContaining({ runtimeAction: "launch_browser_assist", @@ -117,6 +126,66 @@ describe("tauri-mock/core invoke", () => { expect(mocks.invokeViaHttp).not.toHaveBeenCalled(); }); + it("SceneApp mock 应在同一项目内复用上一次 context snapshot", async () => { + vi.mocked(shouldPreferMockInBrowser).mockReturnValue(true); + + await expect( + invoke("sceneapp_plan_launch", { + intent: { + sceneappId: "story-video-suite", + workspaceId: "workspace-default", + projectId: "project-video", + userInput: "根据发布会亮点生成 30 秒短视频草稿", + runtimeContext: { + cloudSessionReady: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + contextOverlay: expect.objectContaining({ + compilerPlan: expect.objectContaining({ + referenceCount: 1, + }), + }), + }), + ); + + await expect( + invoke("sceneapp_plan_launch", { + intent: { + sceneappId: "story-video-suite", + workspaceId: "workspace-default", + projectId: "project-video", + runtimeContext: { + cloudSessionReady: true, + }, + }, + }), + ).resolves.toEqual( + expect.objectContaining({ + contextOverlay: expect.objectContaining({ + compilerPlan: expect.objectContaining({ + referenceCount: 1, + notes: expect.arrayContaining([ + expect.stringContaining("已从项目上下文恢复 1 条历史参考"), + expect.stringContaining("当前 planning 直接复用了 1 条项目级参考"), + expect.stringContaining("当前已复用项目级 TasteProfile"), + ]), + }), + snapshot: expect.objectContaining({ + tasteProfile: expect.objectContaining({ + summary: + "当前 TasteProfile 已在项目沉淀基础上,结合 1 条参考输入更新启发式摘要。", + }), + }), + }), + }), + ); + + expect(mocks.invokeViaHttp).not.toHaveBeenCalled(); + }); + it("工具库存 fallback mock 不应再返回空壳清单", async () => { mocks.invokeViaHttp.mockRejectedValueOnce(new Error("Failed to fetch")); const consoleWarnSpy = vi diff --git a/src/lib/tauri-mock/core.ts b/src/lib/tauri-mock/core.ts index 5d67613bb..70c6063c2 100644 --- a/src/lib/tauri-mock/core.ts +++ b/src/lib/tauri-mock/core.ts @@ -7,8 +7,10 @@ import type { CompanionPetStatus } from "../api/companion"; import type { AgentRun } from "../api/executionRun"; import type { SceneAppCatalog, + SceneAppContextOverlay, SceneAppDescriptor, SceneAppPlanResult, + SceneAppProjectPackPlan, SceneAppRuntimeAdapterPlan, SceneAppScorecard, } from "../api/sceneapp"; @@ -51,6 +53,16 @@ const deprecatedAgentCommandMocks = Object.fromEntries( ), ) as Record never>; +type MockPersistedSceneAppContext = { + sceneappId: string; + workspaceId?: string | null; + projectId?: string | null; + referenceItems: SceneAppContextOverlay["snapshot"]["referenceItems"]; + tasteProfile?: SceneAppContextOverlay["snapshot"]["tasteProfile"]; +}; + +const mockSceneAppContextStore = new Map(); + function normalizeMockMediaTaskId(taskRef?: string): string { const normalized = (taskRef || "task-image-mock-1") .trim() @@ -1294,13 +1306,13 @@ function findMockSceneAppDescriptor(id?: string): SceneAppDescriptor | null { } function extractMockSceneAppUrlCandidate( - userInput?: string, + text?: string, ): string | undefined { - if (typeof userInput !== "string") { + if (typeof text !== "string") { return undefined; } - return userInput + return text .split(/\s+/) .find( (segment) => @@ -1309,6 +1321,352 @@ function extractMockSceneAppUrlCandidate( ?.replace(/["')\]},.>,。)]+$/g, ""); } +function normalizeMockSceneAppOptionalId(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + const normalized = value.trim(); + return normalized || null; +} + +function resolveMockSceneAppWorkspaceId(intent: Record) { + return ( + normalizeMockSceneAppOptionalId(intent.workspaceId) ?? + normalizeMockSceneAppOptionalId(intent.workspace_id) + ); +} + +function resolveMockSceneAppProjectId(intent: Record) { + return ( + normalizeMockSceneAppOptionalId(intent.projectId) ?? + normalizeMockSceneAppOptionalId(intent.project_id) + ); +} + +function resolveMockSceneAppContextStoreKey( + sceneappId: string, + intent: Record, +): string | null { + const projectId = resolveMockSceneAppProjectId(intent); + const workspaceId = resolveMockSceneAppWorkspaceId(intent); + const scopeId = projectId ?? workspaceId; + if (!scopeId) { + return null; + } + return `${sceneappId.trim()}::${scopeId}`; +} + +function pushUniqueMock(values: string[], value?: string | null) { + if (typeof value !== "string") { + return; + } + const normalized = value.trim(); + if (!normalized || values.includes(normalized)) { + return; + } + values.push(normalized); +} + +function truncateMockSceneAppSummary(value: string, maxChars: number) { + const trimmed = value.trim(); + const chars = Array.from(trimmed); + if (chars.length <= maxChars) { + return trimmed; + } + return `${chars.slice(0, maxChars).join("")}…`; +} + +function stableMockSceneAppReferenceItemId( + prefix: string, + key: string, + value: string, +) { + let hash = 2166136261; + for (const ch of `${prefix}:${key}:${value.trim()}`) { + hash ^= ch.charCodeAt(0); + hash = Math.imul(hash, 16777619); + } + return `${prefix}-${key}-${(hash >>> 0).toString(16)}`; +} + +function buildMockSceneAppReferenceItems(intent: Record) { + const items = [] as NonNullable< + SceneAppContextOverlay["snapshot"]["referenceItems"] + >; + const userInput = + typeof intent.userInput === "string" + ? intent.userInput.trim() + : typeof intent.user_input === "string" + ? intent.user_input.trim() + : ""; + if (userInput) { + const userInputUrl = extractMockSceneAppUrlCandidate(userInput); + items.push({ + id: stableMockSceneAppReferenceItemId("user-input", "input", userInput), + label: "用户输入", + sourceKind: "user_input", + contentType: userInputUrl ? "url" : "text", + uri: userInputUrl ?? null, + summary: truncateMockSceneAppSummary(userInput, 80), + selected: true, + }); + } + + const slots = + intent.slots && typeof intent.slots === "object" + ? (intent.slots as Record) + : {}; + for (const [key, rawValue] of Object.entries(slots)) { + if (typeof rawValue !== "string" || !rawValue.trim()) { + continue; + } + const slotUrl = extractMockSceneAppUrlCandidate(rawValue); + items.push({ + id: stableMockSceneAppReferenceItemId("slot", key, rawValue.trim()), + label: key, + sourceKind: "slot", + contentType: slotUrl ? "url" : "slot", + uri: slotUrl ?? null, + summary: truncateMockSceneAppSummary(rawValue, 80), + selected: true, + }); + } + + return items; +} + +function mergeMockSceneAppReferenceItems( + inputItems: NonNullable, + persistedContext?: MockPersistedSceneAppContext | null, +) { + const seen = new Set(); + const merged = [] as NonNullable< + SceneAppContextOverlay["snapshot"]["referenceItems"] + >; + + for (const item of inputItems) { + if (seen.has(item.id)) { + continue; + } + seen.add(item.id); + merged.push(item); + } + + for (const item of persistedContext?.referenceItems ?? []) { + if (seen.has(item.id)) { + continue; + } + seen.add(item.id); + merged.push({ + ...item, + sourceKind: "reference_library", + }); + } + + return merged; +} + +function buildMockSceneAppTasteProfile( + descriptor: SceneAppDescriptor, + intent: Record, + referenceItems: NonNullable, + persistedContext?: MockPersistedSceneAppContext | null, +) { + const userInput = + typeof intent.userInput === "string" + ? intent.userInput.trim() + : typeof intent.user_input === "string" + ? intent.user_input.trim() + : ""; + const persistedTasteProfile = persistedContext?.tasteProfile ?? null; + if (!referenceItems.length && !userInput) { + return persistedTasteProfile; + } + + const keywords: string[] = []; + for (const keyword of persistedTasteProfile?.keywords ?? []) { + pushUniqueMock(keywords, keyword); + } + for (const alias of (descriptor.aliases ?? []).slice(0, 2)) { + pushUniqueMock(keywords, alias); + } + const slots = + intent.slots && typeof intent.slots === "object" + ? (intent.slots as Record) + : {}; + for (const [key, rawValue] of Object.entries(slots)) { + if (typeof rawValue !== "string" || !rawValue.trim()) { + continue; + } + const normalizedKey = key.trim().toLowerCase(); + if ( + !["style", "tone", "mood", "platform", "target_language", "duration"].includes( + normalizedKey, + ) + ) { + continue; + } + pushUniqueMock( + keywords, + `${key}:${truncateMockSceneAppSummary(rawValue, 24)}`, + ); + } + if (descriptor.patternPrimary === "reviewer") { + pushUniqueMock(keywords, "review-first"); + } + pushUniqueMock(keywords, descriptor.outputHint); + + const avoidKeywords: string[] = []; + for (const keyword of persistedTasteProfile?.avoidKeywords ?? []) { + pushUniqueMock(avoidKeywords, keyword); + } + if (referenceItems.length > 0) { + pushUniqueMock(avoidKeywords, "偏离参考素材"); + } + + return { + profileId: `taste-${descriptor.id}`, + summary: + persistedTasteProfile && referenceItems.length > 0 + ? `当前 TasteProfile 已在项目沉淀基础上,结合 ${referenceItems.length} 条参考输入更新启发式摘要。` + : persistedTasteProfile + ? "当前 TasteProfile 已从项目上下文恢复。" + : referenceItems.length === 0 + ? "当前 TasteProfile 先基于用户输入与场景画像生成启发式摘要。" + : `当前 TasteProfile 先基于 ${referenceItems.length} 条参考输入与场景画像生成启发式摘要。`, + keywords, + avoidKeywords, + derivedFromReferenceIds: referenceItems.map((item) => item.id), + confidence: + persistedTasteProfile && referenceItems.length > 0 + ? 0.72 + : persistedTasteProfile + ? 0.64 + : referenceItems.length === 0 + ? 0.38 + : 0.56, + }; +} + +function buildMockSceneAppContextOverlay( + descriptor: SceneAppDescriptor, + intent: Record, + persistedContext?: MockPersistedSceneAppContext | null, +): SceneAppContextOverlay { + const inputReferenceItems = buildMockSceneAppReferenceItems(intent); + const referenceItems = mergeMockSceneAppReferenceItems( + inputReferenceItems, + persistedContext, + ); + const runtimeContext = + intent.runtimeContext && typeof intent.runtimeContext === "object" + ? (intent.runtimeContext as Record) + : {}; + const memoryRefs: string[] = []; + const toolRefs = [...descriptor.capabilityRefs]; + const skillRefs: string[] = [ + descriptor.id, + descriptor.linkedServiceSkillId, + descriptor.linkedSceneKey, + descriptor.compositionProfile?.blueprintRef, + ].filter((value): value is string => typeof value === "string" && Boolean(value)); + + pushUniqueMock( + memoryRefs, + typeof intent.workspaceId === "string" + ? `workspace:${intent.workspaceId}` + : typeof intent.workspace_id === "string" + ? `workspace:${intent.workspace_id}` + : null, + ); + pushUniqueMock( + memoryRefs, + typeof intent.projectId === "string" + ? `project:${intent.projectId}` + : typeof intent.project_id === "string" + ? `project:${intent.project_id}` + : null, + ); + const userInput = + typeof intent.userInput === "string" + ? intent.userInput.trim() + : typeof intent.user_input === "string" + ? intent.user_input.trim() + : ""; + if (userInput) { + pushUniqueMock(memoryRefs, "memory_profile:user_input"); + } + if (runtimeContext.browserSessionAttached === true) { + pushUniqueMock(toolRefs, "browser_session"); + } + if (runtimeContext.cloudSessionReady === true) { + pushUniqueMock(toolRefs, "cloud_session"); + } + if (runtimeContext.automationEnabled === true) { + pushUniqueMock(toolRefs, "automation"); + } + + const activeLayers = ["skill", "tool"]; + if (memoryRefs.length > 0) { + activeLayers.push("memory"); + } + if (referenceItems.length > 0) { + activeLayers.push("reference"); + } + + const tasteProfile = buildMockSceneAppTasteProfile( + descriptor, + intent, + referenceItems, + persistedContext, + ); + if (tasteProfile) { + activeLayers.push("taste"); + } + + const restoredReferenceCount = persistedContext?.referenceItems.length ?? 0; + const notes: string[] = []; + if (memoryRefs.length > 0) { + notes.push(`已装配 ${memoryRefs.length} 条 memory 引用。`); + } + if (restoredReferenceCount > 0) { + notes.push(`已从项目上下文恢复 ${restoredReferenceCount} 条历史参考。`); + } + if (inputReferenceItems.length === 0 && referenceItems.length === 0) { + notes.push("当前尚未选中显式参考素材,将主要依赖用户输入与场景画像。"); + } else if (inputReferenceItems.length > 0) { + notes.push( + `本次新增 ${inputReferenceItems.length} 条参考输入,当前 planning 共带上 ${referenceItems.length} 条参考。`, + ); + } else if (referenceItems.length > 0) { + notes.push(`当前 planning 直接复用了 ${referenceItems.length} 条项目级参考。`); + } + if (persistedContext?.tasteProfile) { + notes.push("当前已复用项目级 TasteProfile,并按最新输入继续更新。"); + } else if (tasteProfile) { + notes.push("当前 TasteProfile 为启发式摘要,后续可继续接入持久化反馈回写。"); + } + + return { + compilerPlan: { + activeLayers, + memoryRefs, + toolRefs, + referenceCount: referenceItems.length, + notes, + }, + snapshot: { + workspaceId: resolveMockSceneAppWorkspaceId(intent), + projectId: resolveMockSceneAppProjectId(intent), + skillRefs, + memoryRefs, + toolRefs, + referenceItems, + tasteProfile, + }, + }; +} + function buildMockSceneAppAdapterPlan( descriptor: SceneAppDescriptor, intent: Record, @@ -1681,6 +2039,13 @@ function buildMockSceneAppPlanResult( const resolvedDescriptor = descriptor ?? mockSceneAppCatalog.items[0]!; const intent = (args?.intent as Record | undefined) ?? args ?? {}; + const persistedContextKey = resolveMockSceneAppContextStoreKey( + resolvedDescriptor.id, + intent, + ); + const persistedContext = persistedContextKey + ? (mockSceneAppContextStore.get(persistedContextKey) ?? null) + : null; const runtimeContext = (intent.runtimeContext as Record | undefined) ?? {}; const unmetRequirements = resolvedDescriptor.launchRequirements.filter( @@ -1709,6 +2074,28 @@ function buildMockSceneAppPlanResult( return false; }, ); + const contextOverlay = buildMockSceneAppContextOverlay( + resolvedDescriptor, + intent, + persistedContext, + ); + if (persistedContextKey) { + mockSceneAppContextStore.set(persistedContextKey, { + sceneappId: resolvedDescriptor.id, + workspaceId: contextOverlay.snapshot.workspaceId ?? null, + projectId: contextOverlay.snapshot.projectId ?? null, + referenceItems: contextOverlay.snapshot.referenceItems, + tasteProfile: contextOverlay.snapshot.tasteProfile ?? null, + }); + } + + const warnings = + unmetRequirements.length > 0 + ? ["当前 SceneApp 仍有未满足的启动前置条件。"] + : []; + if (!persistedContextKey) { + warnings.push("当前未解析到项目目录,暂未写入项目级 Context Snapshot。"); + } return { descriptor: resolvedDescriptor, @@ -1716,6 +2103,12 @@ function buildMockSceneAppPlanResult( ready: unmetRequirements.length === 0, unmetRequirements, }, + contextOverlay, + projectPackPlan: buildMockSceneAppProjectPackPlan( + resolvedDescriptor, + intent, + contextOverlay, + ), plan: { sceneappId: resolvedDescriptor.id, executorKind: @@ -1734,14 +2127,62 @@ function buildMockSceneAppPlanResult( : "workspace_bundle", artifactContract: resolvedDescriptor.deliveryContract, governanceHooks: ["evidence_pack", "scorecard"], - warnings: - unmetRequirements.length > 0 - ? ["当前 SceneApp 仍有未满足的启动前置条件。"] - : [], + warnings, }, }; } +function buildMockSceneAppProjectPackPlan( + descriptor: SceneAppDescriptor, + intent: Record, + contextOverlay: SceneAppContextOverlay, +): SceneAppProjectPackPlan { + const requiredParts = Array.from( + new Set( + ( + descriptor.deliveryProfile?.requiredParts.length + ? descriptor.deliveryProfile.requiredParts + : descriptor.compositionProfile?.steps.map((step) => step.id) ?? [] + ) + .map((part) => part.trim()) + .filter(Boolean), + ), + ); + const projectId = + typeof intent.projectId === "string" && intent.projectId.trim() + ? intent.projectId.trim() + : null; + + return { + packKind: descriptor.deliveryContract, + primaryPart: + descriptor.deliveryProfile?.primaryPart ?? requiredParts[0] ?? undefined, + requiredParts, + viewerKind: descriptor.deliveryProfile?.viewerKind, + completionStrategy: + requiredParts.length > 0 + ? "required_parts_complete" + : descriptor.infraProfile.includes("workspace_storage") + ? "workspace_artifact_writeback" + : "artifact_writeback", + notes: [ + descriptor.deliveryContract === "project_pack" + ? "当前 SceneApp 以结果包作为默认交付单位。" + : "当前 SceneApp 会沿现有结果交付主链回写。", + requiredParts.length > 0 + ? `完整度将按 ${requiredParts.length} 个必含部件判断。` + : "当前场景暂按结果文件回流判断交付。", + projectId + ? `结果会优先回写到项目 ${projectId},便于继续编辑与复盘。` + : "当前还没有绑定项目,结果包只能先按运行时结果临时回流。", + contextOverlay.compilerPlan.referenceCount > 0 || + contextOverlay.snapshot.tasteProfile + ? "结果包会连同参考与风格快照一起进入后续治理链。" + : "结果包会继续进入 evidence 与 scorecard 主链。", + ], + }; +} + function buildMockSceneAppScorecard(sceneappId: string): SceneAppScorecard { if (sceneappId === "story-video-suite") { return { @@ -6243,6 +6684,7 @@ export function mockCommand(cmd: string, handler: (...args: any[]) => any) { */ export function clearMocks() { mockCommands.clear(); + mockSceneAppContextStore.clear(); } /**