Files
homelab/integrations/nextjs/hooks.ts
T

194 lines
5.3 KiB
TypeScript

/**
* React Hooks for LLM & Grafana Integration
*
* Usage:
* import { useChat, useLLMMetrics } from '@/lib/hooks';
*
* function ChatComponent() {
* const { messages, send, isLoading } = useChat();
* // ...
* }
*/
import { useState, useCallback, useRef, useEffect } from 'react';
export interface Message {
role: 'system' | 'user' | 'assistant';
content: string;
}
export interface UseChatOptions {
model?: 'reasoning' | 'ornith:35b' | 'qwen2.5:3b-instruct';
systemPrompt?: string;
onError?: (error: Error) => void;
}
export interface UseChatReturn {
messages: Message[];
send: (content: string) => Promise<void>;
isLoading: boolean;
error: Error | null;
clear: () => void;
}
/**
* Chat hook with streaming support
*/
export function useChat(options: UseChatOptions = {}): UseChatReturn {
const { model = 'reasoning', systemPrompt, onError } = options;
const [messages, setMessages] = useState<Message[]>(
systemPrompt ? [{ role: 'system', content: systemPrompt }] : []
);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const abortRef = useRef<AbortController | null>(null);
const send = useCallback(
async (content: string) => {
const userMessage: Message = { role: 'user', content };
setMessages((prev) => [...prev, userMessage]);
setIsLoading(true);
setError(null);
abortRef.current = new AbortController();
try {
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: [...messages, userMessage],
stream: true,
}),
signal: abortRef.current.signal,
});
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
const reader = response.body?.getReader();
if (!reader) throw new Error('No response body');
const decoder = new TextDecoder();
let assistantContent = '';
// Add placeholder assistant message
setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
while (true) {
const { done, value } = await reader.read();
if (done) break;
const text = decoder.decode(value, { stream: true });
const lines = text.split('\n').filter((line) => line.startsWith('data: '));
for (const line of lines) {
const data = line.slice(6);
if (data === '[DONE]') break;
try {
const parsed = JSON.parse(data);
if (parsed.content) {
assistantContent += parsed.content;
setMessages((prev) => {
const updated = [...prev];
updated[updated.length - 1] = {
role: 'assistant',
content: assistantContent,
};
return updated;
});
}
} catch {
// Skip malformed chunks
}
}
}
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err);
onError?.(err);
}
} finally {
setIsLoading(false);
abortRef.current = null;
}
},
[messages, model, onError]
);
const clear = useCallback(() => {
abortRef.current?.abort();
setMessages(systemPrompt ? [{ role: 'system', content: systemPrompt }] : []);
setError(null);
}, [systemPrompt]);
return { messages, send, isLoading, error, clear };
}
export interface LLMMetrics {
podsReady: number;
cpuByPod: Record<string, number>;
memoryByPod: Record<string, number>;
}
/**
* Metrics hook with auto-refresh
*/
export function useLLMMetrics(refreshInterval = 30000) {
const [metrics, setMetrics] = useState<LLMMetrics | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
const fetchMetrics = useCallback(async () => {
try {
const response = await fetch('/api/metrics');
if (!response.ok) throw new Error(`API error: ${response.status}`);
const data = await response.json();
setMetrics(data);
setError(null);
} catch (err) {
setError(err instanceof Error ? err : new Error('Unknown error'));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
fetchMetrics();
const interval = setInterval(fetchMetrics, refreshInterval);
return () => clearInterval(interval);
}, [fetchMetrics, refreshInterval]);
return { metrics, isLoading, error, refresh: fetchMetrics };
}
/**
* Grafana iframe embed component props generator
*/
export function useGrafanaEmbed(dashboardUid: string, panelId?: number) {
const baseUrl = process.env.NEXT_PUBLIC_GRAFANA_URL || 'https://grafana.riotpiao.com';
const dashboardUrl = `${baseUrl}/d/${dashboardUid}`;
const embedUrl = panelId
? `${baseUrl}/d-solo/${dashboardUid}?panelId=${panelId}&from=now-6h&to=now&refresh=30s`
: null;
return {
dashboardUrl,
embedUrl,
iframeProps: embedUrl
? {
src: embedUrl,
width: '100%',
height: '400',
frameBorder: 0,
style: { border: 'none' },
}
: null,
};
}