1use crate::parser::{self, ParseError};
2use crate::types::{OllamaVisionConfig, TagOptions};
3use reqwest::Client;
4use serde_json::json;
5use std::path::Path;
6
7const DEFAULT_TAG_PROMPT: &str = r#"You are an image tagging assistant. Analyze the provided image and return a JSON array of relevant tags. Each tag should be a single word or short phrase (2-3 words max) that describes a key visual element, style, subject, or mood in the image.
8
9Return ONLY a JSON array of strings. Example: ["portrait", "fantasy", "dark lighting", "woman", "medieval", "oil painting"]
10
11Return between 5 and 15 tags. Focus on:
12- Subject matter (person, animal, landscape, object)
13- Art style (photorealistic, anime, oil painting, digital art)
14- Mood/atmosphere (dark, bright, serene, dramatic)
15- Colors (warm tones, blue, monochrome)
16- Composition (close-up, wide shot, symmetrical)
17- Notable elements (fire, water, armor, flowers)"#;
18
19pub async fn tag_image(
32 client: &Client,
33 config: &OllamaVisionConfig,
34 image_path: &Path,
35 options: &TagOptions,
36) -> Result<Vec<String>, TagError> {
37 let image_b64 = read_image_base64(image_path)?;
38
39 let prompt = options.prompt.as_deref().unwrap_or(DEFAULT_TAG_PROMPT);
40
41 let mut body = json!({
42 "model": config.model,
43 "prompt": prompt,
44 "images": [image_b64],
45 "stream": false,
46 "options": config.options,
47 });
48
49 if options.request_json_format {
50 body["format"] = json!("json");
51 }
52
53 let url = format!("{}/api/generate", config.endpoint);
54 let resp = client
55 .post(&url)
56 .timeout(config.timeout)
57 .json(&body)
58 .send()
59 .await
60 .map_err(|e| TagError::Connection(config.endpoint.clone(), e.to_string()))?;
61
62 if !resp.status().is_success() {
63 let status = resp.status().as_u16();
64 let text = resp.text().await.unwrap_or_default();
65 return Err(TagError::OllamaError(status, text));
66 }
67
68 let json: serde_json::Value = resp
69 .json()
70 .await
71 .map_err(|e| TagError::InvalidResponse(e.to_string()))?;
72
73 let content = json
74 .get("response")
75 .and_then(|v| v.as_str())
76 .unwrap_or("[]");
77
78 let tags = parser::parse_tags(content).map_err(TagError::Parse)?;
79 Ok(truncate_tags(
80 tags,
81 options.max_tags,
82 options.max_tag_length,
83 ))
84}
85
86pub async fn tag_image_base64(
90 client: &Client,
91 config: &OllamaVisionConfig,
92 image_b64: &str,
93 options: &TagOptions,
94) -> Result<Vec<String>, TagError> {
95 let prompt = options.prompt.as_deref().unwrap_or(DEFAULT_TAG_PROMPT);
96
97 let mut body = json!({
98 "model": config.model,
99 "prompt": prompt,
100 "images": [image_b64],
101 "stream": false,
102 "options": config.options,
103 });
104
105 if options.request_json_format {
106 body["format"] = json!("json");
107 }
108
109 let url = format!("{}/api/generate", config.endpoint);
110 let resp = client
111 .post(&url)
112 .timeout(config.timeout)
113 .json(&body)
114 .send()
115 .await
116 .map_err(|e| TagError::Connection(config.endpoint.clone(), e.to_string()))?;
117
118 if !resp.status().is_success() {
119 let status = resp.status().as_u16();
120 let text = resp.text().await.unwrap_or_default();
121 return Err(TagError::OllamaError(status, text));
122 }
123
124 let json: serde_json::Value = resp
125 .json()
126 .await
127 .map_err(|e| TagError::InvalidResponse(e.to_string()))?;
128
129 let content = json
130 .get("response")
131 .and_then(|v| v.as_str())
132 .unwrap_or("[]");
133
134 let tags = parser::parse_tags(content).map_err(TagError::Parse)?;
135 Ok(truncate_tags(
136 tags,
137 options.max_tags,
138 options.max_tag_length,
139 ))
140}
141
142fn truncate_tags(tags: Vec<String>, max_tags: usize, max_tag_length: usize) -> Vec<String> {
144 tags.into_iter()
145 .take(max_tags)
146 .map(|t| truncate_utf8(&t, max_tag_length))
147 .collect()
148}
149
150fn truncate_utf8(text: &str, max_chars: usize) -> String {
151 if text.chars().count() <= max_chars {
152 return text.to_string();
153 }
154
155 text.chars().take(max_chars).collect()
156}
157
158#[cfg(test)]
159mod tests {
160 use super::truncate_utf8;
161
162 #[test]
163 fn truncate_utf8_preserves_codepoint_boundaries() {
164 assert_eq!(truncate_utf8("naive", 3), "nai");
165 assert_eq!(truncate_utf8("cafe\u{301}", 4), "cafe");
166 assert_eq!(truncate_utf8("こんにちは", 3), "こんに");
167 }
168}
169
170#[derive(Debug, thiserror::Error)]
172pub enum TagError {
173 #[error("Cannot connect to Ollama at {0}: {1}")]
174 Connection(String, String),
175
176 #[error("Ollama returned HTTP {0}: {1}")]
177 OllamaError(u16, String),
178
179 #[error("Invalid response from Ollama: {0}")]
180 InvalidResponse(String),
181
182 #[error("Failed to read image: {0}")]
183 ImageRead(String),
184
185 #[error("{0}")]
186 Parse(#[from] ParseError),
187}
188
189impl TagError {
190 pub fn kind(&self) -> &'static str {
192 match self {
193 Self::Connection(..) => "connection",
194 Self::OllamaError(..) => "ollama_error",
195 Self::InvalidResponse(_) => "invalid_response",
196 Self::ImageRead(_) => "image_read",
197 Self::Parse(_) => "parse",
198 }
199 }
200}
201
202fn read_image_base64(path: &Path) -> Result<String, TagError> {
203 let bytes = std::fs::read(path)
204 .map_err(|e| TagError::ImageRead(format!("{}: {}", path.display(), e)))?;
205 Ok(base64::Engine::encode(
206 &base64::engine::general_purpose::STANDARD,
207 &bytes,
208 ))
209}