Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
- 创建 types.ts 定义完整的类型系统 - 重写 DocumentRenderer.tsx 修复语法错误 - 重写 QuizRenderer.tsx 修复语法错误 - 重写 PresentationContainer.tsx 添加类型守卫 - 重写 TypeSwitcher.tsx 修复类型引用 - 更新 index.ts 移除不存在的 ChartRenderer 导出 审计结果: - 类型检查: 通过 - 单元测试: 222 passed - 构建: 成功
443 lines
11 KiB
Rust
443 lines
11 KiB
Rust
//! Pipeline v2 Parser
|
|
//!
|
|
//! Parses YAML pipeline definitions into PipelineV2 structs.
|
|
//!
|
|
//! # Example
|
|
//!
|
|
//! ```yaml
|
|
//! apiVersion: zclaw/v2
|
|
//! kind: Pipeline
|
|
//! metadata:
|
|
//! name: course-generator
|
|
//! displayName: 课程生成器
|
|
//! trigger:
|
|
//! keywords: [课程, 教程]
|
|
//! patterns:
|
|
//! - "帮我做*课程"
|
|
//! params:
|
|
//! - name: topic
|
|
//! type: string
|
|
//! required: true
|
|
//! stages:
|
|
//! - id: outline
|
|
//! type: llm
|
|
//! prompt: "为{params.topic}创建课程大纲"
|
|
//! ```
|
|
|
|
use std::collections::HashSet;
|
|
use std::path::Path;
|
|
use thiserror::Error;
|
|
|
|
use crate::types_v2::{PipelineV2, API_VERSION_V2, Stage};
|
|
|
|
/// Parser errors
|
|
#[derive(Debug, Error)]
|
|
pub enum ParseErrorV2 {
|
|
#[error("IO error: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
|
|
#[error("YAML parse error: {0}")]
|
|
Yaml(#[from] serde_yaml::Error),
|
|
|
|
#[error("Invalid API version: expected '{expected}', got '{actual}'")]
|
|
InvalidVersion { expected: String, actual: String },
|
|
|
|
#[error("Invalid kind: expected 'Pipeline', got '{0}'")]
|
|
InvalidKind(String),
|
|
|
|
#[error("Missing required field: {0}")]
|
|
MissingField(String),
|
|
|
|
#[error("Validation error: {0}")]
|
|
Validation(String),
|
|
}
|
|
|
|
/// Pipeline v2 parser
|
|
pub struct PipelineParserV2;
|
|
|
|
impl PipelineParserV2 {
|
|
/// Parse a pipeline from YAML string
|
|
pub fn parse(yaml: &str) -> Result<PipelineV2, ParseErrorV2> {
|
|
let pipeline: PipelineV2 = serde_yaml::from_str(yaml)?;
|
|
|
|
// Validate API version
|
|
if pipeline.api_version != API_VERSION_V2 {
|
|
return Err(ParseErrorV2::InvalidVersion {
|
|
expected: API_VERSION_V2.to_string(),
|
|
actual: pipeline.api_version.clone(),
|
|
});
|
|
}
|
|
|
|
// Validate kind
|
|
if pipeline.kind != "Pipeline" {
|
|
return Err(ParseErrorV2::InvalidKind(pipeline.kind.clone()));
|
|
}
|
|
|
|
// Validate required fields
|
|
if pipeline.metadata.name.is_empty() {
|
|
return Err(ParseErrorV2::MissingField("metadata.name".to_string()));
|
|
}
|
|
|
|
// Validate stages
|
|
if pipeline.stages.is_empty() {
|
|
return Err(ParseErrorV2::Validation(
|
|
"Pipeline must have at least one stage".to_string(),
|
|
));
|
|
}
|
|
|
|
// Validate stage IDs are unique
|
|
let mut seen_ids = HashSet::new();
|
|
validate_stage_ids(&pipeline.stages, &mut seen_ids)?;
|
|
|
|
// Validate parameter names are unique
|
|
let mut seen_params = HashSet::new();
|
|
for param in &pipeline.params {
|
|
if !seen_params.insert(¶m.name) {
|
|
return Err(ParseErrorV2::Validation(format!(
|
|
"Duplicate parameter name: {}",
|
|
param.name
|
|
)));
|
|
}
|
|
}
|
|
|
|
Ok(pipeline)
|
|
}
|
|
|
|
/// Parse a pipeline from file
|
|
pub fn parse_file(path: &Path) -> Result<PipelineV2, ParseErrorV2> {
|
|
let content = std::fs::read_to_string(path)?;
|
|
Self::parse(&content)
|
|
}
|
|
|
|
/// Parse all v2 pipelines in a directory
|
|
pub fn parse_directory(dir: &Path) -> Result<Vec<(String, PipelineV2)>, ParseErrorV2> {
|
|
let mut pipelines = Vec::new();
|
|
|
|
if !dir.exists() {
|
|
return Ok(pipelines);
|
|
}
|
|
|
|
for entry in std::fs::read_dir(dir)? {
|
|
let entry = entry?;
|
|
let path = entry.path();
|
|
|
|
if path.extension().map(|e| e == "yaml" || e == "yml").unwrap_or(false) {
|
|
match Self::parse_file(&path) {
|
|
Ok(pipeline) => {
|
|
let filename = path
|
|
.file_stem()
|
|
.map(|s| s.to_string_lossy().to_string())
|
|
.unwrap_or_default();
|
|
pipelines.push((filename, pipeline));
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!("Failed to parse pipeline {:?}: {}", path, e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(pipelines)
|
|
}
|
|
|
|
/// Try to parse as v2, return None if not v2 format
|
|
pub fn try_parse(yaml: &str) -> Option<Result<PipelineV2, ParseErrorV2>> {
|
|
// Quick check for v2 version marker
|
|
if !yaml.contains("apiVersion: zclaw/v2") && !yaml.contains("apiVersion: 'zclaw/v2'") {
|
|
return None;
|
|
}
|
|
|
|
Some(Self::parse(yaml))
|
|
}
|
|
}
|
|
|
|
/// Recursively validate stage IDs are unique
|
|
fn validate_stage_ids(stages: &[Stage], seen_ids: &mut HashSet<String>) -> Result<(), ParseErrorV2> {
|
|
for stage in stages {
|
|
let id = stage.id().to_string();
|
|
if !seen_ids.insert(id.clone()) {
|
|
return Err(ParseErrorV2::Validation(format!("Duplicate stage ID: {}", id)));
|
|
}
|
|
|
|
// Recursively validate nested stages
|
|
match stage {
|
|
Stage::Parallel { stage, .. } => {
|
|
validate_stage_ids(std::slice::from_ref(stage), seen_ids)?;
|
|
}
|
|
Stage::Sequential { stages: sub_stages, .. } => {
|
|
validate_stage_ids(sub_stages, seen_ids)?;
|
|
}
|
|
Stage::Conditional { branches, default, .. } => {
|
|
for branch in branches {
|
|
validate_stage_ids(std::slice::from_ref(&branch.then), seen_ids)?;
|
|
}
|
|
if let Some(default_stage) = default {
|
|
validate_stage_ids(std::slice::from_ref(default_stage), seen_ids)?;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_parse_valid_pipeline_v2() {
|
|
let yaml = r#"
|
|
apiVersion: zclaw/v2
|
|
kind: Pipeline
|
|
metadata:
|
|
name: test-pipeline
|
|
displayName: 测试流水线
|
|
trigger:
|
|
keywords: [测试, pipeline]
|
|
patterns:
|
|
- "测试*流水线"
|
|
params:
|
|
- name: topic
|
|
type: string
|
|
required: true
|
|
label: 主题
|
|
stages:
|
|
- id: step1
|
|
type: llm
|
|
prompt: "test"
|
|
"#;
|
|
let pipeline = PipelineParserV2::parse(yaml).unwrap();
|
|
assert_eq!(pipeline.metadata.name, "test-pipeline");
|
|
assert_eq!(pipeline.metadata.display_name, Some("测试流水线".to_string()));
|
|
assert_eq!(pipeline.stages.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_invalid_version() {
|
|
let yaml = r#"
|
|
apiVersion: zclaw/v1
|
|
kind: Pipeline
|
|
metadata:
|
|
name: test
|
|
stages:
|
|
- id: step1
|
|
type: llm
|
|
prompt: "test"
|
|
"#;
|
|
let result = PipelineParserV2::parse(yaml);
|
|
assert!(matches!(result, Err(ParseErrorV2::InvalidVersion { .. })));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_invalid_kind() {
|
|
let yaml = r#"
|
|
apiVersion: zclaw/v2
|
|
kind: NotPipeline
|
|
metadata:
|
|
name: test
|
|
stages:
|
|
- id: step1
|
|
type: llm
|
|
prompt: "test"
|
|
"#;
|
|
let result = PipelineParserV2::parse(yaml);
|
|
assert!(matches!(result, Err(ParseErrorV2::InvalidKind(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_empty_stages() {
|
|
let yaml = r#"
|
|
apiVersion: zclaw/v2
|
|
kind: Pipeline
|
|
metadata:
|
|
name: test
|
|
stages: []
|
|
"#;
|
|
let result = PipelineParserV2::parse(yaml);
|
|
assert!(matches!(result, Err(ParseErrorV2::Validation(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_duplicate_stage_ids() {
|
|
let yaml = r#"
|
|
apiVersion: zclaw/v2
|
|
kind: Pipeline
|
|
metadata:
|
|
name: test
|
|
stages:
|
|
- id: step1
|
|
type: llm
|
|
prompt: "test"
|
|
- id: step1
|
|
type: llm
|
|
prompt: "test2"
|
|
"#;
|
|
let result = PipelineParserV2::parse(yaml);
|
|
assert!(matches!(result, Err(ParseErrorV2::Validation(_))));
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_parallel_stage() {
|
|
let yaml = r#"
|
|
apiVersion: zclaw/v2
|
|
kind: Pipeline
|
|
metadata:
|
|
name: test
|
|
stages:
|
|
- id: parallel1
|
|
type: parallel
|
|
each: "${params.items}"
|
|
stage:
|
|
id: inner
|
|
type: llm
|
|
prompt: "process ${item}"
|
|
"#;
|
|
let pipeline = PipelineParserV2::parse(yaml).unwrap();
|
|
assert_eq!(pipeline.metadata.name, "test");
|
|
assert_eq!(pipeline.stages.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_conditional_stage() {
|
|
let yaml = r#"
|
|
apiVersion: zclaw/v2
|
|
kind: Pipeline
|
|
metadata:
|
|
name: test
|
|
stages:
|
|
- id: cond1
|
|
type: conditional
|
|
condition: "${params.level} == 'advanced'"
|
|
branches:
|
|
- when: "${params.level} == 'advanced'"
|
|
then:
|
|
id: advanced
|
|
type: llm
|
|
prompt: "advanced content"
|
|
default:
|
|
id: basic
|
|
type: llm
|
|
prompt: "basic content"
|
|
"#;
|
|
let pipeline = PipelineParserV2::parse(yaml).unwrap();
|
|
assert_eq!(pipeline.metadata.name, "test");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_sequential_stage() {
|
|
let yaml = r#"
|
|
apiVersion: zclaw/v2
|
|
kind: Pipeline
|
|
metadata:
|
|
name: test
|
|
stages:
|
|
- id: seq1
|
|
type: sequential
|
|
stages:
|
|
- id: sub1
|
|
type: llm
|
|
prompt: "step 1"
|
|
- id: sub2
|
|
type: llm
|
|
prompt: "step 2"
|
|
"#;
|
|
let pipeline = PipelineParserV2::parse(yaml).unwrap();
|
|
assert_eq!(pipeline.metadata.name, "test");
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_all_stage_types() {
|
|
let yaml = r#"
|
|
apiVersion: zclaw/v2
|
|
kind: Pipeline
|
|
metadata:
|
|
name: test-all-types
|
|
stages:
|
|
- id: llm1
|
|
type: llm
|
|
prompt: "llm prompt"
|
|
model: "gpt-4"
|
|
temperature: 0.7
|
|
max_tokens: 1000
|
|
- id: compose1
|
|
type: compose
|
|
template: '{"result": "${stages.llm1}"}'
|
|
- id: skill1
|
|
type: skill
|
|
skill_id: "research-skill"
|
|
input:
|
|
query: "${params.topic}"
|
|
- id: hand1
|
|
type: hand
|
|
hand_id: "browser"
|
|
action: "navigate"
|
|
params:
|
|
url: "https://example.com"
|
|
- id: http1
|
|
type: http
|
|
url: "https://api.example.com/data"
|
|
method: "POST"
|
|
headers:
|
|
Content-Type: "application/json"
|
|
body: '{"query": "${params.query}"}'
|
|
- id: setvar1
|
|
type: set_var
|
|
name: "customVar"
|
|
value: "${stages.http1.result}"
|
|
"#;
|
|
let pipeline = PipelineParserV2::parse(yaml).unwrap();
|
|
assert_eq!(pipeline.metadata.name, "test-all-types");
|
|
assert_eq!(pipeline.stages.len(), 6);
|
|
}
|
|
|
|
#[test]
|
|
fn test_try_parse_v2() {
|
|
// v2 format - should return Some
|
|
let yaml_v2 = r#"
|
|
apiVersion: zclaw/v2
|
|
kind: Pipeline
|
|
metadata:
|
|
name: test
|
|
stages:
|
|
- id: s1
|
|
type: llm
|
|
prompt: "test"
|
|
"#;
|
|
assert!(PipelineParserV2::try_parse(yaml_v2).is_some());
|
|
|
|
// v1 format - should return None
|
|
let yaml_v1 = r#"
|
|
apiVersion: zclaw/v1
|
|
kind: Pipeline
|
|
metadata:
|
|
name: test
|
|
spec:
|
|
steps: []
|
|
"#;
|
|
assert!(PipelineParserV2::try_parse(yaml_v1).is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn test_parse_output_config() {
|
|
let yaml = r#"
|
|
apiVersion: zclaw/v2
|
|
kind: Pipeline
|
|
metadata:
|
|
name: test
|
|
stages:
|
|
- id: s1
|
|
type: llm
|
|
prompt: "test"
|
|
output:
|
|
type: dynamic
|
|
allowSwitch: true
|
|
supportedTypes: [slideshow, quiz, document]
|
|
defaultType: slideshow
|
|
"#;
|
|
let pipeline = PipelineParserV2::parse(yaml).unwrap();
|
|
assert!(pipeline.output.allow_switch);
|
|
assert_eq!(pipeline.output.supported_types.len(), 3);
|
|
}
|
|
}
|