feat: add reasoning streaming, markdown response, structured context snapshot

- Rust: add ThinkingDelta event to ClaudeStreamEvent for reasoning chunks
- ai-agent.ts: forward ThinkingDelta events via onThinking callback
- useAiAgent: stream reasoning live, accumulate response internally,
  reveal as complete block on done
- AiMessage: auto-collapse reasoning when done, use MarkdownContent
  for response rendering, update tests for new behavior
- ai-context: add buildContextSnapshot() for structured JSON context
  with activeNote, openTabs, noteListFilter, vault summary

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
lucaronin
2026-03-04 12:09:02 +01:00
parent 8029e07945
commit 3ab3ca22ff
6 changed files with 177 additions and 31 deletions

View File

@@ -19,6 +19,8 @@ pub enum ClaudeStreamEvent {
Init { session_id: String },
/// Incremental text chunk.
TextDelta { text: String },
/// Incremental thinking/reasoning chunk.
ThinkingDelta { text: String },
/// A tool call started (agent mode only).
ToolStart {
tool_name: String,
@@ -402,6 +404,13 @@ where
});
}
}
Some("thinking_delta") => {
if let Some(text) = delta["thinking"].as_str() {
emit(ClaudeStreamEvent::ThinkingDelta {
text: text.to_string(),
});
}
}
Some("input_json_delta") => {
if let (Some(partial), Some(ref tid)) =
(delta["partial_json"].as_str(), &state.current_tool_id)

View File

@@ -2,15 +2,20 @@ import { describe, it, expect, vi } from 'vitest'
import { render, screen, fireEvent } from '@testing-library/react'
import { AiMessage } from './AiMessage'
vi.mock('./MarkdownContent', () => ({
MarkdownContent: ({ content }: { content: string }) => <div data-testid="markdown-content">{content}</div>,
}))
describe('AiMessage', () => {
it('renders user message', () => {
render(<AiMessage userMessage="Hello AI" actions={[]} />)
expect(screen.getByText('Hello AI')).toBeTruthy()
})
it('renders response text', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Here is the answer" />)
expect(screen.getByText('Here is the answer')).toBeTruthy()
it('renders response as markdown', () => {
render(<AiMessage userMessage="Ask" actions={[]} response="Here is the **answer**" />)
expect(screen.getByTestId('markdown-content')).toBeTruthy()
expect(screen.getByText('Here is the **answer**')).toBeTruthy()
})
it('shows undo button with response', () => {
@@ -18,23 +23,31 @@ describe('AiMessage', () => {
expect(screen.getByTestId('undo-button')).toBeTruthy()
})
it('renders reasoning toggle collapsed by default', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
it('shows reasoning expanded while streaming (reasoningDone=false)', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." reasoningDone={false} actions={[]} />)
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('expands reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking about it..." actions={[]} />)
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
expect(screen.getByText('Thinking about it...')).toBeTruthy()
})
it('collapses reasoning on second click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." actions={[]} />)
it('auto-collapses reasoning when reasoningDone=true', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone actions={[]} />)
expect(screen.getByTestId('reasoning-toggle')).toBeTruthy()
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})
it('expands collapsed reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone actions={[]} />)
// Starts collapsed (reasoningDone=true)
expect(screen.queryByTestId('reasoning-content')).toBeNull()
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
})
it('collapses expanded reasoning on toggle click', () => {
render(<AiMessage userMessage="Ask" reasoning="Thinking..." reasoningDone={false} actions={[]} />)
// Starts expanded (reasoningDone=false)
expect(screen.getByTestId('reasoning-content')).toBeTruthy()
fireEvent.click(screen.getByTestId('reasoning-toggle'))
expect(screen.queryByTestId('reasoning-content')).toBeNull()
})

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react'
import { useState, useCallback, useEffect, useRef } from 'react'
import { CaretRight, CaretDown, Brain, ArrowCounterClockwise } from '@phosphor-icons/react'
import { AiActionCard, type AiActionStatus } from './AiActionCard'
import { MarkdownContent } from './MarkdownContent'
export interface AiAction {
tool: string
@@ -15,6 +16,7 @@ export interface AiAction {
export interface AiMessageProps {
userMessage: string
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
response?: string
isStreaming?: boolean
@@ -44,6 +46,14 @@ function UserBubble({ content }: { content: string }) {
function ReasoningBlock({ text, expanded, onToggle }: {
text: string; expanded: boolean; onToggle: () => void
}) {
const contentRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (expanded && contentRef.current) {
contentRef.current.scrollTop = contentRef.current.scrollHeight
}
}, [expanded, text])
return (
<div style={{ marginBottom: 8 }}>
<button
@@ -58,8 +68,9 @@ function ReasoningBlock({ text, expanded, onToggle }: {
</button>
{expanded && (
<div
ref={contentRef}
className="text-muted-foreground"
style={{ fontSize: 12, lineHeight: 1.5, padding: '4px 0 4px 20px' }}
style={{ fontSize: 12, lineHeight: 1.5, padding: '4px 0 4px 20px', maxHeight: 200, overflowY: 'auto' }}
data-testid="reasoning-content"
>
{text}
@@ -98,7 +109,7 @@ function ActionCardsList({ actions, onOpenNote, expandedIds, onToggleExpand }: {
function ResponseBlock({ text }: { text: string }) {
return (
<div style={{ marginBottom: 4 }}>
<div style={{ fontSize: 13, lineHeight: 1.6 }}>{text}</div>
<MarkdownContent content={text} />
<button
className="flex items-center gap-1 border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
style={{ fontSize: 11, marginTop: 4 }}
@@ -123,10 +134,16 @@ function StreamingIndicator() {
)
}
export function AiMessage({ userMessage, reasoning, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
const [reasoningExpanded, setReasoningExpanded] = useState(false)
export function AiMessage({ userMessage, reasoning, reasoningDone, actions, response, isStreaming, onOpenNote }: AiMessageProps) {
// Manual override: null = follow auto behavior, true/false = user forced
const [userOverride, setUserOverride] = useState(false)
const [expandedActions, setExpandedActions] = useState<Set<string>>(new Set())
// Auto: expanded while reasoning streams, collapsed once done
// User can manually toggle to override the auto state
const autoExpanded = !reasoningDone
const reasoningExpanded = userOverride ? !autoExpanded : autoExpanded
const toggleAction = useCallback((toolId: string) => {
setExpandedActions(prev => {
const next = new Set(prev)
@@ -143,7 +160,7 @@ export function AiMessage({ userMessage, reasoning, actions, response, isStreami
<ReasoningBlock
text={reasoning}
expanded={reasoningExpanded}
onToggle={() => setReasoningExpanded(!reasoningExpanded)}
onToggle={() => setUserOverride(prev => !prev)}
/>
)}
{actions.length > 0 && (

View File

@@ -3,6 +3,9 @@
* Uses Claude CLI subprocess with MCP tools via Tauri.
*
* States: idle -> thinking -> tool-executing -> done/error
*
* Reasoning streams live while Claude thinks, then auto-collapses.
* Response text accumulates internally and is revealed as a complete block on done.
*/
import { useState, useCallback, useRef, useEffect } from 'react'
import type { AiAction } from '../components/AiMessage'
@@ -14,6 +17,7 @@ export type AgentStatus = 'idle' | 'thinking' | 'tool-executing' | 'done' | 'err
export interface AiAgentMessage {
userMessage: string
reasoning?: string
reasoningDone?: boolean
actions: AiAction[]
response?: string
isStreaming?: boolean
@@ -25,6 +29,7 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
const [status, setStatus] = useState<AgentStatus>('idle')
const abortRef = useRef({ aborted: false })
const contextRef = useRef(contextPrompt)
const responseAccRef = useRef('')
useEffect(() => {
contextRef.current = contextPrompt
}, [contextPrompt])
@@ -42,6 +47,7 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
}
abortRef.current = { aborted: false }
responseAccRef.current = ''
const messageId = nextMessageId()
setMessages(prev => [...prev, {
@@ -53,23 +59,31 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
setMessages(prev => prev.map(m => m.id === messageId ? fn(m) : m))
}
// When a contextual prompt is provided (from buildContextualPrompt),
// use it directly — it already includes the system preamble.
const markReasoningDone = () => {
update(m => m.reasoningDone ? m : { ...m, reasoningDone: true })
}
const systemPrompt = contextRef.current ?? buildAgentSystemPrompt()
await streamClaudeAgent(text.trim(), systemPrompt, vaultPath, {
onText: (text) => {
onThinking: (chunk) => {
if (abortRef.current.aborted) return
update(m => ({ ...m, response: (m.response ?? '') + text }))
update(m => ({ ...m, reasoning: (m.reasoning ?? '') + chunk }))
},
onText: (chunk) => {
if (abortRef.current.aborted) return
markReasoningDone()
responseAccRef.current += chunk
},
onToolStart: (toolName, toolId, input) => {
if (abortRef.current.aborted) return
markReasoningDone()
setStatus('tool-executing')
update(m => {
const existing = m.actions.find(a => a.toolId === toolId)
if (existing) {
// Re-emitted with input data — update the existing action
return {
...m,
actions: m.actions.map(a =>
@@ -103,10 +117,12 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
onError: (error) => {
if (abortRef.current.aborted) return
setStatus('error')
const partial = responseAccRef.current
update(m => ({
...m,
isStreaming: false,
response: (m.response ?? '') + `\nError: ${error}`,
reasoningDone: true,
response: partial ? `${partial}\n\nError: ${error}` : `Error: ${error}`,
actions: m.actions.map(a =>
a.status === 'pending' ? { ...a, status: 'error' as const } : a,
),
@@ -116,9 +132,12 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
onDone: () => {
if (abortRef.current.aborted) return
setStatus('done')
const finalResponse = responseAccRef.current || undefined
update(m => ({
...m,
isStreaming: false,
reasoningDone: true,
response: finalResponse,
actions: m.actions.map(a => a.status === 'pending' ? { ...a, status: 'done' as const } : a),
}))
},
@@ -127,6 +146,7 @@ export function useAiAgent(vaultPath: string, contextPrompt?: string) {
const clearConversation = useCallback(() => {
abortRef.current.aborted = true
responseAccRef.current = ''
setMessages([])
setStatus('idle')
}, [])

View File

@@ -24,6 +24,7 @@ export function buildAgentSystemPrompt(vaultContext?: string): string {
type ClaudeAgentStreamEvent =
| { kind: 'Init'; session_id: string }
| { kind: 'TextDelta'; text: string }
| { kind: 'ThinkingDelta'; text: string }
| { kind: 'ToolStart'; tool_name: string; tool_id: string; input?: string }
| { kind: 'ToolDone'; tool_id: string; output?: string }
| { kind: 'Result'; text: string; session_id: string }
@@ -32,6 +33,7 @@ type ClaudeAgentStreamEvent =
export interface AgentStreamCallbacks {
onText: (text: string) => void
onThinking: (text: string) => void
onToolStart: (toolName: string, toolId: string, input?: string) => void
onToolDone: (toolId: string, output?: string) => void
onError: (message: string) => void
@@ -65,6 +67,9 @@ export async function streamClaudeAgent(
case 'TextDelta':
callbacks.onText(data.text)
break
case 'ThinkingDelta':
callbacks.onThinking(data.text)
break
case 'ToolStart':
callbacks.onToolStart(data.tool_name, data.tool_id, data.input)
break

View File

@@ -1,6 +1,6 @@
/**
* AI contextual chat — builds the context note list from the active note
* and its first-degree linked notes (outgoingLinks + relationships).
* AI contextual chat — builds a structured context snapshot from the active note,
* open tabs, vault metadata, and optional explicit note references.
*/
import type { VaultEntry } from '../types'
@@ -34,19 +34,16 @@ export function collectLinkedEntries(
}
}
// outgoingLinks are raw targets (no [[ ]] wrapper)
for (const target of active.outgoingLinks) {
addTarget(target)
}
// relationships values are wikilink references like [[target]]
for (const refs of Object.values(active.relationships)) {
for (const ref of refs) {
addTarget(wikilinkTarget(ref))
}
}
// belongsTo and relatedTo are also wikilink references
for (const ref of active.belongsTo) {
addTarget(wikilinkTarget(ref))
}
@@ -57,7 +54,92 @@ export function collectLinkedEntries(
return linked
}
/** Build a contextual system prompt from the active note and its linked notes. */
/** A note reference from the user's [[wikilink]] selection in the chat input. */
export interface NoteReference {
title: string
path: string
type: string | null
}
/** Parameters for building the structured context snapshot. */
export interface ContextSnapshotParams {
activeEntry: VaultEntry
allContent: Record<string, string>
openTabs?: VaultEntry[]
noteListFilter?: { type: string | null; query: string }
entries: VaultEntry[]
references?: NoteReference[]
}
function entryFrontmatter(e: VaultEntry): Record<string, unknown> {
const fm: Record<string, unknown> = {}
if (e.isA) fm.type = e.isA
if (e.status) fm.status = e.status
if (e.owner) fm.owner = e.owner
if (e.belongsTo.length > 0) fm.belongsTo = e.belongsTo
if (e.relatedTo.length > 0) fm.relatedTo = e.relatedTo
if (Object.keys(e.relationships).length > 0) fm.relationships = e.relationships
return fm
}
/** Build a structured context snapshot as a system prompt for Claude. */
export function buildContextSnapshot(params: ContextSnapshotParams): string {
const { activeEntry, allContent, openTabs, noteListFilter, entries, references } = params
const snapshot: Record<string, unknown> = {
activeNote: {
path: activeEntry.path,
title: activeEntry.title,
type: activeEntry.isA ?? 'Note',
frontmatter: entryFrontmatter(activeEntry),
body: allContent[activeEntry.path] ?? '',
},
}
const otherTabs = openTabs?.filter(t => t.path !== activeEntry.path)
if (otherTabs && otherTabs.length > 0) {
snapshot.openTabs = otherTabs.map(t => ({
path: t.path,
title: t.title,
type: t.isA ?? 'Note',
frontmatter: entryFrontmatter(t),
}))
}
if (noteListFilter && (noteListFilter.type || noteListFilter.query)) {
snapshot.noteListFilter = noteListFilter
}
const types = new Set<string>()
for (const e of entries) {
if (e.isA) types.add(e.isA)
}
snapshot.vault = {
types: [...types].sort(),
totalNotes: entries.length,
}
if (references && references.length > 0) {
snapshot.referencedNotes = references
.filter(ref => allContent[ref.path] !== undefined)
.map(ref => ({
path: ref.path,
title: ref.title,
type: ref.type ?? 'Note',
body: allContent[ref.path] ?? '',
}))
}
const preamble = [
'You are an AI assistant integrated into Laputa, a personal knowledge management app.',
'The user is viewing a specific note. Use the structured context below to answer questions accurately.',
'You can also use MCP tools to search, read, create, or edit notes in the vault.',
].join('\n')
return `${preamble}\n\n## Context Snapshot\n\`\`\`json\n${JSON.stringify(snapshot, null, 2)}\n\`\`\``
}
/** Legacy: Build a contextual system prompt (text-based). */
export function buildContextualPrompt(
active: VaultEntry,
linkedEntries: VaultEntry[],