Phase 22: SearXNG web research pipeline + settings layout overhaul

Research pipeline (research_topic tool):
- New service: services/research.py — sub-query generation, SearXNG
  search, URL fetch, deduplication, and LLM synthesis into a note
- 5 sub-queries × 3 pages = up to 15 sources, capped at 12 for synthesis
- Synthesis uses num_ctx=16384 + max_tokens=8192 for long-form output
- Prompt demands 2500+ words, 6+ topic-appropriate sections, detailed prose
- 429 retry with backoff; 1s inter-query sleep; raw_decode JSON parsing

search_web tool (new):
- Lightweight single-query SearXNG search, results returned inline in chat
- LLM answers conversationally in round 1; no note created
- web_search result type with external links in ToolCallCard

Infrastructure:
- llm.py: generate_completion accepts num_ctx override
- config.py: SEARXNG_URL + Config.searxng_enabled()
- docker-compose: OLLAMA_NUM_PARALLEL=2, commented SEARXNG_URL example
- intent.py: search_web and research_topic routing rules

Settings UI:
- 2-column grid layout (small sections pair up, complex span full width)
- Search Test section: live SearXNG query with result preview
- GET /api/settings/search?q= proxy endpoint
- Research button (magnifier) in ChatView input toolbar → popover modal

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 15:21:38 -05:00
parent 432e0bd2a0
commit 590682a5d2
11 changed files with 1132 additions and 413 deletions
+32
View File
@@ -52,6 +52,12 @@ const label = computed(() => {
return "Completed todo";
case "todo_deleted":
return "Deleted todo";
case "web_search":
return "Web search";
case "research_pending":
return "Research started";
case "research_note":
return "Research note";
default:
return "Tool call";
}
@@ -167,6 +173,12 @@ const taskCount = computed(() => {
return (data.total as number) ?? 0;
});
const webSearch = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "web_search") return null;
return data as { query: string; results: { url: string; title: string; snippet: string }[]; count: number };
});
const searchResults = computed(() => {
const data = props.toolCall.result.data;
if (!data || props.toolCall.result.type !== "search") return null;
@@ -317,6 +329,19 @@ async function applyTag(tag: string) {
</div>
</div>
</template>
<template v-else-if="webSearch">
<span class="tool-search-info">{{ webSearch.count }} result{{ webSearch.count !== 1 ? "s" : "" }}</span>
<div class="tool-search-results web-search-results">
<a
v-for="(r, i) in webSearch.results"
:key="i"
:href="r.url"
target="_blank"
rel="noopener noreferrer"
class="tool-search-item"
>{{ r.title || r.url }}</a>
</div>
</template>
<template v-else-if="linkTo">
<router-link :to="linkTo" class="tool-link">{{ title }}</router-link>
</template>
@@ -400,6 +425,13 @@ async function applyTag(tag: string) {
color: var(--color-text-muted);
margin-right: 0.1rem;
}
.web-search-results {
flex-direction: column;
flex-wrap: nowrap;
}
.web-search-results .tool-search-item::after {
content: none;
}
.tool-event-title {
font-weight: 600;
color: var(--color-text);