Add read-only initial context for chat sessions for TUI and web app

This commit is contained in:
Yiorgis Gozadinos 2026-01-27 13:59:00 +02:00
parent bb634e68a4
commit 614c34b98f
No known key found for this signature in database
11 changed files with 282 additions and 51 deletions

View file

@ -1,6 +1,16 @@
# Changelog
## [Unreleased]
### Added
- **Read-Only Initial Context**: Initial context is now locked after the first message, providing consistent session context
- Chat TUI: `--initial-context` CLI option sets background context for the session
- Context can be edited via command palette before the first message is sent
- After first message, context becomes read-only (view only)
- Clearing chat resets context to CLI value and unlocks editing
- Web app: Memory panel now serves dual purpose - edit initial context before first message, view session context after
- Agent uses `initial_context` as fallback when `session_context` is empty
## [0.27.1] - 2026-01-27
### Added

View file

@ -83,6 +83,7 @@ async def stream_chat(request: Request) -> Response:
initial_qa_history: list[QAResponse] = []
session_id: str | None = None
document_filter: list[str] = []
initial_context: str | None = None
state = getattr(run_input, "state", None)
if state:
chat_state = state.get(AGUI_STATE_KEY, state)
@ -92,6 +93,7 @@ async def stream_chat(request: Request) -> Response:
]
session_id = chat_state.get("session_id")
document_filter = chat_state.get("document_filter", [])
initial_context = chat_state.get("initial_context")
deps = ChatDeps(
client=get_client(db_path),
@ -99,6 +101,7 @@ async def stream_chat(request: Request) -> Response:
session_state=ChatSessionState(
qa_history=initial_qa_history,
document_filter=document_filter,
initial_context=initial_context,
**({"session_id": session_id} if session_id else {}),
),
state_key=AGUI_STATE_KEY,

View file

@ -42,6 +42,7 @@ interface SessionContext {
interface ChatSessionState {
session_id: string;
initial_context: string | null;
citations: Citation[];
qa_history: QAResponse[];
session_context: SessionContext | null;
@ -398,6 +399,7 @@ function ChatContentInner() {
initialState: {
[AGUI_STATE_KEY]: {
session_id: "",
initial_context: null,
citations: [],
qa_history: [],
session_context: null,
@ -407,9 +409,14 @@ function ChatContentInner() {
},
);
// Extract session context and document filter from agent state
// Extract session context, document filter, and initial context from agent state
const sessionContext = agentState?.[AGUI_STATE_KEY]?.session_context ?? null;
const documentFilter = agentState?.[AGUI_STATE_KEY]?.document_filter ?? [];
const initialContext = agentState?.[AGUI_STATE_KEY]?.initial_context ?? "";
// Context is locked after first message (qa_history has entries)
const isContextLocked =
(agentState?.[AGUI_STATE_KEY]?.qa_history?.length ?? 0) > 0;
const handleFilterApply = (selected: string[]) => {
setAgentState({
@ -417,6 +424,7 @@ function ChatContentInner() {
[AGUI_STATE_KEY]: {
...agentState?.[AGUI_STATE_KEY],
session_id: agentState?.[AGUI_STATE_KEY]?.session_id ?? "",
initial_context: agentState?.[AGUI_STATE_KEY]?.initial_context ?? null,
citations: agentState?.[AGUI_STATE_KEY]?.citations ?? [],
qa_history: agentState?.[AGUI_STATE_KEY]?.qa_history ?? [],
session_context: agentState?.[AGUI_STATE_KEY]?.session_context ?? null,
@ -425,6 +433,22 @@ function ChatContentInner() {
});
};
const handleInitialContextChange = (value: string) => {
if (isContextLocked) return;
setAgentState({
...agentState,
[AGUI_STATE_KEY]: {
...agentState?.[AGUI_STATE_KEY],
session_id: agentState?.[AGUI_STATE_KEY]?.session_id ?? "",
initial_context: value || null,
citations: agentState?.[AGUI_STATE_KEY]?.citations ?? [],
qa_history: agentState?.[AGUI_STATE_KEY]?.qa_history ?? [],
session_context: agentState?.[AGUI_STATE_KEY]?.session_context ?? null,
document_filter: agentState?.[AGUI_STATE_KEY]?.document_filter ?? [],
},
});
};
useCoAgentStateRender<AgentState>({
name: "chat_agent",
render: ({ state }) => {
@ -568,12 +592,16 @@ function ChatContentInner() {
</button>
<button
type="button"
className={`header-btn ${sessionContext?.summary ? "has-content" : ""}`}
className={`header-btn ${initialContext || sessionContext?.summary ? "has-content" : ""}`}
onClick={() => setContextOpen(true)}
title={
sessionContext?.summary
? "View session context"
: "No session context yet"
isContextLocked
? sessionContext?.summary
? "View session context"
: "No session context yet"
: initialContext
? "Edit initial context"
: "Set initial context"
}
>
<BrainIcon />
@ -596,6 +624,9 @@ function ChatContentInner() {
isOpen={contextOpen}
onClose={() => setContextOpen(false)}
sessionContext={sessionContext}
initialContext={initialContext}
onInitialContextChange={handleInitialContextChange}
isLocked={isContextLocked}
/>
<DocumentFilter
isOpen={filterOpen}

View file

@ -1,6 +1,6 @@
"use client";
import { useCallback, useId } from "react";
import { useCallback, useEffect, useId, useState } from "react";
interface SessionContext {
summary: string;
@ -11,6 +11,9 @@ interface ContextPanelProps {
isOpen: boolean;
onClose: () => void;
sessionContext: SessionContext | null;
initialContext?: string;
onInitialContextChange?: (value: string) => void;
isLocked?: boolean;
}
function formatRelativeTime(isoString: string): string {
@ -62,8 +65,18 @@ export default function ContextPanel({
isOpen,
onClose,
sessionContext,
initialContext = "",
onInitialContextChange,
isLocked = false,
}: ContextPanelProps) {
const titleId = useId();
const [localValue, setLocalValue] = useState(initialContext);
useEffect(() => {
if (isOpen) {
setLocalValue(initialContext);
}
}, [isOpen, initialContext]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
@ -74,11 +87,18 @@ export default function ContextPanel({
[onClose],
);
const handleSave = useCallback(() => {
onInitialContextChange?.(localValue);
onClose();
}, [localValue, onInitialContextChange, onClose]);
if (!isOpen) {
return null;
}
const hasContext = sessionContext?.summary && sessionContext.summary.trim();
const hasSessionContext = sessionContext?.summary?.trim();
// Show edit mode when: not locked AND no session context yet
const isEditMode = !isLocked && !hasSessionContext;
return (
<>
@ -146,6 +166,24 @@ export default function ContextPanel({
max-height: 400px;
overflow-y: auto;
}
.context-textarea {
width: 100%;
min-height: 200px;
padding: 1rem;
font-size: 0.875rem;
line-height: 1.6;
color: #334155;
background: white;
border: 1px solid #e2e8f0;
border-radius: 8px;
resize: vertical;
font-family: inherit;
}
.context-textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.context-empty {
display: flex;
flex-direction: column;
@ -175,13 +213,15 @@ export default function ContextPanel({
font-size: 0.75rem;
color: #94a3b8;
}
.context-btn-close {
.context-btn {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
border-radius: 6px;
cursor: pointer;
transition: all 0.15s;
}
.context-btn-close {
background: white;
color: #475569;
border: 1px solid #e2e8f0;
@ -190,6 +230,20 @@ export default function ContextPanel({
background: #f8fafc;
border-color: #cbd5e1;
}
.context-btn-save {
background: #3b82f6;
color: white;
border: 1px solid #3b82f6;
margin-left: 0.5rem;
}
.context-btn-save:hover {
background: #2563eb;
border-color: #2563eb;
}
.context-footer-buttons {
display: flex;
gap: 0.5rem;
}
`}</style>
<div
className="context-modal-overlay"
@ -210,14 +264,22 @@ export default function ContextPanel({
<BrainIcon />
</div>
<h2 id={titleId} className="context-modal-title">
Session Context
{isEditMode ? "Initial Context" : "Session Context"}
</h2>
</div>
<p className="context-modal-description">
This is what the assistant has learned from your conversation so
far. It uses this context to provide more relevant answers.
{isEditMode
? "Set background context to guide the conversation. This will be locked after you send your first message."
: "This is what the assistant has learned from your conversation so far. It uses this context to provide more relevant answers."}
</p>
{hasContext ? (
{isEditMode ? (
<textarea
className="context-textarea"
placeholder="Enter any background context or instructions for the assistant..."
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
/>
) : hasSessionContext ? (
<div className="context-content">{sessionContext.summary}</div>
) : (
<div className="context-empty">
@ -235,13 +297,24 @@ export default function ContextPanel({
? `Last updated: ${formatRelativeTime(sessionContext.last_updated)}`
: ""}
</span>
<button
type="button"
className="context-btn-close"
onClick={onClose}
>
Close
</button>
<div className="context-footer-buttons">
<button
type="button"
className="context-btn context-btn-close"
onClick={onClose}
>
{isEditMode ? "Cancel" : "Close"}
</button>
{isEditMode && (
<button
type="button"
className="context-btn context-btn-save"
onClick={handleSave}
>
Save
</button>
)}
</div>
</div>
</div>
</div>

View file

@ -31,8 +31,8 @@ Press `Ctrl+P` to open the command palette:
| Command | Description |
|---------|-------------|
| Memory | Edit initial context (before first message) or view session context (after) |
| Filter documents | Select documents to restrict searches |
| Show context | View current session context |
| Show database info | View document/chunk counts and storage info |
| Visual grounding | View chunk source location in document |
| Clear chat | Clear chat history and reset session |
@ -43,7 +43,9 @@ Press `Ctrl+P` to open the command palette:
- Previous Q/A pairs are used as context for follow-up questions
- Citations are tracked per response and can be inspected
- Document filter restricts all searches to selected documents
- Clearing chat resets session state but preserves document filter
- Initial context can be set via CLI (`--initial-context`) or command palette
- Initial context is editable until the first message is sent, then becomes read-only
- Clearing chat resets session state, restores CLI-provided context, and unlocks editing
## Web Application
@ -55,8 +57,7 @@ Browser-based conversational RAG with a CopilotKit frontend.
- Expandable citations with source documents, pages, and headings
- Visual grounding to view chunk source locations in documents
- Document filter to restrict searches to selected documents
- Session context that summarizes conversation history
- Settings panel for background context configuration
- Memory panel: set initial context before first message, view session context after
### Quick Start

View file

@ -178,10 +178,9 @@ haiku-rag chat
haiku-rag chat --db /path/to/database.lancedb
```
Provide background context for the conversation:
Provide initial background context for the conversation:
```bash
haiku-rag chat --context "Focus on Python programming concepts"
haiku-rag chat --context-file domain-context.txt
haiku-rag chat --initial-context "Focus on Python programming concepts"
```
!!! note
@ -193,12 +192,18 @@ The chat interface provides:
- Expandable citations with source metadata
- Session memory for context-aware follow-up questions
- Visual grounding to inspect chunk source locations
- Background context that persists across the entire conversation
- Initial context that can be edited before the first message
**Initial Context Behavior:**
- Edit initial context via command palette before sending your first message
- Once you send a message, initial context becomes read-only
- The agent uses initial context as a starting point for session summarization
- Clearing chat resets to the CLI-provided context and unlocks editing
Flags:
- `--context`: Background context for the conversation
- `--context-file`: Path to a file containing background context
- `--initial-context`: Initial background context for the conversation (editable until first message)
See [Applications](apps.md#chat-tui) for keyboard shortcuts and features.

View file

@ -133,8 +133,10 @@ class ChatDeps:
)
if "citation_registry" in state_data:
self.session_state.citation_registry = state_data["citation_registry"]
# NOTE: session_context intentionally NOT updated from client
# The agent owns this via server-side cache
if "initial_context" in state_data:
self.session_state.initial_context = state_data.get("initial_context")
# NOTE: session_context is server-managed; we don't accept it from the client
# to maintain server-side ownership of conversation summarization
@dataclass

View file

@ -6,6 +6,7 @@ def run_chat(
db_path: Path | None = None,
read_only: bool = False,
before: datetime | None = None,
initial_context: str | None = None,
) -> None:
"""Run the chat TUI.
@ -13,6 +14,7 @@ def run_chat(
db_path: Path to the LanceDB database. If None, uses default from config.
read_only: Whether to open the database in read-only mode.
before: Query database as it existed before this datetime.
initial_context: Initial background context to provide to the conversation.
"""
try:
from haiku.rag.chat.app import ChatApp
@ -31,5 +33,6 @@ def run_chat(
db_path,
read_only=read_only,
before=before,
initial_context=initial_context,
)
app.run()

View file

@ -88,11 +88,14 @@ class ChatApp(App):
db_path: Path,
read_only: bool = False,
before: datetime | None = None,
initial_context: str | None = None,
) -> None:
super().__init__()
self.db_path = db_path
self.read_only = read_only
self.before = before
self._initial_context = initial_context
self._context_locked = False
self.client: HaikuRAG | None = None
self.config = get_config()
self.agent: Agent[ChatDeps, str] | None = None
@ -135,8 +138,8 @@ class ChatApp(App):
self.action_show_info,
)
yield SystemCommand(
"Session context",
"Show current session context",
"Memory",
"View/edit context (editable before first message)",
self.action_show_context,
)
@ -153,6 +156,7 @@ class ChatApp(App):
# Create agent and session state
self.agent = create_chat_agent(self.config)
self.session_state = ChatSessionState(
initial_context=self._initial_context,
document_filter=self._document_filter,
)
@ -216,6 +220,9 @@ class ChatApp(App):
if not self.client or not self.agent:
return
# Lock context after first message
self._context_locked = True
# Clear the input
event.input.clear()
@ -297,8 +304,10 @@ class ChatApp(App):
await chat_history.clear_messages()
self._last_citations.clear()
self._message_history.clear()
# Reset session state for fresh conversation (preserve document filter)
# Reset context lock and session state (reset to CLI value)
self._context_locked = False
self.session_state = ChatSessionState(
initial_context=self._initial_context,
document_filter=self._document_filter,
)
@ -349,10 +358,17 @@ class ChatApp(App):
await self.push_screen(InfoModal(self.client, self.db_path))
async def action_show_context(self) -> None:
"""Show current session context in a modal."""
"""Show context modal (edit initial context or view session context)."""
from haiku.rag.chat.widgets.context_modal import ContextModal
await self.push_screen(ContextModal(self.session_state))
await self.push_screen(
ContextModal(self.session_state, is_locked=self._context_locked)
)
def on_context_modal_context_updated(self, event: Any) -> None:
"""Handle context updates from modal."""
if self.session_state and not self._context_locked:
self.session_state.initial_context = event.context or None
def on_citation_widget_selected(self, event: CitationWidget.Selected) -> None:
"""Handle citation selection."""

View file

@ -2,20 +2,25 @@ from typing import TYPE_CHECKING
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import Vertical, VerticalScroll
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.message import Message
from textual.screen import ModalScreen
from textual.widgets import Markdown, Static
from textual.widgets import Button, Markdown, Static, TextArea
if TYPE_CHECKING:
from haiku.rag.agents.chat.state import ChatSessionState
class ContextModal(ModalScreen): # pragma: no cover
"""Modal screen for displaying session context."""
"""Modal screen for viewing/editing context.
Before first message (not locked, no session context): Edit initial context
After first message (locked or has session context): View session context
"""
BINDINGS = [
Binding("escape", "dismiss", "Close", show=True),
Binding("ctrl+o", "dismiss", "Close", show=True),
Binding("escape", "cancel", "Close", show=False),
Binding("ctrl+o", "cancel", "Close", show=False),
]
CSS = """
@ -25,11 +30,9 @@ class ContextModal(ModalScreen): # pragma: no cover
}
#context-container {
width: auto;
min-width: 40;
max-width: 80;
width: 70;
height: auto;
max-height: 20;
max-height: 32;
background: $surface;
border: tall $primary;
padding: 1 2;
@ -40,23 +43,87 @@ class ContextModal(ModalScreen): # pragma: no cover
margin-bottom: 1;
}
#context-description {
height: auto;
margin-bottom: 1;
color: $text-muted;
}
#context-editor {
height: 12;
min-height: 8;
max-height: 16;
}
#context-content {
height: 1fr;
max-height: 16;
scrollbar-gutter: stable;
}
#button-row {
height: auto;
margin-top: 1;
align: right middle;
}
#button-row Button {
margin-left: 1;
}
"""
def __init__(self, session_state: "ChatSessionState | None"):
class ContextUpdated(Message):
"""Emitted when the context is saved."""
def __init__(self, context: str) -> None:
super().__init__()
self.context = context
def __init__(
self, session_state: "ChatSessionState | None", is_locked: bool = False
) -> None:
super().__init__()
self.session_state = session_state
self._is_locked = is_locked
@property
def _is_edit_mode(self) -> bool:
"""Edit mode when not locked and no session context yet."""
has_session_context = (
self.session_state
and self.session_state.session_context
and self.session_state.session_context.summary
)
return not self._is_locked and not has_session_context
def compose(self) -> ComposeResult:
with Vertical(id="context-container"):
yield Static("[bold]Session Context[/bold]", id="context-header")
with VerticalScroll(id="context-content"):
yield Markdown(self._get_content())
if self._is_edit_mode:
yield Static("[bold]Initial Context[/bold]", id="context-header")
yield Static(
"Set background context to guide the conversation. "
"This will be locked after you send your first message.",
id="context-description",
)
initial_value = ""
if self.session_state and self.session_state.initial_context:
initial_value = self.session_state.initial_context
yield TextArea(initial_value, id="context-editor")
with Horizontal(id="button-row"):
yield Button("Cancel", id="cancel-btn", variant="default")
yield Button("Save", id="save-btn", variant="primary")
else:
yield Static("[bold]Session Context[/bold]", id="context-header")
yield Static(
"What the assistant has learned from your conversation.",
id="context-description",
)
with VerticalScroll(id="context-content"):
yield Markdown(self._get_session_content())
with Horizontal(id="button-row"):
yield Button("Close", id="cancel-btn", variant="primary")
def _get_content(self) -> str:
def _get_session_content(self) -> str:
if not self.session_state:
return "*No session state.*"
@ -72,5 +139,19 @@ class ContextModal(ModalScreen): # pragma: no cover
return f"**Last updated:** {updated}\n\n---\n\n{ctx.summary}"
async def action_dismiss(self, result=None) -> None:
def on_button_pressed(self, event: Button.Pressed) -> None:
"""Handle button presses."""
if event.button.id == "cancel-btn":
self.action_cancel()
elif event.button.id == "save-btn":
self.action_save()
def action_cancel(self) -> None:
"""Cancel and close without saving."""
self.app.pop_screen()
def action_save(self) -> None:
"""Save context and close."""
editor = self.query_one("#context-editor", TextArea)
self.post_message(self.ContextUpdated(editor.text))
self.app.pop_screen()

View file

@ -593,6 +593,11 @@ def chat(
"--db",
help="Path to the LanceDB database file",
),
initial_context: str | None = typer.Option(
None,
"--initial-context",
help="Initial background context to provide to the conversation",
),
):
"""Launch the chat TUI for conversational RAG."""
from haiku.rag.chat import run_chat
@ -603,6 +608,7 @@ def chat(
db_path,
read_only=_read_only,
before=_before,
initial_context=initial_context,
)