init
This commit is contained in:
@@ -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"
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms);
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 })
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.0–1.0.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cx": { "type": "number", "description": "Center X (0–1)" },
|
||||
"cy": { "type": "number", "description": "Center Y (0–1)" },
|
||||
"rx": { "type": "number", "description": "X radius (0–1), e.g. 0.15 for medium circle" },
|
||||
"ry": { "type": "number", "description": "Y radius (0–1)" },
|
||||
"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.0–5.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.0–1.0.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"x1": { "type": "number", "description": "Left edge (0–1)" },
|
||||
"y1": { "type": "number", "description": "Top edge (0–1)" },
|
||||
"x2": { "type": "number", "description": "Right edge (0–1)" },
|
||||
"y2": { "type": "number", "description": "Bottom edge (0–1)" },
|
||||
"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 0–1.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"points": {
|
||||
"type": "array",
|
||||
"items": { "type": "array", "items": { "type": "number" }, "minItems": 2, "maxItems": 2 },
|
||||
"description": "[[x,y], ...] control points (0–1 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 (0–1)" },
|
||||
"y1": { "type": "number", "description": "Top (0–1)" },
|
||||
"x2": { "type": "number", "description": "Right (0–1)" },
|
||||
"y2": { "type": "number", "description": "Bottom (0–1)" },
|
||||
"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 (0–1). 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 0–1 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 (1–200)" },
|
||||
"opacity": { "type": "number", "description": "0.0 = transparent, 1.0 = fully opaque" },
|
||||
"spacing": { "type": "number", "description": "Dab spacing (0.01–2.0). Default 0.1. Lower is smoother." },
|
||||
"hardness": { "type": "number", "description": "Brush edge hardness (0.0–1.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.01–2.0" },
|
||||
"hardness": { "type": "number", "description": "0.0–1.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.0–1.0" },
|
||||
"cy": { "type": "number", "description": "Center Y 0.0–1.0" },
|
||||
"scale": { "type": "number", "description": "Size as fraction of canvas short side (0.05–0.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 3–5 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.0–1.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
Reference in New Issue
Block a user