feat(rag): RAG scoping and context isolation controls

- Migration 0030: add conversations.rag_project_id (NULL=orphan-only,
  -1=all notes, positive=project), projects.auto_summary and
  projects.summary_updated_at
- Three-value scope semantics thread from build_context() → semantic
  search and keyword fallback via orphan_only + effective_project_id
- Project summarization background job (generate_project_summary,
  backfill_project_summaries) called via Ollama; triggered on project
  update and note saves (debounced 1h); runs at startup
- New LLM tools: search_projects (SequenceMatcher scoring on
  title+description+auto_summary) and set_rag_scope (persists to DB,
  workspace-guarded, emits new_rag_scope in SSE done event)
- execute_tool() accepts conv_id + workspace_project_id; generation_task
  passes both and captures scope changes for SSE done enrichment
- Frontend: Conversation type gets rag_project_id; chat store adds
  ragProjectId computed + updateRagScope(); SSE done handler syncs scope
- ChatView: replace sidebar ProjectSelector with a scope chip pill above
  the input bar, animated dropdown, pulse on model-driven scope change

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 17:44:39 -04:00
parent 1e0d11c907
commit ebc79b34f9
16 changed files with 1578 additions and 38 deletions
+17
View File
@@ -75,6 +75,17 @@ export const useChatStore = defineStore("chat", () => {
const streamingPendingTool = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.pendingTool ?? null);
const lastContextMeta = computed(() => convStreams.value[currentConversation.value?.id ?? 0]?.contextMeta ?? null);
const ragProjectId = computed<number | null>(
() => currentConversation.value?.rag_project_id ?? null
);
async function updateRagScope(convId: number, ragProjectId: number | null): Promise<void> {
await apiPatch(`/api/chat/conversations/${convId}`, { rag_project_id: ragProjectId });
if (currentConversation.value?.id === convId) {
currentConversation.value.rag_project_id = ragProjectId;
}
}
function isStreamingConv(id: number): boolean {
return convStreams.value[id]?.streaming ?? false;
}
@@ -387,6 +398,10 @@ export const useChatStore = defineStore("chat", () => {
};
if (currentConversation.value?.id === convId) {
currentConversation.value.messages.push(assistantMsg);
// Update RAG scope if the model changed it mid-conversation
if (event.data.new_rag_scope !== undefined) {
currentConversation.value.rag_project_id = event.data.new_rag_scope as number | null;
}
}
// Update updated_at only — message_count was already incremented at send time
const idx = conversations.value.findIndex((c) => c.id === convId);
@@ -594,6 +609,8 @@ export const useChatStore = defineStore("chat", () => {
streamingStatus,
streamingPendingTool,
lastContextMeta,
ragProjectId,
updateRagScope,
ollamaStatus,
modelStatus,
defaultModel,
+1
View File
@@ -43,6 +43,7 @@ export interface Conversation {
title: string;
model: string;
message_count: number;
rag_project_id: number | null;
created_at: string;
updated_at: string;
}
+132 -25
View File
@@ -9,7 +9,6 @@ import ChatMessage from "@/components/ChatMessage.vue";
import ToolCallCard from "@/components/ToolCallCard.vue";
import ToolConfirmCard from "@/components/ToolConfirmCard.vue";
import type { Note } from "@/types/note";
import ProjectSelector from "@/components/ProjectSelector.vue";
const route = useRoute();
const router = useRouter();
@@ -47,8 +46,34 @@ const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null
// Note IDs excluded from auto-injection on next message
const excludedNoteIds = ref<number[]>([]);
// Project scope for RAG — when set, semantic & keyword search is restricted to this project
const ragProjectId = ref<number | null>(null);
// Scope chip state
const scopeDropdownOpen = ref(false);
const projects = ref<{ id: number; title: string }[]>([]);
const scopePulse = ref(false);
const scopeLabel = computed(() => {
const id = store.ragProjectId;
if (id === -1) return "All notes";
if (id === null) return "Orphan notes";
return projects.value.find((p) => p.id === id)?.title ?? `Project ${id}`;
});
async function loadProjects() {
try {
const data = await apiGet<{ projects: { id: number; title: string }[] }>("/api/projects?status=active");
projects.value = data.projects ?? [];
} catch {
projects.value = [];
}
}
async function onScopeSelect(value: number | null) {
scopeDropdownOpen.value = false;
if (!convId.value) return;
await store.updateRagScope(convId.value, value);
scopePulse.value = true;
setTimeout(() => { scopePulse.value = false; }, 600);
}
let prevConvId: number | null = null;
@@ -120,7 +145,7 @@ const inputPlaceholder = computed(() => {
onMounted(async () => {
document.addEventListener("keydown", onGlobalKeydown);
await store.fetchConversations();
await Promise.all([store.fetchConversations(), loadProjects()]);
if (convId.value) {
if (store.currentConversation?.id !== convId.value) {
await store.fetchConversation(convId.value);
@@ -305,7 +330,7 @@ async function sendMessage() {
true, // enable thinking in the full chat view
undefined,
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
ragProjectId.value,
store.ragProjectId,
);
sending.value = false;
@@ -520,13 +545,6 @@ onUnmounted(() => {
</button>
</div>
<!-- RAG project scope -->
<div class="rag-scope-section">
<label class="rag-scope-label">Scope notes to project</label>
<ProjectSelector v-model="ragProjectId" />
<p v-if="ragProjectId" class="rag-scope-hint">RAG search restricted to this project</p>
</div>
<div class="conv-list">
<template v-for="group in groupedConversations" :key="group.label">
<div class="conv-group-label">{{ group.label }}</div>
@@ -726,6 +744,39 @@ onUnmounted(() => {
</div>
<div class="input-wrapper">
<!-- Scope chip above input -->
<div class="scope-chip-row">
<div class="scope-chip-wrapper">
<button
class="scope-chip"
:class="{ pulse: scopePulse }"
@click="scopeDropdownOpen = !scopeDropdownOpen"
title="Change RAG scope"
>
<span class="scope-dot"></span> {{ scopeLabel }}
</button>
<div v-if="scopeDropdownOpen" class="scope-dropdown">
<button
class="scope-option"
:class="{ active: store.ragProjectId === null }"
@click="onScopeSelect(null)"
>Orphan notes only</button>
<button
v-for="p in projects"
:key="p.id"
class="scope-option"
:class="{ active: store.ragProjectId === p.id }"
@click="onScopeSelect(p.id)"
>{{ p.title }}</button>
<button
class="scope-option"
:class="{ active: store.ragProjectId === -1 }"
@click="onScopeSelect(-1)"
>All notes</button>
</div>
</div>
</div>
<div class="input-area">
<!-- Research button -->
<div class="research-wrapper">
@@ -950,26 +1001,82 @@ onUnmounted(() => {
background: color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-secondary));
}
.rag-scope-section {
padding: 0.5rem 0.75rem 0.25rem;
border-bottom: 1px solid var(--color-border);
.scope-chip-row {
padding: 0.35rem 0.75rem 0;
display: flex;
}
.rag-scope-label {
display: block;
.scope-chip-wrapper {
position: relative;
}
.scope-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
margin-bottom: 0.3rem;
background: var(--color-bg-secondary);
border: 1px solid var(--color-border);
border-radius: 999px;
padding: 0.2rem 0.65rem;
cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.rag-scope-hint {
margin: 0.3rem 0 0;
font-size: 0.7rem;
.scope-chip:hover {
color: var(--color-text);
border-color: var(--color-primary);
}
.scope-dot {
color: var(--color-primary);
font-style: italic;
font-size: 0.8rem;
}
@keyframes scope-pulse {
0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--color-primary) 40%, transparent); }
100% { box-shadow: 0 0 0 6px transparent; }
}
.scope-chip.pulse {
animation: scope-pulse 0.5s ease-out;
}
.scope-dropdown {
position: absolute;
bottom: calc(100% + 4px);
left: 0;
z-index: 50;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius-md, 8px);
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
min-width: 180px;
overflow: hidden;
}
.scope-option {
display: block;
width: 100%;
text-align: left;
padding: 0.5rem 0.85rem;
font-size: 0.82rem;
color: var(--color-text-muted);
background: transparent;
border: none;
cursor: pointer;
transition: background 0.1s, color 0.1s;
}
.scope-option:hover {
background: var(--color-bg-secondary);
color: var(--color-text);
}
.scope-option.active {
color: var(--color-primary);
font-weight: 600;
}
.conv-list {