This commit is contained in:
2026-07-09 02:59:53 +03:00
commit 7ace048d94
817 changed files with 234289 additions and 0 deletions
+31
View File
@@ -0,0 +1,31 @@
[package]
name = "hcie-ai"
version = "0.1.0"
edition = "2021"
[features]
default = []
ffi = []
[dependencies]
hcie-engine-api = { path = "../hcie-engine-api" }
hcie-protocol = { path = "../hcie-protocol" }
reqwest = { version = "0.12", features = ["json", "multipart", "blocking", "stream"] }
tokio = { version = "1.40", features = ["full"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
async-trait = "0.1"
anyhow = "1.0"
futures-util = "0.3"
base64 = "0.22"
log = "0.4"
bincode = "1.3"
once_cell = "1.20"
[lib]
crate-type = ["rlib", "cdylib"]
[dev-dependencies]
rstest = "0.23"
proptest = "1.5"
approx = "0.5"
+67
View File
@@ -0,0 +1,67 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum AiProviderKind {
Ollama,
OpenAiCompat,
Gemini,
Claude,
LmStudio,
}
impl Default for AiProviderKind {
fn default() -> Self {
Self::Ollama
}
}
impl AiProviderKind {
pub fn label(&self) -> &'static str {
match self {
Self::Ollama => "Ollama (Local)",
Self::OpenAiCompat => "OpenAI Compatible",
Self::Gemini => "Google Gemini",
Self::Claude => "Anthropic Claude",
Self::LmStudio => "LM Studio (Local)",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiConfig {
pub provider: AiProviderKind,
pub base_url: String,
pub api_key: String,
pub model: String,
}
impl Default for AiConfig {
fn default() -> Self {
Self {
provider: AiProviderKind::Ollama,
base_url: "http://localhost:11434".to_string(),
api_key: String::new(),
model: "qwen2.5:7b".to_string(),
}
}
}
impl AiConfig {
pub fn gemini(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
provider: AiProviderKind::Gemini,
base_url: "https://generativelanguage.googleapis.com".to_string(),
api_key: api_key.into(),
model: model.into(),
}
}
pub fn claude(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
provider: AiProviderKind::Claude,
base_url: "https://api.anthropic.com".to_string(),
api_key: api_key.into(),
model: model.into(),
}
}
}
+313
View File
@@ -0,0 +1,313 @@
//! Converts AI ToolCalls into AiAction values that the app handler maps to AppEvents.
use serde_json::Value;
use crate::provider::ToolCall;
use serde::{Serialize, Deserialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum AiAction {
CreateLayer { name: String, layer_type: String },
DeleteLayer { name_or_index: String },
SelectLayer { name_or_index: String },
RenameLayer { name_or_index: String, new_name: String },
SetLayerOpacity { name_or_index: String, opacity: f32 },
SetLayerBlendMode { name_or_index: String, blend_mode: String },
ToggleLayerVisibility { name_or_index: String },
AddVectorPath {
points: Vec<[f32; 2]>,
closed: bool,
fill_color: String,
stroke_color: String,
stroke_width: f32,
},
AddEllipse {
cx: f32, cy: f32,
rx: f32, ry: f32,
fill_color: String,
stroke_color: String,
stroke_width: f32,
},
AddRect {
x1: f32, y1: f32,
x2: f32, y2: f32,
fill_color: String,
stroke_color: String,
stroke_width: f32,
radius: f32,
},
FillRasterRect {
x1: f32, y1: f32,
x2: f32, y2: f32,
color: String,
},
DeleteShape { shape_index: usize },
SetShapeColor { shape_index: usize, stroke_color: String, fill_color: Option<String> },
MoveShape { shape_index: usize, dx: f32, dy: f32 },
ScaleShape { shape_index: usize, scale_x: f32, scale_y: f32 },
PaintStroke {
points: Vec<[f32; 2]>,
color: String,
brush_style: String,
size: f32,
opacity: f32,
spacing: f32,
hardness: f32,
},
SetBrush {
style: Option<String>,
size: Option<f32>,
opacity: Option<f32>,
spacing: Option<f32>,
hardness: Option<f32>,
},
SetForegroundColor { hex: String },
SelectTool { tool: String },
SearchTemplatesDb { object_name: String },
DrawTemplate {
template: String,
cx: f32,
cy: f32,
scale: f32,
stroke_color: String,
stroke_width: f32,
params: std::collections::HashMap<String, String>,
},
Finish { summary: String },
Unknown { name: String },
}
pub fn execute(call: &ToolCall) -> AiAction {
match call.name.as_str() {
"create_layer" => parse_create_layer(&call.arguments),
"delete_layer" => parse_delete_layer(&call.arguments),
"select_layer" => parse_select_layer(&call.arguments),
"rename_layer" => parse_rename_layer(&call.arguments),
"set_layer_opacity" => parse_set_layer_opacity(&call.arguments),
"set_layer_blend_mode" => parse_set_layer_blend_mode(&call.arguments),
"toggle_layer_visibility" => parse_toggle_layer_visibility(&call.arguments),
"add_vector_path" => parse_add_vector_path(&call.arguments),
"add_ellipse" => parse_add_ellipse(&call.arguments),
"add_rect" => parse_add_rect(&call.arguments),
"fill_raster_rect" => parse_fill_raster_rect(&call.arguments),
"delete_shape" => parse_delete_shape(&call.arguments),
"set_shape_color" => parse_set_shape_color(&call.arguments),
"move_shape" => parse_move_shape(&call.arguments),
"scale_shape" => parse_scale_shape(&call.arguments),
"paint_stroke" => parse_paint_stroke(&call.arguments),
"set_brush" => parse_set_brush(&call.arguments),
"set_foreground_color" => parse_set_color(&call.arguments),
"select_tool" => parse_select_tool(&call.arguments),
"search_templates_db" => parse_search_templates_db(&call.arguments),
"draw_template" => parse_draw_template(&call.arguments),
"finish" => AiAction::Finish {
summary: call.arguments["summary"].as_str().unwrap_or("done").to_string(),
},
name => AiAction::Unknown { name: name.to_string() },
}
}
fn parse_search_templates_db(args: &Value) -> AiAction {
AiAction::SearchTemplatesDb {
object_name: args["object_name"].as_str().unwrap_or("").to_string(),
}
}
fn parse_create_layer(args: &Value) -> AiAction {
AiAction::CreateLayer {
name: args["name"].as_str().unwrap_or("AI Layer").to_string(),
layer_type: args["layer_type"].as_str().unwrap_or("vector").to_string(),
}
}
fn parse_delete_layer(args: &Value) -> AiAction {
AiAction::DeleteLayer {
name_or_index: args["name_or_index"].as_str().unwrap_or("0").to_string(),
}
}
fn parse_select_layer(args: &Value) -> AiAction {
AiAction::SelectLayer {
name_or_index: args["name_or_index"].as_str().unwrap_or("0").to_string(),
}
}
fn parse_rename_layer(args: &Value) -> AiAction {
AiAction::RenameLayer {
name_or_index: args["name_or_index"].as_str().unwrap_or("0").to_string(),
new_name: args["new_name"].as_str().unwrap_or("Layer").to_string(),
}
}
fn parse_set_layer_opacity(args: &Value) -> AiAction {
AiAction::SetLayerOpacity {
name_or_index: args["name_or_index"].as_str().unwrap_or("0").to_string(),
opacity: args["opacity"].as_f64().unwrap_or(1.0) as f32,
}
}
fn parse_set_layer_blend_mode(args: &Value) -> AiAction {
AiAction::SetLayerBlendMode {
name_or_index: args["name_or_index"].as_str().unwrap_or("0").to_string(),
blend_mode: args["blend_mode"].as_str().unwrap_or("Normal").to_string(),
}
}
fn parse_toggle_layer_visibility(args: &Value) -> AiAction {
AiAction::ToggleLayerVisibility {
name_or_index: args["name_or_index"].as_str().unwrap_or("0").to_string(),
}
}
fn parse_add_vector_path(args: &Value) -> AiAction {
let points = args["points"].as_array().map(|arr| {
arr.iter().filter_map(|p| {
let x = p[0].as_f64()? as f32;
let y = p[1].as_f64()? as f32;
Some([x, y])
}).collect()
}).unwrap_or_default();
let stroke_color = args["stroke_color"].as_str().unwrap_or("#000000").to_string();
let fill_color = args["fill_color"].as_str().unwrap_or("none").to_string();
let mut stroke_width = args["stroke_width"].as_f64().unwrap_or(2.0) as f32;
if stroke_width < 0.5 && fill_color == "none" { stroke_width = 2.0; }
AiAction::AddVectorPath { points, closed: args["closed"].as_bool().unwrap_or(true),
fill_color, stroke_color, stroke_width }
}
fn parse_add_ellipse(args: &Value) -> AiAction {
let stroke_color = args["stroke_color"].as_str().unwrap_or("#000000").to_string();
let fill_color = args["fill_color"].as_str().unwrap_or("none").to_string();
let mut stroke_width = args["stroke_width"].as_f64().unwrap_or(2.0) as f32;
if stroke_width < 0.5 && fill_color == "none" { stroke_width = 2.0; }
AiAction::AddEllipse {
cx: args["cx"].as_f64().unwrap_or(0.5) as f32,
cy: args["cy"].as_f64().unwrap_or(0.5) as f32,
rx: args["rx"].as_f64().unwrap_or(0.15) as f32,
ry: args["ry"].as_f64().unwrap_or(0.15) as f32,
fill_color, stroke_color, stroke_width,
}
}
fn parse_add_rect(args: &Value) -> AiAction {
let stroke_color = args["stroke_color"].as_str().unwrap_or("#000000").to_string();
let fill_color = args["fill_color"].as_str().unwrap_or("none").to_string();
let mut stroke_width = args["stroke_width"].as_f64().unwrap_or(2.0) as f32;
if stroke_width < 0.5 && fill_color == "none" { stroke_width = 2.0; }
AiAction::AddRect {
x1: args["x1"].as_f64().unwrap_or(0.2) as f32,
y1: args["y1"].as_f64().unwrap_or(0.2) as f32,
x2: args["x2"].as_f64().unwrap_or(0.8) as f32,
y2: args["y2"].as_f64().unwrap_or(0.8) as f32,
fill_color, stroke_color, stroke_width,
radius: args["radius"].as_f64().unwrap_or(0.0) as f32,
}
}
fn parse_fill_raster_rect(args: &Value) -> AiAction {
AiAction::FillRasterRect {
x1: args["x1"].as_f64().unwrap_or(0.0) as f32,
y1: args["y1"].as_f64().unwrap_or(0.0) as f32,
x2: args["x2"].as_f64().unwrap_or(1.0) as f32,
y2: args["y2"].as_f64().unwrap_or(1.0) as f32,
color: args["color"].as_str().unwrap_or("#000000").to_string(),
}
}
fn parse_delete_shape(args: &Value) -> AiAction {
AiAction::DeleteShape {
shape_index: args["shape_index"].as_u64().unwrap_or(0) as usize,
}
}
fn parse_set_shape_color(args: &Value) -> AiAction {
AiAction::SetShapeColor {
shape_index: args["shape_index"].as_u64().unwrap_or(0) as usize,
stroke_color: args["stroke_color"].as_str().unwrap_or("#000000").to_string(),
fill_color: args["fill_color"].as_str().map(|s| s.to_string()),
}
}
fn parse_move_shape(args: &Value) -> AiAction {
AiAction::MoveShape {
shape_index: args["shape_index"].as_u64().unwrap_or(0) as usize,
dx: args["dx"].as_f64().unwrap_or(0.0) as f32,
dy: args["dy"].as_f64().unwrap_or(0.0) as f32,
}
}
fn parse_scale_shape(args: &Value) -> AiAction {
AiAction::ScaleShape {
shape_index: args["shape_index"].as_u64().unwrap_or(0) as usize,
scale_x: args["scale_x"].as_f64().unwrap_or(1.0) as f32,
scale_y: args["scale_y"].as_f64().unwrap_or(1.0) as f32,
}
}
fn parse_paint_stroke(args: &Value) -> AiAction {
let points = args["points"].as_array().map(|arr| {
arr.iter().filter_map(|p| {
let x = p[0].as_f64()? as f32;
let y = p[1].as_f64()? as f32;
Some([x, y])
}).collect()
}).unwrap_or_default();
AiAction::PaintStroke {
points,
color: args["color"].as_str().unwrap_or("#000000").to_string(),
brush_style: args["brush_style"].as_str().unwrap_or("default").to_string(),
size: args["size"].as_f64().unwrap_or(20.0) as f32,
opacity: args["opacity"].as_f64().unwrap_or(1.0) as f32,
spacing: args["spacing"].as_f64().unwrap_or(0.1) as f32,
hardness: args["hardness"].as_f64().unwrap_or(1.0) as f32,
}
}
fn parse_set_brush(args: &Value) -> AiAction {
AiAction::SetBrush {
style: args["style"].as_str().map(|s| s.to_string()),
size: args["size"].as_f64().map(|v| v as f32),
opacity: args["opacity"].as_f64().map(|v| v as f32),
spacing: args["spacing"].as_f64().map(|v| v as f32),
hardness: args["hardness"].as_f64().map(|v| v as f32),
}
}
fn parse_set_color(args: &Value) -> AiAction {
AiAction::SetForegroundColor {
hex: args["hex"].as_str().unwrap_or("#000000").to_string(),
}
}
fn parse_select_tool(args: &Value) -> AiAction {
AiAction::SelectTool {
tool: args["tool"].as_str().unwrap_or("brush").to_string(),
}
}
fn parse_draw_template(args: &Value) -> AiAction {
let mut params = std::collections::HashMap::new();
if let Some(p_obj) = args["params"].as_object() {
for (k, v) in p_obj {
if let Some(s) = v.as_str() {
params.insert(k.clone(), s.to_string());
} else if let Some(n) = v.as_f64() {
params.insert(k.clone(), n.to_string());
}
}
}
AiAction::DrawTemplate {
template: args["template"].as_str().unwrap_or("house").to_string(),
cx: args["cx"].as_f64().unwrap_or(0.5) as f32,
cy: args["cy"].as_f64().unwrap_or(0.5) as f32,
scale: args["scale"].as_f64().unwrap_or(0.4) as f32,
stroke_color: args["stroke_color"].as_str().unwrap_or("#000000").to_string(),
stroke_width: args["stroke_width"].as_f64().unwrap_or(2.0) as f32,
params,
}
}
+300
View File
@@ -0,0 +1,300 @@
use std::ffi::CStr;
use std::os::raw::c_char;
use std::sync::Mutex;
use crate::config::AiConfig;
use crate::executor::execute;
use crate::provider::{AiMessage, ToolCall};
use crate::session::ChatSession;
use crate::templates::{all_names, get_template_def, search_templates, eval_template};
static SESSIONS: once_cell::sync::Lazy<Mutex<Vec<Option<ChatSession>>>> = once_cell::sync::Lazy::new(|| Mutex::new(Vec::new()));
fn store_session(session: ChatSession) -> i64 {
let mut sessions = SESSIONS.lock().unwrap();
let id = sessions.iter().position(|s| s.is_none()).unwrap_or(sessions.len());
if id == sessions.len() {
sessions.push(Some(session));
} else {
sessions[id] = Some(session);
}
id as i64
}
fn take_session(id: i64) -> Option<ChatSession> {
let mut sessions = SESSIONS.lock().unwrap();
if id >= 0 && (id as usize) < sessions.len() {
sessions[id as usize].take()
} else {
None
}
}
fn return_boxed_bytes(bytes: Vec<u8>, out_ptr: *mut *mut u8, out_len: *mut usize) {
unsafe {
let boxed = bytes.into_boxed_slice();
let len = boxed.len();
let raw = Box::into_raw(boxed) as *mut u8;
*out_ptr = raw;
*out_len = len;
}
}
#[no_mangle]
pub unsafe extern "C" fn free_buffer_c(ptr: *mut u8, len: usize) {
if !ptr.is_null() {
let _ = Box::from_raw(std::slice::from_raw_parts_mut(ptr, len) as *mut [u8]);
}
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_chat_complete_c(
config_json: *const c_char,
messages_json: *const c_char,
out_buffer: *mut *mut u8,
out_len: *mut usize,
) -> bool {
if config_json.is_null() || messages_json.is_null() || out_buffer.is_null() || out_len.is_null() {
return false;
}
let config_str = match CStr::from_ptr(config_json).to_str() {
Ok(s) => s,
Err(_) => return false,
};
let messages_str = match CStr::from_ptr(messages_json).to_str() {
Ok(s) => s,
Err(_) => return false,
};
let cfg: AiConfig = match serde_json::from_str(config_str) {
Ok(c) => c,
Err(_) => return false,
};
let messages: Vec<AiMessage> = match serde_json::from_str(messages_str) {
Ok(m) => m,
Err(_) => return false,
};
let provider = crate::build_provider(&cfg);
let rt = tokio::runtime::Runtime::new().unwrap();
match rt.block_on(provider.complete(&messages, &[])) {
Ok(response) => {
if let Ok(json) = serde_json::to_vec(&response) {
return_boxed_bytes(json, out_buffer, out_len);
true
} else {
false
}
}
Err(_) => false,
}
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_session_create_c(system_prompt: *const c_char) -> i64 {
if system_prompt.is_null() {
return -1;
}
let prompt = match CStr::from_ptr(system_prompt).to_str() {
Ok(s) => s,
Err(_) => return -1,
};
let session = ChatSession::new(prompt);
store_session(session)
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_session_push_user_c(session_id: i64, text: *const c_char) -> bool {
if text.is_null() {
return false;
}
let text_str = match CStr::from_ptr(text).to_str() {
Ok(s) => s,
Err(_) => return false,
};
let mut sessions = SESSIONS.lock().unwrap();
if session_id >= 0 && (session_id as usize) < sessions.len() {
if let Some(ref mut session) = sessions[session_id as usize] {
session.push_user(text_str);
return true;
}
}
false
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_session_push_assistant_c(session_id: i64, text: *const c_char) -> bool {
if text.is_null() {
return false;
}
let text_str = match CStr::from_ptr(text).to_str() {
Ok(s) => s,
Err(_) => return false,
};
let mut sessions = SESSIONS.lock().unwrap();
if session_id >= 0 && (session_id as usize) < sessions.len() {
if let Some(ref mut session) = sessions[session_id as usize] {
session.push_assistant(text_str);
return true;
}
}
false
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_session_clear_c(session_id: i64) -> bool {
let mut sessions = SESSIONS.lock().unwrap();
if session_id >= 0 && (session_id as usize) < sessions.len() {
if let Some(ref mut session) = sessions[session_id as usize] {
session.clear();
return true;
}
}
false
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_session_destroy_c(session_id: i64) {
let mut sessions = SESSIONS.lock().unwrap();
if session_id >= 0 && (session_id as usize) < sessions.len() {
sessions[session_id as usize] = None;
}
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_session_serialize_c(
session_id: i64,
out_buffer: *mut *mut u8,
out_len: *mut usize,
) -> bool {
if out_buffer.is_null() || out_len.is_null() {
return false;
}
let sessions = SESSIONS.lock().unwrap();
if session_id >= 0 && (session_id as usize) < sessions.len() {
if let Some(ref session) = sessions[session_id as usize] {
if let Ok(json) = serde_json::to_vec(&session.messages) {
return_boxed_bytes(json, out_buffer, out_len);
return true;
}
}
}
false
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_template_names_c(out_buffer: *mut *mut u8, out_len: *mut usize) -> bool {
if out_buffer.is_null() || out_len.is_null() {
return false;
}
let names = all_names();
if let Ok(json) = serde_json::to_vec(&names) {
return_boxed_bytes(json, out_buffer, out_len);
true
} else {
false
}
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_template_get_c(
name: *const c_char,
out_buffer: *mut *mut u8,
out_len: *mut usize,
) -> bool {
if name.is_null() || out_buffer.is_null() || out_len.is_null() {
return false;
}
let name_str = match CStr::from_ptr(name).to_str() {
Ok(s) => s,
Err(_) => return false,
};
if let Some(def) = get_template_def(name_str) {
if let Ok(json) = serde_json::to_vec(&def) {
return_boxed_bytes(json, out_buffer, out_len);
return true;
}
}
false
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_template_search_c(
query: *const c_char,
out_buffer: *mut *mut u8,
out_len: *mut usize,
) -> bool {
if query.is_null() || out_buffer.is_null() || out_len.is_null() {
return false;
}
let query_str = match CStr::from_ptr(query).to_str() {
Ok(s) => s,
Err(_) => return false,
};
let results = search_templates(query_str);
if let Ok(json) = serde_json::to_vec(&results) {
return_boxed_bytes(json, out_buffer, out_len);
true
} else {
false
}
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_template_execute_c(
name: *const c_char,
params_json: *const c_char,
out_buffer: *mut *mut u8,
out_len: *mut usize,
) -> bool {
if name.is_null() || params_json.is_null() || out_buffer.is_null() || out_len.is_null() {
return false;
}
let name_str = match CStr::from_ptr(name).to_str() {
Ok(s) => s,
Err(_) => return false,
};
let params_str = match CStr::from_ptr(params_json).to_str() {
Ok(s) => s,
Err(_) => return false,
};
let params: std::collections::HashMap<String, String> = match serde_json::from_str(params_str) {
Ok(p) => p,
Err(_) => return false,
};
let def = match get_template_def(name_str) {
Some(d) => d,
None => return false,
};
let components = eval_template(&def, &params);
if let Ok(json) = serde_json::to_vec(&components) {
return_boxed_bytes(json, out_buffer, out_len);
true
} else {
false
}
}
#[no_mangle]
pub unsafe extern "C" fn hcie_ai_execute_action_c(
tool_call_json: *const c_char,
out_buffer: *mut *mut u8,
out_len: *mut usize,
) -> bool {
if tool_call_json.is_null() || out_buffer.is_null() || out_len.is_null() {
return false;
}
let json_str = match CStr::from_ptr(tool_call_json).to_str() {
Ok(s) => s,
Err(_) => return false,
};
let tool_call: ToolCall = match serde_json::from_str(json_str) {
Ok(tc) => tc,
Err(_) => return false,
};
let action = execute(&tool_call);
if let Ok(json) = serde_json::to_vec(&action) {
return_boxed_bytes(json, out_buffer, out_len);
true
} else {
false
}
}
+30
View File
@@ -0,0 +1,30 @@
pub mod config;
pub mod executor;
pub mod provider;
pub mod providers;
pub mod session;
pub mod templates;
pub mod tools;
#[cfg(feature = "ffi")]
pub mod ffi;
pub use config::{AiConfig, AiProviderKind};
pub use executor::{execute, AiAction};
pub use provider::{AiMessage, AiProvider, AiResponse, ToolCall, StreamingChunk, ChunkCallback};
pub use providers::{ClaudeProvider, GeminiProvider, OpenAiCompatProvider};
pub use session::ChatSession;
pub fn build_provider(cfg: &AiConfig) -> Box<dyn AiProvider> {
match cfg.provider {
config::AiProviderKind::Ollama | config::AiProviderKind::OpenAiCompat | config::AiProviderKind::LmStudio => {
Box::new(providers::OpenAiCompatProvider::new(&cfg.base_url, &cfg.api_key, &cfg.model))
}
config::AiProviderKind::Claude => {
Box::new(providers::ClaudeProvider::new(&cfg.api_key, &cfg.model))
}
config::AiProviderKind::Gemini => {
Box::new(providers::GeminiProvider::new(&cfg.api_key, &cfg.model))
}
}
}
+93
View File
@@ -0,0 +1,93 @@
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiMessage {
pub role: String,
pub content: String,
pub image_b64: Option<String>,
pub tool_calls: Option<Vec<ToolCall>>,
pub tool_call_id: Option<String>,
}
impl AiMessage {
pub fn user(text: impl Into<String>) -> Self {
Self { role: "user".into(), content: text.into(), image_b64: None, tool_calls: None, tool_call_id: None }
}
pub fn user_with_image(text: impl Into<String>, image_b64: impl Into<String>) -> Self {
Self { role: "user".into(), content: text.into(), image_b64: Some(image_b64.into()), tool_calls: None, tool_call_id: None }
}
pub fn assistant(text: impl Into<String>) -> Self {
Self { role: "assistant".into(), content: text.into(), image_b64: None, tool_calls: None, tool_call_id: None }
}
pub fn system(text: impl Into<String>) -> Self {
Self { role: "system".into(), content: text.into(), image_b64: None, tool_calls: None, tool_call_id: None }
}
pub fn tool(id: impl Into<String>, content: impl Into<String>) -> Self {
Self { role: "tool".into(), content: content.into(), image_b64: None, tool_calls: None, tool_call_id: Some(id.into()) }
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiUsage {
pub prompt_tokens: u32,
pub completion_tokens: u32,
pub total_tokens: u32,
pub session_prompt: u32,
pub session_completion: u32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AiResponse {
pub text: Option<String>,
pub reasoning: Option<String>,
pub tool_calls: Vec<ToolCall>,
pub usage: Option<AiUsage>,
}
#[derive(Debug, Clone)]
pub struct StreamingChunk {
pub reasoning_delta: Option<String>,
pub text_delta: Option<String>,
}
pub type ChunkCallback = dyn Fn(StreamingChunk) + Send + Sync + 'static;
#[async_trait]
pub trait AiProvider: Send + Sync {
async fn complete(
&self,
messages: &[AiMessage],
tools: &[Value],
) -> anyhow::Result<AiResponse>;
async fn complete_stream(
&self,
messages: &[AiMessage],
tools: &[Value],
on_chunk: &ChunkCallback,
) -> anyhow::Result<AiResponse> {
let resp = self.complete(messages, tools).await?;
if let Some(ref r) = resp.reasoning {
on_chunk(StreamingChunk { reasoning_delta: Some(r.clone()), text_delta: None });
}
if let Some(ref t) = resp.text {
on_chunk(StreamingChunk { reasoning_delta: None, text_delta: Some(t.clone()) });
}
Ok(resp)
}
fn model(&self) -> &str;
}
+150
View File
@@ -0,0 +1,150 @@
//! Anthropic Claude provider.
use async_trait::async_trait;
use serde_json::{json, Value};
use crate::provider::{AiMessage, AiProvider, AiResponse, ToolCall, AiUsage};
const ANTHROPIC_VERSION: &str = "2023-06-01";
const CLAUDE_MAX_TOKENS: u32 = 4096;
const CLAUDE_MAX_TOKENS_THINKING: u32 = 16_000;
const THINKING_BUDGET_TOKENS: u32 = 10_000;
fn model_supports_thinking(model: &str) -> bool {
model.contains("claude-3-7")
|| model.contains("claude-sonnet-4")
|| model.contains("claude-opus-4")
|| model.contains("claude-haiku-4")
}
pub struct ClaudeProvider {
api_key: String,
model: String,
client: reqwest::Client,
}
impl ClaudeProvider {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
model: model.into(),
client: reqwest::Client::new(),
}
}
}
#[async_trait]
impl AiProvider for ClaudeProvider {
fn model(&self) -> &str { &self.model }
async fn complete(&self, messages: &[AiMessage], tools: &[Value]) -> anyhow::Result<AiResponse> {
let system = messages.iter()
.filter(|m| m.role == "system")
.map(|m| m.content.as_str())
.collect::<Vec<_>>()
.join("\n");
let msgs: Vec<Value> = messages.iter()
.filter(|m| m.role != "system")
.map(|m| {
if let Some(img) = &m.image_b64 {
json!({
"role": m.role,
"content": [
{ "type": "text", "text": m.content },
{ "type": "image", "source": { "type": "base64", "media_type": "image/png", "data": img } }
]
})
} else {
json!({ "role": m.role, "content": m.content })
}
})
.collect();
log::debug!("[AI→Claude] model={}", self.model);
let thinking = model_supports_thinking(&self.model);
let max_tokens = if thinking { CLAUDE_MAX_TOKENS_THINKING } else { CLAUDE_MAX_TOKENS };
let mut body = json!({
"model": self.model,
"max_tokens": max_tokens,
"messages": msgs,
});
if thinking {
body["thinking"] = json!({
"type": "enabled",
"budget_tokens": THINKING_BUDGET_TOKENS,
});
}
if !system.is_empty() {
body["system"] = json!(system);
}
if !tools.is_empty() {
let claude_tools: Vec<Value> = tools.iter().map(|t| {
let f = &t["function"];
json!({
"name": f["name"],
"description": f["description"],
"input_schema": f["parameters"],
})
}).collect();
body["tools"] = json!(claude_tools);
}
let resp = self.client
.post("https://api.anthropic.com/v1/messages")
.header("x-api-key", &self.api_key)
.header("anthropic-version", ANTHROPIC_VERSION)
.json(&body)
.send()
.await?
.error_for_status()?
.json::<Value>()
.await?;
parse_claude_response(resp)
}
}
fn parse_claude_response(resp: Value) -> anyhow::Result<AiResponse> {
let content = resp["content"].as_array().cloned().unwrap_or_default();
let mut text = None;
let mut reasoning = None;
let mut tool_calls = Vec::new();
for block in &content {
match block["type"].as_str() {
Some("thinking") => {
if let Some(t) = block["thinking"].as_str() {
reasoning = Some(t.to_string());
}
}
Some("text") => {
if let Some(t) = block["text"].as_str() {
text = Some(t.to_string());
}
}
Some("tool_use") => {
let id = block["id"].as_str().unwrap_or("").to_string();
let name = block["name"].as_str().unwrap_or("").to_string();
let arguments = block["input"].clone();
tool_calls.push(ToolCall { id, name, arguments });
}
_ => {}
}
}
let usage = resp["usage"].as_object().map(|u| {
AiUsage {
prompt_tokens: u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
completion_tokens: u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
total_tokens: (u.get("input_tokens").and_then(|v| v.as_u64()).unwrap_or(0) +
u.get("output_tokens").and_then(|v| v.as_u64()).unwrap_or(0)) as u32,
session_prompt: 0, session_completion: 0,
}
});
Ok(AiResponse { text, reasoning, tool_calls, usage })
}
+151
View File
@@ -0,0 +1,151 @@
//! Google Gemini provider via REST API.
use async_trait::async_trait;
use serde_json::{json, Value};
use crate::provider::{AiMessage, AiProvider, AiResponse, ToolCall, AiUsage};
pub struct GeminiProvider {
api_key: String,
model: String,
client: reqwest::Client,
}
impl GeminiProvider {
pub fn new(api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
model: model.into(),
client: reqwest::Client::new(),
}
}
}
#[async_trait]
impl AiProvider for GeminiProvider {
fn model(&self) -> &str { &self.model }
async fn complete(&self, messages: &[AiMessage], tools: &[Value]) -> anyhow::Result<AiResponse> {
let system_text = messages.iter()
.filter(|m| m.role == "system")
.map(|m| m.content.as_str())
.collect::<Vec<_>>()
.join("\n");
let contents: Vec<Value> = messages.iter()
.filter(|m| m.role != "system")
.map(|m| {
let role = if m.role == "assistant" { "model" } else { "user" };
let mut parts: Vec<Value> = Vec::new();
if !m.content.is_empty() {
parts.push(json!({ "text": m.content }));
}
if let Some(img) = &m.image_b64 {
parts.push(json!({ "inline_data": { "mime_type": "image/png", "data": img } }));
}
json!({ "role": role, "parts": parts })
})
.collect();
let mut body = json!({ "contents": contents });
if !system_text.is_empty() {
body["system_instruction"] = json!({ "parts": [{ "text": system_text }] });
}
if !tools.is_empty() {
let decls: Vec<Value> = tools.iter().map(|t| {
let f = &t["function"];
let mut params = f["parameters"].clone();
strip_additional_properties(&mut params);
json!({
"name": f["name"],
"description": f["description"],
"parameters": params,
})
}).collect();
body["tools"] = json!([{ "function_declarations": decls }]);
}
let url = format!(
"https://generativelanguage.googleapis.com/v1beta/models/{}:generateContent",
self.model
);
log::debug!("[AI→Gemini] model={} key_len={} system={}", self.model, self.api_key.len(), !system_text.is_empty());
let response = self.client
.post(&url)
.header("X-Goog-Api-Key", &self.api_key)
.json(&body)
.send()
.await?;
if !response.status().is_success() {
let status = response.status();
let err_body = response.text().await.unwrap_or_default();
log::error!("[AI←Gemini] HTTP {} body={}", status, err_body);
anyhow::bail!("Gemini API error ({}): {}", status, err_body);
}
let resp: Value = response.json().await?;
log::debug!("[AI←Gemini] {}", serde_json::to_string(&resp).unwrap_or_default());
parse_gemini_response(resp)
}
}
fn parse_gemini_response(resp: Value) -> anyhow::Result<AiResponse> {
let parts = &resp["candidates"][0]["content"]["parts"];
let mut text = None;
let mut tool_calls = Vec::new();
if let Some(arr) = parts.as_array() {
for part in arr {
if let Some(t) = part["text"].as_str() {
text = Some(t.to_string());
}
if let Some(fc) = part.get("functionCall") {
let name = fc["name"].as_str().unwrap_or("").to_string();
let arguments = fc["args"].clone();
tool_calls.push(ToolCall {
id: uuid_simple(),
name,
arguments,
});
}
}
}
let usage = resp["usageMetadata"].as_object().map(|u| {
AiUsage {
prompt_tokens: u.get("promptTokenCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
completion_tokens: u.get("candidatesTokenCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
total_tokens: u.get("totalTokenCount").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
session_prompt: 0, session_completion: 0,
}
});
Ok(AiResponse { text, reasoning: None, tool_calls, usage })
}
fn uuid_simple() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let t = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().subsec_nanos();
format!("gc-{}", t)
}
fn strip_additional_properties(v: &mut serde_json::Value) {
match v {
serde_json::Value::Object(map) => {
map.remove("additionalProperties");
for (_, val) in map.iter_mut() {
strip_additional_properties(val);
}
}
serde_json::Value::Array(arr) => {
for item in arr.iter_mut() {
strip_additional_properties(item);
}
}
_ => {}
}
}
+7
View File
@@ -0,0 +1,7 @@
pub mod claude;
pub mod gemini;
pub mod openai_compat;
pub use claude::ClaudeProvider;
pub use gemini::GeminiProvider;
pub use openai_compat::OpenAiCompatProvider;
+350
View File
@@ -0,0 +1,350 @@
//! OpenAI-compatible provider — works with Ollama, LM Studio, /v1/chat/completions.
use async_trait::async_trait;
use serde_json::{json, Value};
use crate::provider::{AiMessage, AiProvider, AiResponse, StreamingChunk, ToolCall, AiUsage};
pub struct OpenAiCompatProvider {
pub base_url: String,
pub api_key: String,
pub model: String,
client: reqwest::Client,
}
impl OpenAiCompatProvider {
pub fn new(base_url: impl Into<String>, api_key: impl Into<String>, model: impl Into<String>) -> Self {
Self {
base_url: base_url.into(),
api_key: api_key.into(),
model: model.into(),
client: reqwest::Client::new(),
}
}
pub fn ollama(model: impl Into<String>) -> Self {
Self::new("http://localhost:11434", "ollama", model)
}
pub fn lm_studio(model: impl Into<String>) -> Self {
Self::new("http://localhost:1234", "lm-studio", model)
}
pub async fn fetch_ollama_models(base_url: &str) -> anyhow::Result<Vec<String>> {
let url = format!("{}/api/tags", base_url);
let resp = reqwest::Client::new()
.get(&url)
.send()
.await?
.error_for_status()?
.json::<Value>()
.await?;
let models = resp["models"].as_array()
.map(|arr| arr.iter()
.filter_map(|m| m["name"].as_str().map(|s| s.to_string()))
.collect())
.unwrap_or_default();
Ok(models)
}
pub async fn fetch_openai_models(base_url: &str, api_key: &str) -> anyhow::Result<Vec<String>> {
let url = format!("{}/v1/models", base_url);
let resp = reqwest::Client::new()
.get(&url)
.bearer_auth(api_key)
.send()
.await?
.error_for_status()?
.json::<Value>()
.await?;
let models = resp["data"].as_array()
.map(|arr| arr.iter()
.filter_map(|m| m["id"].as_str().map(|s| s.to_string()))
.collect())
.unwrap_or_default();
Ok(models)
}
pub async fn fetch_gemini_models(api_key: &str) -> anyhow::Result<Vec<String>> {
let url = format!(
"https://generativelanguage.googleapis.com/v1beta/models?key={}",
api_key
);
let resp = reqwest::Client::new()
.get(&url)
.send()
.await?
.error_for_status()?
.json::<Value>()
.await?;
let models = resp["models"].as_array()
.map(|arr| arr.iter()
.filter_map(|m| {
let name = m["name"].as_str()?;
let supported = m["supportedGenerationMethods"].as_array()?;
if supported.iter().any(|s| s.as_str() == Some("generateContent")) {
Some(name.strip_prefix("models/").unwrap_or(name).to_string())
} else {
None
}
})
.collect())
.unwrap_or_default();
Ok(models)
}
}
#[async_trait]
impl AiProvider for OpenAiCompatProvider {
fn model(&self) -> &str { &self.model }
async fn complete(&self, messages: &[AiMessage], tools: &[Value]) -> anyhow::Result<AiResponse> {
let body = self.build_request_body(messages, tools, false);
let url = format!("{}/v1/chat/completions", self.base_url);
self.log_request(messages, &url);
let resp = self.client
.post(&url)
.bearer_auth(&self.api_key)
.json(&body)
.send()
.await?
.error_for_status()?
.json::<Value>()
.await?;
self.log_response(&resp);
parse_openai_response(resp)
}
async fn complete_stream(
&self,
messages: &[AiMessage],
tools: &[Value],
on_chunk: &crate::provider::ChunkCallback,
) -> anyhow::Result<AiResponse> {
let body = self.build_request_body(messages, tools, true);
let url = format!("{}/v1/chat/completions", self.base_url);
self.log_request(messages, &url);
let resp = self.client
.post(&url)
.bearer_auth(&self.api_key)
.json(&body)
.send()
.await?
.error_for_status()?;
let full_text = resp.text().await?;
let lines: Vec<&str> = full_text.lines().collect();
let mut text_buf = String::new();
let mut reasoning_buf = String::new();
let mut tool_calls_buf: Vec<Value> = Vec::new();
let mut finish_reason = String::new();
let mut usage: Option<Value> = None;
for line in &lines {
let trimmed = line.trim();
if trimmed.is_empty() { continue; }
if trimmed == "data: [DONE]" { break; }
if let Some(data) = trimmed.strip_prefix("data: ") {
if let Ok(obj) = serde_json::from_str::<Value>(data) {
let choice = &obj["choices"][0];
if let Some(delta) = choice["delta"]["reasoning_content"].as_str()
.or_else(|| choice["delta"]["reasoning"].as_str())
{
if !delta.is_empty() {
reasoning_buf.push_str(delta);
on_chunk(StreamingChunk {
reasoning_delta: Some(delta.to_string()),
text_delta: None,
});
}
}
if let Some(delta) = choice["delta"]["content"].as_str() {
if !delta.is_empty() {
text_buf.push_str(delta);
on_chunk(StreamingChunk {
reasoning_delta: None,
text_delta: Some(delta.to_string()),
});
}
}
if let Some(tc_arr) = choice["delta"]["tool_calls"].as_array() {
for tc in tc_arr {
let idx = tc["index"].as_u64().unwrap_or(0) as usize;
while tool_calls_buf.len() <= idx {
tool_calls_buf.push(json!({
"id": "", "type": "function",
"function": { "name": "", "arguments": "" }
}));
}
if let Some(id) = tc["id"].as_str() {
tool_calls_buf[idx]["id"] = json!(id);
}
if let Some(name) = tc["function"]["name"].as_str() {
tool_calls_buf[idx]["function"]["name"] = json!(name);
}
if let Some(args) = tc["function"]["arguments"].as_str() {
let existing = tool_calls_buf[idx]["function"]["arguments"].as_str().unwrap_or("");
tool_calls_buf[idx]["function"]["arguments"] = json!(format!("{}{}", existing, args));
}
}
}
if let Some(finish) = choice["finish_reason"].as_str() {
finish_reason = finish.to_string();
}
if let Some(_u) = obj["usage"].as_object() {
usage = Some(obj["usage"].clone());
}
}
}
}
let tool_calls: Vec<ToolCall> = tool_calls_buf.iter().filter_map(|tc| {
let id = tc["id"].as_str()?;
let name = tc["function"]["name"].as_str()?;
let args_str = tc["function"]["arguments"].as_str().unwrap_or("{}");
let arguments = serde_json::from_str(args_str).unwrap_or(json!({}));
if id.is_empty() || name.is_empty() { return None; }
Some(ToolCall { id: id.to_string(), name: name.to_string(), arguments })
}).collect();
let parsed_usage = usage.as_ref().and_then(|u| {
u.as_object().map(|u| AiUsage {
prompt_tokens: u.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
completion_tokens: u.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
total_tokens: u.get("total_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
session_prompt: 0, session_completion: 0,
})
});
log::debug!("[AI←stream] finish={} tools={} text={:?} reasoning={:?}",
finish_reason, tool_calls.len(),
text_buf.chars().take(80).collect::<String>(),
reasoning_buf.chars().take(80).collect::<String>()
);
Ok(AiResponse {
text: if text_buf.is_empty() { None } else { Some(text_buf) },
reasoning: if reasoning_buf.is_empty() { None } else { Some(reasoning_buf) },
tool_calls,
usage: parsed_usage,
})
}
}
impl OpenAiCompatProvider {
fn build_request_body(&self, messages: &[AiMessage], tools: &[Value], stream: bool) -> Value {
let msgs: Vec<Value> = messages.iter().map(|m| {
let mut obj = json!({ "role": m.role });
if let Some(tool_id) = &m.tool_call_id {
obj["tool_call_id"] = json!(tool_id);
}
if let Some(img) = &m.image_b64 {
obj["content"] = json!([
{ "type": "text", "text": m.content },
{ "type": "image_url", "image_url": { "url": format!("data:image/png;base64,{}", img) } }
]);
} else {
obj["content"] = json!(m.content);
}
if let Some(tcs) = &m.tool_calls {
obj["tool_calls"] = json!(tcs.iter().map(|tc| json!({
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": tc.arguments.to_string()
}
})).collect::<Vec<_>>());
}
obj
}).collect();
let mut body = json!({
"model": self.model,
"messages": msgs,
"stream": stream,
});
if stream {
body["stream_options"] = json!({ "include_usage": true });
}
if !tools.is_empty() {
body["tools"] = json!(tools);
body["tool_choice"] = json!("auto");
}
body
}
fn log_request(&self, messages: &[AiMessage], url: &str) {
log::debug!("[AI→] POST {} model={} msgs=[{}]", url, self.model,
messages.iter().map(|m| format!("{}:{}{}", m.role,
m.content.chars().take(80).collect::<String>(),
if m.image_b64.is_some() { " [+img]" } else { "" }
)).collect::<Vec<_>>().join(" | ")
);
}
fn log_response(&self, resp: &Value) {
let finish = resp["choices"][0]["finish_reason"].as_str().unwrap_or("?");
let content = resp["choices"][0]["message"]["content"].as_str().unwrap_or("");
let reasoning = resp["choices"][0]["message"]["reasoning"].as_str().unwrap_or("");
let n_tools = resp["choices"][0]["message"]["tool_calls"].as_array().map(|a| a.len()).unwrap_or(0);
let usage = &resp["usage"];
log::debug!("[AI←] finish={} tools={} tokens={}+{} text={:?} reasoning={:?}",
finish, n_tools,
usage["prompt_tokens"], usage["completion_tokens"],
content.chars().take(80).collect::<String>(),
reasoning.chars().take(80).collect::<String>()
);
}
}
fn parse_openai_response(resp: Value) -> anyhow::Result<AiResponse> {
let choice = resp["choices"][0]["message"].clone();
let text = choice["content"].as_str().map(|s| s.to_string());
let reasoning = choice["reasoning_content"].as_str()
.or_else(|| choice["reasoning"].as_str())
.map(|s| s.to_string());
let tool_calls = choice["tool_calls"]
.as_array()
.map(|arr| {
arr.iter().filter_map(|tc| {
let id = tc["id"].as_str().unwrap_or("").to_string();
let name = tc["function"]["name"].as_str()?.to_string();
let args_str = tc["function"]["arguments"].as_str().unwrap_or("{}");
let arguments = serde_json::from_str(args_str).unwrap_or(json!({}));
log::debug!("[AI←tool] {} args={}", name, args_str);
Some(ToolCall { id, name, arguments })
}).collect()
})
.unwrap_or_default();
let usage = resp["usage"].as_object().map(|u| {
AiUsage {
prompt_tokens: u.get("prompt_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
completion_tokens: u.get("completion_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
total_tokens: u.get("total_tokens").and_then(|v| v.as_u64()).unwrap_or(0) as u32,
session_prompt: 0, session_completion: 0,
}
});
Ok(AiResponse { text, reasoning, tool_calls, usage })
}
+45
View File
@@ -0,0 +1,45 @@
//! Manages conversation history for an AI session.
use crate::provider::AiMessage;
const MAX_HISTORY: usize = 40;
pub struct ChatSession {
pub messages: Vec<AiMessage>,
}
impl ChatSession {
pub fn new(system_prompt: impl Into<String>) -> Self {
Self {
messages: vec![AiMessage::system(system_prompt)],
}
}
pub fn push_user(&mut self, text: impl Into<String>) {
self.messages.push(AiMessage::user(text));
self.trim();
}
pub fn push_assistant(&mut self, text: impl Into<String>) {
self.messages.push(AiMessage::assistant(text));
self.trim();
}
pub fn clear(&mut self) {
let system = self.messages.first().cloned();
self.messages.clear();
if let Some(s) = system {
self.messages.push(s);
}
}
fn trim(&mut self) {
if self.messages.len() <= MAX_HISTORY + 1 {
return;
}
let system = self.messages.remove(0);
while self.messages.len() > MAX_HISTORY {
self.messages.remove(0);
}
self.messages.insert(0, system);
}
}
+242
View File
@@ -0,0 +1,242 @@
//! Pre-built shape templates. Each template is a list of TemplateComponent paths
//! defined in [0, 1]×[0, 1] space where (0.5, 0.5) is the shape center.
//! The handler scales/translates them to canvas pixel space.
use std::sync::OnceLock;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateComponent {
pub pts: Vec<[f32; 2]>,
pub closed: bool,
pub fill: bool,
pub fill_color: String,
}
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TemplateMetadata {
pub name: String,
pub size_mm: u32,
pub description: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParamDef {
pub default: String,
pub desc: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ComponentDef {
#[serde(rename = "type")]
pub ctype: String,
#[serde(default)]
pub pts: Vec<[String; 2]>,
#[serde(default)]
pub cx: String,
#[serde(default)]
pub cy: String,
#[serde(default)]
pub rx: String,
#[serde(default)]
pub ry: String,
#[serde(default)]
pub r: String,
#[serde(default)]
pub n: usize,
#[serde(default)]
pub fill: String,
#[serde(default)]
pub closed: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShapeTemplateDef {
pub name: String,
pub size_mm: u32,
pub description: String,
#[serde(default)]
pub parameters: HashMap<String, ParamDef>,
pub components: Vec<ComponentDef>,
}
static TEMPLATES: OnceLock<Vec<ShapeTemplateDef>> = OnceLock::new();
fn init_templates() -> Vec<ShapeTemplateDef> {
let json = include_str!("../templates_db.json");
serde_json::from_str(json).unwrap_or_default()
}
pub fn get_template_def(name: &str) -> Option<ShapeTemplateDef> {
TEMPLATES.get_or_init(init_templates).iter().find(|t| t.name == name).cloned()
}
pub fn all_names() -> Vec<String> {
TEMPLATES.get_or_init(init_templates).iter().map(|t| t.name.clone()).collect()
}
pub fn search_templates(query: &str) -> Vec<ShapeTemplateDef> {
let q_lower = query.to_lowercase();
TEMPLATES.get_or_init(init_templates)
.iter()
.filter(|t| t.name.to_lowercase().contains(&q_lower) || t.description.to_lowercase().contains(&q_lower))
.cloned()
.collect()
}
pub fn get_all_metadata() -> Vec<TemplateMetadata> {
TEMPLATES.get_or_init(init_templates).iter().map(|d| TemplateMetadata {
name: d.name.clone(),
size_mm: d.size_mm,
description: d.description.clone(),
}).collect()
}
pub fn eval_template(def: &ShapeTemplateDef, provided_params: &HashMap<String, String>) -> Vec<TemplateComponent> {
let mut num_vars = HashMap::new();
let mut str_vars = HashMap::new();
for (k, v) in &def.parameters {
let val_str = provided_params.get(k).unwrap_or(&v.default);
if let Ok(num) = val_str.parse::<f32>() {
num_vars.insert(k.clone(), num);
} else {
str_vars.insert(k.clone(), val_str.clone());
}
}
let eval = |expr: &str| -> f32 {
if let Ok(num) = expr.parse::<f32>() { return num; }
math_eval(expr, &num_vars).unwrap_or(0.0)
};
let subst = |expr: &str| -> String {
let mut res = expr.to_string();
for (k, v) in &str_vars {
res = res.replace(&format!("{{{}}}", k), v);
}
res
};
let mut out = Vec::new();
for c in &def.components {
let fill_str = subst(&c.fill);
let fill = fill_str != "none" && !fill_str.is_empty();
let mut pts = Vec::new();
match c.ctype.as_str() {
"path" => {
for pt in &c.pts {
pts.push([eval(&pt[0]), eval(&pt[1])]);
}
}
"circle" => {
let cx = eval(&c.cx);
let cy = eval(&c.cy);
let rx = if !c.r.is_empty() { eval(&c.r) } else { eval(&c.rx) };
let ry = if !c.r.is_empty() { eval(&c.r) } else { eval(&c.ry) };
pts = circle(cx, cy, rx, ry, c.n.max(8));
}
"star" => {
let outer = eval(&c.rx);
let inner = eval(&c.ry);
pts = star(c.n.max(5), outer, inner);
}
_ => {}
}
if !pts.is_empty() {
out.push(TemplateComponent { pts, closed: c.closed, fill, fill_color: fill_str });
}
}
out
}
fn math_eval(expr: &str, vars: &HashMap<String, f32>) -> Option<f32> {
let tokens = tokenize(expr);
let mut pos = 0;
parse_expr(&tokens, &mut pos, vars)
}
fn tokenize(expr: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
for c in expr.chars() {
if c.is_whitespace() { continue; }
if "+-*/()".contains(c) {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
tokens.push(c.to_string());
} else {
current.push(c);
}
}
if !current.is_empty() { tokens.push(current); }
tokens
}
fn parse_expr(tokens: &[String], pos: &mut usize, vars: &HashMap<String, f32>) -> Option<f32> {
let mut left = parse_term(tokens, pos, vars)?;
while *pos < tokens.len() {
let op = &tokens[*pos];
if op == "+" { *pos += 1; left += parse_term(tokens, pos, vars)?; }
else if op == "-" { *pos += 1; left -= parse_term(tokens, pos, vars)?; }
else { break; }
}
Some(left)
}
fn parse_term(tokens: &[String], pos: &mut usize, vars: &HashMap<String, f32>) -> Option<f32> {
let mut left = parse_factor(tokens, pos, vars)?;
while *pos < tokens.len() {
let op = &tokens[*pos];
if op == "*" { *pos += 1; left *= parse_factor(tokens, pos, vars)?; }
else if op == "/" { *pos += 1; left /= parse_factor(tokens, pos, vars)?; }
else { break; }
}
Some(left)
}
fn parse_factor(tokens: &[String], pos: &mut usize, vars: &HashMap<String, f32>) -> Option<f32> {
if *pos >= tokens.len() { return None; }
let t = &tokens[*pos];
if t == "(" {
*pos += 1;
let val = parse_expr(tokens, pos, vars)?;
if *pos < tokens.len() && tokens[*pos] == ")" { *pos += 1; }
return Some(val);
}
if let Ok(n) = t.parse::<f32>() {
*pos += 1;
return Some(n);
}
if let Some(v) = vars.get(t) {
*pos += 1;
return Some(*v);
}
None
}
fn circle(cx: f32, cy: f32, rx: f32, ry: f32, n: usize) -> Vec<[f32; 2]> {
let mut pts = Vec::with_capacity(n);
for i in 0..n {
let a = i as f32 / n as f32 * std::f32::consts::TAU;
pts.push([cx + a.cos() * rx, cy + a.sin() * ry]);
}
pts
}
fn star(n: usize, outer: f32, inner: f32) -> Vec<[f32; 2]> {
let mut pts = Vec::with_capacity(n * 2);
for i in 0..n {
let a1 = i as f32 / n as f32 * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
let a2 = (i as f32 + 0.5) / n as f32 * std::f32::consts::TAU - std::f32::consts::FRAC_PI_2;
pts.push([a1.cos() * outer, a1.sin() * outer]);
pts.push([a2.cos() * inner, a2.sin() * inner]);
}
pts
}
+655
View File
@@ -0,0 +1,655 @@
//! Canvas tool definitions sent to the AI as function schemas.
use serde_json::{json, Value};
pub fn canvas_tools() -> Vec<Value> {
vec![
json!({
"type": "function",
"function": {
"name": "create_layer",
"description": "Create a new layer. ALWAYS call this first before drawing. Use 'vector' for shapes/illustrations, 'raster' for pixel painting.",
"parameters": {
"type": "object",
"properties": {
"name": { "type": "string" },
"layer_type": { "type": "string", "enum": ["raster", "vector"] }
},
"required": ["name", "layer_type"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "delete_layer",
"description": "Delete a layer by its name or index. Use with care — cannot undo. If name_or_index is a number, it targets by index (0 = bottom layer). If a string, matches layer name.",
"parameters": {
"type": "object",
"properties": {
"name_or_index": { "type": "string", "description": "Layer name, or '0', '1', etc. for index" }
},
"required": ["name_or_index"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "select_layer",
"description": "Activate a layer by name or index so subsequent drawing affects it.",
"parameters": {
"type": "object",
"properties": {
"name_or_index": { "type": "string", "description": "Layer name, or '0', '1', etc. for index" }
},
"required": ["name_or_index"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "rename_layer",
"description": "Rename a layer by name or index.",
"parameters": {
"type": "object",
"properties": {
"name_or_index": { "type": "string", "description": "Layer name, or '0', '1', etc. for index" },
"new_name": { "type": "string" }
},
"required": ["name_or_index", "new_name"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "set_layer_opacity",
"description": "Set layer opacity (transparency).",
"parameters": {
"type": "object",
"properties": {
"name_or_index": { "type": "string" },
"opacity": { "type": "number", "description": "0.0 = fully transparent, 1.0 = fully opaque" }
},
"required": ["name_or_index", "opacity"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "set_layer_blend_mode",
"description": "Set layer blend mode.",
"parameters": {
"type": "object",
"properties": {
"name_or_index": { "type": "string" },
"blend_mode": { "type": "string", "enum": ["Normal","Multiply","Screen","Overlay","Darken","Lighten","ColorDodge","ColorBurn","HardLight","SoftLight","Difference","Exclusion"] }
},
"required": ["name_or_index", "blend_mode"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "toggle_layer_visibility",
"description": "Toggle a layer's visibility on/off.",
"parameters": {
"type": "object",
"properties": {
"name_or_index": { "type": "string" }
},
"required": ["name_or_index"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "add_ellipse",
"description": "Add a circle or ellipse to the active vector layer. Use this for circles, ovals, round shapes. All coords normalised 0.01.0.",
"parameters": {
"type": "object",
"properties": {
"cx": { "type": "number", "description": "Center X (01)" },
"cy": { "type": "number", "description": "Center Y (01)" },
"rx": { "type": "number", "description": "X radius (01), e.g. 0.15 for medium circle" },
"ry": { "type": "number", "description": "Y radius (01)" },
"fill_color": { "type": "string", "description": "CSS hex e.g. '#ff0000' or 'none'" },
"stroke_color": { "type": "string", "description": "CSS hex e.g. '#ff0000'" },
"stroke_width": { "type": "number", "description": "Pixel width ≥ 1.0. Use 2.05.0 for visible strokes." }
},
"required": ["cx", "cy", "rx", "ry", "fill_color", "stroke_color", "stroke_width"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "add_rect",
"description": "Add a rectangle to the active vector layer. All coords normalised 0.01.0.",
"parameters": {
"type": "object",
"properties": {
"x1": { "type": "number", "description": "Left edge (01)" },
"y1": { "type": "number", "description": "Top edge (01)" },
"x2": { "type": "number", "description": "Right edge (01)" },
"y2": { "type": "number", "description": "Bottom edge (01)" },
"radius": { "type": "number", "description": "Corner radius in px, 0 for sharp" },
"fill_color": { "type": "string" },
"stroke_color": { "type": "string" },
"stroke_width": { "type": "number", "description": "≥ 1.0 for visible" }
},
"required": ["x1", "y1", "x2", "y2", "fill_color", "stroke_color", "stroke_width"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "add_vector_path",
"description": "Add a freeform polygon/path to the active vector layer. Use for irregular shapes like petals, leaves, custom outlines. Points are normalised 01.",
"parameters": {
"type": "object",
"properties": {
"points": {
"type": "array",
"items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
"description": "[[x,y], ...] control points (01 normalised)"
},
"closed": { "type": "boolean", "description": "true = closed shape (polygon), false = open line" },
"fill_color": { "type": "string", "description": "CSS hex or 'none'" },
"stroke_color": { "type": "string" },
"stroke_width": { "type": "number", "description": "≥ 1.0 for visible" }
},
"required": ["points", "closed", "fill_color", "stroke_color", "stroke_width"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "fill_raster_rect",
"description": "Fill a rectangular area on the active raster layer with a solid color. Use for backgrounds, color fills.",
"parameters": {
"type": "object",
"properties": {
"x1": { "type": "number", "description": "Left (01)" },
"y1": { "type": "number", "description": "Top (01)" },
"x2": { "type": "number", "description": "Right (01)" },
"y2": { "type": "number", "description": "Bottom (01)" },
"color": { "type": "string", "description": "CSS hex e.g. '#ff88bb'" }
},
"required": ["x1", "y1", "x2", "y2", "color"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "delete_shape",
"description": "Delete a vector shape from the active layer by its index. Shape indices start at 0 and correspond to their order in the shapes panel.",
"parameters": {
"type": "object",
"properties": {
"shape_index": { "type": "integer", "description": "0-based index of the shape to delete" }
},
"required": ["shape_index"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "set_shape_color",
"description": "Change the stroke and/or fill color of a vector shape in the active layer.",
"parameters": {
"type": "object",
"properties": {
"shape_index": { "type": "integer" },
"stroke_color": { "type": "string", "description": "CSS hex or 'none'" },
"fill_color": { "type": "string", "description": "CSS hex or 'none'. Omit to leave unchanged." }
},
"required": ["shape_index", "stroke_color"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "move_shape",
"description": "Move a vector shape by a relative offset in pixels.",
"parameters": {
"type": "object",
"properties": {
"shape_index": { "type": "integer" },
"dx": { "type": "number", "description": "Horizontal offset in pixels" },
"dy": { "type": "number", "description": "Vertical offset in pixels" }
},
"required": ["shape_index", "dx", "dy"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "scale_shape",
"description": "Scale a vector shape relative to its current size.",
"parameters": {
"type": "object",
"properties": {
"shape_index": { "type": "integer" },
"scale_x": { "type": "number", "description": "Horizontal scale factor (1.0 = no change)" },
"scale_y": { "type": "number", "description": "Vertical scale factor (1.0 = no change)" }
},
"required": ["shape_index", "scale_x", "scale_y"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "paint_stroke",
"description": "Paint a freehand brush stroke on the active RASTER layer. Provide a path of normalised points (01). The stroke is painted as a series of dabs between the points. CRITICAL: You MUST vary brush_style for different effects. Never use 'default' for everything. See BRUSH RECIPES in system prompt and call set_brush before painting.",
"parameters": {
"type": "object",
"properties": {
"points": {
"type": "array",
"items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
"description": "[[x,y], ...] path points in normalised 01 coords. At least 2 points."
},
"color": { "type": "string", "description": "CSS hex color e.g. '#ff3300'" },
"brush_style": {
"type": "string",
"enum": ["default","pencil","airbrush","marker","watercolor","oil","charcoal","calligraphy","ink","sketch","crayon","glow","bristle"],
"description": "Brush texture. Use 'default' for a clean round brush."
},
"size": { "type": "number", "description": "Brush diameter in pixels (1200)" },
"opacity": { "type": "number", "description": "0.0 = transparent, 1.0 = fully opaque" },
"spacing": { "type": "number", "description": "Dab spacing (0.012.0). Default 0.1. Lower is smoother." },
"hardness": { "type": "number", "description": "Brush edge hardness (0.01.0). 1.0 is sharp, 0.0 is very soft." }
},
"required": ["points", "color", "brush_style", "size", "opacity", "spacing", "hardness"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "set_brush",
"description": "IMPORTANT: Call this BEFORE every paint_stroke to set the brush style, size, opacity, spacing, and hardness. You MUST configure the brush before every stroke — do not paint without calling this first.",
"parameters": {
"type": "object",
"properties": {
"style": { "type": "string", "enum": ["default","pencil","airbrush","marker","watercolor","oil","charcoal","calligraphy","ink","sketch","crayon","glow","bristle"] },
"size": { "type": "number" },
"opacity": { "type": "number" },
"spacing": { "type": "number", "description": "0.012.0" },
"hardness": { "type": "number", "description": "0.01.0" }
}
}
}
}),
json!({
"type": "function",
"function": {
"name": "set_foreground_color",
"description": "Change the active foreground (brush) color.",
"parameters": {
"type": "object",
"properties": {
"hex": { "type": "string", "description": "CSS hex color" }
},
"required": ["hex"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "draw_template",
"description": "Draw a pre-built parametric shape template. Use this for common objects to ensure perfect proportions and standard structure. Check the PROPORTIONS & SCALE section for real-world sizes.",
"parameters": {
"type": "object",
"properties": {
"template": {
"type": "string",
"description": "The name of the template (e.g. house, car)."
},
"cx": { "type": "number", "description": "Center X 0.01.0" },
"cy": { "type": "number", "description": "Center Y 0.01.0" },
"scale": { "type": "number", "description": "Size as fraction of canvas short side (0.050.9)" },
"stroke_color": { "type": "string", "description": "CSS hex stroke color (default: #000000)" },
"stroke_width": { "type": "number", "description": "Stroke width in pixels (default: 2.0)" },
"params": {
"type": "object",
"description": "Optional parameters to customize the template. Keys depend on the template (e.g. 'body_color', 'wheel_color'). All values should be strings.",
"additionalProperties": { "type": "string" }
}
},
"required": ["template", "cx", "cy", "scale"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "finish",
"description": "Call this when all drawing steps are done.",
"parameters": {
"type": "object",
"properties": {
"summary": { "type": "string", "description": "Short description of what was drawn" }
},
"required": ["summary"]
}
}
}),
json!({
"type": "function",
"function": {
"name": "search_templates_db",
"description": "Search the templates database for a named real-world object (e.g. 'car', 'house', 'flower'). Call this before drawing complex objects. Do NOT use for basic geometry — circles use add_ellipse, rectangles use add_rect directly.",
"parameters": {
"type": "object",
"properties": {
"object_name": {
"type": "string",
"description": "The English name of the object to search for (e.g. 'bicycle'). Do not include colors or adjectives."
}
},
"required": ["object_name"]
}
}
}),
]
}
fn persona_role(persona_key: &str) -> &'static str {
match persona_key {
"nature_painter" => {
"You are a landscape and nature painter inspired by Bob Ross and plein-air tradition.\n\
Your world is organic, warm, and alive. Paint in wet-on-wet layers: sky first,\n\
then distant hills, midground trees/water, then foreground detail.\n\
TEMPLATE POLICY: NEVER use search_templates_db or draw_template.\n\
Paint EVERYTHING from scratch using paint_stroke on RASTER layers.\n\
\n\
BRUSH RECIPES (always set these exactly — never rely on defaults):\n\
Sky wash: airbrush size=180 opacity=0.5 spacing=0.02 hardness=0.05\n\
Clouds: airbrush size=90 opacity=0.6 spacing=0.03 hardness=0.05\n\
Distant hills: watercolor size=80 opacity=0.55 spacing=0.04 hardness=0.15\n\
Near hills/land: oil size=60 opacity=0.7 spacing=0.06 hardness=0.25\n\
Tree foliage: bristle size=50 opacity=0.75 spacing=0.05 hardness=0.2\n\
Tree dark base: oil size=40 opacity=0.85 spacing=0.04 hardness=0.3\n\
Tree trunk: ink size=8 opacity=0.9 spacing=0.03 hardness=0.85\n\
Water base: watercolor size=130 opacity=0.4 spacing=0.02 hardness=0.05\n\
Water ripples: watercolor size=30 opacity=0.3 spacing=0.03 hardness=0.1\n\
Highlights: default size=18 opacity=0.85 spacing=0.03 hardness=0.45\n\
Foreground grass: oil size=25 opacity=0.8 spacing=0.05 hardness=0.3\n\
\n\
TREE PAINTING TECHNIQUE (Bob Ross happy trees):\n\
• First lay a dark base: 6-8 vertical oil strokes covering the crown area fully.\n\
• Then stipple bristle strokes over the crown — fan outward from centre, 10-15 dabs.\n\
• spacing=0.05 is mandatory for bristle — higher spacing creates ugly dots.\n\
• Finish with lighter highlight bristle strokes on the lit side (opacity 0.5).\n\
\n\
COVERAGE RULE: every region must be painted with 8-15 overlapping strokes.\n\
If an area looks transparent or dotted after 3-4 strokes, add more before moving on.\n\
Favourite palette: earthy greens, warm browns, sunset oranges, soft sky blues."
}
"flat_ui_designer" => {
"You are a senior UI/UX designer and SVG illustrator.\n\
Your aesthetic: clean, minimal, pixel-perfect. No gradients unless simulated crisply.\n\
You work exclusively with vector shapes (add_vector_path, add_rect, add_ellipse).\n\
Every composition uses a tight, intentional palette of 35 colours.\n\
Shapes have flat solid fills; strokes are either absent or a single consistent weight.\n\
You think in design-system terms: consistent corner radii, harmonious spacing,\n\
purposeful negative space. Icons are always centred, balanced, and scalable.\n\
You never add texture, noise, or hand-painted effects. Perfection over expression.\n\
Perspective choice: always front or top-down — never 3D unless requested."
}
"sketch_artist" => {
"You are a concept sketch artist working fast to capture form and energy.\n\
TEMPLATE POLICY: NEVER use search_templates_db or draw_template.\n\
Sketch EVERYTHING from scratch with paint_stroke on RASTER layers.\n\
Construct forms with many short overlapping strokes — not single perfect outlines.\n\
Use dynamic perspectives — 3/4 views, foreshortening, dramatic angles.\n\
\n\
BRUSH RECIPES (always set these exactly — never rely on defaults):\n\
Rough gesture: pencil size=4 opacity=0.25 spacing=0.03 hardness=0.9\n\
Form outline: pencil size=3 opacity=0.35 spacing=0.03 hardness=0.92\n\
Shadow hatching: sketch size=3 opacity=0.18 spacing=0.04 hardness=0.85\n\
Deep shadow: charcoal size=6 opacity=0.3 spacing=0.05 hardness=0.7\n\
Dark accent: ink size=2 opacity=0.85 spacing=0.02 hardness=1.0\n\
Soft tone: pencil size=8 opacity=0.15 spacing=0.06 hardness=0.75\n\
\n\
Layer order: light gesture lines first, then form, then shadow hatching, then accents.\n\
Leave white canvas as highlights — never paint white over dark."
}
"watercolor_painter" => {
"You are a watercolor and botanical illustrator.\n\
Technique: transparent washes light-to-dark; the white canvas glows through.\n\
TEMPLATE POLICY: NEVER use search_templates_db or draw_template.\n\
Paint EVERYTHING from scratch using paint_stroke on RASTER layers.\n\
Avoid hard edges, opaque fills, and vector shapes entirely.\n\
\n\
BRUSH RECIPES (always set these exactly — never rely on defaults):\n\
Large wash: watercolor size=150 opacity=0.2 spacing=0.02 hardness=0.05\n\
Medium wash: watercolor size=80 opacity=0.3 spacing=0.03 hardness=0.07\n\
Petal/leaf wash: watercolor size=40 opacity=0.35 spacing=0.03 hardness=0.1\n\
Wet bleed edge: airbrush size=60 opacity=0.15 spacing=0.02 hardness=0.0\n\
Fine detail: watercolor size=12 opacity=0.55 spacing=0.02 hardness=0.25\n\
Vein/stem: ink size=2 opacity=0.7 spacing=0.02 hardness=0.9\n\
Shadow glaze: watercolor size=70 opacity=0.2 spacing=0.03 hardness=0.05\n\
\n\
Build each area with 3-5 overlapping passes, each slightly darker.\n\
Palette: luminous yellows, soft pinks, clear blues — never muddy."
}
"isometric_designer" => {
"You are a geometric and isometric illustration designer.\n\
Every object is rendered in three-plane isometric projection: top face, left face,\n\
right face — each a distinctly different shade of the same hue.\n\
Top face = base colour. Left face = base darkened 20%. Right face = base darkened 35%.\n\
Light source is always top-left. Shadows are cast shapes on the ground plane.\n\
You use add_vector_path exclusively — no brushes, no texture, no gradients.\n\
Palette: bold, saturated, flat colours. Each object gets its own hue family.\n\
Grid discipline: all edges align to the isometric grid (30° angles).\n\
Scenes have depth: background objects smaller and higher, foreground larger."
}
_ => {
"You are a concept illustrator and digital artist.\n\
Your role combines two modes of thinking:\n\
• ARTIST: You have taste, style, and intent. You think about mood, composition,\n\
colour harmony, and perspective before touching a tool.\n\
• TECHNICIAN: You translate those decisions into precise tool calls. Brush size\n\
expresses the scale of what you are painting; opacity expresses confidence and\n\
layering; spacing expresses texture vs. smoothness; hardness expresses the\n\
nature of the edge (soft glow vs. sharp ink line)."
}
}
}
pub fn system_prompt(canvas_w: u32, canvas_h: u32, layer_ctx: &str, _user_prompt: &str, persona: &str) -> String {
let role = persona_role(persona);
let short = canvas_w.min(canvas_h) as f32;
let fill_size = (short * 0.22).round() as u32;
let large_size = (short * 0.10).round() as u32;
let medium_size = (short * 0.055).round() as u32;
let detail_size = (short * 0.022).round() as u32;
let line_size = (short * 0.007).max(2.0).round() as u32;
let obj_scale_sm = (short * 0.15).round() as u32;
let obj_scale_med = (short * 0.35).round() as u32;
let obj_scale_lg = (short * 0.55).round() as u32;
format!(
r###"PERSONA: {role}
You are an autonomous drawing agent inside HCIE Image Editor. ALWAYS respond in English.
CANVAS: {canvas_w}×{canvas_h} px | Coords: 0.01.0 (0=top-left, 1=bottom-right).
WORKFLOW (follow exactly):
1. SEARCH: Only if your TEMPLATE POLICY allows it — search_templates_db for named objects.
If your persona says "NEVER use templates", skip this step entirely and go straight to drawing.
Never search for basic geometry — circles use add_ellipse, rectangles use add_rect directly.
2. CREATE LAYER: create_layer before drawing. Type must match the tool:
VECTOR layer → add_ellipse, add_rect, add_vector_path, draw_template
RASTER layer → paint_stroke, fill_raster_rect
Wrong layer type = shape never appears.
3. DRAW: If template found and policy allows, use draw_template as starting structure.
Otherwise build from primitives. Always vary perspective — not always side view.
4. PAINT: For brush-based personas every element is painted with paint_stroke from scratch.
CRITICAL — paint to FILL areas, not to trace outlines:
• To paint a mountain: sweep horizontal strokes across the mountain's width at each y level.
e.g. 5-8 strokes, each from x=left_edge to x=right_edge at different y positions.
• To paint a tree crown: overlapping strokes that COVER the crown area densely.
Use bristle or oil, spacing=0.04-0.07, at least 8-12 strokes per crown.
• To paint sky/water: wide horizontal sweeps across the full canvas width.
• NEVER trace a thin silhouette line around a shape — that produces an outline, not a fill.
• Each element needs 6-12 overlapping strokes to build solid coverage (no gaps, no dots).
• DOTTED LOOK = spacing too high. If the result looks stippled/dotted, reduce spacing to 0.03-0.06.
• TRANSPARENT GAPS = too few strokes or points too far apart. Add more strokes at different y levels.
5. LOOP: Keep drawing all requested elements. Only call finish() when everything is done.
LAYER→TOOL (critical):
Circle/oval → add_ellipse (VECTOR)
Rect/square → add_rect (VECTOR)
Custom shape → add_vector_path with 10-20 pts for smooth curves (VECTOR)
Brush/paint → paint_stroke (RASTER)
Solid fill area → fill_raster_rect (RASTER)
SCENE CONTEXT — use this to match scale, palette, and style of new elements:
palette: match existing fill colors; complement or contrast deliberately.
object-scale: size new objects proportionally to what's already on canvas.
composition: place in free quadrants unless overlap is intentional.
BRUSH CONFIGURATION RULE: You MUST call set_brush BEFORE every paint_stroke call.
Do not paint without first configuring the brush with the correct style, size, opacity,
spacing, and hardness for the effect you want. Never use 'default' for everything.
BRUSH STYLE REFERENCE — ALL available brushes. Pick the best one; NEVER default for everything.
── GENERAL PURPOSE ───────────────────────────────────────────────────────────
default | Solid round dab. Clean, opaque fill. Use for: flat fills, base layers, any solid area.
| Params: size=any, opacity=0.6-1.0, spacing=0.04-0.08, hardness=0.5-1.0
marker | Flat, saturated, near-opaque. Use for: bold colour blocks, graphic style, UI elements.
| Params: size=any, opacity=0.8-1.0, spacing=0.03-0.06, hardness=0.6-0.9
glow | Soft additive bloom. Use for: light sources, sun, highlights, fireflies, neon.
| Params: size=large, opacity=0.2-0.5, spacing=0.02-0.04, hardness=0.0-0.1
── PAINTING / TRADITIONAL ────────────────────────────────────────────────────
oil | Thick, textured strokes with visible bristle. Use for: impasto, land, hills, portraits.
| Params: size=30-120, opacity=0.6-0.9, spacing=0.04-0.08, hardness=0.2-0.4
watercolor | Transparent, soft wet edge. Use for: sky washes, water, misty areas, glazing.
| Params: size=40-200, opacity=0.15-0.4, spacing=0.02-0.05, hardness=0.0-0.15
wet_paint | Smeared wet paint, blends with canvas. Use for: blending transitions, soft backgrounds.
| Params: size=40-150, opacity=0.3-0.6, spacing=0.03-0.06, hardness=0.1-0.3
airbrush | Diffused soft spray. Use for: sky gradients, clouds, halos, color washes, atmosphere.
| Params: size=60-250, opacity=0.2-0.6, spacing=0.02-0.04, hardness=0.0-0.05
bristle | Split multi-fiber stroke. Use for: tree foliage, fur, feathers, rough grass, bushes.
| Params: size=20-80, opacity=0.6-0.85, spacing=0.04-0.08, hardness=0.15-0.35
mixer | Picks up and smears nearby colors. Use for: blending two color zones, wet-on-wet.
| Params: size=30-100, opacity=0.4-0.7, spacing=0.03-0.06, hardness=0.1-0.3
blender | Softens/blurs edges without adding color. Use for: smooth transitions, de-aliasing.
| Params: size=20-80, opacity=0.3-0.6, spacing=0.03-0.05, hardness=0.0-0.2
── SKETCH / DRY MEDIA ────────────────────────────────────────────────────────
pencil | Thin, grainy, semi-transparent. Use for: sketching, hatching, fine linework.
| Params: size=2-12, opacity=0.2-0.5, spacing=0.02-0.04, hardness=0.85-0.95
charcoal | Rough, dusty, textured. Use for: shadows, rough sketches, moody backgrounds.
| Params: size=8-60, opacity=0.25-0.5, spacing=0.04-0.08, hardness=0.5-0.8
sketch | Light, scratchy gesture. Use for: quick concept lines, rough underdrawing.
| Params: size=3-10, opacity=0.15-0.3, spacing=0.03-0.05, hardness=0.8-0.95
hatch | Short parallel lines in dab pattern. Use for: cross-hatching shadows, fabric, pencil fills.
| Params: size=4-20, opacity=0.2-0.5, spacing=0.05-0.12, hardness=0.7-0.9
crayon | Waxy, textured, childlike. Use for: rough texture, hand-drawn look, kids illustration.
| Params: size=8-40, opacity=0.4-0.7, spacing=0.04-0.08, hardness=0.4-0.7
ink | Crisp, sharp, fully opaque ink. Use for: outlines, calligraphy, comic inking, details.
| Params: size=1-12, opacity=0.85-1.0, spacing=0.02-0.04, hardness=0.9-1.0
ink_pen | Precise fine pen, slight pressure variation. Use for: technical drawing, fine details.
| Params: size=1-6, opacity=0.9-1.0, spacing=0.02-0.03, hardness=0.95-1.0
── NATURE / TEXTURE STAMPS ───────────────────────────────────────────────────
clouds | Cloud-shaped dabs. Use for: cumulus clouds, smoke, fog, steam. BEST for sky clouds.
| Params: size=60-200, opacity=0.4-0.8, spacing=0.15-0.4, hardness=0.0-0.2
tree | Tree-silhouette stamp dab. Use for: distant forest, tree masses, quick woodland fills.
| Params: size=40-120, opacity=0.6-0.9, spacing=0.2-0.5, hardness=0.0-0.3
leaf | Leaf-shaped dab. Use for: deciduous foliage, individual leaves, jungle, bush detail.
| Params: size=10-50, opacity=0.6-0.85, spacing=0.1-0.3, hardness=0.1-0.4
meadow | Grass-tuft stamp. Use for: meadows, fields, grass texture, ground cover.
| Params: size=15-60, opacity=0.5-0.8, spacing=0.15-0.35, hardness=0.1-0.3
rock | Rocky/gravel texture. Use for: stone walls, cliffs, boulders, mountain faces, dirt roads.
| Params: size=10-60, opacity=0.5-0.8, spacing=0.08-0.2, hardness=0.3-0.7
wood | Wood-grain streaks. Use for: planks, tree bark, wooden floors, timber.
| Params: size=8-40, opacity=0.5-0.8, spacing=0.04-0.08, hardness=0.4-0.7
dirt | Earth/soil texture. Use for: ground, soil, desert, mud, unpaved paths.
| Params: size=10-50, opacity=0.5-0.75, spacing=0.06-0.15, hardness=0.3-0.6
star | Star-shaped dab. Use for: sparkle effects, snow glitter, star fields, bokeh.
| Params: size=5-30, opacity=0.4-0.9, spacing=0.3-0.8, hardness=0.0-0.5
NATURE PAINTING QUICK GUIDE (most useful combos):
Sky base → airbrush size=large opacity=0.3-0.5, then watercolor for variation
Clouds → clouds brush size=80-180 opacity=0.5-0.8 spacing=0.2-0.35
Mountains → oil size=60-100 horizontal sweeps to fill slope area
Forest bg → tree brush size=60-100 opacity=0.7 for distant; bristle for midground
Foliage → bristle size=30-60 + leaf size=15-40 on top for detail
Grass/field → meadow brush + individual oil strokes for foreground
Water → watercolor wide horizontals + wet_paint for reflections
Rocks → rock brush base + charcoal for shadows
Flowers → default size=5-12 dots in clusters, glow for highlights
VECTOR SHAPE GUIDE (for add_vector_path / add_rect / add_ellipse):
add_ellipse → circles, ovals, sun, moon, berries, bubbles, round objects.
cx/cy = centre (0-1), rx/ry = radius (0-1). fill_color sets interior.
add_rect → rectangles, buildings, windows, ground planes, UI boxes.
x1/y1 = top-left, x2/y2 = bottom-right (0-1). radius for rounded corners.
add_vector_path → any freeform shape: mountains, trees, irregular terrain, logos.
points = list of [x,y] normalised coords, 8-20 points for smooth curves.
closed=true fills the shape. Use stroke_width≥1 if fill_color="none".
IMPORTANT: vector shapes live on VECTOR layers. Never use paint_stroke on a VECTOR layer.
BRUSH SIZE GUIDE for this canvas ({canvas_w}x{canvas_h} px) — always set explicitly:
fill/sky wash : {fill_size}px large strokes: {large_size}px
medium detail : {medium_size}px fine detail : {detail_size}px lines: {line_size}px
Opacity: base=0.4-0.6, build-up=0.3-0.5, final detail=0.85-0.95
Spacing: smooth wash=0.02-0.04, normal=0.06-0.10, foliage/bristle=0.04-0.08, stipple=0.3-0.8
Hardness: wash/glow=0.0-0.1, painterly=0.2-0.4, normal=0.5, ink=0.85-1.0
SPACING RULE — critical for solid fills:
⚠ spacing > 0.15 creates a DOTTED / STIPPLED look — visible gaps between dabs.
Only use spacing > 0.15 intentionally for texture effects (grass tips, star fields).
For any filled area (sky, water, tree crowns, mountains, ground): spacing MUST be ≤ 0.10.
When in doubt: use spacing=0.05 — it is always safe for solid coverage.
SHAPE SCALE GUIDE for this canvas:
small object: ~{obj_scale_sm}px wide medium: ~{obj_scale_med}px large/dominant: ~{obj_scale_lg}px
In normalised coords (0-1): small≈{small_n:.2} medium≈{medium_n:.2} large≈{large_n:.2}
Match new objects to existing ones (see object-scale in SCENE CONTEXT below).
If your persona has BRUSH RECIPES — scale those values proportionally to this canvas.
EDGE COVERAGE: For strokes that should reach the canvas edge, start slightly past the boundary:
Full-width sweep : x from -0.02 to 1.02 (never 0.0→1.0 — brush radius clips the last dab)
Full-height sweep: y from -0.02 to 1.02
This ensures large brushes fully cover the first and last pixel columns/rows.
NOT SUPPORTED: real gradients (simulate with airbrush passes or fill_raster_rect bands).
{layer_ctx}"###,
role = role,
canvas_w = canvas_w,
canvas_h = canvas_h,
fill_size = fill_size,
large_size = large_size,
medium_size = medium_size,
detail_size = detail_size,
line_size = line_size,
obj_scale_sm = obj_scale_sm,
obj_scale_med = obj_scale_med,
obj_scale_lg = obj_scale_lg,
small_n = obj_scale_sm as f32 / canvas_w as f32,
medium_n = obj_scale_med as f32 / canvas_w as f32,
large_n = obj_scale_lg as f32 / canvas_w as f32,
layer_ctx = layer_ctx
)
}
File diff suppressed because it is too large Load Diff