194 lines
5.3 KiB
TypeScript
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,
|
||
|
|
};
|
||
|
|
}
|