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:
@@ -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);
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
<div class="input-wrapper">
|
||||
<div class="input-area">
|
||||
<!-- Research button -->
|
||||
<div class="research-wrapper">
|
||||
<button
|
||||
class="btn-attach"
|
||||
@click="toggleResearchModal"
|
||||
:disabled="store.streaming || !store.chatReady"
|
||||
title="Research a topic"
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Research modal -->
|
||||
<div v-if="showResearchModal" class="research-modal">
|
||||
<div class="research-modal-header">Research topic</div>
|
||||
<input
|
||||
class="research-topic-input"
|
||||
v-model="researchTopic"
|
||||
placeholder="e.g. quantum computing"
|
||||
@keydown.enter="submitResearch"
|
||||
@keydown.escape="showResearchModal = false"
|
||||
/>
|
||||
<div class="research-modal-actions">
|
||||
<button class="btn-research-cancel" @click="showResearchModal = false">Cancel</button>
|
||||
<button class="btn-research-go" @click="submitResearch" :disabled="!researchTopic.trim()">Go</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Note picker button -->
|
||||
<div class="note-picker-wrapper">
|
||||
<button
|
||||
@@ -1005,6 +1066,84 @@ details[open] .thinking-summary::before {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Research modal */
|
||||
.research-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
.research-modal {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 8px);
|
||||
left: 0;
|
||||
width: 280px;
|
||||
background: var(--color-bg-card);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
box-shadow: 0 4px 16px var(--color-shadow);
|
||||
z-index: 10;
|
||||
padding: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.research-modal-header {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.research-topic-input {
|
||||
width: 100%;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-bg-secondary);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.research-topic-input:focus {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.research-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
.btn-research-cancel {
|
||||
padding: 0.3rem 0.65rem;
|
||||
background: none;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-research-cancel:hover {
|
||||
background: var(--color-bg-secondary);
|
||||
}
|
||||
.btn-research-go {
|
||||
padding: 0.3rem 0.75rem;
|
||||
background: var(--color-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: #fff;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.btn-research-go:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
.btn-research-go:not(:disabled):hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.input-area textarea {
|
||||
flex: 1;
|
||||
resize: none;
|
||||
|
||||
+560
-410
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user