refactor: remove Anthropic API integration, CLI agent only (ADR-0028)
Remove AIChatPanel, useAIChat hook, Rust ai_chat command, and anthropic_key from settings. AI is now exclusively via Claude CLI subprocess (AiPanel). Simplifies codebase and eliminates API key management for users. ADR-0027 superseded. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,192 +0,0 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { renderHook, act } from '@testing-library/react'
|
||||
|
||||
// Capture what streamClaudeChat receives
|
||||
const streamClaudeChatMock = vi.fn<
|
||||
Parameters<typeof import('../utils/ai-chat').streamClaudeChat>,
|
||||
ReturnType<typeof import('../utils/ai-chat').streamClaudeChat>
|
||||
>()
|
||||
|
||||
vi.mock('../utils/ai-chat', async () => {
|
||||
const actual = await vi.importActual<typeof import('../utils/ai-chat')>('../utils/ai-chat')
|
||||
return {
|
||||
...actual,
|
||||
streamClaudeChat: (...args: Parameters<typeof actual.streamClaudeChat>) => {
|
||||
streamClaudeChatMock(...args)
|
||||
// Simulate async: emit text, then done
|
||||
const callbacks = args[3]
|
||||
setTimeout(() => {
|
||||
callbacks.onText('mock response')
|
||||
callbacks.onDone()
|
||||
}, 10)
|
||||
return Promise.resolve('')
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import { useAIChat } from './useAIChat'
|
||||
|
||||
beforeEach(() => {
|
||||
streamClaudeChatMock.mockClear()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useAIChat', () => {
|
||||
it('sends first message as raw text without history', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(1)
|
||||
const [message, , sessionId] = streamClaudeChatMock.mock.calls[0]
|
||||
// First message: raw text, no history wrapping, no session_id
|
||||
expect(message).toBe('hello')
|
||||
expect(sessionId).toBeUndefined()
|
||||
})
|
||||
|
||||
it('embeds conversation history in second message', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// First exchange
|
||||
act(() => { result.current.sendMessage('What is 2+2?') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Second message — should include history from first exchange
|
||||
act(() => { result.current.sendMessage('What is that times 3?') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(2)
|
||||
const [message] = streamClaudeChatMock.mock.calls[1]
|
||||
expect(message).toContain('<conversation_history>')
|
||||
expect(message).toContain('What is 2+2?')
|
||||
expect(message).toContain('mock response')
|
||||
expect(message).toContain('What is that times 3?')
|
||||
})
|
||||
|
||||
it('accumulates history across multiple exchanges', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Exchange 1
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 2
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Exchange 3
|
||||
act(() => { result.current.sendMessage('Q3') })
|
||||
|
||||
expect(streamClaudeChatMock).toHaveBeenCalledTimes(3)
|
||||
|
||||
// First call: no history
|
||||
expect(streamClaudeChatMock.mock.calls[0][0]).toBe('Q1')
|
||||
// Second call: history from first exchange
|
||||
const secondMsg = streamClaudeChatMock.mock.calls[1][0]
|
||||
expect(secondMsg).toContain('Q1')
|
||||
expect(secondMsg).toContain('Q2')
|
||||
// Third call: history from both exchanges
|
||||
const thirdMsg = streamClaudeChatMock.mock.calls[2][0]
|
||||
expect(thirdMsg).toContain('Q1')
|
||||
expect(thirdMsg).toContain('Q2')
|
||||
expect(thirdMsg).toContain('Q3')
|
||||
})
|
||||
|
||||
it('never passes session_id (no --resume)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
act(() => { result.current.sendMessage('Q1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
act(() => { result.current.sendMessage('Q2') })
|
||||
|
||||
// All calls should have undefined session_id
|
||||
for (const call of streamClaudeChatMock.mock.calls) {
|
||||
expect(call[2]).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('resets history after clearConversation', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Build up some history
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Clear
|
||||
act(() => { result.current.clearConversation() })
|
||||
expect(result.current.messages).toHaveLength(0)
|
||||
|
||||
// Next message should have no history
|
||||
act(() => { result.current.sendMessage('fresh start') })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
expect(lastCall[0]).toBe('fresh start') // raw text, no history wrapping
|
||||
expect(lastCall[2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('includes system prompt on every message when context notes exist', async () => {
|
||||
const notes = [{ path: 'note.md', title: 'Test Note' }] as import('../types').VaultEntry[]
|
||||
|
||||
const { result } = renderHook(() => useAIChat(notes))
|
||||
|
||||
// First message
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Second message
|
||||
act(() => { result.current.sendMessage('follow up') })
|
||||
|
||||
// Both calls should have system prompt
|
||||
const firstSystemPrompt = streamClaudeChatMock.mock.calls[0][1]
|
||||
expect(firstSystemPrompt).toBeTruthy()
|
||||
expect(firstSystemPrompt).toContain('Test Note')
|
||||
|
||||
const secondSystemPrompt = streamClaudeChatMock.mock.calls[1][1]
|
||||
expect(secondSystemPrompt).toBeTruthy()
|
||||
expect(secondSystemPrompt).toContain('Test Note')
|
||||
})
|
||||
|
||||
it('retries with correct history (excludes retried exchange)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// First exchange
|
||||
act(() => { result.current.sendMessage('hello') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
|
||||
// Retry the assistant response (index 1)
|
||||
act(() => { result.current.retryMessage(1) })
|
||||
|
||||
const lastCall = streamClaudeChatMock.mock.calls[streamClaudeChatMock.mock.calls.length - 1]
|
||||
// Should re-send the user message with no history (retrying first exchange)
|
||||
expect(lastCall[0]).toBe('hello')
|
||||
expect(lastCall[2]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('reads latest messages from ref (not stale closure)', async () => {
|
||||
const { result } = renderHook(() => useAIChat([]))
|
||||
|
||||
// Send first message
|
||||
act(() => { result.current.sendMessage('msg1') })
|
||||
await act(async () => { vi.advanceTimersByTime(50) })
|
||||
|
||||
// Verify messages state is correct
|
||||
expect(result.current.messages).toHaveLength(2)
|
||||
expect(result.current.messages[0].content).toBe('msg1')
|
||||
expect(result.current.messages[1].content).toBe('mock response')
|
||||
|
||||
// Send second message — the ref should have the latest messages
|
||||
// even without depending on messages in useCallback deps
|
||||
act(() => { result.current.sendMessage('msg2') })
|
||||
|
||||
const secondCall = streamClaudeChatMock.mock.calls[1]
|
||||
const sentMessage = secondCall[0]
|
||||
// Must contain history from first exchange
|
||||
expect(sentMessage).toContain('[user]: msg1')
|
||||
expect(sentMessage).toContain('[assistant]: mock response')
|
||||
expect(sentMessage).toContain('[user]: msg2')
|
||||
})
|
||||
})
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* Custom hook encapsulating AI chat state and message handling.
|
||||
* Uses Claude CLI subprocess via Tauri for streaming responses.
|
||||
*
|
||||
* Conversation continuity embeds prior exchanges in each prompt
|
||||
* (each CLI invocation is a fresh subprocess with no memory).
|
||||
* History is trimmed to MAX_HISTORY_TOKENS, dropping oldest first.
|
||||
*
|
||||
* Uses a ref (messagesRef) to read the latest messages in callbacks,
|
||||
* avoiding stale closure issues with React's useCallback memoization.
|
||||
*/
|
||||
import { useState, useCallback, useRef, useEffect } from 'react'
|
||||
import type { VaultEntry } from '../types'
|
||||
import {
|
||||
type ChatMessage, type ChatStreamCallbacks, nextMessageId,
|
||||
buildSystemPrompt, streamClaudeChat,
|
||||
trimHistory, formatMessageWithHistory, MAX_HISTORY_TOKENS,
|
||||
} from '../utils/ai-chat'
|
||||
|
||||
interface ChatStreamRefs {
|
||||
abortRef: React.RefObject<boolean>
|
||||
setMessages: React.Dispatch<React.SetStateAction<ChatMessage[]>>
|
||||
setStreamingContent: React.Dispatch<React.SetStateAction<string>>
|
||||
setIsStreaming: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}
|
||||
|
||||
/** Create stream callbacks that accumulate text and update React state. */
|
||||
function makeStreamCallbacks(
|
||||
refs: ChatStreamRefs,
|
||||
): { callbacks: ChatStreamCallbacks; getAccumulated: () => string } {
|
||||
let accumulated = ''
|
||||
const callbacks: ChatStreamCallbacks = {
|
||||
onText: (chunk) => {
|
||||
if (refs.abortRef.current) return
|
||||
accumulated += chunk
|
||||
refs.setStreamingContent(accumulated)
|
||||
},
|
||||
onError: (error) => {
|
||||
if (refs.abortRef.current) return
|
||||
refs.setMessages(prev => [...prev, { role: 'assistant', content: `Error: ${error}`, id: nextMessageId() }])
|
||||
refs.setStreamingContent('')
|
||||
refs.setIsStreaming(false)
|
||||
},
|
||||
onDone: () => {
|
||||
if (refs.abortRef.current) return
|
||||
if (accumulated) {
|
||||
refs.setMessages(prev => [...prev, { role: 'assistant', content: accumulated, id: nextMessageId() }])
|
||||
}
|
||||
refs.setStreamingContent('')
|
||||
refs.setIsStreaming(false)
|
||||
},
|
||||
}
|
||||
return { callbacks, getAccumulated: () => accumulated }
|
||||
}
|
||||
|
||||
export function useAIChat(
|
||||
contextNotes: VaultEntry[],
|
||||
) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||
const [isStreaming, setIsStreaming] = useState(false)
|
||||
const [streamingContent, setStreamingContent] = useState('')
|
||||
const abortRef = useRef(false)
|
||||
const isStreamingRef = useRef(false)
|
||||
const messagesRef = useRef<ChatMessage[]>([])
|
||||
|
||||
// Keep refs in sync with state — runs after render, before next user interaction.
|
||||
useEffect(() => { messagesRef.current = messages }, [messages])
|
||||
useEffect(() => { isStreamingRef.current = isStreaming }, [isStreaming])
|
||||
|
||||
/** Internal: send text, reading history from the messages ref. */
|
||||
const doSend = useCallback((text: string, historyOverride?: ChatMessage[]) => {
|
||||
if (!text.trim() || isStreamingRef.current) return
|
||||
|
||||
const history = historyOverride ?? messagesRef.current
|
||||
|
||||
setMessages(prev => [...prev, { role: 'user', content: text.trim(), id: nextMessageId() }])
|
||||
setIsStreaming(true)
|
||||
setStreamingContent('')
|
||||
abortRef.current = false
|
||||
|
||||
// Always include system prompt (each request is a fresh subprocess).
|
||||
const systemPrompt = buildSystemPrompt(contextNotes).prompt || undefined
|
||||
|
||||
// Embed conversation history in the prompt for continuity.
|
||||
const trimmedHistory = trimHistory(history, MAX_HISTORY_TOKENS)
|
||||
const formattedMessage = formatMessageWithHistory(trimmedHistory, text.trim())
|
||||
|
||||
const { callbacks } = makeStreamCallbacks({
|
||||
abortRef, setMessages, setStreamingContent, setIsStreaming,
|
||||
})
|
||||
|
||||
streamClaudeChat(formattedMessage, systemPrompt, undefined, callbacks)
|
||||
.catch(() => { /* errors forwarded via onError */ })
|
||||
}, [contextNotes])
|
||||
|
||||
const sendMessage = useCallback((text: string) => {
|
||||
doSend(text)
|
||||
}, [doSend])
|
||||
|
||||
const clearConversation = useCallback(() => {
|
||||
abortRef.current = true
|
||||
setMessages([])
|
||||
setIsStreaming(false)
|
||||
setStreamingContent('')
|
||||
}, [])
|
||||
|
||||
const retryMessage = useCallback((msgIndex: number) => {
|
||||
const currentMessages = messagesRef.current
|
||||
const userMsgIndex = msgIndex - 1
|
||||
if (userMsgIndex < 0) return
|
||||
const userMsg = currentMessages[userMsgIndex]
|
||||
if (userMsg.role !== 'user') return
|
||||
|
||||
const historyForRetry = currentMessages.slice(0, userMsgIndex)
|
||||
setMessages(prev => prev.slice(0, userMsgIndex))
|
||||
doSend(userMsg.content, historyForRetry)
|
||||
}, [doSend])
|
||||
|
||||
return { messages, isStreaming, streamingContent, sendMessage, clearConversation, retryMessage }
|
||||
}
|
||||
@@ -144,7 +144,7 @@ export function buildViewCommands(
|
||||
{ id: 'toggle-inspector', label: 'Toggle Properties Panel', group: 'View', keywords: ['properties', 'inspector', 'panel', 'right', 'sidebar'], enabled: true, execute: onToggleInspector },
|
||||
{ id: 'toggle-diff', label: 'Toggle Diff Mode', group: 'View', keywords: ['diff', 'changes', 'git', 'compare', 'version'], enabled: hasActiveNote && activeNoteModified, execute: () => onToggleDiff?.() },
|
||||
{ id: 'toggle-raw-editor', label: 'Toggle Raw Editor', group: 'View', keywords: ['raw', 'source', 'markdown', 'frontmatter', 'code', 'textarea'], enabled: hasActiveNote, execute: () => onToggleRawEditor?.() },
|
||||
{ id: 'toggle-ai-chat', label: 'Toggle AI Chat', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-ai-panel', label: 'Toggle AI Panel', group: 'View', shortcut: '⌘I', keywords: ['ai', 'agent', 'chat', 'assistant', 'contextual'], enabled: true, execute: () => onToggleAIChat?.() },
|
||||
{ id: 'toggle-backlinks', label: 'Toggle Backlinks', group: 'View', keywords: ['backlinks', 'references', 'links', 'mentions', 'incoming'], enabled: hasActiveNote, execute: onToggleInspector },
|
||||
{ id: 'zoom-in', label: `Zoom In (${zoomLevel}%)`, group: 'View', shortcut: '⌘=', keywords: ['zoom', 'bigger', 'larger', 'scale'], enabled: zoomLevel < 150, execute: onZoomIn },
|
||||
{ id: 'zoom-out', label: `Zoom Out (${zoomLevel}%)`, group: 'View', shortcut: '⌘-', keywords: ['zoom', 'smaller', 'scale'], enabled: zoomLevel > 80, execute: onZoomOut },
|
||||
|
||||
@@ -193,7 +193,6 @@ describe('dispatchMenuEvent', () => {
|
||||
expect(h.onToggleDiff).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// New View menu items
|
||||
it('view-toggle-ai-chat triggers toggle AI chat', () => {
|
||||
const h = makeHandlers()
|
||||
dispatchMenuEvent('view-toggle-ai-chat', h)
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Settings } from '../types'
|
||||
import { useSettings } from './useSettings'
|
||||
|
||||
const defaultSettings: Settings = {
|
||||
anthropic_key: null,
|
||||
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
@@ -18,7 +18,6 @@ const defaultSettings: Settings = {
|
||||
}
|
||||
|
||||
const savedSettings: Settings = {
|
||||
anthropic_key: 'sk-ant-test123',
|
||||
openai_key: null,
|
||||
google_key: 'AIza-test',
|
||||
github_token: null,
|
||||
@@ -71,7 +70,6 @@ describe('useSettings', () => {
|
||||
expect(result.current.loaded).toBe(true)
|
||||
})
|
||||
|
||||
expect(result.current.settings.anthropic_key).toBe('sk-ant-test123')
|
||||
expect(result.current.settings.google_key).toBe('AIza-test')
|
||||
expect(mockInvokeFn).toHaveBeenCalledWith('get_settings', {})
|
||||
})
|
||||
@@ -84,7 +82,6 @@ describe('useSettings', () => {
|
||||
})
|
||||
|
||||
const newSettings: Settings = {
|
||||
anthropic_key: 'sk-ant-new',
|
||||
openai_key: 'sk-openai-new',
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
|
||||
@@ -8,7 +8,6 @@ function tauriCall<T>(command: string, tauriArgs: Record<string, unknown>, mockA
|
||||
}
|
||||
|
||||
const EMPTY_SETTINGS: Settings = {
|
||||
anthropic_key: null,
|
||||
openai_key: null,
|
||||
google_key: null,
|
||||
github_token: null,
|
||||
|
||||
@@ -16,7 +16,7 @@ vi.mock('../lib/telemetry', () => ({
|
||||
}))
|
||||
|
||||
const baseSettings: Settings = {
|
||||
anthropic_key: null, openai_key: null, google_key: null,
|
||||
openai_key: null, google_key: null,
|
||||
github_token: null, github_username: null, auto_pull_interval_minutes: null,
|
||||
telemetry_consent: null, crash_reporting_enabled: null,
|
||||
analytics_enabled: null, anonymous_id: null, update_channel: null,
|
||||
|
||||
Reference in New Issue
Block a user