feat: implement R42 chat infrastructure with Anthropic API integration and custom design system
This commit is contained in:
137
src/components/chat/useChat.js
Normal file
137
src/components/chat/useChat.js
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { storage } from '../../lib/storage';
|
||||
import { anthropicApi } from '../../lib/api';
|
||||
import { buildKbContext, validateDelta, deltaKey } from './rag';
|
||||
import { buildSystemPrompt, PROPOSE_GRAPH_DELTA_TOOL, STRINGS } from './prompts';
|
||||
|
||||
const MAX_HISTORY = 50;
|
||||
|
||||
/**
|
||||
* Conversation hook for R42.
|
||||
* Owns the message list, persists to chat:thread:{userId}, calls Anthropic,
|
||||
* and surfaces validated graph delta suggestions inline.
|
||||
*
|
||||
* Note: buildKbContext is async (reads PocketBase), so send() is fully async.
|
||||
*/
|
||||
export function useChat({ user, isAdmin }) {
|
||||
const threadKey = user ? `chat:thread:${user.id}` : null;
|
||||
const [messages, setMessages] = useState([]);
|
||||
const [isThinking, setIsThinking] = useState(false);
|
||||
const [errored, setErrored] = useState(false);
|
||||
const seenDeltaKeys = useRef(new Set());
|
||||
|
||||
// Load persisted thread + seed greeting
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
const stored = storage.get(threadKey, null);
|
||||
if (stored && Array.isArray(stored) && stored.length) {
|
||||
setMessages(stored);
|
||||
for (const m of stored) {
|
||||
if (m.suggestion?.key) seenDeltaKeys.current.add(m.suggestion.key);
|
||||
}
|
||||
} else {
|
||||
setMessages([
|
||||
{
|
||||
id: `m_${Date.now()}`,
|
||||
role: 'assistant',
|
||||
content: STRINGS.greeting(user.name || 'daar'),
|
||||
ts: Date.now(),
|
||||
},
|
||||
]);
|
||||
}
|
||||
}, [user, threadKey]);
|
||||
|
||||
// Persist on change
|
||||
useEffect(() => {
|
||||
if (!threadKey) return;
|
||||
const capped = messages.slice(-MAX_HISTORY);
|
||||
storage.set(threadKey, capped);
|
||||
}, [messages, threadKey]);
|
||||
|
||||
const updateMessage = useCallback((id, patch) => {
|
||||
setMessages(prev => prev.map(m => (m.id === id ? { ...m, ...patch } : m)));
|
||||
}, []);
|
||||
|
||||
const send = useCallback(async (text) => {
|
||||
const trimmed = (text || '').trim();
|
||||
if (!trimmed || !user) return;
|
||||
|
||||
const userMsg = {
|
||||
id: `m_${Date.now()}_u`,
|
||||
role: 'user',
|
||||
content: trimmed,
|
||||
ts: Date.now(),
|
||||
};
|
||||
const next = [...messages, userMsg];
|
||||
setMessages(next);
|
||||
setIsThinking(true);
|
||||
setErrored(false);
|
||||
|
||||
try {
|
||||
const { context: kbContext, topics: kbTopics } = await buildKbContext(trimmed);
|
||||
const systemPrompt = buildSystemPrompt({
|
||||
userName: user.name || 'daar',
|
||||
isAdmin,
|
||||
kbContext,
|
||||
});
|
||||
|
||||
// Strip UI-only fields before sending to Anthropic
|
||||
const apiMessages = next
|
||||
.filter(m => m.role === 'user' || m.role === 'assistant')
|
||||
.map(m => ({ role: m.role, content: m.content }));
|
||||
|
||||
const response = await anthropicApi.chat(systemPrompt, apiMessages, {
|
||||
tools: [PROPOSE_GRAPH_DELTA_TOOL],
|
||||
});
|
||||
|
||||
let textOut = '';
|
||||
let suggestion = null;
|
||||
for (const block of response.content || []) {
|
||||
if (block.type === 'text') {
|
||||
textOut += (textOut ? '\n' : '') + (block.text || '');
|
||||
} else if (block.type === 'tool_use' && block.name === PROPOSE_GRAPH_DELTA_TOOL.name) {
|
||||
const validated = validateDelta(block.input, kbTopics);
|
||||
if (validated) {
|
||||
const key = deltaKey(validated);
|
||||
if (!seenDeltaKeys.current.has(key)) {
|
||||
seenDeltaKeys.current.add(key);
|
||||
suggestion = { ...validated, key, status: 'pending' };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const assistantMsg = {
|
||||
id: `m_${Date.now()}_a`,
|
||||
role: 'assistant',
|
||||
content: textOut || (suggestion ? STRINGS.suggestionTitle : STRINGS.errorGeneric),
|
||||
ts: Date.now(),
|
||||
...(suggestion ? { suggestion } : {}),
|
||||
};
|
||||
setMessages(prev => [...prev, assistantMsg]);
|
||||
} catch (e) {
|
||||
console.error('[R42] chat error', e);
|
||||
setErrored(true);
|
||||
const isKey = /api key/i.test(e?.message || '');
|
||||
setMessages(prev => [
|
||||
...prev,
|
||||
{
|
||||
id: `m_${Date.now()}_e`,
|
||||
role: 'error',
|
||||
content: isKey ? STRINGS.errorNoKey : STRINGS.errorGeneric,
|
||||
ts: Date.now(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsThinking(false);
|
||||
}
|
||||
}, [messages, user, isAdmin]);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isThinking,
|
||||
errored,
|
||||
send,
|
||||
updateMessage,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user