feat: enrich sceneapp context surfaces

This commit is contained in:
coso
2026-04-17 08:28:15 +08:00
parent b400187197
commit b98578c7a4
28 changed files with 2652 additions and 34 deletions
+15
View File
@@ -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", ...)`
- 当前已改成:
+2 -1
View File
@@ -25,9 +25,10 @@ pub async fn sceneapp_get_descriptor(id: String) -> Result<Option<SceneAppDescri
#[tauri::command]
pub async fn sceneapp_plan_launch(
db: State<'_, DbConnection>,
intent: SceneAppLaunchIntent,
) -> Result<SceneAppPlanResult, String> {
SceneAppService::plan_launch(intent)
SceneAppService::plan_launch(db.inner(), intent)
}
#[tauri::command]
+49 -4
View File
@@ -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<SceneAppPlanResult, String> {
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<SceneAppPlanResult, String> {
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<SceneAppRunSummary> {
+473
View File
@@ -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<String>, value: Option<String>) {
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::<String>();
summary.push('…');
summary
}
fn extract_url_candidate(text: &str) -> Option<String> {
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<ReferenceItem> {
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<ReferenceItem>,
persisted_context: Option<&PersistedSceneAppContext>,
) -> Vec<ReferenceItem> {
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<String> {
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<String> {
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<String> {
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<TasteProfile> {
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("项目沉淀基础")));
}
}
+65
View File
@@ -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<String>,
pub summary: Option<String>,
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<String>,
pub avoid_keywords: Vec<String>,
pub derived_from_reference_ids: Vec<String>,
pub confidence: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct ContextCompilerPlan {
pub active_layers: Vec<String>,
pub memory_refs: Vec<String>,
pub tool_refs: Vec<String>,
pub reference_count: usize,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct ContextLayerSnapshot {
pub workspace_id: Option<String>,
pub project_id: Option<String>,
pub skill_refs: Vec<String>,
pub memory_refs: Vec<String>,
pub tool_refs: Vec<String>,
pub reference_items: Vec<ReferenceItem>,
pub taste_profile: Option<TasteProfile>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct SceneAppContextOverlay {
pub compiler_plan: ContextCompilerPlan,
pub snapshot: ContextLayerSnapshot,
}
+3
View File
@@ -0,0 +1,3 @@
pub mod compiler;
pub mod dto;
pub mod store;
+241
View File
@@ -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<String>,
pub project_id: Option<String>,
#[serde(default)]
pub reference_items: Vec<ReferenceItem>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub taste_profile: Option<TasteProfile>,
}
fn normalize_optional_id(value: Option<&str>) -> Option<String> {
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<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<Option<PersistedSceneAppContext>, 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::<PersistedSceneAppContext>(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<Option<PathBuf>, 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));
}
}
+16
View File
@@ -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<SceneAppLaunchRequirement>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SceneAppProjectPackPlan {
pub pack_kind: SceneAppDeliveryContract,
pub primary_part: Option<String>,
pub required_parts: Vec<String>,
pub viewer_kind: Option<String>,
pub completion_strategy: String,
pub notes: Vec<String>,
}
#[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<SceneAppContextOverlay>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub project_pack_plan: Option<SceneAppProjectPackPlan>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
+1
View File
@@ -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;
+230 -1
View File
@@ -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<SceneAppExecutionPlan
.collect()
}
fn dedupe_strings<I>(values: I) -> Vec<String>
where
I: IntoIterator<Item = String>,
{
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<String> {
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::<Vec<_>>(),
)
})
.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")));
}
}
@@ -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,
@@ -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({
</div>
</div>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<div className="rounded-[24px] border border-slate-200 bg-white p-4">
<div className="flex items-start justify-between gap-3">
<div className="text-sm font-medium text-slate-900">执行预规划</div>
<span className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] font-medium text-slate-600">
{detailView.planning.statusLabel}
</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-600">
{detailView.planning.summary}
</p>
{planLoading ? (
<p className="mt-3 text-xs font-medium text-lime-700">
正在根据当前项目与输入刷新预规划…
</p>
) : null}
{planError ? (
<p className="mt-3 text-sm leading-6 text-amber-700">{planError}</p>
) : null}
{detailView.planning.unmetRequirements.length ? (
<div className="mt-4">
<div className="text-xs font-medium text-slate-500">仍待补齐</div>
<div
data-testid="sceneapp-detail-planning-unmet"
className="mt-2 flex flex-col gap-2"
>
{detailView.planning.unmetRequirements.map((message, index) => (
<div
key={`${detailView.id}-planning-unmet-${index}`}
className="rounded-[18px] border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm leading-6 text-amber-800"
>
{message}
</div>
))}
</div>
</div>
) : null}
{detailView.contextPlan ? (
<div className="mt-4 space-y-3">
{detailView.contextPlan.activeLayers.length ? (
<div>
<div className="text-xs font-medium text-slate-500">活跃层</div>
<div
data-testid="sceneapp-detail-context-layers"
className="mt-2 flex flex-wrap gap-2"
>
{detailView.contextPlan.activeLayers.map((layer) => (
<span
key={layer.key}
className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[11px] font-medium text-sky-700"
>
{layer.label}
</span>
))}
</div>
</div>
) : null}
<div
data-testid="sceneapp-detail-context-reference-count"
className="text-sm leading-6 text-slate-600"
>
<span className="font-medium text-slate-700">参考注入:</span>
{detailView.contextPlan.referenceCount} 条
</div>
{detailView.contextPlan.memoryRefs.length ? (
<div>
<div className="text-xs font-medium text-slate-500">记忆引用</div>
<div className="mt-2 flex flex-wrap gap-2">
{detailView.contextPlan.memoryRefs.map((memoryRef) => (
<span
key={memoryRef.key}
className="rounded-full border border-slate-200 bg-slate-50 px-2.5 py-1 text-[11px] font-medium text-slate-600"
>
{memoryRef.label}
</span>
))}
</div>
</div>
) : null}
{detailView.contextPlan.toolRefs.length ? (
<div>
<div className="text-xs font-medium text-slate-500">工具开放面</div>
<div className="mt-2 flex flex-wrap gap-2">
{detailView.contextPlan.toolRefs.map((toolRef) => (
<span
key={toolRef.key}
className="rounded-full border border-emerald-200 bg-emerald-50 px-2.5 py-1 text-[11px] font-medium text-emerald-700"
>
{toolRef.label}
</span>
))}
</div>
</div>
) : null}
{detailView.contextPlan.tasteSummary ? (
<div
data-testid="sceneapp-detail-context-taste-summary"
className="rounded-[18px] border border-lime-200 bg-lime-50/70 px-3 py-2 text-sm leading-6 text-lime-900"
>
<span className="font-medium">风格摘要:</span>
{detailView.contextPlan.tasteSummary}
</div>
) : null}
{detailView.contextPlan.notes.length ? (
<div className="space-y-2">
{detailView.contextPlan.notes.map((note, index) => (
<div
key={`${detailView.id}-context-note-${index}`}
className="rounded-[18px] border border-slate-200 bg-slate-50 px-3 py-2 text-sm leading-6 text-slate-600"
>
{note}
</div>
))}
</div>
) : null}
</div>
) : (
<p className="mt-4 text-sm leading-6 text-slate-500">
当前还没有生成上下文覆盖层,继续补项目或启动输入后会自动刷新。
</p>
)}
{detailView.planning.warnings.length ? (
<div className="mt-4 space-y-2">
{detailView.planning.warnings.map((warning, index) => (
<div
key={`${detailView.id}-planning-warning-${index}`}
className="rounded-[18px] border border-amber-200 bg-amber-50 px-3 py-2 text-sm leading-6 text-amber-800"
>
{warning}
</div>
))}
</div>
) : null}
</div>
<div className="rounded-[24px] border border-slate-200 bg-white p-4">
<div className="text-sm font-medium text-slate-900">Project Pack 规划</div>
{detailView.projectPackPlan ? (
<>
<div className="mt-3 space-y-2 text-sm leading-6 text-slate-600">
<div>
<span className="font-medium text-slate-700">交付形态:</span>
{detailView.projectPackPlan.packKindLabel}
</div>
<div data-testid="sceneapp-detail-pack-strategy">
<span className="font-medium text-slate-700">完成策略:</span>
{detailView.projectPackPlan.completionStrategyLabel}
</div>
{detailView.projectPackPlan.primaryPart ? (
<div>
<span className="font-medium text-slate-700">主件:</span>
{detailView.projectPackPlan.primaryPart}
</div>
) : null}
{detailView.projectPackPlan.viewerLabel ? (
<div>
<span className="font-medium text-slate-700">查看方式:</span>
{detailView.projectPackPlan.viewerLabel}
</div>
) : null}
</div>
{detailView.projectPackPlan.requiredParts.length ? (
<div
data-testid="sceneapp-detail-pack-required-parts"
className="mt-3 flex flex-wrap gap-2"
>
{detailView.projectPackPlan.requiredParts.map((part) => (
<span
key={part.key}
className="rounded-full border border-lime-200 bg-lime-50 px-2.5 py-1 text-[11px] font-medium text-lime-700"
>
{part.label}
</span>
))}
</div>
) : (
<p className="mt-3 text-sm leading-6 text-slate-500">
当前规划还没有显式声明整包部件,继续沿现有结果回流主链执行。
</p>
)}
{detailView.projectPackPlan.notes.length ? (
<div className="mt-4 space-y-2">
{detailView.projectPackPlan.notes.map((note, index) => (
<div
key={`${detailView.id}-pack-note-${index}`}
className="rounded-[18px] border border-slate-200 bg-slate-50 px-3 py-2 text-sm leading-6 text-slate-600"
>
{note}
</div>
))}
</div>
) : null}
</>
) : (
<p className="mt-3 text-sm leading-6 text-slate-500">
当前规划还没有显式暴露 Project Pack 结果,继续沿现有交付合同运行。
</p>
)}
</div>
</div>
<div className="rounded-[24px] border border-slate-200 bg-white p-4">
<div className="text-sm font-medium text-slate-900">启动输入</div>
<p className="mt-1 text-sm leading-6 text-slate-500">
@@ -277,6 +277,79 @@ export function SceneAppRunDetailPanel({
</div>
</div>
) : null}
{runDetailView.packCompletionStrategyLabel ||
runDetailView.packViewerLabel ||
runDetailView.plannedDeliveryRequiredParts.length ||
runDetailView.packPlanNotes.length ? (
<div className="mt-4 rounded-[18px] border border-dashed border-slate-200 bg-white p-4">
<div className="text-xs font-medium text-slate-500">
规划结果包基线
</div>
<p className="mt-2 text-sm leading-6 text-slate-600">
这部分展示预规划阶段承诺的结果包口径,方便和当前真实回流的交付覆盖率对照。
</p>
{runDetailView.packCompletionStrategyLabel ? (
<div
data-testid="sceneapp-run-detail-pack-strategy"
className="mt-3 text-sm text-slate-700"
>
<span className="font-medium text-slate-900">完成口径:</span>
{runDetailView.packCompletionStrategyLabel}
</div>
) : null}
{runDetailView.packViewerLabel ? (
<div className="mt-2 text-sm text-slate-700">
<span className="font-medium text-slate-900">规划入口:</span>
{runDetailView.packViewerLabel}
</div>
) : null}
{runDetailView.plannedDeliveryRequiredParts.length ? (
<div className="mt-3">
<div className="text-xs font-medium text-slate-500">
规划必含部件
</div>
<div
data-testid="sceneapp-run-detail-pack-required-parts"
className="mt-2 flex flex-wrap gap-2"
>
{runDetailView.plannedDeliveryRequiredParts.map((part) => (
<span
key={`planned-${part.key}`}
className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[11px] font-medium text-sky-700"
>
{part.label}
</span>
))}
</div>
</div>
) : null}
{runDetailView.packPlanNotes.length ? (
<div className="mt-3">
<div className="text-xs font-medium text-slate-500">
规划备注
</div>
<div
data-testid="sceneapp-run-detail-pack-notes"
className="mt-2 flex flex-wrap gap-2"
>
{runDetailView.packPlanNotes.map((note) => (
<span
key={note}
className="rounded-full border border-lime-200 bg-lime-50 px-2.5 py-1 text-[11px] font-medium text-lime-700"
>
{note}
</span>
))}
</div>
</div>
) : null}
</div>
) : null}
</div>
<div className="mt-4 rounded-[22px] border border-slate-200 bg-white p-4">
@@ -65,7 +65,9 @@ export function SceneAppScorecardPanel({
</p>
{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}
</div>
) : null}
{scorecardView.completionStrategyLabel ? (
<div
data-testid="sceneapp-scorecard-completion-strategy"
className="mt-2 text-sm text-slate-700"
>
<span className="font-medium text-slate-900">完成口径:</span>
{scorecardView.completionStrategyLabel}
</div>
) : null}
{scorecardView.deliveryRequiredParts.length ? (
<div
data-testid="sceneapp-scorecard-delivery-parts"
@@ -148,6 +159,26 @@ export function SceneAppScorecardPanel({
{scorecardView.topFailureSignalLabel}
</div>
) : null}
{scorecardView.packPlanNotes.length ? (
<div className="mt-3">
<div className="text-xs font-medium text-slate-500">
规划备注
</div>
<div
data-testid="sceneapp-scorecard-pack-notes"
className="mt-2 flex flex-wrap gap-2"
>
{scorecardView.packPlanNotes.map((note) => (
<span
key={note}
className="rounded-full border border-sky-200 bg-sky-50 px-2.5 py-1 text-[11px] font-medium text-sky-700"
>
{note}
</span>
))}
</div>
</div>
) : null}
{scorecardView.observedFailureSignals.length ? (
<div
data-testid="sceneapp-scorecard-observed-failure-signals"
@@ -14,6 +14,8 @@ import type { Page, PageParams } from "@/types/page";
type SceneAppPlanResultOverrides = {
descriptor?: Partial<SceneAppPlanResult["descriptor"]>;
readiness?: Partial<SceneAppPlanResult["readiness"]>;
contextOverlay?: Partial<NonNullable<SceneAppPlanResult["contextOverlay"]>>;
projectPackPlan?: Partial<NonNullable<SceneAppPlanResult["projectPackPlan"]>>;
plan?: Partial<Omit<SceneAppPlanResult["plan"], "adapterPlan">> & {
adapterPlan?: Partial<SceneAppPlanResult["plan"]["adapterPlan"]>;
};
@@ -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,
@@ -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
@@ -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<SceneAppRunSummary | null>(null);
const [selectedRunLoading, setSelectedRunLoading] = useState(false);
const [selectedRunError, setSelectedRunError] = useState<string | null>(null);
const [selectedPlanResult, setSelectedPlanResult] =
useState<SceneAppPlanResult | null>(null);
const [selectedPlanLoading, setSelectedPlanLoading] = useState(false);
const [selectedPlanError, setSelectedPlanError] = useState<string | null>(
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<SceneAppEntryCardItem | null>(() => {
if (!selectedDescriptor) {
@@ -723,14 +738,77 @@ export function useSceneAppsPageRuntime({
urlCandidate,
});
}, [launchInput, selectedDescriptor, selectedProjectId, urlCandidate]);
const previewLaunchSeed = useMemo<SceneAppSeed | null>(() => {
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,
+29
View File
@@ -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",
}),
}),
);
+6
View File
@@ -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<SceneAppCatalog> {
+1
View File
@@ -0,0 +1 @@
export * from "./types";
+50
View File
@@ -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;
}
+33
View File
@@ -5,6 +5,8 @@ import type { SceneAppPlanResult } from "./types";
type SceneAppPlanResultOverrides = {
descriptor?: Partial<SceneAppPlanResult["descriptor"]>;
readiness?: Partial<SceneAppPlanResult["readiness"]>;
contextOverlay?: Partial<NonNullable<SceneAppPlanResult["contextOverlay"]>>;
projectPackPlan?: Partial<NonNullable<SceneAppPlanResult["projectPackPlan"]>>;
plan?: Partial<Omit<SceneAppPlanResult["plan"], "adapterPlan">> & {
adapterPlan?: Partial<SceneAppPlanResult["plan"]["adapterPlan"]>;
};
@@ -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 主链。");
});
+2
View File
@@ -165,6 +165,8 @@ function readRequestMetadata(result: SceneAppPlanResult): Record<string, unknown
function resolveSceneAppNotes(result: SceneAppPlanResult): string[] {
return dedupeStrings([
...(result.contextOverlay?.compilerPlan.notes ?? []),
...(result.projectPackPlan?.notes ?? []),
...result.plan.warnings,
...result.plan.adapterPlan.notes,
result.readiness.ready ? undefined : "当前 SceneApp 仍有未满足的启动前置条件。",
+175
View File
@@ -10,10 +10,23 @@ import {
} from "./product";
import type {
SceneAppDescriptor,
SceneAppPlanResult,
SceneAppRunSummary,
SceneAppScorecard,
} from "./types";
type SceneAppPlanResultOverrides = {
descriptor?: Partial<SceneAppDescriptor>;
readiness?: Partial<SceneAppPlanResult["readiness"]>;
contextOverlay?: Partial<NonNullable<SceneAppPlanResult["contextOverlay"]>>;
projectPackPlan?: Partial<
NonNullable<SceneAppPlanResult["projectPackPlan"]>
>;
plan?: Partial<Omit<SceneAppPlanResult["plan"], "adapterPlan">> & {
adapterPlan?: Partial<SceneAppPlanResult["plan"]["adapterPlan"]>;
};
};
function createDescriptor(
overrides: Partial<SceneAppDescriptor> = {},
): 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> = {},
): 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(),
+224 -16
View File
@@ -6,6 +6,7 @@ import type {
SceneAppGovernanceArtifactKind,
SceneAppGovernanceArtifactRef,
SceneAppNativeSkillRuntimeRef,
SceneAppPlanResult,
SceneAppRunSummary,
SceneAppScorecard,
} from "./types";
@@ -76,6 +77,20 @@ const SCORECARD_SIGNAL_LABELS: Record<string, string> = {
runtime_failure: "运行链稳定性",
};
const CONTEXT_LAYER_LABELS: Record<string, string> = {
skill: "Skill",
memory: "Memory",
taste: "Taste",
tool: "Tool",
reference: "Reference",
};
const PROJECT_PACK_COMPLETION_LABELS: Record<string, string> = {
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<SceneAppDescriptor, "deliveryContract" | "deliveryProfile">
| 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<SceneAppPlanResult, "projectPackPlan"> | 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<SceneAppDescriptor, "title" | "deliveryProfile">;
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({
+22
View File
@@ -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 {
+69
View File
@@ -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
+449 -7
View File
@@ -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<string, () => never>;
type MockPersistedSceneAppContext = {
sceneappId: string;
workspaceId?: string | null;
projectId?: string | null;
referenceItems: SceneAppContextOverlay["snapshot"]["referenceItems"];
tasteProfile?: SceneAppContextOverlay["snapshot"]["tasteProfile"];
};
const mockSceneAppContextStore = new Map<string, MockPersistedSceneAppContext>();
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<string, unknown>) {
return (
normalizeMockSceneAppOptionalId(intent.workspaceId) ??
normalizeMockSceneAppOptionalId(intent.workspace_id)
);
}
function resolveMockSceneAppProjectId(intent: Record<string, unknown>) {
return (
normalizeMockSceneAppOptionalId(intent.projectId) ??
normalizeMockSceneAppOptionalId(intent.project_id)
);
}
function resolveMockSceneAppContextStoreKey(
sceneappId: string,
intent: Record<string, unknown>,
): 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<string, unknown>) {
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<string, unknown>)
: {};
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<SceneAppContextOverlay["snapshot"]["referenceItems"]>,
persistedContext?: MockPersistedSceneAppContext | null,
) {
const seen = new Set<string>();
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<string, unknown>,
referenceItems: NonNullable<SceneAppContextOverlay["snapshot"]["referenceItems"]>,
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<string, unknown>)
: {};
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<string, unknown>,
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<string, unknown>)
: {};
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<string, unknown>,
@@ -1681,6 +2039,13 @@ function buildMockSceneAppPlanResult(
const resolvedDescriptor = descriptor ?? mockSceneAppCatalog.items[0]!;
const intent =
(args?.intent as Record<string, unknown> | undefined) ?? args ?? {};
const persistedContextKey = resolveMockSceneAppContextStoreKey(
resolvedDescriptor.id,
intent,
);
const persistedContext = persistedContextKey
? (mockSceneAppContextStore.get(persistedContextKey) ?? null)
: null;
const runtimeContext =
(intent.runtimeContext as Record<string, unknown> | 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<string, unknown>,
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();
}
/**