Skip to content

Commit 3003ed6

Browse files
authored
feat(retrieval): support image search (#3093)
Add multimodal image vectorization and image query support across the server, SDKs, and CLI.
1 parent f820872 commit 3003ed6

36 files changed

Lines changed: 924 additions & 78 deletions

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/ov_cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ futures = "0.3"
2222
colored = "2.1"
2323
dirs = "5.0"
2424
anyhow = "1.0"
25+
base64 = "0.22"
2526
chrono = "0.4"
2627
mime_guess = "2.0"
2728
thiserror = "1.0"

crates/ov_cli/src/client.rs

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ use serde_json::{Map, Value};
33
use std::env;
44
use std::path::Path;
55

6+
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
7+
68
pub use crate::base_client::{BaseClient, FileUploader, TimeoutConfig};
79

810
use crate::error::{Error, Result};
@@ -33,6 +35,32 @@ fn compact_request_body(body: &mut Value) {
3335
});
3436
}
3537

38+
fn normalize_image_input(image: Option<String>) -> Result<Option<String>> {
39+
let Some(value) = image else {
40+
return Ok(None);
41+
};
42+
if value.starts_with("data:image/")
43+
|| value.starts_with("http://")
44+
|| value.starts_with("https://")
45+
|| value.starts_with("viking://")
46+
{
47+
return Ok(Some(value));
48+
}
49+
50+
let path = Path::new(&value);
51+
if path.is_file() {
52+
let bytes = std::fs::read(path)?;
53+
let mime = mime_guess::from_path(path).first_or_octet_stream();
54+
return Ok(Some(format!(
55+
"data:{};base64,{}",
56+
mime,
57+
BASE64_STANDARD.encode(bytes)
58+
)));
59+
}
60+
61+
Ok(Some(value))
62+
}
63+
3664
#[derive(serde::Serialize)]
3765
pub struct SnapshotCommitReq {
3866
pub message: String,
@@ -475,6 +503,7 @@ impl HttpClient {
475503
&self,
476504
query: String,
477505
uri: String,
506+
image: Option<String>,
478507
node_limit: i32,
479508
threshold: Option<f64>,
480509
since: Option<String>,
@@ -484,8 +513,10 @@ impl HttpClient {
484513
context_type: Option<Vec<String>>,
485514
tags: Option<Vec<String>>,
486515
) -> Result<serde_json::Value> {
516+
let image_url = normalize_image_input(image)?;
487517
let mut body = serde_json::json!({
488518
"query": query,
519+
"image_url": image_url,
489520
"target_uri": uri,
490521
"limit": node_limit,
491522
"score_threshold": threshold,
@@ -504,6 +535,7 @@ impl HttpClient {
504535
&self,
505536
query: String,
506537
uri: String,
538+
image: Option<String>,
507539
session_id: Option<String>,
508540
node_limit: i32,
509541
threshold: Option<f64>,
@@ -514,8 +546,10 @@ impl HttpClient {
514546
context_type: Option<Vec<String>>,
515547
tags: Option<Vec<String>>,
516548
) -> Result<serde_json::Value> {
549+
let image_url = normalize_image_input(image)?;
517550
let mut body = serde_json::json!({
518551
"query": query,
552+
"image_url": image_url,
519553
"target_uri": uri,
520554
"session_id": session_id,
521555
"limit": node_limit,

crates/ov_cli/src/commands/search.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ pub async fn find(
116116
client: &HttpClient,
117117
query: &str,
118118
uri: &str,
119+
image: Option<String>,
119120
node_limit: i32,
120121
threshold: Option<f64>,
121122
since: Option<&str>,
@@ -131,6 +132,7 @@ pub async fn find(
131132
.find(
132133
query.to_string(),
133134
uri.to_string(),
135+
image,
134136
node_limit,
135137
threshold,
136138
since.map(|s| s.to_string()),
@@ -154,6 +156,7 @@ pub async fn search(
154156
client: &HttpClient,
155157
query: &str,
156158
uri: &str,
159+
image: Option<String>,
157160
session_id: Option<String>,
158161
node_limit: i32,
159162
threshold: Option<f64>,
@@ -170,6 +173,7 @@ pub async fn search(
170173
.search(
171174
query.to_string(),
172175
uri.to_string(),
176+
image,
173177
session_id,
174178
node_limit,
175179
threshold,

crates/ov_cli/src/handlers.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1256,8 +1256,9 @@ pub async fn handle_get(uri: String, local_path: String, ctx: CliContext) -> Res
12561256
}
12571257

12581258
pub async fn handle_find(
1259-
query: String,
1259+
query: Option<String>,
12601260
uri: String,
1261+
image: Option<String>,
12611262
node_limit: i32,
12621263
threshold: Option<f64>,
12631264
after: Option<String>,
@@ -1267,7 +1268,16 @@ pub async fn handle_find(
12671268
tags: Option<Vec<String>>,
12681269
ctx: CliContext,
12691270
) -> Result<()> {
1271+
let query = query.unwrap_or_default();
1272+
if query.trim().is_empty() && image.is_none() {
1273+
return Err(Error::Client(
1274+
"Search query or --image must not be empty.".to_string(),
1275+
));
1276+
}
12701277
let mut params = vec![format!("--uri={}", uri), format!("-n {}", node_limit)];
1278+
if let Some(ref img) = image {
1279+
params.push(format!("--image {}", img));
1280+
}
12711281
if let Some(t) = threshold {
12721282
params.push(format!("--threshold {}", t));
12731283
}
@@ -1294,6 +1304,7 @@ pub async fn handle_find(
12941304
&client,
12951305
&query,
12961306
&uri,
1307+
image,
12971308
node_limit,
12981309
threshold,
12991310
after.as_deref(),
@@ -1309,8 +1320,9 @@ pub async fn handle_find(
13091320
}
13101321

13111322
pub async fn handle_search(
1312-
query: String,
1323+
query: Option<String>,
13131324
uri: String,
1325+
image: Option<String>,
13141326
session_id: Option<String>,
13151327
node_limit: i32,
13161328
threshold: Option<f64>,
@@ -1321,7 +1333,16 @@ pub async fn handle_search(
13211333
tags: Option<Vec<String>>,
13221334
ctx: CliContext,
13231335
) -> Result<()> {
1336+
let query = query.unwrap_or_default();
1337+
if query.trim().is_empty() && image.is_none() {
1338+
return Err(Error::Client(
1339+
"Search query or --image must not be empty.".to_string(),
1340+
));
1341+
}
13241342
let mut params = vec![format!("--uri={}", uri), format!("-n {}", node_limit)];
1343+
if let Some(ref img) = image {
1344+
params.push(format!("--image {}", img));
1345+
}
13251346
if let Some(s) = &session_id {
13261347
params.push(format!("--session-id {}", s));
13271348
}
@@ -1351,6 +1372,7 @@ pub async fn handle_search(
13511372
&client,
13521373
&query,
13531374
&uri,
1375+
image,
13541376
session_id,
13551377
node_limit,
13561378
threshold,

crates/ov_cli/src/help_ui.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -455,6 +455,10 @@ const COMMAND_HELP_SPECS: &[CommandHelpSpec] = &[
455455
label: "ov find \"auth flow\" -u viking://projects/acme -L 1,2",
456456
description: "Search a subtree and include overview/file results.",
457457
},
458+
HelpItem {
459+
label: "ov find --image ./query.png -u viking://resources/images",
460+
description: "Search by image with a local file or image URI.",
461+
},
458462
],
459463
next_steps: &[
460464
HelpItem {
@@ -2818,7 +2822,7 @@ mod tests {
28182822
);
28192823

28202824
assert!(rendered.contains("OpenViking v"));
2821-
assert!(rendered.contains("ov find [OPTIONS] <query>"));
2825+
assert!(rendered.contains("ov find [OPTIONS] [query]"));
28222826
assert!(rendered.contains("Examples"));
28232827
assert!(rendered.contains("Common options"));
28242828
assert!(rendered.contains("Next"));
@@ -2881,7 +2885,7 @@ mod tests {
28812885
);
28822886

28832887
assert!(rendered.contains("OpenViking v"));
2884-
assert!(rendered.contains("ov find [OPTIONS] <query>"));
2888+
assert!(rendered.contains("ov find [OPTIONS] [query]"));
28852889
assert!(rendered.contains("Usage:"));
28862890
}
28872891

crates/ov_cli/src/main.rs

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -580,7 +580,14 @@ enum Commands {
580580
Find {
581581
/// Search query
582582
#[arg(value_name = "query")]
583-
query: String,
583+
query: Option<String>,
584+
/// Image query: local path, data URI, HTTP URL, or viking:// URI
585+
#[arg(
586+
long = "image",
587+
value_name = "path|uri",
588+
help_heading = "Common options"
589+
)]
590+
image: Option<String>,
584591
/// Target URI
585592
#[arg(
586593
short,
@@ -638,7 +645,14 @@ enum Commands {
638645
Search {
639646
/// Search query
640647
#[arg(value_name = "query")]
641-
query: String,
648+
query: Option<String>,
649+
/// Image query: local path, data URI, HTTP URL, or viking:// URI
650+
#[arg(
651+
long = "image",
652+
value_name = "path|uri",
653+
help_heading = "Common options"
654+
)]
655+
image: Option<String>,
642656
/// Target URI
643657
#[arg(
644658
short,
@@ -3096,6 +3110,7 @@ async fn main() {
30963110
Commands::Get { uri, local_path } => handlers::handle_get(uri, local_path, ctx).await,
30973111
Commands::Find {
30983112
query,
3113+
image,
30993114
uri,
31003115
node_limit,
31013116
threshold,
@@ -3108,6 +3123,7 @@ async fn main() {
31083123
handlers::handle_find(
31093124
query,
31103125
uri,
3126+
image,
31113127
node_limit,
31123128
threshold,
31133129
after,
@@ -3121,6 +3137,7 @@ async fn main() {
31213137
}
31223138
Commands::Search {
31233139
query,
3140+
image,
31243141
uri,
31253142
session_id,
31263143
node_limit,
@@ -3134,6 +3151,7 @@ async fn main() {
31343151
handlers::handle_search(
31353152
query,
31363153
uri,
3154+
image,
31373155
session_id,
31383156
node_limit,
31393157
threshold,
@@ -3237,6 +3255,20 @@ mod tests {
32373255
}
32383256
}
32393257

3258+
#[test]
3259+
fn cli_parses_find_image_without_query() {
3260+
let cli = Cli::try_parse_from(["ov", "find", "--image", "cat.png"])
3261+
.expect("find image should parse");
3262+
3263+
match cli.command {
3264+
Commands::Find { query, image, .. } => {
3265+
assert_eq!(query, None);
3266+
assert_eq!(image.as_deref(), Some("cat.png"));
3267+
}
3268+
_ => panic!("expected find command"),
3269+
}
3270+
}
3271+
32403272
#[test]
32413273
fn cli_parses_admin_migrate_cleanup_flag() {
32423274
let migrate = Cli::try_parse_from(["ov", "--sudo", "admin", "migrate"])
@@ -3275,6 +3307,20 @@ mod tests {
32753307
}
32763308
}
32773309

3310+
#[test]
3311+
fn cli_parses_search_image_with_query() {
3312+
let cli = Cli::try_parse_from(["ov", "search", "poster", "--image", "viking://x.png"])
3313+
.expect("search image should parse");
3314+
3315+
match cli.command {
3316+
Commands::Search { query, image, .. } => {
3317+
assert_eq!(query.as_deref(), Some("poster"));
3318+
assert_eq!(image.as_deref(), Some("viking://x.png"));
3319+
}
3320+
_ => panic!("expected search command"),
3321+
}
3322+
}
3323+
32783324
#[test]
32793325
fn cli_find_and_search_reject_removed_peer_id_flag() {
32803326
assert!(Cli::try_parse_from(["ov", "find", "invoice", "--peer-id", "peer-a"]).is_err());

0 commit comments

Comments
 (0)