mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: enrich sceneapp context surfaces
This commit is contained in:
@@ -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", ...)`
|
||||
- 当前已改成:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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("项目沉淀基础")));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
pub mod compiler;
|
||||
pub mod dto;
|
||||
pub mod store;
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,6 +1,7 @@
|
||||
pub mod adapters;
|
||||
pub mod application;
|
||||
pub mod catalog;
|
||||
pub mod context;
|
||||
pub mod dto;
|
||||
pub mod governance;
|
||||
pub mod runtime;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export * from "./types";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 主链。");
|
||||
});
|
||||
|
||||
|
||||
@@ -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 仍有未满足的启动前置条件。",
|
||||
|
||||
@@ -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
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user