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:
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user