feat(integrations): add NextJS LLM/Grafana integration + enable dashboard embedding
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user