feat(integrations): add NextJS LLM/Grafana integration + enable dashboard embedding
This commit is contained in:
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* NextJS API Route Examples
|
||||
*
|
||||
* Copy these to your NextJS app's pages/api/ or app/api/ directory
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// pages/api/chat.ts (Pages Router)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
/*
|
||||
import type { NextApiRequest, NextApiResponse } from 'next';
|
||||
import { chat, streamChat, type Message } from '@/lib/llm-client';
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (req.method !== 'POST') {
|
||||
return res.status(405).json({ error: 'Method not allowed' });
|
||||
}
|
||||
|
||||
const { messages, model = 'reasoning', stream = false } = req.body;
|
||||
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return res.status(400).json({ error: 'messages required' });
|
||||
}
|
||||
|
||||
try {
|
||||
if (stream) {
|
||||
res.setHeader('Content-Type', 'text/event-stream');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
res.setHeader('Connection', 'keep-alive');
|
||||
|
||||
for await (const chunk of streamChat({ model, messages })) {
|
||||
res.write(`data: ${JSON.stringify({ content: chunk })}\n\n`);
|
||||
}
|
||||
res.write('data: [DONE]\n\n');
|
||||
res.end();
|
||||
} else {
|
||||
const response = await chat({ model, messages });
|
||||
res.json(response);
|
||||
}
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: err instanceof Error ? err.message : 'Unknown error' });
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// app/api/chat/route.ts (App Router)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
/*
|
||||
import { NextRequest } from 'next/server';
|
||||
import { chat, createStreamResponse, type Model, type Message } from '@/lib/llm-client';
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { messages, model = 'reasoning', stream = false } = await req.json();
|
||||
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return Response.json({ error: 'messages required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
return createStreamResponse({ model, messages });
|
||||
}
|
||||
|
||||
const response = await chat({ model, messages });
|
||||
return Response.json(response);
|
||||
}
|
||||
*/
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
// app/api/metrics/route.ts (App Router - Grafana proxy)
|
||||
// ═══════════════════════════════════════════════════════════════════════════════
|
||||
/*
|
||||
import { NextRequest } from 'next/server';
|
||||
import { getLLMMetrics, getDashboard } from '@/lib/grafana-client';
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { searchParams } = new URL(req.url);
|
||||
const type = searchParams.get('type') || 'metrics';
|
||||
|
||||
try {
|
||||
if (type === 'dashboard') {
|
||||
const dashboard = await getDashboard('llm-frontend');
|
||||
return Response.json(dashboard);
|
||||
}
|
||||
|
||||
const metrics = await getLLMMetrics();
|
||||
return Response.json(metrics);
|
||||
} catch (err) {
|
||||
return Response.json(
|
||||
{ error: err instanceof Error ? err.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* Grafana Dashboard API Client
|
||||
*
|
||||
* Fetches dashboard data and panel snapshots from Grafana.
|
||||
* Requires service account token with Viewer role.
|
||||
*
|
||||
* Setup:
|
||||
* 1. Grafana UI > Administration > Service Accounts > Add
|
||||
* 2. Create token, set GRAFANA_API_TOKEN env var
|
||||
* 3. Or use Grafana API directly: POST /api/serviceaccounts
|
||||
*/
|
||||
|
||||
const GRAFANA_URL = process.env.GRAFANA_URL || 'https://grafana.riotpiao.com';
|
||||
const GRAFANA_API_TOKEN = process.env.GRAFANA_API_TOKEN || '';
|
||||
|
||||
interface GrafanaHeaders {
|
||||
'Content-Type': string;
|
||||
Authorization?: string;
|
||||
}
|
||||
|
||||
function headers(): GrafanaHeaders {
|
||||
const h: GrafanaHeaders = { 'Content-Type': 'application/json' };
|
||||
if (GRAFANA_API_TOKEN) {
|
||||
h.Authorization = `Bearer ${GRAFANA_API_TOKEN}`;
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
export interface Dashboard {
|
||||
uid: string;
|
||||
title: string;
|
||||
panels: Panel[];
|
||||
}
|
||||
|
||||
export interface Panel {
|
||||
id: number;
|
||||
title: string;
|
||||
type: string;
|
||||
gridPos: { h: number; w: number; x: number; y: number };
|
||||
}
|
||||
|
||||
export interface DashboardMeta {
|
||||
uid: string;
|
||||
title: string;
|
||||
url: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get dashboard by UID
|
||||
*/
|
||||
export async function getDashboard(uid: string): Promise<Dashboard> {
|
||||
const response = await fetch(`${GRAFANA_URL}/api/dashboards/uid/${uid}`, {
|
||||
headers: headers(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grafana API error: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return data.dashboard;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all dashboards
|
||||
*/
|
||||
export async function listDashboards(): Promise<DashboardMeta[]> {
|
||||
const response = await fetch(`${GRAFANA_URL}/api/search?type=dash-db`, {
|
||||
headers: headers(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Grafana API error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Query Prometheus datasource directly via Grafana proxy
|
||||
* Bypasses need for direct Prometheus access
|
||||
*/
|
||||
export async function queryPrometheus(
|
||||
query: string,
|
||||
start?: number,
|
||||
end?: number,
|
||||
step?: number
|
||||
): Promise<PrometheusResult> {
|
||||
const params = new URLSearchParams({
|
||||
query,
|
||||
start: String(start || Math.floor(Date.now() / 1000) - 3600),
|
||||
end: String(end || Math.floor(Date.now() / 1000)),
|
||||
step: String(step || 60),
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`${GRAFANA_URL}/api/datasources/proxy/uid/prometheus/api/v1/query_range?${params}`,
|
||||
{ headers: headers() }
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Prometheus query error: ${response.status}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export interface PrometheusResult {
|
||||
status: string;
|
||||
data: {
|
||||
resultType: string;
|
||||
result: {
|
||||
metric: Record<string, string>;
|
||||
values: [number, string][];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get LLM-specific metrics
|
||||
*/
|
||||
export async function getLLMMetrics(): Promise<LLMMetrics> {
|
||||
const [podsReady, cpuUsage, memoryUsage] = await Promise.all([
|
||||
queryPrometheus('sum(kube_pod_status_ready{namespace="llm-serving",condition="true"})'),
|
||||
queryPrometheus('sum(rate(container_cpu_usage_seconds_total{namespace="llm-serving"}[5m])) by (pod)'),
|
||||
queryPrometheus('sum(container_memory_working_set_bytes{namespace="llm-serving"}) by (pod)'),
|
||||
]);
|
||||
|
||||
return {
|
||||
podsReady: extractLatestValue(podsReady),
|
||||
cpuByPod: extractSeriesLatest(cpuUsage),
|
||||
memoryByPod: extractSeriesLatest(memoryUsage),
|
||||
};
|
||||
}
|
||||
|
||||
export interface LLMMetrics {
|
||||
podsReady: number;
|
||||
cpuByPod: Record<string, number>;
|
||||
memoryByPod: Record<string, number>;
|
||||
}
|
||||
|
||||
function extractLatestValue(result: PrometheusResult): number {
|
||||
const values = result.data?.result?.[0]?.values;
|
||||
if (!values || values.length === 0) return 0;
|
||||
return parseFloat(values[values.length - 1][1]);
|
||||
}
|
||||
|
||||
function extractSeriesLatest(result: PrometheusResult): Record<string, number> {
|
||||
const out: Record<string, number> = {};
|
||||
for (const series of result.data?.result || []) {
|
||||
const pod = series.metric.pod || 'unknown';
|
||||
const values = series.values;
|
||||
if (values && values.length > 0) {
|
||||
out[pod] = parseFloat(values[values.length - 1][1]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate iframe embed URL for a panel
|
||||
* Requires Grafana security.allow_embedding=true
|
||||
*/
|
||||
export function getPanelEmbedUrl(
|
||||
dashboardUid: string,
|
||||
panelId: number,
|
||||
from = 'now-6h',
|
||||
to = 'now',
|
||||
refresh = '30s'
|
||||
): string {
|
||||
return `${GRAFANA_URL}/d-solo/${dashboardUid}?panelId=${panelId}&from=${from}&to=${to}&refresh=${refresh}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate full dashboard URL
|
||||
*/
|
||||
export function getDashboardUrl(dashboardUid: string): string {
|
||||
return `${GRAFANA_URL}/d/${dashboardUid}`;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* NextJS Integration for Homelab LLM & Grafana
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
* SETUP
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* 1. Copy files to your NextJS project:
|
||||
* cp integrations/nextjs/*.ts your-nextjs-app/lib/
|
||||
*
|
||||
* 2. Environment variables (.env.local):
|
||||
* LLM_BASE_URL=https://api.riotpiao.com/v1
|
||||
* GRAFANA_URL=https://grafana.riotpiao.com
|
||||
* GRAFANA_API_TOKEN=<service-account-token>
|
||||
* NEXT_PUBLIC_GRAFANA_URL=https://grafana.riotpiao.com
|
||||
*
|
||||
* 3. Create Grafana service account (for API access):
|
||||
* - Grafana UI > Administration > Service Accounts
|
||||
* - Create account with Viewer role
|
||||
* - Generate token, save to GRAFANA_API_TOKEN
|
||||
*
|
||||
* 4. Copy API routes (see api-routes.ts for templates):
|
||||
* - app/api/chat/route.ts (LLM chat endpoint)
|
||||
* - app/api/metrics/route.ts (Grafana metrics proxy)
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
* USAGE EXAMPLES
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* Server-side (API routes, server components):
|
||||
*
|
||||
* import { chat, streamChat } from '@/lib/llm-client';
|
||||
* import { getLLMMetrics, getDashboard } from '@/lib/grafana-client';
|
||||
*
|
||||
* // Non-streaming chat
|
||||
* const response = await chat({
|
||||
* model: 'reasoning',
|
||||
* messages: [{ role: 'user', content: 'Hello' }],
|
||||
* });
|
||||
*
|
||||
* // Streaming chat
|
||||
* for await (const chunk of streamChat({ model: 'reasoning', messages })) {
|
||||
* process.stdout.write(chunk);
|
||||
* }
|
||||
*
|
||||
* // Get LLM pod metrics
|
||||
* const metrics = await getLLMMetrics();
|
||||
* console.log(`Pods ready: ${metrics.podsReady}`);
|
||||
*
|
||||
* Client-side (React components):
|
||||
*
|
||||
* import { useChat, useLLMMetrics, useGrafanaEmbed } from '@/lib/hooks';
|
||||
*
|
||||
* function ChatUI() {
|
||||
* const { messages, send, isLoading } = useChat({ model: 'reasoning' });
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* {messages.map((m, i) => <p key={i}>{m.role}: {m.content}</p>)}
|
||||
* <button onClick={() => send('Hello')} disabled={isLoading}>
|
||||
* Send
|
||||
* </button>
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* function MetricsDashboard() {
|
||||
* const { metrics, isLoading } = useLLMMetrics(30000);
|
||||
* const { iframeProps } = useGrafanaEmbed('llm-frontend', 2);
|
||||
*
|
||||
* if (isLoading) return <p>Loading...</p>;
|
||||
*
|
||||
* return (
|
||||
* <div>
|
||||
* <p>Pods ready: {metrics?.podsReady}</p>
|
||||
* {iframeProps && <iframe {...iframeProps} />}
|
||||
* </div>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
* AVAILABLE MODELS
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* | model | Engine | Notes |
|
||||
* |--------------------|---------|--------------------------------|
|
||||
* | reasoning | vLLM | Qwen3-32B, best for complex |
|
||||
* | ornith:35b | Ollama | General purpose |
|
||||
* | qwen2.5:3b-instruct| Ollama | Fast, smaller tasks |
|
||||
*
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
* GRAFANA EMBEDDING (optional, requires config change)
|
||||
* ═══════════════════════════════════════════════════════════════════════════════
|
||||
*
|
||||
* To enable iframe embedding, add to grafana.ini:
|
||||
* security:
|
||||
* allow_embedding: true
|
||||
*
|
||||
* Or via Helm values (k8s/infra/logging/grafana-values.yaml):
|
||||
* grafana.ini:
|
||||
* security:
|
||||
* allow_embedding: true
|
||||
*
|
||||
* Panel embed URLs follow pattern:
|
||||
* https://grafana.riotpiao.com/d-solo/llm-frontend?panelId=2&from=now-6h&to=now
|
||||
*
|
||||
* Dashboard UID for LLM: llm-frontend
|
||||
* Panel IDs: 2 (pods ready), 11 (CPU), 12 (Memory), 13 (Restarts)
|
||||
*/
|
||||
|
||||
// Re-export all modules
|
||||
export * from './llm-client';
|
||||
export * from './grafana-client';
|
||||
export * from './hooks';
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* LLM Client for api.riotpiao.com
|
||||
*
|
||||
* Usage:
|
||||
* import { chat, streamChat } from './llm-client';
|
||||
*
|
||||
* // Non-streaming
|
||||
* const response = await chat({ model: 'reasoning', messages: [...] });
|
||||
*
|
||||
* // Streaming
|
||||
* for await (const chunk of streamChat({ model: 'reasoning', messages: [...] })) {
|
||||
* process.stdout.write(chunk);
|
||||
* }
|
||||
*/
|
||||
|
||||
export type Model = 'reasoning' | 'ornith:35b' | 'qwen2.5:3b-instruct';
|
||||
|
||||
export interface Message {
|
||||
role: 'system' | 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatRequest {
|
||||
model: Model;
|
||||
messages: Message[];
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
stream?: boolean;
|
||||
}
|
||||
|
||||
export interface ChatResponse {
|
||||
id: string;
|
||||
object: string;
|
||||
created: number;
|
||||
model: string;
|
||||
choices: {
|
||||
index: number;
|
||||
message: Message;
|
||||
finish_reason: string;
|
||||
}[];
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
const LLM_BASE_URL = process.env.LLM_BASE_URL || 'https://api.riotpiao.com/v1';
|
||||
|
||||
/**
|
||||
* Non-streaming chat completion
|
||||
*/
|
||||
export async function chat(request: ChatRequest): Promise<ChatResponse> {
|
||||
const response = await fetch(`${LLM_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...request, stream: false }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`LLM API error: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* Streaming chat completion - yields content chunks
|
||||
*/
|
||||
export async function* streamChat(request: ChatRequest): AsyncGenerator<string> {
|
||||
const response = await fetch(`${LLM_BASE_URL}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...request, stream: true }),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`LLM API error: ${response.status} ${await response.text()}`);
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
if (!reader) throw new Error('No response body');
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') return;
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
if (content) yield content;
|
||||
} catch {
|
||||
// Skip malformed chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook compatible streaming (returns ReadableStream for Response)
|
||||
*/
|
||||
export function createStreamResponse(request: ChatRequest): Response {
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
for await (const chunk of streamChat(request)) {
|
||||
controller.enqueue(new TextEncoder().encode(chunk));
|
||||
}
|
||||
controller.close();
|
||||
} catch (err) {
|
||||
controller.error(err);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
|
||||
});
|
||||
}
|
||||
@@ -44,6 +44,10 @@ grafana.ini:
|
||||
server:
|
||||
root_url: https://grafana.riotpiao.com
|
||||
|
||||
# Allow embedding dashboards in iframes (NextJS integration)
|
||||
security:
|
||||
allow_embedding: true
|
||||
|
||||
# No anonymous read access — every user must log in via Authentik SSO.
|
||||
auth.anonymous:
|
||||
enabled: false
|
||||
|
||||
Reference in New Issue
Block a user