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:
commit
884e275b0e
8 changed files with 976 additions and 0 deletions
161
src/lib.rs
Normal file
161
src/lib.rs
Normal 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())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue