use crate::{Comment, Config, Level, Options, Preset, Verdict};
use serde::{Deserialize, Serialize};
use std::rc::Rc;
use std::time::Duration;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(typescript_custom_section)]
const TYPES: &str = r#"
/** Strictness level. */
export type Level = "light" | "medium" | "heavy";
/** Built-in preset. */
export type Preset = "comments" | "product-review" | "launch-thread";
/** The outcome for a comment. */
export type Decision = "approve" | "reject" | "needs_review";
/** Why a comment got its decision. */
export type Reason =
| "useful" | "too_short" | "stock_phrase" | "duplicate" | "injection_attempt"
| "spam_promo" | "toxic" | "generic" | "off_topic" | "not_specific"
| "custom_rule" | "low_confidence" | "model_unavailable";
/** A comment with the context it was posted in. Only `text` is required. */
export interface Comment {
/** The comment text. Treated strictly as data. */
text: string;
/** Title of the post or product the comment is on. */
title?: string;
/** Description of the post or product. */
description?: string;
/** The comment this one replies to, if any. */
parent?: string;
}
/** The judgement for one comment: the same fields as the CLI's verdict JSON. */
export interface Verdict {
verdict: Decision;
reason: Reason;
/** The model's probability for the outcome, or 1 for a pre-check. */
confidence: number;
level: Level;
/** The model that answered, or null when no model was called. */
model: string | null;
/** Time spent judging, in milliseconds. */
latency_ms: number;
/** The custom rule that fired, when one did. */
rule?: string;
}
/** How to judge comments. Every field is optional. */
export interface CheckerOptions {
/** Strictness level. Default "medium". */
level?: Level;
/** Built-in preset. Default "comments". */
preset?: Preset;
/** OpenRouter decision models, tried in order. Default ["~typesafe/jev-latest"]. */
models?: string[];
/** Plain-English rules enforced at every level. */
rules?: string[];
/** Extra stock phrases for the free pre-check. */
stock_phrases?: string[];
/** A replysieve TOML config. A field given above replaces the config's value; an empty `models` keeps the config's models. */
config?: string;
/** Milliseconds one model gets before the next is tried. Default 10000. */
timeout_ms?: number;
/** Another OpenRouter-compatible base URL. Default "https://openrouter.ai". */
base_url?: string;
/** Log model fallbacks and timings with console.log. Never logs comment text. */
log?: boolean;
}
"#;
#[derive(Default, Deserialize)]
#[serde(default, deny_unknown_fields)]
struct JsOptions {
level: Option<String>,
preset: Option<String>,
models: Option<Vec<String>>,
rules: Option<Vec<String>>,
stock_phrases: Option<Vec<String>>,
config: Option<String>,
timeout_ms: Option<u32>,
base_url: Option<String>,
log: bool,
}
fn error(message: impl std::fmt::Display) -> JsValue {
let e = js_sys::Error::new(&message.to_string());
e.set_name("ReplysieveError");
e.into()
}
fn js_error(e: crate::Error) -> JsValue {
match e {
crate::Error::MissingApiKey => error(
"no OpenRouter API key: pass apiKey to new Checker(options, apiKey); get one at https://openrouter.ai/keys",
),
}
}
fn to_js(value: &impl Serialize) -> Result<JsValue, JsValue> {
value
.serialize(&serde_wasm_bindgen::Serializer::json_compatible())
.map_err(error)
}
fn comment(value: JsValue) -> Result<Comment, JsValue> {
match value.as_string() {
Some(text) => Ok(Comment::new(text)),
None => serde_wasm_bindgen::from_value(value)
.map_err(|e| error(format!("invalid comment: {e}"))),
}
}
fn comments(value: JsValue) -> Result<Vec<Comment>, JsValue> {
if !js_sys::Array::is_array(&value) {
return Err(error("comments must be an array"));
}
js_sys::Array::from(&value).iter().map(comment).collect()
}
fn core_options(o: &JsOptions) -> Result<Options, JsValue> {
let config = match &o.config {
Some(text) => {
Config::from_toml(text).map_err(|e| error(format!("invalid config: {e:#}")))?
}
None => Config::default(),
};
let preset = match o.preset.as_deref().or(config.preset.as_deref()) {
Some(name) => name.parse::<Preset>().map_err(error)?,
None => Preset::default(),
};
let level = match o.level.as_deref() {
Some(name) => name.parse::<Level>().map_err(error)?,
None => config.level.unwrap_or_default(),
};
let mut opts = Options {
level,
preset,
rules: o.rules.clone().unwrap_or(config.rules),
stock_phrases: o.stock_phrases.clone().unwrap_or(config.stock_phrases),
..Options::default()
};
match &o.models {
Some(models) if !models.is_empty() => opts.models = models.clone(),
_ if !config.models.is_empty() => opts.models = config.models,
_ => {}
}
Ok(opts)
}
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console, js_name = log)]
fn console_log(message: &str);
}
struct ConsoleLog;
impl log::Log for ConsoleLog {
fn enabled(&self, m: &log::Metadata) -> bool {
m.target().starts_with("replysieve")
}
fn log(&self, r: &log::Record) {
if self.enabled(r.metadata()) {
console_log(&format!("replysieve: {}", r.args()));
}
}
fn flush(&self) {}
}
static LOGGER: ConsoleLog = ConsoleLog;
#[wasm_bindgen]
pub struct Checker {
inner: Rc<crate::Checker>,
}
#[wasm_bindgen]
impl Checker {
#[wasm_bindgen(constructor)]
pub fn new(
#[wasm_bindgen(unchecked_param_type = "CheckerOptions | undefined")] options: JsValue,
#[wasm_bindgen(js_name = apiKey)] api_key: Option<String>,
) -> Result<Checker, JsValue> {
let o: JsOptions = if options.is_undefined() || options.is_null() {
JsOptions::default()
} else {
js_sys::JSON::stringify(&options)
.ok()
.and_then(|json| json.as_string())
.ok_or_else(|| error("invalid options: not an object"))
.and_then(|json| {
serde_json::from_str(&json).map_err(|e| error(format!("invalid options: {e}")))
})?
};
if o.timeout_ms == Some(0) {
return Err(error("invalid options: timeout_ms must be above 0"));
}
if o.log {
let _ = log::set_logger(&LOGGER);
log::set_max_level(log::LevelFilter::Info);
}
let base_url = o.base_url.as_deref().unwrap_or(crate::DEFAULT_BASE_URL);
let timeout = o
.timeout_ms
.map(|ms| Duration::from_millis(ms.into()))
.unwrap_or(crate::openrouter::DEFAULT_TIMEOUT);
let checker = crate::Checker::with_base_url(core_options(&o)?, api_key, base_url)
.with_timeout(timeout);
Ok(Checker {
inner: Rc::new(checker),
})
}
#[wasm_bindgen(unchecked_return_type = "Verdict | null")]
pub fn precheck(
&self,
#[wasm_bindgen(unchecked_param_type = "string | Comment")] comment: JsValue,
recent: Option<Vec<String>>,
) -> Result<JsValue, JsValue> {
let c = self::comment(comment)?;
match self.inner.precheck(&c, &recent.unwrap_or_default()) {
Some(v) => to_js(&v),
None => Ok(JsValue::NULL),
}
}
#[wasm_bindgen(unchecked_return_type = "Promise<Verdict>")]
pub fn check(
&self,
#[wasm_bindgen(unchecked_param_type = "string | Comment")] comment: JsValue,
recent: Option<Vec<String>>,
) -> js_sys::Promise {
let checker = Rc::clone(&self.inner);
wasm_bindgen_futures::future_to_promise(async move {
let c = self::comment(comment)?;
let v: Verdict = checker
.check(&c, &recent.unwrap_or_default())
.await
.map_err(js_error)?;
to_js(&v)
})
}
#[wasm_bindgen(js_name = checkBatch, unchecked_return_type = "Promise<Verdict[]>")]
pub fn check_batch(
&self,
#[wasm_bindgen(unchecked_param_type = "Array<string | Comment>")] comments: JsValue,
recent: Option<Vec<String>>,
) -> js_sys::Promise {
let checker = Rc::clone(&self.inner);
wasm_bindgen_futures::future_to_promise(async move {
let cs = self::comments(comments)?;
let vs = checker
.check_batch(&cs, &recent.unwrap_or_default())
.await
.map_err(js_error)?;
to_js(&vs)
})
}
}