1#[cfg(target_arch = "wasm32")]
2use std::collections::BTreeMap;
3
4#[cfg(target_arch = "wasm32")]
5use greentic_interfaces_guest::component_v0_6::node;
6#[cfg(target_arch = "wasm32")]
7use greentic_types::cbor::canonical;
8#[cfg(target_arch = "wasm32")]
9use greentic_types::schemas::common::schema_ir::{AdditionalProperties, SchemaIr};
10
11pub mod i18n;
12pub mod i18n_bundle;
13pub mod qa;
14pub use qa::{
15 apply_store, describe, get_answer_schema, get_example_answers, next, next_with_ctx,
16 render_card, render_json_ui, render_text, submit_all, submit_patch, validate_answers,
17};
18
19const COMPONENT_NAME: &str = "component-qa";
20const COMPONENT_ORG: &str = "ai.greentic";
21const COMPONENT_VERSION: &str = env!("CARGO_PKG_VERSION");
22
23#[cfg(target_arch = "wasm32")]
24#[used]
25#[unsafe(link_section = ".greentic.wasi")]
26static WASI_TARGET_MARKER: [u8; 13] = *b"wasm32-wasip2";
27
28#[cfg(target_arch = "wasm32")]
29struct Component;
30
31#[cfg(target_arch = "wasm32")]
32impl node::Guest for Component {
33 fn describe() -> node::ComponentDescriptor {
34 let input_schema_cbor = input_schema_cbor();
35 let output_schema_cbor = output_schema_cbor();
36 node::ComponentDescriptor {
37 name: COMPONENT_NAME.to_string(),
38 version: COMPONENT_VERSION.to_string(),
39 summary: Some("Greentic QA component".to_string()),
40 capabilities: Vec::new(),
41 ops: vec![
42 node::Op {
43 name: "handle_message".to_string(),
44 summary: Some("Handle a single message input".to_string()),
45 input: node::IoSchema {
46 schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
47 content_type: "application/cbor".to_string(),
48 schema_version: None,
49 },
50 output: node::IoSchema {
51 schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
52 content_type: "application/cbor".to_string(),
53 schema_version: None,
54 },
55 examples: Vec::new(),
56 },
57 node::Op {
58 name: "qa-spec".to_string(),
59 summary: Some("Return QA spec (CBOR) for a requested mode".to_string()),
60 input: node::IoSchema {
61 schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
62 content_type: "application/cbor".to_string(),
63 schema_version: None,
64 },
65 output: node::IoSchema {
66 schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
67 content_type: "application/cbor".to_string(),
68 schema_version: None,
69 },
70 examples: Vec::new(),
71 },
72 node::Op {
73 name: "apply-answers".to_string(),
74 summary: Some(
75 "Apply QA answers and optionally return config override".to_string(),
76 ),
77 input: node::IoSchema {
78 schema: node::SchemaSource::InlineCbor(input_schema_cbor.clone()),
79 content_type: "application/cbor".to_string(),
80 schema_version: None,
81 },
82 output: node::IoSchema {
83 schema: node::SchemaSource::InlineCbor(output_schema_cbor.clone()),
84 content_type: "application/cbor".to_string(),
85 schema_version: None,
86 },
87 examples: Vec::new(),
88 },
89 node::Op {
90 name: "i18n-keys".to_string(),
91 summary: Some("Return i18n keys referenced by QA/setup".to_string()),
92 input: node::IoSchema {
93 schema: node::SchemaSource::InlineCbor(input_schema_cbor),
94 content_type: "application/cbor".to_string(),
95 schema_version: None,
96 },
97 output: node::IoSchema {
98 schema: node::SchemaSource::InlineCbor(output_schema_cbor),
99 content_type: "application/cbor".to_string(),
100 schema_version: None,
101 },
102 examples: Vec::new(),
103 },
104 ],
105 schemas: Vec::new(),
106 setup: None,
107 }
108 }
109
110 fn invoke(
111 operation: String,
112 envelope: node::InvocationEnvelope,
113 ) -> Result<node::InvocationResult, node::NodeError> {
114 Ok(node::InvocationResult {
115 ok: true,
116 output_cbor: run_component_cbor(&operation, envelope.payload_cbor),
117 output_metadata_cbor: None,
118 })
119 }
120}
121
122#[cfg(target_arch = "wasm32")]
123greentic_interfaces_guest::export_component_v060!(Component);
124
125pub fn describe_payload() -> String {
126 serde_json::json!({
127 "component": {
128 "name": COMPONENT_NAME,
129 "org": COMPONENT_ORG,
130 "version": COMPONENT_VERSION,
131 "world": "greentic:component/component@0.6.0",
132 "self_describing": true
133 }
134 })
135 .to_string()
136}
137
138pub fn handle_message(operation: &str, input: &str) -> String {
139 format!("{COMPONENT_NAME}::{operation} => {}", input.trim())
140}
141
142#[cfg(target_arch = "wasm32")]
143fn encode_cbor<T: serde::Serialize>(value: &T) -> Vec<u8> {
144 canonical::to_canonical_cbor_allow_floats(value).expect("encode cbor")
145}
146
147#[cfg(target_arch = "wasm32")]
148fn parse_payload(input: &[u8]) -> serde_json::Value {
149 if let Ok(value) = canonical::from_cbor(input) {
150 return value;
151 }
152 serde_json::from_slice(input).unwrap_or_else(|_| serde_json::json!({}))
153}
154
155#[cfg(target_arch = "wasm32")]
156fn input_schema() -> SchemaIr {
157 SchemaIr::Object {
158 properties: BTreeMap::from([(
159 "input".to_string(),
160 SchemaIr::String {
161 min_len: Some(0),
162 max_len: None,
163 regex: None,
164 format: None,
165 },
166 )]),
167 required: vec!["input".to_string()],
168 additional: AdditionalProperties::Allow,
169 }
170}
171
172#[cfg(target_arch = "wasm32")]
173fn output_schema() -> SchemaIr {
174 SchemaIr::Object {
175 properties: BTreeMap::from([(
176 "message".to_string(),
177 SchemaIr::String {
178 min_len: Some(0),
179 max_len: None,
180 regex: None,
181 format: None,
182 },
183 )]),
184 required: vec!["message".to_string()],
185 additional: AdditionalProperties::Allow,
186 }
187}
188
189#[cfg(target_arch = "wasm32")]
190fn input_schema_cbor() -> Vec<u8> {
191 encode_cbor(&input_schema())
192}
193
194#[cfg(target_arch = "wasm32")]
195fn output_schema_cbor() -> Vec<u8> {
196 encode_cbor(&output_schema())
197}
198
199#[cfg(target_arch = "wasm32")]
200fn normalized_mode(payload: &serde_json::Value) -> qa::NormalizedMode {
201 let mode = payload
202 .get("mode")
203 .and_then(|v| v.as_str())
204 .or_else(|| payload.get("operation").and_then(|v| v.as_str()))
205 .unwrap_or("setup");
206 qa::normalize_mode(mode).unwrap_or(qa::NormalizedMode::Setup)
207}
208
209#[cfg(target_arch = "wasm32")]
210fn run_component_cbor(operation: &str, input: Vec<u8>) -> Vec<u8> {
211 let value = parse_payload(&input);
212 let output = match operation {
213 "qa-spec" => {
214 let mode = normalized_mode(&value);
215 qa::qa_spec_json(mode, &value)
216 }
217 "apply-answers" => {
218 let mode = normalized_mode(&value);
219 qa::apply_answers(mode, &value)
220 }
221 "i18n-keys" => serde_json::Value::Array(
222 qa::i18n_keys()
223 .into_iter()
224 .map(serde_json::Value::String)
225 .collect(),
226 ),
227 _ => {
228 let op_name = value
229 .get("operation")
230 .and_then(|v| v.as_str())
231 .unwrap_or(operation);
232 let input_text = value
233 .get("input")
234 .and_then(|v| v.as_str())
235 .map(ToOwned::to_owned)
236 .unwrap_or_else(|| value.to_string());
237 serde_json::json!({
238 "message": handle_message(op_name, &input_text)
239 })
240 }
241 };
242
243 encode_cbor(&output)
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn describe_payload_is_json() {
252 let payload = describe_payload();
253 let json: serde_json::Value = serde_json::from_str(&payload).expect("valid json");
254 assert_eq!(json["component"]["name"], "component-qa");
255 }
256
257 #[test]
258 fn handle_message_round_trips() {
259 let body = handle_message("handle", "demo");
260 assert!(body.contains("demo"));
261 }
262}