feat: studio.translate.ollama v0.1.0 — Ollama-backed translate hook

First plugin to exercise the Studio-plugin translate hook
end-to-end. Routes through an Ollama-shaped /api/chat
endpoint configured via OLLAMA_ENDPOINT (default
http://localhost:11434/api/chat).

System prompt constrains the model to ONLY the translation —
no commentary, no markdown frames — so the translate hook
contract (plain string in, plain string out) stays honest.

Declared permissions: net: localhost + net: 127.0.0.1 +
net: ollama. Operator policy extends the ceiling for
remote / cloud LLM endpoints.

v0.1 limits: no glossary, no batching, no caching. Same SDK
v0.1 single-world contract — declares Declined stubs for the
theme and output-view hooks.

Built artefact: ~108 KiB stripped wasm. Uploaded to
releases.fai.flemming.ai (sha256 0101ed6e…) and reachable via
`fai install studio.translate.ollama`.

Signed-off-by: flemming-it <sf@flemming.it>
This commit is contained in:
flemming-it 2026-05-25 23:02:44 +02:00
commit 884e275b0e
8 changed files with 976 additions and 0 deletions

161
src/lib.rs Normal file
View file

@ -0,0 +1,161 @@
//! `studio.translate.ollama` — second Studio plugin.
//!
//! Implements the `translate` hook by routing through an
//! Ollama-shaped `/api/chat` endpoint. Reads its endpoint URL
//! from the `OLLAMA_ENDPOINT` environment variable (operator
//! sets it in the hub's operator config, exposed to plugins
//! via the `env:` permission). Default falls back to
//! `http://localhost:11434/api/chat`.
//!
//! Same SDK v0.1 shape as `studio.theme.solarized`: declares
//! all three Guest impls; theme + output-view return
//! `PluginError::Declined`, only translate does real work.
//!
//! Wire format: standard Ollama `/api/chat` non-streaming
//! request with a system prompt that instructs the model to
//! return ONLY the translation, no commentary.
#![allow(clippy::result_large_err)]
use fai_studio_plugin_sdk::{bindings, export_plugin};
type ColorScheme = bindings::fai::studio_plugin::plugin_types::ColorScheme;
type PluginError = bindings::fai::studio_plugin::plugin_types::PluginError;
type RenderedOutput = bindings::fai::studio_plugin::plugin_types::RenderedOutput;
const DEFAULT_ENDPOINT: &str = "http://localhost:11434/api/chat";
const DEFAULT_MODEL: &str = "qwen2.5:14b";
struct OllamaTranslatePlugin;
impl bindings::exports::fai::studio_plugin::translate::Guest for OllamaTranslatePlugin {
fn translate(
text: String,
from_locale: String,
to_locale: String,
) -> Result<String, PluginError> {
if to_locale.trim().is_empty() {
return Err(PluginError::Declined(
"translate: to_locale is empty (host must supply it)".to_string(),
));
}
if text.trim().is_empty() {
// No work to do; return verbatim so the calling UI
// can keep the original placeholder.
return Ok(text);
}
do_translate(&text, &from_locale, &to_locale)
}
}
// ─── theme + output-view: declined stubs ────────────────────
// v0.1 of the SDK declares all three hooks in a single WIT
// world. Plugins that only serve one hook return Declined for
// the others; the hub falls back to its built-in renderer or
// the [EN] badge.
impl bindings::exports::fai::studio_plugin::theme::Guest for OllamaTranslatePlugin {
fn theme_for(_brightness: String) -> Result<ColorScheme, PluginError> {
Err(PluginError::Declined(
"studio.translate.ollama is a translate plugin, not a theme plugin".to_string(),
))
}
}
impl bindings::exports::fai::studio_plugin::output_view::Guest for OllamaTranslatePlugin {
fn render(_mime_type: String, _data: Vec<u8>) -> Result<RenderedOutput, PluginError> {
Err(PluginError::Declined(
"studio.translate.ollama is a translate plugin, not an output viewer".to_string(),
))
}
}
export_plugin!(OllamaTranslatePlugin);
// ─── HTTP path ──────────────────────────────────────────────
// Compiled only for wasm32-wasip2; the host-side test fixture
// (none yet) gets the stub.
#[cfg(target_arch = "wasm32")]
fn do_translate(text: &str, from_locale: &str, to_locale: &str) -> Result<String, PluginError> {
let endpoint = read_env("OLLAMA_ENDPOINT", DEFAULT_ENDPOINT);
let model = read_env("OLLAMA_MODEL", DEFAULT_MODEL);
// Two-message chat: one system prompt that constrains the
// model to return ONLY the translation, one user message
// with the source text. The locale hint goes in the system
// prompt so the model treats it as a directive, not as
// translatable content.
let from_clause = if from_locale.trim().is_empty() {
"auto-detected from the input".to_string()
} else {
from_locale.to_string()
};
let system_prompt = format!(
"You are a translation engine. Translate the user's text \
from {from_clause} to {to_locale}. Output ONLY the \
translated text. Do not add commentary, explanations, \
quotation marks, or any framing. Preserve formatting \
(newlines, punctuation, capitalisation) of the source."
);
let body = serde_json::json!({
"model": model,
"messages": [
{ "role": "system", "content": system_prompt },
{ "role": "user", "content": text },
],
"stream": false,
})
.to_string();
let request = waki::Client::new()
.post(&endpoint)
.header(
waki::header::HeaderName::from_static("content-type"),
waki::header::HeaderValue::from_static("application/json"),
)
.body(body);
let response = request
.send()
.map_err(|e| PluginError::Misconfigured(format!("ollama unreachable: {e}")))?;
let status = response.status_code();
if !(200..300).contains(&status) {
return Err(PluginError::Misconfigured(format!(
"ollama returned HTTP {status}"
)));
}
let bytes = response
.body()
.map_err(|e| PluginError::Internal(format!("body read: {e}")))?;
let parsed: serde_json::Value = serde_json::from_slice(&bytes)
.map_err(|e| PluginError::Internal(format!("response is not JSON: {e}")))?;
let translated = parsed
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_str())
.ok_or_else(|| {
PluginError::Internal(
"ollama response missing `message.content` field".to_string(),
)
})?;
Ok(translated.trim().to_string())
}
#[cfg(not(target_arch = "wasm32"))]
fn do_translate(_text: &str, _from: &str, _to: &str) -> Result<String, PluginError> {
Err(PluginError::Internal(
"translate hook can only execute under the WASM runtime".to_string(),
))
}
/// Read an env-var if the plugin has the `env: VAR`
/// permission; fall back to a default otherwise. waki + std's
/// `std::env::var` work the same way under wasm32-wasip2 —
/// `var()` returns `Err(NotPresent)` for both unset variables
/// AND variables the WASI policy did not expose.
#[cfg(target_arch = "wasm32")]
fn read_env(name: &str, default: &str) -> String {
std::env::var(name).unwrap_or_else(|_| default.to_string())
}