From 590682a5d2066b200f0ba954ca71b6ae37b6909c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Feb 2026 15:21:38 -0500 Subject: [PATCH] Phase 22: SearXNG web research pipeline + settings layout overhaul MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docker-compose.yml | 3 + frontend/src/components/ToolCallCard.vue | 32 + frontend/src/views/ChatView.vue | 141 ++- frontend/src/views/SettingsView.vue | 970 ++++++++++-------- src/fabledassistant/config.py | 7 + src/fabledassistant/routes/settings.py | 14 + .../services/generation_task.py | 40 + src/fabledassistant/services/intent.py | 6 + src/fabledassistant/services/llm.py | 13 +- src/fabledassistant/services/research.py | 242 +++++ src/fabledassistant/services/tools.py | 77 ++ 11 files changed, 1132 insertions(+), 413 deletions(-) create mode 100644 src/fabledassistant/services/research.py diff --git a/docker-compose.yml b/docker-compose.yml index 107121c..7613d2c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,6 +13,8 @@ services: OLLAMA_URL: "http://ollama:11434" OLLAMA_MODEL: "${OLLAMA_MODEL:-llama3.1}" SECRET_KEY: "${SECRET_KEY:-dev-secret-change-me}" + # Uncomment and set to enable web research via SearXNG: + # SEARXNG_URL: "http://searxng:8080" healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:5000/api/health')"] interval: 10s @@ -40,6 +42,7 @@ services: - ollama_models:/root/.ollama environment: OLLAMA_MAX_LOADED_MODELS: "2" + OLLAMA_NUM_PARALLEL: "2" OLLAMA_KEEP_ALIVE: "30m" OLLAMA_FLASH_ATTENTION: "1" deploy: diff --git a/frontend/src/components/ToolCallCard.vue b/frontend/src/components/ToolCallCard.vue index 298494a..75408b7 100644 --- a/frontend/src/components/ToolCallCard.vue +++ b/frontend/src/components/ToolCallCard.vue @@ -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) { + @@ -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); diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue index d42e7fc..8116212 100644 --- a/frontend/src/views/ChatView.vue +++ b/frontend/src/views/ChatView.vue @@ -22,6 +22,10 @@ const sending = ref(false); const summarizing = ref(false); const sidebarOpen = ref(false); +// Research modal state +const showResearchModal = ref(false); +const researchTopic = ref(""); + // Note picker state const attachedNote = ref<{ id: number; title: string } | null>(null); const showNotePicker = ref(false); @@ -239,6 +243,27 @@ function onInputKeydown(e: KeyboardEvent) { } } +// Research modal +function toggleResearchModal() { + showResearchModal.value = !showResearchModal.value; + if (showResearchModal.value) { + researchTopic.value = ""; + nextTick(() => { + const el = document.querySelector(".research-topic-input") as HTMLInputElement; + el?.focus(); + }); + } +} + +function submitResearch() { + const topic = researchTopic.value.trim(); + if (!topic) return; + messageInput.value = `Research: ${topic}`; + showResearchModal.value = false; + researchTopic.value = ""; + sendMessage(); +} + // Note picker function toggleNotePicker() { showNotePicker.value = !showNotePicker.value; @@ -302,7 +327,12 @@ function removeIncludedNote(noteId: number) { // Keyboard shortcuts function onGlobalKeydown(e: KeyboardEvent) { if (e.key !== "Escape") return; - // Close note picker first + // Close research modal first + if (showResearchModal.value) { + showResearchModal.value = false; + return; + } + // Close note picker if (showNotePicker.value) { showNotePicker.value = false; return; @@ -479,6 +509,37 @@ onUnmounted(() => {
+ +
+ + + +
+
Research topic
+ +
+ + +
+
+
+