Release v0.3.14
- Add image_query support to web.run (DuckDuckGo image search) - Encode tool-call function names for Chat Completions history rebuild - Allow safe MCP meta tools in multi_tool_use.parallel - Update prompts for stronger citation placement and quote-limit guidance
This commit is contained in:
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [0.3.14] - 2026-02-05
|
||||
|
||||
### Added
|
||||
- `web.run` now supports `image_query` (DuckDuckGo image search)
|
||||
- `multi_tool_use.parallel` now supports safe MCP meta tools (`list_mcp_resources`, `mcp_read_resource`, etc.)
|
||||
|
||||
### Fixed
|
||||
- Encode tool-call function names when rebuilding Chat Completions history (keeps dotted tool names API-safe)
|
||||
|
||||
### Changed
|
||||
- Prompts: stronger `web.run` citation placement and quote-limit guidance
|
||||
|
||||
## [0.3.13] - 2026-02-04
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "deepseek-tui"
|
||||
version = "0.3.13"
|
||||
version = "0.3.14"
|
||||
edition = "2024"
|
||||
description = "Unofficial DeepSeek CLI - Just run 'deepseek' to start chatting"
|
||||
license = "MIT"
|
||||
|
||||
@@ -40,10 +40,10 @@ The Codex harness baseline (as of 2026-02-03) includes:
|
||||
| Patch apply | apply_patch | apply_patch | Parity | - |
|
||||
| Code search | rg via shell | grep_files, file_search, exec_shell | Parity | - |
|
||||
| Shell exec | exec_command + write_stdin | exec_shell | Parity | PTY + stdin streaming via exec_shell_wait/exec_shell_interact |
|
||||
| Web search/browse | web.run (search/open/click/find/screenshot) | web.run + web_search | Partial | web.run implemented; citations via prompts only (no word-limit enforcement) |
|
||||
| Image search | image_query | missing | Missing | - |
|
||||
| Web search/browse | web.run (search/open/click/find/screenshot) | web.run + web_search | Partial | web.run implemented; citation placement + quote limits enforced via prompts (no word-limit enforcement) |
|
||||
| Image search | image_query | web.run image_query | Parity | DuckDuckGo image search via web.run.image_query |
|
||||
| Structured data | weather/finance/sports/time/calculator | weather/finance/sports/time/calculator | Partial | Uses public data sources; coverage may vary by league/market |
|
||||
| Multi-tool parallel | multi_tool_use.parallel | multi_tool_use.parallel | Partial | Read-only tools only; no MCP tools |
|
||||
| Multi-tool parallel | multi_tool_use.parallel | multi_tool_use.parallel | Partial | Read-only tools plus safe MCP meta tools (list/read/get prompt) |
|
||||
| User input tool | request_user_input | request_user_input | Parity | - |
|
||||
| MCP resources | list/read resources + get prompt | list_mcp_resources, list_mcp_resource_templates, mcp_read_resource, mcp_get_prompt | Parity | - |
|
||||
| Sub-agents | spawn/send_input/wait/close | agent_spawn/send_input/wait/agent_cancel/agent_list/agent_swarm | Partial | send_input/wait added; close maps to agent_cancel |
|
||||
@@ -75,10 +75,10 @@ Citation format (current): `[cite:ref_id]` using the `ref_id` returned by `web.r
|
||||
|
||||
## Gap Backlog (Prioritized)
|
||||
|
||||
1. Add image_query tool (image search parity)
|
||||
2. Enforce web.run citation placement/quote limits in prompts or tooling
|
||||
3. Expand structured data coverage for edge leagues/markets
|
||||
4. Allow multi_tool_use.parallel to include MCP tools (where safe)
|
||||
1. ✅ Add image_query support (image search parity)
|
||||
2. ✅ Enforce web.run citation placement/quote limits in prompts or tooling
|
||||
3. ☐ Expand structured data coverage for edge leagues/markets
|
||||
4. ✅ Allow multi_tool_use.parallel to include MCP tools (where safe)
|
||||
|
||||
## Parity Gates (Acceptance)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ Unofficial terminal UI (TUI) + CLI for the [DeepSeek platform](https://platform.
|
||||
- **Task management**: Todo lists, implementation plans, persistent notes
|
||||
- **Sub-agent system**: Spawn, coordinate, and cancel background agents (including swarms)
|
||||
- **User input prompts**: Ask structured, multiple-choice questions during tool flows
|
||||
- **Web browsing**: `web.run` search/open/click/find/screenshot with citation-ready sources
|
||||
- **Web browsing**: `web.run` search/open/click/find/screenshot/image_query with citation-ready sources
|
||||
- **Structured data tools**: weather, finance, sports, time, calculator
|
||||
- **Multi‑model support** – DeepSeek‑Reasoner, DeepSeek‑Chat, and other DeepSeek models
|
||||
- **Context‑aware** – loads project‑specific instructions from `AGENTS.md`
|
||||
@@ -123,7 +123,7 @@ DeepSeek CLI exposes a comprehensive set of tools to the model across 5 categori
|
||||
- **`edit_file`** – Search and replace text in files
|
||||
- **`apply_patch`** – Apply unified diff patches with fuzzy matching
|
||||
- **`grep_files`** – Search files by regex pattern with context lines
|
||||
- **`web.run`** – Browse the web (search/open/click/find/screenshot) with ref_ids for citations
|
||||
- **`web.run`** – Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
|
||||
- **`web_search`** – Quick web search (fallback when citations are not needed)
|
||||
- **`request_user_input`** – Ask the user short multiple-choice questions
|
||||
- **`multi_tool_use.parallel`** – Execute multiple read-only tools in parallel
|
||||
|
||||
+40
-1
@@ -515,7 +515,7 @@ fn build_chat_messages(
|
||||
"id": id,
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"name": to_api_tool_name(name),
|
||||
"arguments": args,
|
||||
}
|
||||
}));
|
||||
@@ -1040,6 +1040,45 @@ mod tests {
|
||||
assert!(assistant.get("tool_calls").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_encode_tool_call_names() {
|
||||
let messages = vec![
|
||||
Message {
|
||||
role: "assistant".to_string(),
|
||||
content: vec![ContentBlock::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "web.run".to_string(),
|
||||
input: json!({}),
|
||||
}],
|
||||
},
|
||||
Message {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentBlock::ToolResult {
|
||||
tool_use_id: "tool-1".to_string(),
|
||||
content: "ok".to_string(),
|
||||
}],
|
||||
},
|
||||
];
|
||||
|
||||
let out = build_chat_messages(None, &messages, "deepseek-chat");
|
||||
let assistant = out
|
||||
.iter()
|
||||
.find(|value| value.get("role").and_then(Value::as_str) == Some("assistant"))
|
||||
.expect("assistant message");
|
||||
let tool_calls = assistant
|
||||
.get("tool_calls")
|
||||
.and_then(Value::as_array)
|
||||
.expect("tool_calls array");
|
||||
let function_name = tool_calls
|
||||
.first()
|
||||
.and_then(|call| call.get("function"))
|
||||
.and_then(|func| func.get("name"))
|
||||
.and_then(Value::as_str)
|
||||
.expect("tool call function name");
|
||||
|
||||
assert_eq!(function_name, to_api_tool_name("web.run"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn chat_messages_strips_orphaned_tool_calls_after_compaction() {
|
||||
// Simulates post-compaction state: assistant has tool_calls but the
|
||||
|
||||
+46
-24
@@ -473,6 +473,17 @@ fn should_parallelize_tool_batch(plans: &[ToolExecutionPlan]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn mcp_tool_is_parallel_safe(name: &str) -> bool {
|
||||
matches!(
|
||||
name,
|
||||
"list_mcp_resources"
|
||||
| "list_mcp_resource_templates"
|
||||
| "mcp_read_resource"
|
||||
| "read_mcp_resource"
|
||||
| "mcp_get_prompt"
|
||||
)
|
||||
}
|
||||
|
||||
fn format_tool_error(err: &ToolError, tool_name: &str) -> String {
|
||||
match err {
|
||||
ToolError::InvalidInput { message } => {
|
||||
@@ -945,6 +956,11 @@ impl Engine {
|
||||
tool_exec_lock: Arc<RwLock<()>>,
|
||||
) -> Result<ToolResult, ToolError> {
|
||||
let calls = parse_parallel_tool_calls(&input)?;
|
||||
let mcp_pool = if calls.iter().any(|(tool, _)| McpPool::is_mcp_tool(tool)) {
|
||||
Some(self.ensure_mcp_pool().await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(registry) = tool_registry else {
|
||||
return Err(ToolError::not_available(
|
||||
"tool registry unavailable for multi_tool_use.parallel",
|
||||
@@ -959,34 +975,40 @@ impl Engine {
|
||||
));
|
||||
}
|
||||
if McpPool::is_mcp_tool(&tool_name) {
|
||||
return Err(ToolError::invalid_input(
|
||||
"multi_tool_use.parallel does not support MCP tools",
|
||||
));
|
||||
}
|
||||
let Some(spec) = registry.get(&tool_name) else {
|
||||
return Err(ToolError::not_available(format!(
|
||||
"tool '{tool_name}' is not registered"
|
||||
)));
|
||||
};
|
||||
if !spec.is_read_only() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Tool '{tool_name}' is not read-only and cannot run in parallel"
|
||||
)));
|
||||
}
|
||||
if spec.approval_requirement() != ApprovalRequirement::Auto {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Tool '{tool_name}' requires approval and cannot run in parallel"
|
||||
)));
|
||||
}
|
||||
if !spec.supports_parallel() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Tool '{tool_name}' does not support parallel execution"
|
||||
)));
|
||||
if !mcp_tool_is_parallel_safe(&tool_name) {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Tool '{tool_name}' is an MCP tool and cannot run in parallel. \
|
||||
Allowed MCP tools: list_mcp_resources, list_mcp_resource_templates, \
|
||||
mcp_read_resource, read_mcp_resource, mcp_get_prompt."
|
||||
)));
|
||||
}
|
||||
} else {
|
||||
let Some(spec) = registry.get(&tool_name) else {
|
||||
return Err(ToolError::not_available(format!(
|
||||
"tool '{tool_name}' is not registered"
|
||||
)));
|
||||
};
|
||||
if !spec.is_read_only() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Tool '{tool_name}' is not read-only and cannot run in parallel"
|
||||
)));
|
||||
}
|
||||
if spec.approval_requirement() != ApprovalRequirement::Auto {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Tool '{tool_name}' requires approval and cannot run in parallel"
|
||||
)));
|
||||
}
|
||||
if !spec.supports_parallel() {
|
||||
return Err(ToolError::invalid_input(format!(
|
||||
"Tool '{tool_name}' does not support parallel execution"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let registry_ref = registry;
|
||||
let lock = tool_exec_lock.clone();
|
||||
let tx_event = self.tx_event.clone();
|
||||
let mcp_pool = mcp_pool.clone();
|
||||
tasks.push(async move {
|
||||
let result = Engine::execute_tool_with_lock(
|
||||
lock,
|
||||
@@ -996,7 +1018,7 @@ impl Engine {
|
||||
tool_name.clone(),
|
||||
tool_input.clone(),
|
||||
Some(registry_ref),
|
||||
None,
|
||||
mcp_pool,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -17,6 +17,13 @@ Tool selection guidance:
|
||||
- Use exec_shell for objective verification: build, test, format, lint, and targeted checks.
|
||||
- Use web.run when local context is insufficient or time-sensitive, and cite sources as [cite:ref_id].
|
||||
|
||||
Web browsing and citations:
|
||||
- Use web.run when info might have changed or you are unsure.
|
||||
- Cite non-trivial factual claims using [cite:ref_id] (the ref_id returned by web.run).
|
||||
- Place citations at the end of the sentence/paragraph they support; do not dump all citations at the end.
|
||||
- Quote limits: do not quote more than 25 words verbatim from a single non-lyrical source (10 words for lyrics).
|
||||
- Avoid reproducing full articles or large excerpts; prefer short quotes + paraphrase.
|
||||
|
||||
Testing and stop conditions:
|
||||
- After any change, run the most relevant tests/checks before declaring success.
|
||||
- Start narrow (targeted tests) and expand to broader checks when appropriate.
|
||||
@@ -35,7 +42,7 @@ FILE OPERATIONS:
|
||||
- edit_file: Search and replace text in a file
|
||||
- apply_patch: Apply a unified diff patch to a file
|
||||
- grep_files: Search files by regex
|
||||
- web.run: Browse the web (search/open/click/find/screenshot) with ref_ids for citations
|
||||
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
|
||||
- web_search: Quick web search (fallback when citations are not needed)
|
||||
- request_user_input: Ask the user short multiple-choice questions
|
||||
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
|
||||
|
||||
@@ -11,7 +11,7 @@ Available tools in this mode:
|
||||
- edit_file: Search and replace text in a file (ask first)
|
||||
- apply_patch: Apply a unified diff patch (ask first)
|
||||
- grep_files: Search files by regex
|
||||
- web.run: Browse the web (search/open/click/find/screenshot) with ref_ids for citations
|
||||
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
|
||||
- web_search: Quick web search (fallback when citations are not needed)
|
||||
- request_user_input: Ask the user short multiple-choice questions
|
||||
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
|
||||
|
||||
@@ -18,7 +18,7 @@ EXPLORATION:
|
||||
- list_dir: Browse directories in the workspace
|
||||
- read_file: Read file contents to understand context
|
||||
- grep_files: Search files by regex
|
||||
- web.run: Browse the web (search/open/click/find/screenshot) with ref_ids for citations
|
||||
- web.run: Browse the web (search/open/click/find/screenshot/image_query) with ref_ids for citations
|
||||
- web_search: Quick web search (fallback when citations are not needed)
|
||||
- request_user_input: Ask the user short multiple-choice questions
|
||||
- multi_tool_use.parallel: Execute multiple read-only tools in parallel
|
||||
|
||||
+291
-2
@@ -9,7 +9,7 @@ use super::spec::{
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use regex::Regex;
|
||||
use serde::Serialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
@@ -151,11 +151,40 @@ struct ScreenshotResult {
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ImageResultEntry {
|
||||
image: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
thumbnail: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
url: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
source: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
width: Option<u32>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
height: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
struct ImageQueryResult {
|
||||
query: String,
|
||||
source: String,
|
||||
count: usize,
|
||||
results: Vec<ImageResultEntry>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
warning: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Default)]
|
||||
struct WebRunOutput {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
search_query: Option<Vec<SearchResult>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
image_query: Option<Vec<ImageQueryResult>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
open: Option<Vec<PageViewResult>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
click: Option<Vec<PageViewResult>>,
|
||||
@@ -176,7 +205,7 @@ impl ToolSpec for WebRunTool {
|
||||
}
|
||||
|
||||
fn description(&self) -> &'static str {
|
||||
"Browse the web (search/open/click/find/screenshot) and return structured results with ref_ids for citations."
|
||||
"Browse the web (search/open/click/find/screenshot/image_query) and return structured results with ref_ids for citations."
|
||||
}
|
||||
|
||||
fn input_schema(&self) -> Value {
|
||||
@@ -190,6 +219,22 @@ impl ToolSpec for WebRunTool {
|
||||
"properties": {
|
||||
"q": { "type": "string" },
|
||||
"recency": { "type": "integer" },
|
||||
"max_results": { "type": "integer" },
|
||||
"timeout_ms": { "type": "integer" },
|
||||
"domains": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["q"]
|
||||
}
|
||||
},
|
||||
"image_query": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"q": { "type": "string" },
|
||||
"recency": { "type": "integer" },
|
||||
"max_results": { "type": "integer" },
|
||||
"timeout_ms": { "type": "integer" },
|
||||
"domains": { "type": "array", "items": { "type": "string" } }
|
||||
},
|
||||
"required": ["q"]
|
||||
@@ -332,6 +377,63 @@ impl ToolSpec for WebRunTool {
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(images) = input.get("image_query").and_then(|v| v.as_array()) {
|
||||
let mut results = Vec::new();
|
||||
for image in images {
|
||||
let query = required_str(image, "q")?.trim().to_string();
|
||||
if query.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let recency = optional_u64(image, "recency", 0);
|
||||
let max_results = usize::try_from(optional_u64(
|
||||
image,
|
||||
"max_results",
|
||||
response_length.max_results() as u64,
|
||||
))
|
||||
.unwrap_or(response_length.max_results())
|
||||
.clamp(1, MAX_RESULTS);
|
||||
let timeout_ms = optional_u64(image, "timeout_ms", DEFAULT_TIMEOUT_MS).min(60_000);
|
||||
|
||||
let domains = image
|
||||
.get("domains")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let (entries, warning) =
|
||||
run_image_search(&query, max_results, timeout_ms, &domains).await?;
|
||||
|
||||
let mut warnings = Vec::new();
|
||||
if recency > 0 {
|
||||
warnings.push(format!(
|
||||
"Recency filter not enforced (requested last {recency} days)"
|
||||
));
|
||||
}
|
||||
if let Some(w) = warning {
|
||||
warnings.push(w);
|
||||
}
|
||||
|
||||
results.push(ImageQueryResult {
|
||||
query,
|
||||
source: "duckduckgo_images".to_string(),
|
||||
count: entries.len(),
|
||||
results: entries,
|
||||
warning: if warnings.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(warnings.join("; "))
|
||||
},
|
||||
});
|
||||
}
|
||||
if !results.is_empty() {
|
||||
output.image_query = Some(results);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(opens) = input.get("open").and_then(|v| v.as_array()) {
|
||||
let mut views = Vec::new();
|
||||
for open in opens {
|
||||
@@ -520,6 +622,172 @@ fn domain_matches(url: &str, domains: &[String]) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct DuckDuckGoImageResponse {
|
||||
#[serde(default)]
|
||||
results: Vec<DuckDuckGoImageResult>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
struct DuckDuckGoImageResult {
|
||||
image: String,
|
||||
#[serde(default)]
|
||||
thumbnail: Option<String>,
|
||||
#[serde(default)]
|
||||
title: Option<String>,
|
||||
#[serde(default)]
|
||||
url: Option<String>,
|
||||
#[serde(default)]
|
||||
source: Option<String>,
|
||||
#[serde(default)]
|
||||
width: Option<u32>,
|
||||
#[serde(default)]
|
||||
height: Option<u32>,
|
||||
}
|
||||
|
||||
fn extract_duckduckgo_vqd(html: &str) -> Option<String> {
|
||||
let html = html.trim();
|
||||
if html.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
for (prefix, suffix) in [("vqd='", "'"), ("vqd=\"", "\"")] {
|
||||
if let Some(start) = html.find(prefix) {
|
||||
let rest = &html[start + prefix.len()..];
|
||||
if let Some(end) = rest.find(suffix) {
|
||||
let token = rest[..end].trim();
|
||||
if !token.is_empty() {
|
||||
return Some(token.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: look for `vqd=` and accept a conservative token charset.
|
||||
if let Some(start) = html.find("vqd=") {
|
||||
let rest = &html[start + 4..];
|
||||
let mut token = String::new();
|
||||
for ch in rest.chars() {
|
||||
if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' {
|
||||
token.push(ch);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
if !token.is_empty() {
|
||||
return Some(token);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
async fn run_image_search(
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
timeout_ms: u64,
|
||||
domains: &[String],
|
||||
) -> Result<(Vec<ImageResultEntry>, Option<String>), ToolError> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(Duration::from_millis(timeout_ms))
|
||||
.user_agent(USER_AGENT)
|
||||
.build()
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to build HTTP client: {e}")))?;
|
||||
|
||||
// Step 1: fetch the HTML page to obtain the `vqd` token used by the images API.
|
||||
let encoded = url_encode(query);
|
||||
let seed_url = format!("https://duckduckgo.com/?q={encoded}&iax=images&ia=images");
|
||||
let seed_resp = client
|
||||
.get(&seed_url)
|
||||
.header(
|
||||
"Accept",
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
)
|
||||
.header("Accept-Language", "en-US,en;q=0.5")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| {
|
||||
ToolError::execution_failed(format!("Image search seed request failed: {e}"))
|
||||
})?;
|
||||
|
||||
let seed_status = seed_resp.status();
|
||||
let seed_body = seed_resp.text().await.map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to read image seed response: {e}"))
|
||||
})?;
|
||||
|
||||
if !seed_status.is_success() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"Image search seed request failed: HTTP {}",
|
||||
seed_status.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
let vqd = extract_duckduckgo_vqd(&seed_body).ok_or_else(|| {
|
||||
ToolError::execution_failed("Failed to extract DuckDuckGo image token (vqd)")
|
||||
})?;
|
||||
|
||||
// Step 2: query the DuckDuckGo images JSON endpoint.
|
||||
let api_url = format!("https://duckduckgo.com/i.js?l=us-en&o=json&q={encoded}&vqd={vqd}&p=1");
|
||||
let api_resp = client
|
||||
.get(&api_url)
|
||||
.header("Accept", "application/json")
|
||||
.header("Referer", "https://duckduckgo.com/")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Image search request failed: {e}")))?;
|
||||
|
||||
let api_status = api_resp.status();
|
||||
let api_body = api_resp
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| ToolError::execution_failed(format!("Failed to read image response: {e}")))?;
|
||||
|
||||
if !api_status.is_success() {
|
||||
return Err(ToolError::execution_failed(format!(
|
||||
"Image search failed: HTTP {}",
|
||||
api_status.as_u16()
|
||||
)));
|
||||
}
|
||||
|
||||
let parsed: DuckDuckGoImageResponse = serde_json::from_str(&api_body).map_err(|e| {
|
||||
ToolError::execution_failed(format!("Failed to parse image search JSON: {e}"))
|
||||
})?;
|
||||
|
||||
let mut results = parsed
|
||||
.results
|
||||
.into_iter()
|
||||
.filter(|item| !item.image.trim().is_empty())
|
||||
.map(|item| ImageResultEntry {
|
||||
image: item.image,
|
||||
thumbnail: item.thumbnail,
|
||||
title: item.title,
|
||||
url: item.url,
|
||||
source: item.source,
|
||||
width: item.width,
|
||||
height: item.height,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
// Domain filter is applied to the source page URL when available.
|
||||
let warning = if !domains.is_empty() {
|
||||
let before = results.len();
|
||||
results.retain(|entry| match entry.url.as_deref() {
|
||||
Some(url) => domain_matches(url, domains),
|
||||
None => true,
|
||||
});
|
||||
if before != results.len() {
|
||||
Some("Filtered image results by domain list".to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
results.truncate(max_results);
|
||||
Ok((results, warning))
|
||||
}
|
||||
|
||||
fn page_from_search(query: &str, results: &[SearchEntry]) -> WebPage {
|
||||
let mut lines = Vec::new();
|
||||
let mut links = Vec::new();
|
||||
@@ -1069,4 +1337,25 @@ mod tests {
|
||||
assert!(wrapped.len() > 1);
|
||||
assert!(wrapped.iter().all(|l| l.len() <= 20));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_duckduckgo_vqd_token() {
|
||||
let html_single = "<script>var x = {vqd='3-1234567890'};</script>";
|
||||
assert_eq!(
|
||||
extract_duckduckgo_vqd(html_single),
|
||||
Some("3-1234567890".to_string())
|
||||
);
|
||||
|
||||
let html_double = "<script>var x = {vqd=\"3-abcdef\"};</script>";
|
||||
assert_eq!(
|
||||
extract_duckduckgo_vqd(html_double),
|
||||
Some("3-abcdef".to_string())
|
||||
);
|
||||
|
||||
let html_plain = "https://duckduckgo.com/?q=test&vqd=3-xyz_123&ia=images";
|
||||
assert_eq!(
|
||||
extract_duckduckgo_vqd(html_plain),
|
||||
Some("3-xyz_123".to_string())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user