mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-21 03:56:23 +08:00
feat(server): refactor copilot (#14892)
#### PR Dependency Tree * **PR #14892** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal)
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use jsonschema::Draft;
|
||||
use napi::{Error, Result, Status};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::{
|
||||
super::contract_schema::{transcript_input_schema, transcript_result_schema},
|
||||
ActionRecipe, ActionRecipeStep, ActionStepKind,
|
||||
};
|
||||
|
||||
fn invalid_recipe(message: impl Into<String>) -> Error {
|
||||
Error::new(Status::InvalidArg, message.into())
|
||||
}
|
||||
|
||||
pub fn built_in_recipes() -> Vec<ActionRecipe> {
|
||||
vec![
|
||||
action_recipe("mindmap.generate", "v1"),
|
||||
action_recipe("slides.outline", "v1"),
|
||||
action_recipe("image.filter.sketch", "v1"),
|
||||
action_recipe("image.filter.clay", "v1"),
|
||||
action_recipe("image.filter.anime", "v1"),
|
||||
action_recipe("image.filter.pixel", "v1"),
|
||||
transcript_recipe("transcript.audio.gemini", "v1"),
|
||||
]
|
||||
}
|
||||
|
||||
pub fn find_recipe(id: &str, version: Option<&str>) -> Result<ActionRecipe> {
|
||||
let catalog = load_catalog()?;
|
||||
catalog
|
||||
.into_iter()
|
||||
.find(|recipe| recipe.id == id && version.is_none_or(|version| recipe.version == version))
|
||||
.ok_or_else(|| {
|
||||
invalid_recipe(format!(
|
||||
"Action recipe not found: {}{}",
|
||||
id,
|
||||
version.map(|version| format!("@{version}")).unwrap_or_default()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load_catalog() -> Result<Vec<ActionRecipe>> {
|
||||
let recipes = built_in_recipes();
|
||||
validate_catalog(&recipes)?;
|
||||
Ok(recipes)
|
||||
}
|
||||
|
||||
pub fn validate_catalog(recipes: &[ActionRecipe]) -> Result<()> {
|
||||
let mut keys = HashSet::new();
|
||||
for recipe in recipes {
|
||||
validate_recipe(recipe)?;
|
||||
let key = format!("{}@{}", recipe.id, recipe.version);
|
||||
if !keys.insert(key.clone()) {
|
||||
return Err(invalid_recipe(format!("Duplicated action recipe: {key}")));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn validate_recipe(recipe: &ActionRecipe) -> Result<()> {
|
||||
if recipe.id.trim().is_empty() {
|
||||
return Err(invalid_recipe("Action recipe id is required"));
|
||||
}
|
||||
if recipe.version.trim().is_empty() {
|
||||
return Err(invalid_recipe("Action recipe version is required"));
|
||||
}
|
||||
if recipe.steps.is_empty() {
|
||||
return Err(invalid_recipe(format!(
|
||||
"Action recipe {}@{} must declare at least one step",
|
||||
recipe.id, recipe.version
|
||||
)));
|
||||
}
|
||||
compile_schema("inputSchema", &recipe.input_schema)?;
|
||||
compile_schema("outputSchema", &recipe.output_schema)?;
|
||||
|
||||
let mut step_ids = HashSet::new();
|
||||
let mut has_final = false;
|
||||
for step in &recipe.steps {
|
||||
if step.id.trim().is_empty() {
|
||||
return Err(invalid_recipe(format!(
|
||||
"Action recipe {}@{} contains a step without id",
|
||||
recipe.id, recipe.version
|
||||
)));
|
||||
}
|
||||
if !step_ids.insert(step.id.clone()) {
|
||||
return Err(invalid_recipe(format!(
|
||||
"Action recipe {}@{} contains duplicated step id {}",
|
||||
recipe.id, recipe.version, step.id
|
||||
)));
|
||||
}
|
||||
if step.kind == ActionStepKind::Final {
|
||||
has_final = true;
|
||||
}
|
||||
}
|
||||
if !has_final {
|
||||
return Err(invalid_recipe(format!(
|
||||
"Action recipe {}@{} must end with a final step",
|
||||
recipe.id, recipe.version
|
||||
)));
|
||||
}
|
||||
if recipe
|
||||
.steps
|
||||
.last()
|
||||
.is_some_and(|step| step.kind != ActionStepKind::Final)
|
||||
{
|
||||
return Err(invalid_recipe(format!(
|
||||
"Action recipe {}@{} must end with a final step",
|
||||
recipe.id, recipe.version
|
||||
)));
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn compile_schema(label: &str, schema: &Value) -> Result<()> {
|
||||
jsonschema::options()
|
||||
.with_draft(Draft::Draft7)
|
||||
.build(schema)
|
||||
.map(|_| ())
|
||||
.map_err(|error| invalid_recipe(format!("Invalid action recipe {label}: {error}")))
|
||||
}
|
||||
|
||||
fn action_recipe(id: &str, version: &str) -> ActionRecipe {
|
||||
let steps = if id.starts_with("image.filter.") {
|
||||
vec![
|
||||
ActionRecipeStep {
|
||||
id: "generate-image".to_string(),
|
||||
kind: ActionStepKind::PromptImage,
|
||||
input: Some(json!({
|
||||
"preparedRoutes": { "$state": "preparedRoutes.generate-image" },
|
||||
"outputKey": "artifact"
|
||||
})),
|
||||
state_patch: Some(json!({ "imageGenerated": true })),
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({
|
||||
"copy": { "$state": "artifact" }
|
||||
})),
|
||||
state_patch: Some(json!({ "finalized": true })),
|
||||
},
|
||||
]
|
||||
} else if id == "slides.outline" {
|
||||
vec![
|
||||
ActionRecipeStep {
|
||||
id: "generate-structured".to_string(),
|
||||
kind: ActionStepKind::PromptStructured,
|
||||
input: Some(json!({
|
||||
"preparedRoutes": { "$state": "preparedRoutes.generate" },
|
||||
"unwrapKey": "result",
|
||||
"outputKey": "generated"
|
||||
})),
|
||||
state_patch: Some(json!({ "generatedAt": "promptStructured" })),
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "validate-json".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: Some(json!({
|
||||
"value": { "$state": "generated" },
|
||||
"schema": text_action_output_schema()
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "project-outline".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: Some(json!({
|
||||
"slidesOutlineMarkdown": { "$state": "generated" },
|
||||
"outputKey": "outlineMarkdown"
|
||||
})),
|
||||
state_patch: Some(json!({ "projectedAt": "slidesOutlineMarkdown" })),
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({
|
||||
"copy": { "$state": "outlineMarkdown" }
|
||||
})),
|
||||
state_patch: Some(json!({ "finalized": true })),
|
||||
},
|
||||
]
|
||||
} else {
|
||||
vec![
|
||||
ActionRecipeStep {
|
||||
id: "generate-structured".to_string(),
|
||||
kind: ActionStepKind::PromptStructured,
|
||||
input: Some(json!({
|
||||
"preparedRoutes": { "$state": "preparedRoutes.generate" },
|
||||
"unwrapKey": "result",
|
||||
"outputKey": "generated"
|
||||
})),
|
||||
state_patch: Some(json!({ "generatedAt": "promptStructured" })),
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "validate-json".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: Some(json!({
|
||||
"value": { "$state": "generated" },
|
||||
"schema": text_action_output_schema()
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({
|
||||
"copy": { "$state": "generated" }
|
||||
})),
|
||||
state_patch: Some(json!({ "finalized": true })),
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
recipe(id, version, action_output_schema(id), steps)
|
||||
}
|
||||
|
||||
fn transcript_recipe(id: &str, version: &str) -> ActionRecipe {
|
||||
let mut recipe = recipe(
|
||||
id,
|
||||
version,
|
||||
transcript_result_schema(),
|
||||
vec![
|
||||
ActionRecipeStep {
|
||||
id: "transcribe".to_string(),
|
||||
kind: ActionStepKind::PromptStructured,
|
||||
input: Some(json!({
|
||||
"preparedRoutes": { "$state": "preparedRoutes.transcribe" },
|
||||
"outputKey": "transcriptResult"
|
||||
})),
|
||||
state_patch: Some(json!({ "transcribedAt": "promptStructured" })),
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({
|
||||
"sourceAudio": { "$state": "sourceAudio" },
|
||||
"quality": { "$state": "quality" },
|
||||
"infos": { "$state": "infos" },
|
||||
"sliceManifest": { "$state": "sliceManifest" },
|
||||
"normalizedSegments": { "$state": "transcriptResult.normalizedSegments" },
|
||||
"normalizedTranscript": { "$state": "transcriptResult.normalizedTranscript" },
|
||||
"summaryJson": { "$state": "transcriptResult.summaryJson" },
|
||||
"providerMeta": { "$state": "transcriptResult.providerMeta" },
|
||||
"version": "transcript-result-v1",
|
||||
"strategy": id.strip_prefix("transcript.audio.").unwrap_or(id)
|
||||
})),
|
||||
state_patch: Some(json!({ "finalized": true })),
|
||||
},
|
||||
],
|
||||
);
|
||||
recipe.input_schema = transcript_input_schema();
|
||||
recipe
|
||||
}
|
||||
|
||||
fn action_output_schema(id: &str) -> Value {
|
||||
if id.starts_with("image.filter.") {
|
||||
json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"url": { "type": "string" },
|
||||
"data_base64": { "type": "string" },
|
||||
"media_type": { "type": "string" }
|
||||
},
|
||||
"anyOf": [
|
||||
{ "required": ["url"] },
|
||||
{ "required": ["data_base64", "media_type"] }
|
||||
],
|
||||
"additionalProperties": true
|
||||
})
|
||||
} else {
|
||||
text_action_output_schema()
|
||||
}
|
||||
}
|
||||
|
||||
fn text_action_output_schema() -> Value {
|
||||
json!({
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
})
|
||||
}
|
||||
|
||||
fn recipe(id: &str, version: &str, output_schema: Value, steps: Vec<ActionRecipeStep>) -> ActionRecipe {
|
||||
ActionRecipe {
|
||||
id: id.to_string(),
|
||||
version: version.to_string(),
|
||||
input_schema: json!({}),
|
||||
output_schema,
|
||||
steps,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
use napi_derive::napi;
|
||||
use schemars::JsonSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionRecipe {
|
||||
pub id: String,
|
||||
pub version: String,
|
||||
pub input_schema: Value,
|
||||
pub output_schema: Value,
|
||||
pub steps: Vec<ActionRecipeStep>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionRecipeStep {
|
||||
pub id: String,
|
||||
pub kind: ActionStepKind,
|
||||
#[serde(default)]
|
||||
pub input: Option<Value>,
|
||||
#[serde(default)]
|
||||
pub state_patch: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum ActionStepKind {
|
||||
PromptStructured,
|
||||
PromptImage,
|
||||
ValidateJson,
|
||||
Transform,
|
||||
Final,
|
||||
}
|
||||
|
||||
#[napi(string_enum = "snake_case")]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ActionEventType {
|
||||
ActionStart,
|
||||
StepStart,
|
||||
Attachment,
|
||||
StepEnd,
|
||||
ActionDone,
|
||||
Error,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionEvent {
|
||||
#[serde(rename = "type")]
|
||||
#[napi(js_name = "type")]
|
||||
pub event_type: ActionEventType,
|
||||
pub action_id: String,
|
||||
pub action_version: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub step_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub status: Option<ActionRunStatus>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub attachment: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_message: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub trace: Option<ActionTrace>,
|
||||
}
|
||||
|
||||
#[napi(string_enum = "snake_case")]
|
||||
#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ActionRunStatus {
|
||||
Created,
|
||||
Running,
|
||||
Succeeded,
|
||||
Failed,
|
||||
Aborted,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionRuntimeInput {
|
||||
pub recipe_id: String,
|
||||
#[serde(default)]
|
||||
pub recipe_version: Option<String>,
|
||||
#[serde(default)]
|
||||
pub input: Value,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionRuntimeOutput {
|
||||
pub result: Value,
|
||||
pub status: ActionRunStatus,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<String>,
|
||||
pub state: Value,
|
||||
pub steps: Vec<ActionStepRuntimeState>,
|
||||
pub trace: ActionTrace,
|
||||
pub events: Vec<ActionEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionStepRuntimeState {
|
||||
pub id: String,
|
||||
pub input: Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub output: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state_patch: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error: Option<ActionStepError>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionStepError {
|
||||
pub code: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActionTrace {
|
||||
pub action_id: String,
|
||||
pub action_version: String,
|
||||
pub status: ActionRunStatus,
|
||||
#[serde(default)]
|
||||
pub lightweight: Vec<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TranscriptInputContract {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub source_audio: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub quality: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub infos: Option<Vec<TranscriptAudioInfo>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub slice_manifest: Option<Vec<TranscriptSliceManifestItem>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub prepared_routes: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TranscriptAudioInfo {
|
||||
pub url: String,
|
||||
pub mime_type: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub index: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TranscriptSliceManifestItem {
|
||||
pub index: i64,
|
||||
pub file_name: String,
|
||||
pub mime_type: String,
|
||||
pub start_sec: f64,
|
||||
pub duration_sec: f64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub byte_size: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct NormalizedTranscriptSegment {
|
||||
pub speaker: String,
|
||||
pub start_sec: f64,
|
||||
pub end_sec: f64,
|
||||
pub start: String,
|
||||
pub end: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MeetingSummary {
|
||||
pub title: String,
|
||||
pub duration_minutes: f64,
|
||||
pub attendees: Vec<String>,
|
||||
pub key_points: Vec<String>,
|
||||
pub action_items: Vec<MeetingSummaryActionItem>,
|
||||
pub decisions: Vec<String>,
|
||||
pub open_questions: Vec<String>,
|
||||
pub blockers: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct MeetingSummaryActionItem {
|
||||
pub description: String,
|
||||
#[schemars(required)]
|
||||
pub owner: Option<String>,
|
||||
#[schemars(required)]
|
||||
pub deadline: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TranscriptGeneratedResult {
|
||||
#[schemars(required)]
|
||||
pub normalized_segments: Option<Vec<NormalizedTranscriptSegment>>,
|
||||
pub normalized_transcript: String,
|
||||
#[schemars(required)]
|
||||
pub summary_json: Option<MeetingSummary>,
|
||||
#[schemars(required)]
|
||||
pub provider_meta: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct TranscriptResult {
|
||||
#[schemars(required)]
|
||||
pub source_audio: Option<Value>,
|
||||
#[schemars(required)]
|
||||
pub quality: Option<Value>,
|
||||
#[schemars(required)]
|
||||
pub infos: Option<Vec<TranscriptAudioInfo>>,
|
||||
#[schemars(required)]
|
||||
pub slice_manifest: Option<Vec<TranscriptSliceManifestItem>>,
|
||||
#[schemars(required)]
|
||||
pub normalized_segments: Option<Vec<NormalizedTranscriptSegment>>,
|
||||
pub normalized_transcript: String,
|
||||
#[schemars(required)]
|
||||
pub summary_json: Option<MeetingSummary>,
|
||||
#[schemars(required)]
|
||||
pub provider_meta: Option<Value>,
|
||||
pub version: String,
|
||||
pub strategy: String,
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
mod catalog;
|
||||
mod contract;
|
||||
mod runtime;
|
||||
mod slides_outline;
|
||||
|
||||
use std::sync::{Arc, atomic::AtomicBool, mpsc};
|
||||
|
||||
#[cfg(test)]
|
||||
use catalog::{load_catalog, validate_catalog, validate_recipe};
|
||||
use contract::{
|
||||
ActionEvent, ActionEventType, ActionRecipe, ActionRecipeStep, ActionRunStatus, ActionRuntimeInput,
|
||||
ActionRuntimeOutput, ActionStepError, ActionStepKind, ActionStepRuntimeState, ActionTrace,
|
||||
};
|
||||
pub(crate) use contract::{TranscriptGeneratedResult, TranscriptInputContract, TranscriptResult};
|
||||
use napi::{
|
||||
Result,
|
||||
threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode},
|
||||
};
|
||||
#[cfg(test)]
|
||||
use runtime::{ACTION_ABORTED_ERROR_CODE, run_action_recipe_for_test, run_action_recipe_for_test_with_control};
|
||||
use runtime::{ActionRuntimeControl, run_action_recipe_prepared_with_control};
|
||||
|
||||
use crate::llm::{LlmStreamHandle, STREAM_END_MARKER};
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub fn run_native_action_recipe_prepared_stream(
|
||||
input: ActionRuntimeInput,
|
||||
callback: ThreadsafeFunction<String, ()>,
|
||||
) -> Result<LlmStreamHandle> {
|
||||
let action_id = input.recipe_id.clone();
|
||||
let action_version = input.recipe_version.clone().unwrap_or_default();
|
||||
let aborted = Arc::new(AtomicBool::new(false));
|
||||
let aborted_in_worker = aborted.clone();
|
||||
let (event_sender, event_receiver) = mpsc::channel::<ActionEvent>();
|
||||
let error_sender = event_sender.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
if let Err(error) = run_action_recipe_prepared_with_control(
|
||||
input,
|
||||
ActionRuntimeControl {
|
||||
abort_signal: Some(aborted_in_worker.clone()),
|
||||
event_sender: Some(event_sender),
|
||||
#[cfg(test)]
|
||||
abort_after_events: None,
|
||||
#[cfg(test)]
|
||||
mock_output: None,
|
||||
},
|
||||
) {
|
||||
let _ = error_sender.send(ActionEvent {
|
||||
event_type: ActionEventType::Error,
|
||||
action_id,
|
||||
action_version,
|
||||
step_id: None,
|
||||
status: Some(ActionRunStatus::Failed),
|
||||
attachment: None,
|
||||
result: None,
|
||||
error_code: Some("action_runtime_error".to_string()),
|
||||
error_message: Some(error.reason.clone()),
|
||||
trace: None,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
std::thread::spawn(move || {
|
||||
for event in event_receiver {
|
||||
match serde_json::to_string(&event) {
|
||||
Ok(event) => {
|
||||
let _ = callback.call(Ok(event), ThreadsafeFunctionCallMode::NonBlocking);
|
||||
}
|
||||
Err(error) => {
|
||||
let _ = callback.call(
|
||||
Ok(
|
||||
serde_json::json!({
|
||||
"type": "error",
|
||||
"actionId": event.action_id,
|
||||
"actionVersion": event.action_version,
|
||||
"errorCode": "action_event_encode_failed",
|
||||
"errorMessage": error.to_string()
|
||||
})
|
||||
.to_string(),
|
||||
),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = callback.call(
|
||||
Ok(STREAM_END_MARKER.to_string()),
|
||||
ThreadsafeFunctionCallMode::NonBlocking,
|
||||
);
|
||||
});
|
||||
|
||||
Ok(LlmStreamHandle { aborted })
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,564 @@
|
||||
use std::{
|
||||
cell::Cell,
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
mpsc::Sender,
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use llm_runtime::{
|
||||
RecipeDefinition, RecipeRuntimeEvent, RecipeRuntimeOutput, RecipeRuntimeStatus, RecipeStepExecution,
|
||||
RecipeStepExecutor, StepExecutionError, execute_transform_step, execute_validate_json_step, resolve_state_ref,
|
||||
run_recipe_runtime, validate_json_schema,
|
||||
};
|
||||
use napi::{Error, Result, Status};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
use super::{
|
||||
ActionEvent, ActionEventType, ActionRecipe, ActionRunStatus, ActionRuntimeInput, ActionRuntimeOutput,
|
||||
ActionStepError, ActionStepKind, ActionStepRuntimeState, ActionTrace, catalog::find_recipe,
|
||||
slides_outline::project_slides_outline_markdown,
|
||||
};
|
||||
use crate::llm::{
|
||||
LlmPreparedImageDispatchRoutePayload, dispatch_prepared_image_route_payloads, dispatch_prepared_structured_routes,
|
||||
};
|
||||
|
||||
pub const ACTION_ABORTED_ERROR_CODE: &str = "action_aborted";
|
||||
pub const ACTION_INVALID_STEP_ERROR_CODE: &str = "action_invalid_step";
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ActionRuntimeControl {
|
||||
pub abort_signal: Option<Arc<AtomicBool>>,
|
||||
pub event_sender: Option<Sender<ActionEvent>>,
|
||||
#[cfg(test)]
|
||||
pub abort_after_events: Option<usize>,
|
||||
#[cfg(test)]
|
||||
pub mock_output: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ActionRuntimeState {
|
||||
pub status: ActionRunStatus,
|
||||
pub result: Value,
|
||||
pub action_state: Value,
|
||||
pub steps: Vec<ActionStepRuntimeState>,
|
||||
pub events: Vec<ActionEvent>,
|
||||
pub trace: ActionTrace,
|
||||
pub error_code: Option<String>,
|
||||
}
|
||||
|
||||
fn invalid_input(message: impl Into<String>) -> Error {
|
||||
Error::new(Status::InvalidArg, message.into())
|
||||
}
|
||||
|
||||
pub fn run_action_recipe_prepared_with_control(
|
||||
input: ActionRuntimeInput,
|
||||
control: ActionRuntimeControl,
|
||||
) -> Result<ActionRuntimeOutput> {
|
||||
let recipe = find_recipe(&input.recipe_id, input.recipe_version.as_deref())?;
|
||||
validate_value("input", &recipe.input_schema, &input.input)?;
|
||||
|
||||
run_recipe(recipe, input, control)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn run_action_recipe_for_test(
|
||||
recipe: ActionRecipe,
|
||||
input: ActionRuntimeInput,
|
||||
) -> Result<ActionRuntimeOutput> {
|
||||
validate_value("input", &recipe.input_schema, &input.input)?;
|
||||
run_recipe(recipe, input, ActionRuntimeControl::default())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn run_action_recipe_for_test_with_control(
|
||||
recipe: ActionRecipe,
|
||||
input: ActionRuntimeInput,
|
||||
control: ActionRuntimeControl,
|
||||
) -> Result<ActionRuntimeOutput> {
|
||||
validate_value("input", &recipe.input_schema, &input.input)?;
|
||||
run_recipe(recipe, input, control)
|
||||
}
|
||||
|
||||
fn run_recipe(
|
||||
recipe: ActionRecipe,
|
||||
input: ActionRuntimeInput,
|
||||
control: ActionRuntimeControl,
|
||||
) -> Result<ActionRuntimeOutput> {
|
||||
let mut runtime = Runtime::new(recipe, input, control);
|
||||
runtime.run()
|
||||
}
|
||||
|
||||
struct Runtime {
|
||||
recipe: ActionRecipe,
|
||||
state: ActionRuntimeState,
|
||||
started_at: Instant,
|
||||
control: ActionRuntimeControl,
|
||||
}
|
||||
|
||||
impl Runtime {
|
||||
fn new(recipe: ActionRecipe, input: ActionRuntimeInput, control: ActionRuntimeControl) -> Self {
|
||||
let trace = ActionTrace {
|
||||
action_id: recipe.id.clone(),
|
||||
action_version: recipe.version.clone(),
|
||||
status: ActionRunStatus::Created,
|
||||
lightweight: Vec::new(),
|
||||
error_code: None,
|
||||
};
|
||||
|
||||
Self {
|
||||
recipe,
|
||||
state: ActionRuntimeState {
|
||||
status: ActionRunStatus::Created,
|
||||
result: input.input.clone(),
|
||||
action_state: input.input,
|
||||
steps: Vec::new(),
|
||||
events: Vec::new(),
|
||||
trace,
|
||||
error_code: None,
|
||||
},
|
||||
started_at: Instant::now(),
|
||||
control,
|
||||
}
|
||||
}
|
||||
|
||||
fn run(&mut self) -> Result<ActionRuntimeOutput> {
|
||||
let recipe = self.recipe_definition();
|
||||
let action_id = self.recipe.id.clone();
|
||||
let action_version = self.recipe.version.clone();
|
||||
let output_schema = self.recipe.output_schema.clone();
|
||||
let step_patches = self
|
||||
.recipe
|
||||
.steps
|
||||
.iter()
|
||||
.map(|step| (step.id.clone(), step.state_patch.clone()))
|
||||
.collect::<std::collections::HashMap<_, _>>();
|
||||
let attachments = Arc::new(Mutex::new(Vec::new()));
|
||||
let mut executor = AffineActionStepExecutor::new(&self.control, attachments.clone());
|
||||
let mut events = Vec::new();
|
||||
let mut lightweight = Vec::new();
|
||||
let event_sender = self.control.event_sender.clone();
|
||||
let abort_signal = self.control.abort_signal.clone();
|
||||
let event_count = Cell::new(0usize);
|
||||
#[cfg(test)]
|
||||
let abort_after_events = self.control.abort_after_events;
|
||||
|
||||
let mut record = |event: ActionEvent| {
|
||||
lightweight.push(json!({
|
||||
"type": event.event_type,
|
||||
"stepId": event.step_id,
|
||||
"status": event.status
|
||||
}));
|
||||
if let Some(sender) = &event_sender {
|
||||
let _ = sender.send(event.clone());
|
||||
}
|
||||
events.push(event);
|
||||
event_count.set(events.len());
|
||||
};
|
||||
|
||||
let runtime_output = run_recipe_runtime(
|
||||
recipe,
|
||||
self.state.action_state.clone(),
|
||||
&mut executor,
|
||||
|event| {
|
||||
for action_event in map_recipe_event(&action_id, &action_version, event, &attachments) {
|
||||
record(action_event);
|
||||
}
|
||||
},
|
||||
|| {
|
||||
abort_signal
|
||||
.as_ref()
|
||||
.is_some_and(|signal| signal.load(Ordering::SeqCst))
|
||||
|| {
|
||||
#[cfg(test)]
|
||||
{
|
||||
abort_after_events.is_some_and(|max_events| event_count.get() >= max_events)
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if matches!(runtime_output.status, RecipeRuntimeStatus::Succeeded) {
|
||||
validate_value("output", &output_schema, &runtime_output.result)?;
|
||||
}
|
||||
|
||||
self.state = self.action_state_from_runtime_output(runtime_output, events, lightweight, step_patches);
|
||||
self.finalize_trace();
|
||||
if let Some(event) = self
|
||||
.state
|
||||
.events
|
||||
.iter_mut()
|
||||
.rev()
|
||||
.find(|event| matches!(event.event_type, ActionEventType::ActionDone))
|
||||
{
|
||||
event.trace = Some(self.state.trace.clone());
|
||||
}
|
||||
Ok(self.output())
|
||||
}
|
||||
|
||||
fn recipe_definition(&self) -> RecipeDefinition {
|
||||
RecipeDefinition {
|
||||
id: self.recipe.id.clone(),
|
||||
version: self.recipe.version.clone(),
|
||||
steps: self
|
||||
.recipe
|
||||
.steps
|
||||
.iter()
|
||||
.map(|step| RecipeStepExecution {
|
||||
id: step.id.clone(),
|
||||
kind: action_step_kind_name(step.kind).to_string(),
|
||||
input: step.input.clone(),
|
||||
state_patch: step.state_patch.clone(),
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn action_state_from_runtime_output(
|
||||
&self,
|
||||
output: RecipeRuntimeOutput,
|
||||
events: Vec<ActionEvent>,
|
||||
lightweight: Vec<Value>,
|
||||
step_patches: std::collections::HashMap<String, Option<Value>>,
|
||||
) -> ActionRuntimeState {
|
||||
let status = recipe_status_to_action_status(&output.status);
|
||||
let error_code = output
|
||||
.trace
|
||||
.error_code
|
||||
.as_deref()
|
||||
.map(map_recipe_error_code)
|
||||
.map(ToString::to_string);
|
||||
ActionRuntimeState {
|
||||
status,
|
||||
result: output.result,
|
||||
action_state: output.state,
|
||||
steps: output
|
||||
.steps
|
||||
.into_iter()
|
||||
.map(|step| ActionStepRuntimeState {
|
||||
id: step.id.clone(),
|
||||
input: step.input.unwrap_or(Value::Null),
|
||||
output: step.output,
|
||||
state_patch: step_patches.get(&step.id).cloned().flatten(),
|
||||
error: step.error.map(ActionStepError::from),
|
||||
})
|
||||
.collect(),
|
||||
events,
|
||||
trace: ActionTrace {
|
||||
action_id: self.recipe.id.clone(),
|
||||
action_version: self.recipe.version.clone(),
|
||||
status,
|
||||
lightweight,
|
||||
error_code: error_code.clone(),
|
||||
},
|
||||
error_code,
|
||||
}
|
||||
}
|
||||
|
||||
fn output(&mut self) -> ActionRuntimeOutput {
|
||||
self.finalize_trace();
|
||||
|
||||
ActionRuntimeOutput {
|
||||
result: self.state.result.clone(),
|
||||
status: self.state.status,
|
||||
error_code: self.state.error_code.clone(),
|
||||
state: self.state.action_state.clone(),
|
||||
steps: self.state.steps.clone(),
|
||||
trace: self.state.trace.clone(),
|
||||
events: self.state.events.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_trace(&mut self) {
|
||||
self.state.trace.status = self.state.status;
|
||||
if self
|
||||
.state
|
||||
.trace
|
||||
.lightweight
|
||||
.last()
|
||||
.and_then(|event| event.get("type"))
|
||||
.is_some_and(|event_type| event_type == "action_trace")
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.state.trace.lightweight.push(json!({
|
||||
"type": "action_trace",
|
||||
"actionId": self.recipe.id.clone(),
|
||||
"actionVersion": self.recipe.version.clone(),
|
||||
"status": self.state.status,
|
||||
"durationMs": self.started_at.elapsed().as_millis()
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
fn recipe_status_to_action_status(status: &RecipeRuntimeStatus) -> ActionRunStatus {
|
||||
match status {
|
||||
RecipeRuntimeStatus::Created => ActionRunStatus::Created,
|
||||
RecipeRuntimeStatus::Running => ActionRunStatus::Running,
|
||||
RecipeRuntimeStatus::Succeeded => ActionRunStatus::Succeeded,
|
||||
RecipeRuntimeStatus::Failed => ActionRunStatus::Failed,
|
||||
RecipeRuntimeStatus::Aborted => ActionRunStatus::Aborted,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_recipe_error_code(code: &str) -> &str {
|
||||
match code {
|
||||
"aborted" => ACTION_ABORTED_ERROR_CODE,
|
||||
"invalid_step" | "invalid_schema" | "invalid_value" => ACTION_INVALID_STEP_ERROR_CODE,
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn map_recipe_event(
|
||||
action_id: &str,
|
||||
action_version: &str,
|
||||
event: &RecipeRuntimeEvent,
|
||||
attachments: &Arc<Mutex<Vec<Value>>>,
|
||||
) -> Vec<ActionEvent> {
|
||||
let status = recipe_status_to_action_status(&event.status);
|
||||
let mut events = Vec::new();
|
||||
if event.event_type == "step_end" {
|
||||
let mut pending = attachments.lock().expect("attachment queue lock");
|
||||
events.extend(pending.drain(..).map(|attachment| ActionEvent {
|
||||
event_type: ActionEventType::Attachment,
|
||||
action_id: action_id.to_string(),
|
||||
action_version: action_version.to_string(),
|
||||
step_id: None,
|
||||
status: Some(ActionRunStatus::Running),
|
||||
attachment: Some(attachment),
|
||||
result: None,
|
||||
error_code: None,
|
||||
error_message: None,
|
||||
trace: None,
|
||||
}));
|
||||
}
|
||||
|
||||
let event_type = match event.event_type.as_str() {
|
||||
"recipe_start" => ActionEventType::ActionStart,
|
||||
"step_start" => ActionEventType::StepStart,
|
||||
"step_end" => ActionEventType::StepEnd,
|
||||
"recipe_done" => ActionEventType::ActionDone,
|
||||
"error" => ActionEventType::Error,
|
||||
_ => return events,
|
||||
};
|
||||
let error = event.error.as_ref();
|
||||
events.push(ActionEvent {
|
||||
event_type,
|
||||
action_id: action_id.to_string(),
|
||||
action_version: action_version.to_string(),
|
||||
step_id: event.step_id.clone(),
|
||||
status: Some(status),
|
||||
attachment: None,
|
||||
result: event.result.clone(),
|
||||
error_code: error.map(|error| map_recipe_error_code(&error.code).to_string()),
|
||||
error_message: error.map(|error| error.message.clone()),
|
||||
trace: None,
|
||||
});
|
||||
events
|
||||
}
|
||||
|
||||
impl From<StepExecutionError> for ActionStepError {
|
||||
fn from(error: StepExecutionError) -> Self {
|
||||
let code = if error.code == "invalid_step" || error.code == "invalid_schema" || error.code == "invalid_value" {
|
||||
ACTION_INVALID_STEP_ERROR_CODE.to_string()
|
||||
} else {
|
||||
error.code
|
||||
};
|
||||
Self {
|
||||
code,
|
||||
message: error.message,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn action_step_kind_name(kind: ActionStepKind) -> &'static str {
|
||||
match kind {
|
||||
ActionStepKind::PromptStructured => "promptStructured",
|
||||
ActionStepKind::PromptImage => "promptImage",
|
||||
ActionStepKind::ValidateJson => "validateJson",
|
||||
ActionStepKind::Transform => "transform",
|
||||
ActionStepKind::Final => "final",
|
||||
}
|
||||
}
|
||||
|
||||
struct AffineActionStepExecutor<'a> {
|
||||
#[cfg(test)]
|
||||
control: &'a ActionRuntimeControl,
|
||||
#[cfg(not(test))]
|
||||
_marker: std::marker::PhantomData<&'a ()>,
|
||||
attachments: Arc<Mutex<Vec<Value>>>,
|
||||
}
|
||||
|
||||
impl<'a> AffineActionStepExecutor<'a> {
|
||||
fn new(_control: &'a ActionRuntimeControl, attachments: Arc<Mutex<Vec<Value>>>) -> Self {
|
||||
Self {
|
||||
#[cfg(test)]
|
||||
control: _control,
|
||||
#[cfg(not(test))]
|
||||
_marker: std::marker::PhantomData,
|
||||
attachments,
|
||||
}
|
||||
}
|
||||
|
||||
fn test_mock_output(&self, _step_id: &str) -> Option<&Value> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
self
|
||||
.control
|
||||
.mock_output
|
||||
.as_ref()
|
||||
.and_then(|mock_output| mock_output.get(_step_id))
|
||||
.filter(|value| !value.is_null())
|
||||
}
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_structured_step(
|
||||
&self,
|
||||
step: &RecipeStepExecution,
|
||||
input: Option<Value>,
|
||||
) -> std::result::Result<Value, StepExecutionError> {
|
||||
let value = if let Some(routes) = input
|
||||
.as_ref()
|
||||
.and_then(|input| input.get("preparedRoutes"))
|
||||
.filter(|routes| !routes.is_null())
|
||||
{
|
||||
let (_provider_id, response) =
|
||||
dispatch_prepared_structured_routes(&serde_json::to_string(routes).map_err(|error| {
|
||||
StepExecutionError::new(
|
||||
"invalid_step",
|
||||
format!("Invalid promptStructured prepared routes: {error}"),
|
||||
)
|
||||
})?)
|
||||
.map_err(|error| StepExecutionError::new("invalid_step", error.reason.clone()))?;
|
||||
response.output_json.unwrap_or(Value::Null)
|
||||
} else if let Some(mock_output) = self.test_mock_output(&step.id) {
|
||||
mock_output.clone()
|
||||
} else {
|
||||
return Err(StepExecutionError::new(
|
||||
"invalid_step",
|
||||
"promptStructured requires preparedRoutes",
|
||||
));
|
||||
};
|
||||
Ok(
|
||||
input
|
||||
.as_ref()
|
||||
.and_then(|input| input.get("unwrapKey"))
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|key| value.get(key).cloned())
|
||||
.unwrap_or(value),
|
||||
)
|
||||
}
|
||||
|
||||
fn prompt_image_step(
|
||||
&mut self,
|
||||
step: &RecipeStepExecution,
|
||||
input: Option<Value>,
|
||||
) -> std::result::Result<Value, StepExecutionError> {
|
||||
let attachment = if let Some(routes) = input
|
||||
.as_ref()
|
||||
.and_then(|input| input.get("preparedRoutes"))
|
||||
.filter(|routes| !routes.is_null())
|
||||
{
|
||||
let payload =
|
||||
serde_json::from_value::<Vec<LlmPreparedImageDispatchRoutePayload>>(routes.clone()).map_err(|error| {
|
||||
StepExecutionError::new("invalid_step", format!("Invalid promptImage prepared routes: {error}"))
|
||||
})?;
|
||||
let (_provider_id, response) = dispatch_prepared_image_route_payloads(payload)
|
||||
.map_err(|error| StepExecutionError::new("invalid_step", error.reason.clone()))?;
|
||||
image_response_attachment(response.provider_metadata, response.images)
|
||||
.ok_or_else(|| StepExecutionError::new("invalid_step", "promptImage native dispatch produced no image"))?
|
||||
} else if let Some(mock_output) = self.test_mock_output(&step.id) {
|
||||
mock_output.clone()
|
||||
} else {
|
||||
return Err(StepExecutionError::new(
|
||||
"invalid_step",
|
||||
"promptImage requires preparedRoutes",
|
||||
));
|
||||
};
|
||||
self
|
||||
.attachments
|
||||
.lock()
|
||||
.expect("attachment queue lock")
|
||||
.push(attachment.clone());
|
||||
Ok(attachment)
|
||||
}
|
||||
|
||||
fn transform_step(&self, input: Option<Value>, state: &Value) -> std::result::Result<Value, StepExecutionError> {
|
||||
if let Some(value) = execute_transform_step(input.clone(), state)? {
|
||||
return Ok(value);
|
||||
}
|
||||
|
||||
let Some(input) = input else {
|
||||
return Ok(state.clone());
|
||||
};
|
||||
if let Some(slides_outline) = input.get("slidesOutlineMarkdown") {
|
||||
let value = resolve_state_ref(slides_outline, state);
|
||||
return project_slides_outline_markdown(&value)
|
||||
.map(Value::String)
|
||||
.map_err(|message| StepExecutionError::new("invalid_step", message));
|
||||
}
|
||||
|
||||
Ok(input)
|
||||
}
|
||||
}
|
||||
|
||||
impl RecipeStepExecutor for AffineActionStepExecutor<'_> {
|
||||
fn execute_step(
|
||||
&mut self,
|
||||
step: &RecipeStepExecution,
|
||||
input: Option<Value>,
|
||||
state: &Value,
|
||||
) -> std::result::Result<Value, StepExecutionError> {
|
||||
match step.kind.as_str() {
|
||||
"promptStructured" => self.prompt_structured_step(step, input),
|
||||
"promptImage" => self.prompt_image_step(step, input),
|
||||
"validateJson" => execute_validate_json_step(input.or_else(|| Some(state.clone()))),
|
||||
"transform" | "final" => self.transform_step(input, state),
|
||||
other => Err(StepExecutionError::new(
|
||||
"invalid_step",
|
||||
format!("Unsupported action step kind: {other}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn image_response_attachment(provider_metadata: Value, images: Vec<llm_adapter::core::ImageArtifact>) -> Option<Value> {
|
||||
let image = images.into_iter().next()?;
|
||||
let mut attachment = Map::new();
|
||||
if let Some(url) = image.url {
|
||||
attachment.insert("url".to_string(), Value::String(url));
|
||||
}
|
||||
if let Some(data_base64) = image.data_base64 {
|
||||
attachment.insert("data_base64".to_string(), Value::String(data_base64));
|
||||
}
|
||||
attachment.insert("media_type".to_string(), Value::String(image.media_type));
|
||||
if let Some(width) = image.width {
|
||||
attachment.insert("width".to_string(), json!(width));
|
||||
}
|
||||
if let Some(height) = image.height {
|
||||
attachment.insert("height".to_string(), json!(height));
|
||||
}
|
||||
if !image.provider_metadata.is_null() {
|
||||
attachment.insert("providerMetadata".to_string(), image.provider_metadata);
|
||||
} else if !provider_metadata.is_null() {
|
||||
attachment.insert("providerMetadata".to_string(), provider_metadata);
|
||||
}
|
||||
if !attachment.contains_key("url") && !attachment.contains_key("data_base64") {
|
||||
return None;
|
||||
}
|
||||
Some(Value::Object(attachment))
|
||||
}
|
||||
|
||||
fn validate_value(label: &str, schema: &Value, value: &Value) -> Result<()> {
|
||||
validate_json_schema(label, schema, value).map_err(|error| invalid_input(error.message))
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
pub(super) fn project_slides_outline_markdown(value: &Value) -> Result<String, String> {
|
||||
let text = match value {
|
||||
Value::String(text) => text.as_str(),
|
||||
Value::Object(object) => {
|
||||
if let Some(Value::String(text)) = object.get("result") {
|
||||
text
|
||||
} else if let Some(Value::String(text)) = object.get("content") {
|
||||
text
|
||||
} else if let Some(Value::String(text)) = object.get("text") {
|
||||
text
|
||||
} else {
|
||||
return Err("slidesOutlineMarkdown requires a string result".to_string());
|
||||
}
|
||||
}
|
||||
_ => return Err("slidesOutlineMarkdown requires a string result".to_string()),
|
||||
};
|
||||
|
||||
if is_markdown_list(text) {
|
||||
return Ok(text.to_string());
|
||||
}
|
||||
|
||||
let mut projected = Vec::new();
|
||||
for line in text.lines().filter(|line| !line.trim().is_empty()) {
|
||||
let item = serde_json::from_str::<Value>(line)
|
||||
.map_err(|_| "slidesOutlineMarkdown requires markdown or NDJSON object lines".to_string())?;
|
||||
if !item.is_object() {
|
||||
return Err("slidesOutlineMarkdown requires markdown or NDJSON object lines".to_string());
|
||||
}
|
||||
projected.push(render_slide_item(&item)?);
|
||||
}
|
||||
|
||||
if projected.is_empty() {
|
||||
Err("slidesOutlineMarkdown requires markdown or NDJSON object lines".to_string())
|
||||
} else {
|
||||
Ok(projected.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_markdown_list(text: &str) -> bool {
|
||||
let mut saw_line = false;
|
||||
for line in text.lines().map(str::trim_start).filter(|line| !line.trim().is_empty()) {
|
||||
saw_line = true;
|
||||
if !(line.starts_with("- ") || line.starts_with("* ") || line.starts_with("+ ")) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
saw_line
|
||||
}
|
||||
|
||||
fn render_legacy_slide_item(item: &Value) -> Option<String> {
|
||||
let kind = item.get("type").and_then(Value::as_str)?;
|
||||
let content = item.get("content").and_then(value_to_optional_string)?;
|
||||
if content.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match kind {
|
||||
"name" => Some(format!("- {content}")),
|
||||
"title" => Some(format!(" - {content}")),
|
||||
"content" => {
|
||||
if content.contains('\n') {
|
||||
Some(
|
||||
content
|
||||
.lines()
|
||||
.map(|line| format!(" - {line}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
} else {
|
||||
Some(format!(" - {content}"))
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_slide_item(item: &Value) -> Result<String, String> {
|
||||
if let Some(markdown) = render_legacy_slide_item(item) {
|
||||
return Ok(markdown);
|
||||
}
|
||||
if item.get("content").and_then(Value::as_object).is_some() {
|
||||
return render_structured_slide_item(item);
|
||||
}
|
||||
if item.get("content").and_then(Value::as_str).is_some() {
|
||||
return render_labeled_string_slide_item(item);
|
||||
}
|
||||
Err("slidesOutlineMarkdown item is not a recognized slide outline object".to_string())
|
||||
}
|
||||
|
||||
fn render_labeled_string_slide_item(item: &Value) -> Result<String, String> {
|
||||
let content = item
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires string content".to_string())?;
|
||||
if content.trim().is_empty() {
|
||||
return Err("slidesOutlineMarkdown labeled item requires string content".to_string());
|
||||
}
|
||||
let labels = parse_labeled_segments(content);
|
||||
let title = labels
|
||||
.get("title")
|
||||
.cloned()
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires Title".to_string())?;
|
||||
let keywords = labels
|
||||
.get("image keywords")
|
||||
.cloned()
|
||||
.or_else(|| labels.get("keywords").cloned())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires Image Keywords".to_string())?;
|
||||
let description = labels
|
||||
.get("description")
|
||||
.cloned()
|
||||
.or_else(|| labels.get("content").cloned())
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "slidesOutlineMarkdown labeled item requires Description".to_string())?;
|
||||
|
||||
Ok(
|
||||
[
|
||||
format!("- {title}"),
|
||||
format!(" - {title}"),
|
||||
format!(" - {keywords}"),
|
||||
format!(" - {description}"),
|
||||
]
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn render_structured_slide_item(item: &Value) -> Result<String, String> {
|
||||
let item_object = item
|
||||
.as_object()
|
||||
.ok_or_else(|| "slidesOutlineMarkdown structured item requires object content".to_string())?;
|
||||
let content = item
|
||||
.get("content")
|
||||
.and_then(Value::as_object)
|
||||
.ok_or_else(|| "slidesOutlineMarkdown structured item requires object content".to_string())?;
|
||||
let title = string_prop(content, &["title", "name", "page_name", "pageName"])
|
||||
.or_else(|| string_prop(item_object, &["title", "name", "page_name", "pageName", "page"]))
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| "slidesOutlineMarkdown requires slide title".to_string())?;
|
||||
let sections = content.get("sections").and_then(Value::as_array);
|
||||
let rendered_sections = if let Some(sections) = sections.filter(|sections| !sections.is_empty()) {
|
||||
sections
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, section)| render_slide_section(section, index + 1))
|
||||
.collect::<Result<Vec<_>, _>>()?
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.collect::<Vec<_>>()
|
||||
} else {
|
||||
render_slide_object(content)?
|
||||
};
|
||||
|
||||
Ok(
|
||||
std::iter::once(format!("- {title}"))
|
||||
.chain(rendered_sections)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
)
|
||||
}
|
||||
|
||||
fn parse_labeled_segments(text: &str) -> std::collections::HashMap<String, String> {
|
||||
text
|
||||
.split(';')
|
||||
.filter_map(|segment| {
|
||||
let (key, value) = segment.split_once(':')?;
|
||||
let key = key.trim().to_ascii_lowercase();
|
||||
let value = value.trim().to_string();
|
||||
if key.is_empty() || value.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some((key, value))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn render_slide_section(section: &Value, index: usize) -> Result<Vec<String>, String> {
|
||||
let Some(object) = section.as_object() else {
|
||||
return Err(format!("slidesOutlineMarkdown section {index} requires object content"));
|
||||
};
|
||||
|
||||
render_slide_object(object)
|
||||
}
|
||||
|
||||
fn render_slide_object(object: &Map<String, Value>) -> Result<Vec<String>, String> {
|
||||
let title = required_string_prop(
|
||||
object,
|
||||
&["title", "name", "section", "page_name", "pageName"],
|
||||
"slide section title",
|
||||
)?;
|
||||
let keywords = string_prop(
|
||||
object,
|
||||
&["image_keywords", "imageKeywords", "keywords", "image_keywords_optional"],
|
||||
)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| title.clone());
|
||||
let content = required_string_prop(
|
||||
object,
|
||||
&["content", "description", "summary", "text"],
|
||||
"slide section content",
|
||||
)?;
|
||||
|
||||
Ok(vec![
|
||||
format!(" - {title}"),
|
||||
format!(" - {keywords}"),
|
||||
format!(" - {content}"),
|
||||
])
|
||||
}
|
||||
|
||||
fn string_prop(object: &Map<String, Value>, keys: &[&str]) -> Option<String> {
|
||||
keys
|
||||
.iter()
|
||||
.find_map(|key| object.get(*key).and_then(value_to_optional_string))
|
||||
}
|
||||
|
||||
fn required_string_prop(object: &Map<String, Value>, keys: &[&str], name: &str) -> Result<String, String> {
|
||||
string_prop(object, keys)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| format!("slidesOutlineMarkdown requires {name}"))
|
||||
}
|
||||
|
||||
fn value_to_optional_string(value: &Value) -> Option<String> {
|
||||
match value {
|
||||
Value::String(text) => Some(text.clone()),
|
||||
Value::Number(number) => Some(number.to_string()),
|
||||
Value::Array(items) => {
|
||||
let joined = items
|
||||
.iter()
|
||||
.filter_map(value_to_optional_string)
|
||||
.filter(|value| !value.is_empty())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
Some(joined)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
use napi::Status;
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
ACTION_ABORTED_ERROR_CODE, ActionEventType, ActionRecipe, ActionRecipeStep, ActionRunStatus, ActionRuntimeControl,
|
||||
ActionRuntimeInput, ActionStepKind, load_catalog, run_action_recipe_for_test,
|
||||
run_action_recipe_for_test_with_control, run_action_recipe_prepared_with_control, validate_catalog, validate_recipe,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn validates_built_in_recipe_catalog() {
|
||||
let catalog = load_catalog().unwrap();
|
||||
let mindmap = catalog.iter().find(|recipe| recipe.id == "mindmap.generate").unwrap();
|
||||
assert!(
|
||||
mindmap
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.kind == ActionStepKind::PromptStructured)
|
||||
);
|
||||
assert!(
|
||||
mindmap
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.kind == ActionStepKind::ValidateJson)
|
||||
);
|
||||
let slides = catalog.iter().find(|recipe| recipe.id == "slides.outline").unwrap();
|
||||
assert!(
|
||||
slides
|
||||
.steps
|
||||
.iter()
|
||||
.any(|step| step.id == "project-outline" && step.kind == ActionStepKind::Transform)
|
||||
);
|
||||
assert!(catalog.iter().any(|recipe| recipe.id == "transcript.audio.gemini"));
|
||||
assert!(!catalog.iter().any(|recipe| recipe.id == "transcript.audio.local-asr"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_transcript_action_final_result_is_schema_checked() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "transcript.audio.gemini".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({
|
||||
"sourceAudio": { "blobId": "blob-1", "mimeType": "audio/opus" },
|
||||
"quality": null,
|
||||
"infos": [{ "url": "https://example.com/audio.opus", "mimeType": "audio/opus", "index": 0 }],
|
||||
"sliceManifest": [{
|
||||
"index": 0,
|
||||
"fileName": "audio.opus",
|
||||
"mimeType": "audio/opus",
|
||||
"startSec": 12,
|
||||
"durationSec": 30,
|
||||
"byteSize": 42
|
||||
}],
|
||||
}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"transcribe": {
|
||||
"normalizedTranscript": "00:00:01 A: Hello",
|
||||
"summaryJson": {
|
||||
"title": "Sync",
|
||||
"durationMinutes": 1,
|
||||
"attendees": ["A"],
|
||||
"keyPoints": ["Hello"],
|
||||
"actionItems": [],
|
||||
"decisions": [],
|
||||
"openQuestions": [],
|
||||
"blockers": []
|
||||
},
|
||||
"providerMeta": { "provider": "gemini", "model": "gemini-2.5-flash" }
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(output.result["version"], json!("transcript-result-v1"));
|
||||
assert_eq!(output.result["strategy"], json!("gemini"));
|
||||
assert_eq!(output.result["normalizedSegments"], json!(null));
|
||||
assert_eq!(output.result["sourceAudio"]["blobId"], json!("blob-1"));
|
||||
assert_eq!(
|
||||
output.result["infos"][0]["url"],
|
||||
json!("https://example.com/audio.opus")
|
||||
);
|
||||
assert_eq!(output.result["sliceManifest"][0]["startSec"], json!(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_transcript_action_rejects_malformed_summary() {
|
||||
let error = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "transcript.audio.gemini".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"transcribe": {
|
||||
"normalizedTranscript": "00:00:01 A: Hello",
|
||||
"summaryJson": { "title": "Sync" },
|
||||
"providerMeta": { "provider": "gemini", "model": "gemini-2.5-flash" }
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert!(error.reason.contains("does not match JSON schema"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_action_final_result_comes_from_prompt_output_state() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "mindmap.generate".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-structured": {
|
||||
"result": "- Root"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(output.result, json!("- Root"));
|
||||
assert_eq!(output.state["generated"], json!("- Root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_action_unwraps_structured_text_result() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "mindmap.generate".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-structured": {
|
||||
"result": "- Root"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(output.result, json!("- Root"));
|
||||
assert_eq!(output.state["generated"], json!("- Root"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_slides_outline_projects_final_result_to_markdown() {
|
||||
let outline = [
|
||||
serde_json::to_string(&json!({
|
||||
"page": "Cover",
|
||||
"type": "cover",
|
||||
"content": {
|
||||
"title": "Apple Inc.",
|
||||
"description": "Company overview",
|
||||
"image_keywords": ["Apple logo", "Apple Park"]
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
serde_json::to_string(&json!({
|
||||
"page": 2,
|
||||
"type": "content",
|
||||
"content": {
|
||||
"title": "Products",
|
||||
"sections": [{
|
||||
"title": "iPhone",
|
||||
"keywords": ["smartphone", "iOS"],
|
||||
"content": "Flagship product line"
|
||||
}]
|
||||
}
|
||||
}))
|
||||
.unwrap(),
|
||||
serde_json::to_string(&json!({
|
||||
"page": 3,
|
||||
"type": "cover",
|
||||
"content": "Page Name: Closing; Title: Outlook; Description: Future strategy; Image Keywords: roadmap, devices"
|
||||
}))
|
||||
.unwrap(),
|
||||
]
|
||||
.join("\n");
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "slides.outline".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-structured": {
|
||||
"result": outline
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(
|
||||
output.result,
|
||||
json!(
|
||||
[
|
||||
"- Apple Inc.",
|
||||
" - Apple Inc.",
|
||||
" - Apple logo, Apple Park",
|
||||
" - Company overview",
|
||||
"- Products",
|
||||
" - iPhone",
|
||||
" - smartphone, iOS",
|
||||
" - Flagship product line",
|
||||
"- Outlook",
|
||||
" - Outlook",
|
||||
" - roadmap, devices",
|
||||
" - Future strategy"
|
||||
]
|
||||
.join("\n")
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
output
|
||||
.steps
|
||||
.iter()
|
||||
.find(|step| step.id == "project-outline")
|
||||
.and_then(|step| step.output.as_ref()),
|
||||
Some(&output.result)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_outline_transform_keeps_legacy_markdown_shape() {
|
||||
let outline = [
|
||||
serde_json::to_string(&json!({ "page": 1, "type": "name", "content": "Launch deck" })).unwrap(),
|
||||
serde_json::to_string(&json!({ "page": 1, "type": "title", "content": "Context" })).unwrap(),
|
||||
serde_json::to_string(&json!({ "page": 1, "type": "content", "content": "Problem\nOpportunity" })).unwrap(),
|
||||
]
|
||||
.join("\n");
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "project-outline".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: Some(json!({
|
||||
"slidesOutlineMarkdown": { "$state": "outline" },
|
||||
"outputKey": "outlineMarkdown"
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
let output = run_action_recipe_for_test(
|
||||
recipe,
|
||||
runtime_input(json!({
|
||||
"outline": outline
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(
|
||||
output.result,
|
||||
json!(["- Launch deck", " - Context", " - Problem", " - Opportunity"].join("\n"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_outline_transform_rejects_unrecognized_text() {
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "project-outline".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: Some(json!({
|
||||
"slidesOutlineMarkdown": { "$state": "outline" },
|
||||
"outputKey": "outlineMarkdown"
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
let output = run_action_recipe_for_test(
|
||||
recipe,
|
||||
runtime_input(json!({
|
||||
"outline": "not valid ndjson"
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Failed);
|
||||
assert_eq!(output.error_code, Some("action_invalid_step".to_string()));
|
||||
assert_eq!(
|
||||
output.events.last().and_then(|event| event.error_message.as_deref()),
|
||||
Some("slidesOutlineMarkdown requires markdown or NDJSON object lines")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_outline_transform_accepts_cover_without_image_keywords() {
|
||||
let outline = serde_json::to_string(&json!({
|
||||
"page": 1,
|
||||
"type": "cover",
|
||||
"content": {
|
||||
"title": "Launch deck",
|
||||
"description": "Overview"
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "project-outline".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: Some(json!({
|
||||
"slidesOutlineMarkdown": { "$state": "outline" },
|
||||
"outputKey": "outlineMarkdown"
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
let output = run_action_recipe_for_test(
|
||||
recipe,
|
||||
runtime_input(json!({
|
||||
"outline": outline
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
output.result,
|
||||
json!(
|
||||
[
|
||||
"- Launch deck",
|
||||
" - Launch deck",
|
||||
" - Launch deck",
|
||||
" - Overview"
|
||||
]
|
||||
.join("\n")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn slides_outline_transform_accepts_page_name_from_item() {
|
||||
let outline = serde_json::to_string(&json!({
|
||||
"page": 2,
|
||||
"type": "content",
|
||||
"page_name": "Workspace Benefits",
|
||||
"content": {
|
||||
"sections": [
|
||||
{
|
||||
"section": "Unified writing",
|
||||
"keywords": ["docs", "canvas"],
|
||||
"text": "AFFiNE combines documents and whiteboards."
|
||||
}
|
||||
]
|
||||
}
|
||||
}))
|
||||
.unwrap();
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "project-outline".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: Some(json!({
|
||||
"slidesOutlineMarkdown": { "$state": "outline" },
|
||||
"outputKey": "outlineMarkdown"
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": { "$state": "outlineMarkdown" } })),
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
let output = run_action_recipe_for_test(
|
||||
recipe,
|
||||
runtime_input(json!({
|
||||
"outline": outline
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(
|
||||
output.result,
|
||||
json!(
|
||||
[
|
||||
"- Workspace Benefits",
|
||||
" - Unified writing",
|
||||
" - docs, canvas",
|
||||
" - AFFiNE combines documents and whiteboards."
|
||||
]
|
||||
.join("\n")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_action_events_for_server_contract() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "mindmap.generate".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-structured": {
|
||||
"result": "- Root"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
let first = serde_json::to_value(output.events.first().unwrap()).unwrap();
|
||||
let last = serde_json::to_value(output.events.last().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(first["type"], json!("action_start"));
|
||||
assert_eq!(last["type"], json!("action_done"));
|
||||
assert_eq!(last["status"], json!("succeeded"));
|
||||
assert_eq!(last["trace"]["status"], json!("succeeded"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_action_fails_without_routes_or_mock_output() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "mindmap.generate".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
ActionRuntimeControl::default(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Failed);
|
||||
assert!(
|
||||
output
|
||||
.events
|
||||
.last()
|
||||
.and_then(|event| event.error_message.as_deref())
|
||||
.unwrap_or_default()
|
||||
.contains("promptStructured requires")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_image_action_uses_prompt_image_step_output() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "image.filter.sketch".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-image": {
|
||||
"url": "https://example.com/artifact-1.png"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(output.result, json!({ "url": "https://example.com/artifact-1.png" }));
|
||||
assert_eq!(
|
||||
output.state.pointer("/artifact/url"),
|
||||
Some(&json!("https://example.com/artifact-1.png"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn built_in_image_action_accepts_inline_artifact_output() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "image.filter.sketch".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
mock_control(json!({
|
||||
"generate-image": {
|
||||
"data_base64": "aW1n",
|
||||
"media_type": "image/webp"
|
||||
}
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(
|
||||
output.result,
|
||||
json!({
|
||||
"data_base64": "aW1n",
|
||||
"media_type": "image/webp"
|
||||
})
|
||||
);
|
||||
assert_eq!(output.state.pointer("/artifact/data_base64"), Some(&json!("aW1n")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_recipe_without_final_step() {
|
||||
let recipe = ActionRecipe {
|
||||
id: "invalid.recipe".to_string(),
|
||||
version: "v1".to_string(),
|
||||
input_schema: json!({}),
|
||||
output_schema: json!({}),
|
||||
steps: vec![ActionRecipeStep {
|
||||
id: "start".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let error = validate_recipe(&recipe).unwrap_err();
|
||||
assert_eq!(error.status, Status::InvalidArg);
|
||||
assert!(error.reason.contains("must end with a final step"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_duplicated_recipe_identity() {
|
||||
let recipe = ActionRecipe {
|
||||
id: "duplicated.recipe".to_string(),
|
||||
version: "v1".to_string(),
|
||||
input_schema: json!({}),
|
||||
output_schema: json!({}),
|
||||
steps: vec![ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
}],
|
||||
};
|
||||
|
||||
let error = validate_catalog(&[recipe.clone(), recipe]).unwrap_err();
|
||||
assert_eq!(error.status, Status::InvalidArg);
|
||||
assert!(error.reason.contains("Duplicated action recipe"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_recipe_where_final_step_is_not_last() {
|
||||
let recipe = ActionRecipe {
|
||||
id: "invalid.recipe".to_string(),
|
||||
version: "v1".to_string(),
|
||||
input_schema: json!({}),
|
||||
output_schema: json!({}),
|
||||
steps: vec![
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "after-final".to_string(),
|
||||
kind: ActionStepKind::Transform,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let error = validate_recipe(&recipe).unwrap_err();
|
||||
assert_eq!(error.status, Status::InvalidArg);
|
||||
assert!(error.reason.contains("must end with a final step"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_json_and_prompt_projection_steps() {
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "prompt-structured".to_string(),
|
||||
kind: ActionStepKind::PromptStructured,
|
||||
input: Some(json!({})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "prompt-image".to_string(),
|
||||
kind: ActionStepKind::PromptImage,
|
||||
input: Some(json!({})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "validate-json".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: Some(json!({
|
||||
"schema": { "type": "object", "required": ["title"] },
|
||||
"value": { "title": "Hello" }
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": { "done": true } })),
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let output = run_action_recipe_for_test_with_control(
|
||||
recipe,
|
||||
runtime_input(json!({})),
|
||||
mock_control(json!({
|
||||
"prompt-structured": { "title": "Hello" },
|
||||
"prompt-image": { "url": "https://example.com/artifact-1.png" }
|
||||
})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
output
|
||||
.events
|
||||
.iter()
|
||||
.map(|event| event.event_type)
|
||||
.filter(|event_type| matches!(event_type, ActionEventType::Attachment))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![ActionEventType::Attachment]
|
||||
);
|
||||
assert_eq!(output.steps[2].output, Some(json!(true)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_prompt_steps_without_prepared_routes_or_explicit_boundary() {
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "prompt".to_string(),
|
||||
kind: ActionStepKind::PromptStructured,
|
||||
input: Some(json!({})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let output = run_action_recipe_for_test(recipe, runtime_input(json!({}))).unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Failed);
|
||||
assert_eq!(output.error_code, Some("action_invalid_step".to_string()));
|
||||
assert!(
|
||||
output
|
||||
.events
|
||||
.last()
|
||||
.and_then(|event| event.error_message.as_deref())
|
||||
.unwrap_or_default()
|
||||
.contains("requires")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_prompt_image_without_prepared_routes() {
|
||||
let recipe = test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "prompt-image".to_string(),
|
||||
kind: ActionStepKind::PromptImage,
|
||||
input: Some(json!({})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
},
|
||||
]);
|
||||
|
||||
let output = run_action_recipe_for_test(recipe, runtime_input(json!({}))).unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Failed);
|
||||
assert!(
|
||||
output
|
||||
.events
|
||||
.last()
|
||||
.and_then(|event| event.error_message.as_deref())
|
||||
.unwrap_or_default()
|
||||
.contains("preparedRoutes")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_json_distinguishes_invalid_schema_from_invalid_value() {
|
||||
let invalid_value = run_action_recipe_for_test(
|
||||
test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "validate-json".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: Some(json!({
|
||||
"schema": { "type": "object", "required": ["title"] },
|
||||
"value": {}
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": {} })),
|
||||
state_patch: None,
|
||||
},
|
||||
]),
|
||||
runtime_input(json!({})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(invalid_value.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(invalid_value.steps[0].output, Some(json!(false)));
|
||||
|
||||
let invalid_schema = run_action_recipe_for_test(
|
||||
test_recipe(vec![
|
||||
ActionRecipeStep {
|
||||
id: "validate-json".to_string(),
|
||||
kind: ActionStepKind::ValidateJson,
|
||||
input: Some(json!({
|
||||
"schema": { "type": 1 },
|
||||
"value": {}
|
||||
})),
|
||||
state_patch: None,
|
||||
},
|
||||
ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: None,
|
||||
state_patch: None,
|
||||
},
|
||||
]),
|
||||
runtime_input(json!({})),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(invalid_schema.status, ActionRunStatus::Failed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emits_ordered_action_events_and_final_result() {
|
||||
let output = run_action_recipe_for_test(
|
||||
test_recipe(vec![ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": {} })),
|
||||
state_patch: Some(json!({ "finalized": true })),
|
||||
}]),
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "test.recipe".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({ "content": "hello" }),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Succeeded);
|
||||
assert_eq!(output.result, json!({}));
|
||||
assert_eq!(output.error_code, None);
|
||||
assert_eq!(output.state, json!({ "content": "hello", "finalized": true }));
|
||||
assert_eq!(output.steps.len(), 1);
|
||||
assert_eq!(output.steps[0].id, "final");
|
||||
assert_eq!(output.steps[0].output, Some(json!({})));
|
||||
assert_eq!(output.steps[0].state_patch, Some(json!({ "finalized": true })));
|
||||
assert_eq!(output.steps[0].error, None);
|
||||
assert_eq!(
|
||||
output.events.iter().map(|event| event.event_type).collect::<Vec<_>>(),
|
||||
vec![
|
||||
ActionEventType::ActionStart,
|
||||
ActionEventType::StepStart,
|
||||
ActionEventType::StepEnd,
|
||||
ActionEventType::ActionDone,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
fn runtime_input(input: serde_json::Value) -> ActionRuntimeInput {
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "test.recipe".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input,
|
||||
}
|
||||
}
|
||||
|
||||
fn mock_control(mock_output: serde_json::Value) -> ActionRuntimeControl {
|
||||
ActionRuntimeControl {
|
||||
abort_signal: None,
|
||||
event_sender: None,
|
||||
abort_after_events: None,
|
||||
mock_output: Some(mock_output),
|
||||
}
|
||||
}
|
||||
|
||||
fn test_recipe(steps: Vec<ActionRecipeStep>) -> ActionRecipe {
|
||||
ActionRecipe {
|
||||
id: "test.recipe".to_string(),
|
||||
version: "v1".to_string(),
|
||||
input_schema: json!({}),
|
||||
output_schema: json!({}),
|
||||
steps,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generates_lightweight_trace() {
|
||||
let output = run_action_recipe_for_test(
|
||||
test_recipe(vec![ActionRecipeStep {
|
||||
id: "final".to_string(),
|
||||
kind: ActionStepKind::Final,
|
||||
input: Some(json!({ "copy": {} })),
|
||||
state_patch: None,
|
||||
}]),
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "test.recipe".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.trace.status, ActionRunStatus::Succeeded);
|
||||
assert!(!output.trace.lightweight.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abort_control_stops_runtime() {
|
||||
let output = run_action_recipe_prepared_with_control(
|
||||
ActionRuntimeInput {
|
||||
recipe_id: "image.filter.sketch".to_string(),
|
||||
recipe_version: Some("v1".to_string()),
|
||||
input: json!({}),
|
||||
},
|
||||
ActionRuntimeControl {
|
||||
abort_signal: None,
|
||||
event_sender: None,
|
||||
abort_after_events: Some(1),
|
||||
mock_output: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(output.status, ActionRunStatus::Aborted);
|
||||
assert_eq!(output.error_code, Some(ACTION_ABORTED_ERROR_CODE.to_string()));
|
||||
assert_eq!(
|
||||
output.events.last().map(|event| event.event_type),
|
||||
Some(ActionEventType::Error)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user