test
This commit is contained in:
parent
afdc19bebf
commit
67d9fa33e5
@ -6,7 +6,6 @@ const passport = require('passport');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const bodyParser = require('body-parser');
|
||||
const db = require('./db/models');
|
||||
const config = require('./config');
|
||||
const swaggerUI = require('swagger-ui-express');
|
||||
const swaggerJsDoc = require('swagger-jsdoc');
|
||||
@ -16,6 +15,7 @@ const fileRoutes = require('./routes/file');
|
||||
const searchRoutes = require('./routes/search');
|
||||
const sqlRoutes = require('./routes/sql');
|
||||
const pexelsRoutes = require('./routes/pexels');
|
||||
const publicLlmRoutes = require('./routes/publicLlm');
|
||||
|
||||
const openaiRoutes = require('./routes/openai');
|
||||
|
||||
@ -101,7 +101,7 @@ app.use('/api/auth', authRoutes);
|
||||
app.use('/api/file', fileRoutes);
|
||||
app.use('/api/pexels', pexelsRoutes);
|
||||
app.enable('trust proxy');
|
||||
|
||||
app.use('/api/public-llm', publicLlmRoutes);
|
||||
|
||||
app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes);
|
||||
|
||||
|
||||
343
backend/src/routes/publicLlm.js
Normal file
343
backend/src/routes/publicLlm.js
Normal file
@ -0,0 +1,343 @@
|
||||
const express = require('express');
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
const { LocalAIApi } = require('../ai/LocalAIApi');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const DIAGNOSTICS_CACHE_TTL_MS = 2 * 60 * 1000;
|
||||
const DEFAULT_PROBE_PROMPT = 'Reply with OK only.';
|
||||
|
||||
let diagnosticsCache = {
|
||||
expiresAt: 0,
|
||||
payload: null,
|
||||
};
|
||||
|
||||
const RELEVANT_ENV_KEYS = [
|
||||
'AI_PROXY_BASE_URL',
|
||||
'AI_RESPONSES_PATH',
|
||||
'AI_DEFAULT_MODEL',
|
||||
'AI_PROJECT_HEADER',
|
||||
'AI_TIMEOUT',
|
||||
'AI_VERIFY_TLS',
|
||||
'PROJECT_ID',
|
||||
'PROJECT_UUID',
|
||||
'OPENAI_API_KEY',
|
||||
'GEMINI_API_KEY',
|
||||
'GEMINI_MODEL',
|
||||
'GOOGLE_API_KEY',
|
||||
'GOOGLE_CLOUD_PROJECT',
|
||||
'GOOGLE_GENAI_USE_VERTEXAI',
|
||||
'OPENCODE_MODEL',
|
||||
];
|
||||
|
||||
function shellQuote(value) {
|
||||
return `'${String(value ?? '').replace(/'/g, `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return [...new Set((values || [])
|
||||
.map((value) => String(value || '').trim())
|
||||
.filter(Boolean))];
|
||||
}
|
||||
|
||||
function getEnvironmentEntries() {
|
||||
return Object.keys(process.env)
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: String(process.env[key] ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
function getRelevantEnvironmentEntries() {
|
||||
const availableKeys = uniqueStrings([
|
||||
...RELEVANT_ENV_KEYS,
|
||||
...Object.keys(process.env).filter((key) => /(AI|LLM|MODEL|OPENAI|GEMINI|GOOGLE|PROJECT|API)/i.test(key)),
|
||||
]);
|
||||
|
||||
return availableKeys
|
||||
.filter((key) => Object.prototype.hasOwnProperty.call(process.env, key))
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((key) => ({
|
||||
key,
|
||||
value: String(process.env[key] ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
function buildAbsoluteUrl(baseUrl, pathValue) {
|
||||
const trimmedBase = String(baseUrl || '').replace(/\/+$/, '');
|
||||
const trimmedPath = String(pathValue || '').trim();
|
||||
|
||||
if (!trimmedPath) {
|
||||
return trimmedBase;
|
||||
}
|
||||
|
||||
if (/^https?:\/\//i.test(trimmedPath)) {
|
||||
return trimmedPath;
|
||||
}
|
||||
|
||||
if (trimmedPath.startsWith('/')) {
|
||||
return `${trimmedBase}${trimmedPath}`;
|
||||
}
|
||||
|
||||
return `${trimmedBase}/${trimmedPath}`;
|
||||
}
|
||||
|
||||
function resolveProxyConfig() {
|
||||
const baseUrl = process.env.AI_PROXY_BASE_URL || 'https://flatlogic.com';
|
||||
const projectId = process.env.PROJECT_ID || null;
|
||||
let responsesPath = process.env.AI_RESPONSES_PATH || null;
|
||||
|
||||
if (!responsesPath && projectId) {
|
||||
responsesPath = `/projects/${projectId}/ai-request`;
|
||||
}
|
||||
|
||||
const defaultModel = process.env.AI_DEFAULT_MODEL || 'gpt-5-mini';
|
||||
const projectHeader = process.env.AI_PROJECT_HEADER || 'project-uuid';
|
||||
const projectUuid = process.env.PROJECT_UUID || null;
|
||||
|
||||
return {
|
||||
baseUrl,
|
||||
responsesPath,
|
||||
resolvedUrl: buildAbsoluteUrl(baseUrl, responsesPath),
|
||||
defaultModel,
|
||||
projectHeader,
|
||||
projectUuid,
|
||||
projectId,
|
||||
timeoutSeconds: Number.parseInt(String(process.env.AI_TIMEOUT || 30), 10) || 30,
|
||||
verifyTls: !['false', '0'].includes(String(process.env.AI_VERIFY_TLS ?? 'true').toLowerCase()),
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequestOrigin(req) {
|
||||
return `${req.protocol}://${req.get('host')}`;
|
||||
}
|
||||
|
||||
function buildProbeCandidates(proxyConfig) {
|
||||
return uniqueStrings([
|
||||
proxyConfig.defaultModel,
|
||||
'gpt-5-mini',
|
||||
'gpt-4.1-mini',
|
||||
'gpt-4o-mini',
|
||||
process.env.GEMINI_MODEL,
|
||||
process.env.OPENCODE_MODEL,
|
||||
]);
|
||||
}
|
||||
|
||||
async function probeModel(model) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
try {
|
||||
const response = await LocalAIApi.createResponse(
|
||||
{
|
||||
model,
|
||||
input: [{ role: 'user', content: DEFAULT_PROBE_PROMPT }],
|
||||
},
|
||||
{
|
||||
poll_interval: 2,
|
||||
poll_timeout: 25,
|
||||
},
|
||||
);
|
||||
|
||||
if (response.success) {
|
||||
return {
|
||||
model,
|
||||
success: true,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
previewText: LocalAIApi.extractText(response) || null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
model,
|
||||
success: false,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
previewText: null,
|
||||
error: response.error || response.message || 'unknown_error',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Model probe failed for ${model}:`, error);
|
||||
return {
|
||||
model,
|
||||
success: false,
|
||||
latencyMs: Date.now() - startedAt,
|
||||
previewText: null,
|
||||
error: error.message || String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function buildCurlPayload(model, prompt) {
|
||||
return {
|
||||
model,
|
||||
input: [{ role: 'user', content: prompt }],
|
||||
};
|
||||
}
|
||||
|
||||
function joinCurlLines(lines) {
|
||||
return lines
|
||||
.filter(Boolean)
|
||||
.map((line, index, allLines) => (index < allLines.length - 1 ? `${line} \\` : line))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function buildAppProxyCurl(appProxyUrl, model, prompt) {
|
||||
const payload = {
|
||||
model,
|
||||
prompt,
|
||||
};
|
||||
|
||||
return joinCurlLines([
|
||||
`curl -sS ${shellQuote(appProxyUrl)}`,
|
||||
'-X POST',
|
||||
"-H 'Content-Type: application/json'",
|
||||
`--data ${shellQuote(JSON.stringify(payload))}`,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildFlatlogicProxyCurl(proxyConfig, model, prompt) {
|
||||
const payload = buildCurlPayload(model, prompt);
|
||||
payload.project_uuid = proxyConfig.projectUuid;
|
||||
const projectHeaderLine = shellQuote(`${proxyConfig.projectHeader}: ${proxyConfig.projectUuid || ''}`);
|
||||
|
||||
return joinCurlLines([
|
||||
`curl -sS ${shellQuote(proxyConfig.resolvedUrl)}`,
|
||||
'-X POST',
|
||||
"-H 'Content-Type: application/json'",
|
||||
`-H ${projectHeaderLine}`,
|
||||
`--data ${shellQuote(JSON.stringify(payload))}`,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildOpenAICurl(model, prompt) {
|
||||
const apiKey = process.env.OPENAI_API_KEY || '';
|
||||
const payload = buildCurlPayload(model, prompt);
|
||||
const url = 'https://api.openai.com/v1/responses';
|
||||
const authorizationHeader = shellQuote(`Authorization: Bearer ${apiKey}`);
|
||||
|
||||
return joinCurlLines([
|
||||
`curl -sS ${shellQuote(url)}`,
|
||||
'-X POST',
|
||||
"-H 'Content-Type: application/json'",
|
||||
`-H ${authorizationHeader}`,
|
||||
`--data ${shellQuote(JSON.stringify(payload))}`,
|
||||
]);
|
||||
}
|
||||
|
||||
function buildGeminiCurl(prompt) {
|
||||
const apiKey = process.env.GEMINI_API_KEY || process.env.GOOGLE_API_KEY || '';
|
||||
const configuredModel = String(process.env.GEMINI_MODEL || '').trim();
|
||||
const model = configuredModel.startsWith('gemini-') ? configuredModel : 'gemini-2.5-flash';
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(model)}:generateContent?key=${encodeURIComponent(apiKey)}`;
|
||||
const payload = {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return joinCurlLines([
|
||||
`curl -sS ${shellQuote(url)}`,
|
||||
'-X POST',
|
||||
"-H 'Content-Type: application/json'",
|
||||
`--data ${shellQuote(JSON.stringify(payload))}`,
|
||||
]);
|
||||
}
|
||||
|
||||
async function buildDiagnostics(req) {
|
||||
const proxyConfig = resolveProxyConfig();
|
||||
const requestOrigin = buildRequestOrigin(req);
|
||||
const appProxyUrl = `${requestOrigin}/api/public-llm/proxy`;
|
||||
const defaultPrompt = 'Reply with a concise health check for this LLM proxy.';
|
||||
const probeCandidates = buildProbeCandidates(proxyConfig);
|
||||
const probeResults = await Promise.all(probeCandidates.map((model) => probeModel(model)));
|
||||
const availableModels = probeResults.filter((result) => result.success).map((result) => result.model);
|
||||
const unavailableModels = probeResults.filter((result) => !result.success).map((result) => result.model);
|
||||
const preferredModel = availableModels[0] || probeCandidates[0] || proxyConfig.defaultModel;
|
||||
|
||||
return {
|
||||
generatedAt: new Date().toISOString(),
|
||||
requestOrigin,
|
||||
appProxyUrl,
|
||||
defaultPrompt,
|
||||
envVars: getEnvironmentEntries(),
|
||||
llmEnvVars: getRelevantEnvironmentEntries(),
|
||||
proxyConfig,
|
||||
probeCandidates,
|
||||
probeResults,
|
||||
availableModels,
|
||||
unavailableModels,
|
||||
curlCommands: {
|
||||
appProxy: buildAppProxyCurl(appProxyUrl, preferredModel, defaultPrompt),
|
||||
flatlogicProxy: buildFlatlogicProxyCurl(proxyConfig, preferredModel, defaultPrompt),
|
||||
openaiDirect: buildOpenAICurl(preferredModel, defaultPrompt),
|
||||
geminiDirect: buildGeminiCurl(defaultPrompt),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
router.get(
|
||||
'/diagnostics',
|
||||
wrapAsync(async (req, res) => {
|
||||
const forceRefresh = String(req.query.refresh || '').toLowerCase() === 'true';
|
||||
|
||||
if (!forceRefresh && diagnosticsCache.payload && diagnosticsCache.expiresAt > Date.now()) {
|
||||
return res.status(200).send(diagnosticsCache.payload);
|
||||
}
|
||||
|
||||
const payload = await buildDiagnostics(req);
|
||||
diagnosticsCache = {
|
||||
expiresAt: Date.now() + DIAGNOSTICS_CACHE_TTL_MS,
|
||||
payload,
|
||||
};
|
||||
|
||||
return res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/proxy',
|
||||
wrapAsync(async (req, res) => {
|
||||
const prompt = typeof req.body.prompt === 'string' ? req.body.prompt.trim() : '';
|
||||
const input = Array.isArray(req.body.input) ? req.body.input : null;
|
||||
const model = typeof req.body.model === 'string' && req.body.model.trim()
|
||||
? req.body.model.trim()
|
||||
: resolveProxyConfig().defaultModel;
|
||||
|
||||
if (!prompt && !input) {
|
||||
return res.status(400).send({
|
||||
success: false,
|
||||
error: 'input_missing',
|
||||
message: 'Either "prompt" or "input" must be provided.',
|
||||
});
|
||||
}
|
||||
|
||||
const payload = {
|
||||
model,
|
||||
input: input || [{ role: 'user', content: prompt }],
|
||||
};
|
||||
|
||||
const response = await LocalAIApi.createResponse(payload, {
|
||||
poll_interval: 2,
|
||||
poll_timeout: 60,
|
||||
});
|
||||
|
||||
if (!response.success) {
|
||||
console.error('Public LLM proxy error:', response);
|
||||
const status = response.error === 'input_missing' ? 400 : 502;
|
||||
return res.status(status).send(response);
|
||||
}
|
||||
|
||||
return res.status(200).send({
|
||||
success: true,
|
||||
model,
|
||||
text: LocalAIApi.extractText(response) || '',
|
||||
response,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
@ -1,6 +1,5 @@
|
||||
import React, {useEffect, useRef} from 'react'
|
||||
import React, { useEffect, useRef, useState } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { useState } from 'react'
|
||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||
import BaseDivider from './BaseDivider'
|
||||
import BaseIcon from './BaseIcon'
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import React, { ReactNode, useEffect } from 'react'
|
||||
import { useState } from 'react'
|
||||
import React, { ReactNode, useEffect, useState } from 'react'
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||
import menuAside from '../menuAside'
|
||||
|
||||
@ -1,166 +1,731 @@
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||
import Head from 'next/head';
|
||||
import Link from 'next/link';
|
||||
import axios from 'axios';
|
||||
import BaseButton from '../components/BaseButton';
|
||||
import CardBox from '../components/CardBox';
|
||||
import SectionFullScreen from '../components/SectionFullScreen';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
import BaseDivider from '../components/BaseDivider';
|
||||
import BaseButtons from '../components/BaseButtons';
|
||||
import NotificationBar from '../components/NotificationBar';
|
||||
import { getPageTitle } from '../config';
|
||||
import { useAppSelector } from '../stores/hooks';
|
||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
||||
import LayoutGuest from '../layouts/Guest';
|
||||
|
||||
type EnvironmentEntry = {
|
||||
key: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type ProxyConfig = {
|
||||
baseUrl: string;
|
||||
responsesPath: string | null;
|
||||
resolvedUrl: string;
|
||||
defaultModel: string;
|
||||
projectHeader: string;
|
||||
projectUuid: string | null;
|
||||
projectId: string | null;
|
||||
timeoutSeconds: number;
|
||||
verifyTls: boolean;
|
||||
};
|
||||
|
||||
type ProbeResult = {
|
||||
model: string;
|
||||
success: boolean;
|
||||
latencyMs: number;
|
||||
previewText: string | null;
|
||||
error: string | null;
|
||||
};
|
||||
|
||||
type CurlCommands = {
|
||||
appProxy: string;
|
||||
flatlogicProxy: string;
|
||||
openaiDirect: string;
|
||||
geminiDirect: string;
|
||||
};
|
||||
|
||||
type DiagnosticsPayload = {
|
||||
generatedAt: string;
|
||||
requestOrigin: string;
|
||||
appProxyUrl: string;
|
||||
defaultPrompt: string;
|
||||
envVars: EnvironmentEntry[];
|
||||
llmEnvVars: EnvironmentEntry[];
|
||||
proxyConfig: ProxyConfig;
|
||||
probeCandidates: string[];
|
||||
probeResults: ProbeResult[];
|
||||
availableModels: string[];
|
||||
unavailableModels: string[];
|
||||
curlCommands: CurlCommands;
|
||||
};
|
||||
|
||||
type ProxyResponsePayload = {
|
||||
success: boolean;
|
||||
model: string;
|
||||
text: string;
|
||||
response: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type FeedbackState = {
|
||||
type: 'success' | 'error';
|
||||
text: string;
|
||||
} | null;
|
||||
|
||||
const shellQuote = (value: string) => `'${String(value ?? '').replace(/'/g, `'"'"'`)}'`;
|
||||
|
||||
const joinCurlLines = (lines: string[]) => lines
|
||||
.filter(Boolean)
|
||||
.map((line, index, allLines) => (index < allLines.length - 1 ? `${line} \\` : line))
|
||||
.join('\n');
|
||||
|
||||
const buildResponsePayload = (model: string, prompt: string) => ({
|
||||
model,
|
||||
input: [{ role: 'user', content: prompt }],
|
||||
});
|
||||
|
||||
const buildGeminiPayload = (prompt: string) => ({
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [{ text: prompt }],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const formatDateTime = (value?: string) => {
|
||||
if (!value) {
|
||||
return '—';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
|
||||
return date.toLocaleString();
|
||||
};
|
||||
|
||||
export default function Starter() {
|
||||
const [illustrationImage, setIllustrationImage] = useState({
|
||||
src: undefined,
|
||||
photographer: undefined,
|
||||
photographer_url: undefined,
|
||||
})
|
||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
||||
const [contentType, setContentType] = useState('video');
|
||||
const [contentPosition, setContentPosition] = useState('left');
|
||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
||||
const [diagnostics, setDiagnostics] = useState<DiagnosticsPayload | null>(null);
|
||||
const [isLoadingDiagnostics, setIsLoadingDiagnostics] = useState(true);
|
||||
const [diagnosticsError, setDiagnosticsError] = useState('');
|
||||
const [prompt, setPrompt] = useState('Reply with a concise health check for this LLM proxy.');
|
||||
const [selectedModel, setSelectedModel] = useState('');
|
||||
const [envSearch, setEnvSearch] = useState('');
|
||||
const [proxyResponse, setProxyResponse] = useState<ProxyResponsePayload | null>(null);
|
||||
const [proxyError, setProxyError] = useState('');
|
||||
const [isProxying, setIsProxying] = useState(false);
|
||||
const [feedback, setFeedback] = useState<FeedbackState>(null);
|
||||
|
||||
const title = 'App Preview'
|
||||
const loadDiagnostics = async (forceRefresh = false) => {
|
||||
setIsLoadingDiagnostics(true);
|
||||
setDiagnosticsError('');
|
||||
|
||||
// Fetch Pexels image/video
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
const image = await getPexelsImage();
|
||||
const video = await getPexelsVideo();
|
||||
setIllustrationImage(image);
|
||||
setIllustrationVideo(video);
|
||||
}
|
||||
fetchData();
|
||||
}, []);
|
||||
try {
|
||||
const response = await axios.get<DiagnosticsPayload>(
|
||||
forceRefresh ? '/public-llm/diagnostics?refresh=true' : '/public-llm/diagnostics',
|
||||
);
|
||||
const payload = response.data;
|
||||
setDiagnostics(payload);
|
||||
setPrompt((currentPrompt) => currentPrompt || payload.defaultPrompt);
|
||||
setSelectedModel((currentModel) => currentModel || payload.availableModels[0] || payload.proxyConfig.defaultModel);
|
||||
} catch (error) {
|
||||
console.error('Failed to load diagnostics:', error);
|
||||
setDiagnosticsError(error instanceof Error ? error.message : 'Failed to load diagnostics.');
|
||||
} finally {
|
||||
setIsLoadingDiagnostics(false);
|
||||
}
|
||||
};
|
||||
|
||||
const imageBlock = (image) => (
|
||||
<div
|
||||
className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'
|
||||
style={{
|
||||
backgroundImage: `${
|
||||
image
|
||||
? `url(${image?.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}}
|
||||
>
|
||||
<div className='flex justify-center w-full bg-blue-300/20'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={image?.photographer_url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Photo by {image?.photographer} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
useEffect(() => {
|
||||
loadDiagnostics();
|
||||
}, []);
|
||||
|
||||
const videoBlock = (video) => {
|
||||
if (video?.video_files?.length > 0) {
|
||||
return (
|
||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
||||
<video
|
||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
||||
autoPlay
|
||||
loop
|
||||
muted
|
||||
>
|
||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
||||
<a
|
||||
className='text-[8px]'
|
||||
href={video?.user?.url}
|
||||
target='_blank'
|
||||
rel='noreferrer'
|
||||
>
|
||||
Video by {video.user.name} on Pexels
|
||||
</a>
|
||||
</div>
|
||||
</div>)
|
||||
}
|
||||
};
|
||||
const environmentMap = useMemo(() => {
|
||||
const entries = diagnostics?.envVars || [];
|
||||
return new Map(entries.map((entry) => [entry.key, entry.value]));
|
||||
}, [diagnostics]);
|
||||
|
||||
const activeModel = useMemo(() => {
|
||||
if (!diagnostics) {
|
||||
return selectedModel || 'gpt-5-mini';
|
||||
}
|
||||
|
||||
return selectedModel.trim() || diagnostics.availableModels[0] || diagnostics.proxyConfig.defaultModel;
|
||||
}, [diagnostics, selectedModel]);
|
||||
|
||||
const activePrompt = useMemo(() => {
|
||||
return prompt.trim() || diagnostics?.defaultPrompt || 'Reply with OK only.';
|
||||
}, [diagnostics?.defaultPrompt, prompt]);
|
||||
|
||||
const filteredEnvVars = useMemo(() => {
|
||||
const query = envSearch.trim().toLowerCase();
|
||||
const entries = diagnostics?.envVars || [];
|
||||
|
||||
if (!query) {
|
||||
return entries;
|
||||
}
|
||||
|
||||
return entries.filter((entry) => {
|
||||
const haystack = `${entry.key} ${entry.value}`.toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
}, [diagnostics?.envVars, envSearch]);
|
||||
|
||||
const curlCards = useMemo(() => {
|
||||
if (!diagnostics) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const openAiKey = environmentMap.get('OPENAI_API_KEY') || '';
|
||||
const geminiKey = environmentMap.get('GEMINI_API_KEY') || environmentMap.get('GOOGLE_API_KEY') || '';
|
||||
const geminiModelFromEnv = (environmentMap.get('GEMINI_MODEL') || '').trim();
|
||||
const geminiModel = geminiModelFromEnv.startsWith('gemini-') ? geminiModelFromEnv : 'gemini-2.5-flash';
|
||||
|
||||
const appProxyCommand = joinCurlLines([
|
||||
`curl -sS ${shellQuote(diagnostics.appProxyUrl)}`,
|
||||
'-X POST',
|
||||
"-H 'Content-Type: application/json'",
|
||||
`--data ${shellQuote(JSON.stringify({ model: activeModel, prompt: activePrompt }))}`,
|
||||
]);
|
||||
|
||||
const flatlogicProxyCommand = joinCurlLines([
|
||||
`curl -sS ${shellQuote(diagnostics.proxyConfig.resolvedUrl)}`,
|
||||
'-X POST',
|
||||
"-H 'Content-Type: application/json'",
|
||||
`-H ${shellQuote(`${diagnostics.proxyConfig.projectHeader}: ${diagnostics.proxyConfig.projectUuid || ''}`)}`,
|
||||
`--data ${shellQuote(JSON.stringify({
|
||||
...buildResponsePayload(activeModel, activePrompt),
|
||||
project_uuid: diagnostics.proxyConfig.projectUuid,
|
||||
}))}`,
|
||||
]);
|
||||
|
||||
const openAiCommand = joinCurlLines([
|
||||
`curl -sS ${shellQuote('https://api.openai.com/v1/responses')}`,
|
||||
'-X POST',
|
||||
"-H 'Content-Type: application/json'",
|
||||
`-H ${shellQuote(`Authorization: Bearer ${openAiKey}`)}`,
|
||||
`--data ${shellQuote(JSON.stringify(buildResponsePayload(activeModel, activePrompt)))}`,
|
||||
]);
|
||||
|
||||
const geminiCommand = joinCurlLines([
|
||||
`curl -sS ${shellQuote(`https://generativelanguage.googleapis.com/v1beta/models/${encodeURIComponent(geminiModel)}:generateContent?key=${encodeURIComponent(geminiKey)}`)}`,
|
||||
'-X POST',
|
||||
"-H 'Content-Type: application/json'",
|
||||
`--data ${shellQuote(JSON.stringify(buildGeminiPayload(activePrompt)))}`,
|
||||
]);
|
||||
|
||||
return [
|
||||
{
|
||||
id: 'appProxy',
|
||||
title: 'App proxy curl',
|
||||
description: 'Calls the new public reverse-proxy endpoint exposed by this app.',
|
||||
command: appProxyCommand,
|
||||
},
|
||||
{
|
||||
id: 'flatlogicProxy',
|
||||
title: 'Flatlogic AI proxy curl',
|
||||
description: 'Uses the project UUID header and the configured Flatlogic AI proxy URL.',
|
||||
command: flatlogicProxyCommand,
|
||||
},
|
||||
{
|
||||
id: 'openaiDirect',
|
||||
title: 'OpenAI direct curl',
|
||||
description: 'Prints the full OpenAI URL and API key from the current environment.',
|
||||
command: openAiCommand,
|
||||
},
|
||||
{
|
||||
id: 'geminiDirect',
|
||||
title: 'Gemini direct curl',
|
||||
description: 'Prints a Gemini generateContent command with the current API key.',
|
||||
command: geminiCommand,
|
||||
},
|
||||
];
|
||||
}, [activeModel, activePrompt, diagnostics, environmentMap]);
|
||||
|
||||
const handleCopy = async (value: string, label: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setFeedback({ type: 'success', text: `${label} copied.` });
|
||||
window.setTimeout(() => {
|
||||
setFeedback((current) => (current?.text === `${label} copied.` ? null : current));
|
||||
}, 2200);
|
||||
} catch (error) {
|
||||
console.error(`Failed to copy ${label}:`, error);
|
||||
setFeedback({ type: 'error', text: `Failed to copy ${label}.` });
|
||||
}
|
||||
};
|
||||
|
||||
const handleProxySubmit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
setProxyError('');
|
||||
setProxyResponse(null);
|
||||
setIsProxying(true);
|
||||
|
||||
try {
|
||||
const response = await axios.post<ProxyResponsePayload>('/public-llm/proxy', {
|
||||
model: activeModel,
|
||||
prompt: activePrompt,
|
||||
});
|
||||
|
||||
setProxyResponse(response.data);
|
||||
} catch (error) {
|
||||
console.error('Proxy request failed:', error);
|
||||
setProxyError(error instanceof Error ? error.message : 'Proxy request failed.');
|
||||
} finally {
|
||||
setIsProxying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const envExportText = useMemo(() => {
|
||||
return (diagnostics?.envVars || [])
|
||||
.map((entry) => `${entry.key}=${entry.value}`)
|
||||
.join('\n');
|
||||
}, [diagnostics?.envVars]);
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Visible env vars',
|
||||
value: diagnostics?.envVars.length ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'LLM env keys',
|
||||
value: diagnostics?.llmEnvVars.length ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Available models',
|
||||
value: diagnostics?.availableModels.length ?? '—',
|
||||
},
|
||||
{
|
||||
label: 'Failed probes',
|
||||
value: diagnostics?.unavailableModels.length ?? '—',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
contentPosition === 'background'
|
||||
? {
|
||||
backgroundImage: `${
|
||||
illustrationImage
|
||||
? `url(${illustrationImage.src?.original})`
|
||||
: 'linear-gradient(rgba(255, 255, 255, 0.5), rgba(255, 255, 255, 0.5))'
|
||||
}`,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'left center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
}
|
||||
: {}
|
||||
}
|
||||
>
|
||||
<>
|
||||
<Head>
|
||||
<title>{getPageTitle('Starter Page')}</title>
|
||||
<title>{getPageTitle('LLM Proxy Console')}</title>
|
||||
</Head>
|
||||
|
||||
<SectionFullScreen bg='violet'>
|
||||
<div
|
||||
className={`flex ${
|
||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
||||
} min-h-screen w-full`}
|
||||
>
|
||||
{contentType === 'image' && contentPosition !== 'background'
|
||||
? imageBlock(illustrationImage)
|
||||
: null}
|
||||
{contentType === 'video' && contentPosition !== 'background'
|
||||
? videoBlock(illustrationVideo)
|
||||
: null}
|
||||
<div className='flex items-center justify-center flex-col space-y-4 w-full lg:w-full'>
|
||||
<CardBox className='w-full md:w-3/5 lg:w-2/3'>
|
||||
<CardBoxComponentTitle title="Welcome to your App Preview app!"/>
|
||||
|
||||
<div className="space-y-3">
|
||||
<p className='text-center text-gray-500'>This is a React.js/Node.js app generated by the <a className={`${textColor}`} href="https://flatlogic.com/generator">Flatlogic Web App Generator</a></p>
|
||||
<p className='text-center text-gray-500'>For guides and documentation please check
|
||||
your local README.md and the <a className={`${textColor}`} href="https://flatlogic.com/documentation">Flatlogic documentation</a></p>
|
||||
</div>
|
||||
|
||||
<BaseButtons>
|
||||
<BaseButton
|
||||
href='/login'
|
||||
label='Login'
|
||||
color='info'
|
||||
className='w-full'
|
||||
/>
|
||||
<div className="min-h-screen bg-slate-950 text-slate-50">
|
||||
<div className="relative isolate overflow-hidden bg-[radial-gradient(circle_at_top_left,_rgba(37,99,235,0.35),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(14,165,233,0.18),_transparent_26%),linear-gradient(135deg,_#020617_0%,_#0f172a_55%,_#111827_100%)]">
|
||||
<div className="absolute left-8 top-24 h-40 w-40 rounded-full bg-cyan-400/20 blur-3xl" />
|
||||
<div className="absolute right-0 top-0 h-64 w-64 rounded-full bg-blue-500/20 blur-3xl" />
|
||||
|
||||
</BaseButtons>
|
||||
</CardBox>
|
||||
<main className="relative mx-auto max-w-7xl px-6 py-8 lg:px-8">
|
||||
<header className="mb-8 flex flex-col gap-4 border-b border-white/10 pb-6 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<p className="mb-3 inline-flex items-center rounded-full border border-cyan-400/25 bg-cyan-400/10 px-3 py-1 text-xs font-medium uppercase tracking-[0.22em] text-cyan-200">
|
||||
Public LLM reverse proxy diagnostics
|
||||
</p>
|
||||
<h1 className="max-w-4xl text-4xl font-semibold tracking-tight text-white md:text-5xl">
|
||||
LLM Proxy Console with live probes, full env dump, and copy-ready curl commands.
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-base leading-7 text-slate-300 md:text-lg">
|
||||
首页现在直接展示系统环境变量、检测到的 LLM 参数、可用模型探测结果,以及一个可立即发送 prompt 的反向代理测试区。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<BaseButton href="/login" label="Login" color="info" />
|
||||
<BaseButton href="/dashboard" label="Admin interface" color="whiteDark" outline />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{feedback && (
|
||||
<NotificationBar color={feedback.type === 'success' ? 'success' : 'danger'}>
|
||||
{feedback.text}
|
||||
</NotificationBar>
|
||||
)}
|
||||
|
||||
{diagnosticsError && (
|
||||
<NotificationBar color="danger">
|
||||
Diagnostics failed to load: {diagnosticsError}
|
||||
</NotificationBar>
|
||||
)}
|
||||
|
||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="rounded-3xl border border-white/10 bg-white/5 p-5 shadow-[0_20px_50px_rgba(2,6,23,0.35)] backdrop-blur"
|
||||
>
|
||||
<div className="text-sm text-slate-400">{stat.label}</div>
|
||||
<div className="mt-3 text-3xl font-semibold text-white">{stat.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-6 xl:grid-cols-[1.2fr,0.8fr]">
|
||||
<CardBox className="border border-white/10 bg-white/5 shadow-[0_24px_70px_rgba(2,6,23,0.38)] backdrop-blur">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-cyan-200">Probe summary</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Model availability and proxy wiring</h2>
|
||||
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-300">
|
||||
These probes are executed against the configured Flatlogic AI proxy using live project credentials. Refresh to rerun the checks.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => loadDiagnostics(true)}
|
||||
className="rounded-xl border border-cyan-300/30 bg-cyan-300/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:border-cyan-200/60 hover:bg-cyan-300/20 focus:outline-none focus:ring-4 focus:ring-cyan-400/20"
|
||||
>
|
||||
{isLoadingDiagnostics ? 'Refreshing…' : 'Refresh probes'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(JSON.stringify(diagnostics || {}, null, 2), 'Diagnostics JSON')}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-100 transition hover:border-white/20 hover:bg-white/10 focus:outline-none focus:ring-4 focus:ring-white/10"
|
||||
>
|
||||
Copy diagnostics JSON
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-6 lg:grid-cols-[1.1fr,0.9fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-emerald-400/20 bg-emerald-400/10 p-4">
|
||||
<div className="text-sm font-medium uppercase tracking-[0.18em] text-emerald-100">Available via proxy</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{(diagnostics?.availableModels || []).length > 0 ? (
|
||||
diagnostics?.availableModels.map((model) => (
|
||||
<button
|
||||
key={model}
|
||||
type="button"
|
||||
onClick={() => setSelectedModel(model)}
|
||||
className={`rounded-full border px-3 py-1 text-sm transition ${
|
||||
activeModel === model
|
||||
? 'border-emerald-200 bg-emerald-200 text-slate-950'
|
||||
: 'border-emerald-200/20 bg-emerald-200/10 text-emerald-50 hover:border-emerald-200/50'
|
||||
}`}
|
||||
>
|
||||
{model}
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-emerald-50/80">No successful probes yet.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-rose-300/15 bg-rose-300/10 p-4">
|
||||
<div className="text-sm font-medium uppercase tracking-[0.18em] text-rose-100">Failed candidates</div>
|
||||
<div className="mt-3 space-y-3">
|
||||
{(diagnostics?.probeResults || []).filter((result) => !result.success).length > 0 ? (
|
||||
diagnostics?.probeResults
|
||||
.filter((result) => !result.success)
|
||||
.map((result) => (
|
||||
<div key={result.model} className="rounded-2xl border border-white/10 bg-slate-950/30 p-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-medium text-white">{result.model}</span>
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-rose-100/80">{result.latencyMs} ms</span>
|
||||
</div>
|
||||
<p className="mt-2 break-all text-sm text-slate-300">{result.error}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-rose-50/80">No failed probes in the latest run.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4">
|
||||
<div className="mb-3 text-sm font-medium uppercase tracking-[0.18em] text-slate-300">Proxy config</div>
|
||||
<dl className="space-y-3 text-sm text-slate-200">
|
||||
<div className="grid gap-1 sm:grid-cols-[140px,1fr]">
|
||||
<dt className="text-slate-400">Base URL</dt>
|
||||
<dd className="break-all font-mono text-xs text-slate-100">{diagnostics?.proxyConfig.baseUrl || '—'}</dd>
|
||||
</div>
|
||||
<div className="grid gap-1 sm:grid-cols-[140px,1fr]">
|
||||
<dt className="text-slate-400">Response path</dt>
|
||||
<dd className="break-all font-mono text-xs text-slate-100">{diagnostics?.proxyConfig.responsesPath || '—'}</dd>
|
||||
</div>
|
||||
<div className="grid gap-1 sm:grid-cols-[140px,1fr]">
|
||||
<dt className="text-slate-400">Resolved URL</dt>
|
||||
<dd className="break-all font-mono text-xs text-slate-100">{diagnostics?.proxyConfig.resolvedUrl || '—'}</dd>
|
||||
</div>
|
||||
<div className="grid gap-1 sm:grid-cols-[140px,1fr]">
|
||||
<dt className="text-slate-400">Project header</dt>
|
||||
<dd className="break-all font-mono text-xs text-slate-100">{diagnostics?.proxyConfig.projectHeader || '—'}</dd>
|
||||
</div>
|
||||
<div className="grid gap-1 sm:grid-cols-[140px,1fr]">
|
||||
<dt className="text-slate-400">Project UUID</dt>
|
||||
<dd className="break-all font-mono text-xs text-slate-100">{diagnostics?.proxyConfig.projectUuid || '—'}</dd>
|
||||
</div>
|
||||
<div className="grid gap-1 sm:grid-cols-[140px,1fr]">
|
||||
<dt className="text-slate-400">Default model</dt>
|
||||
<dd className="break-all font-mono text-xs text-slate-100">{diagnostics?.proxyConfig.defaultModel || '—'}</dd>
|
||||
</div>
|
||||
<div className="grid gap-1 sm:grid-cols-[140px,1fr]">
|
||||
<dt className="text-slate-400">Refreshed</dt>
|
||||
<dd className="text-xs text-slate-100">{formatDateTime(diagnostics?.generatedAt)}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4">
|
||||
<div className="mb-3 text-sm font-medium uppercase tracking-[0.18em] text-slate-300">Quick admin links</div>
|
||||
<div className="flex flex-wrap gap-2 text-sm text-slate-200">
|
||||
{[
|
||||
{ href: '/dashboard', label: 'Dashboard' },
|
||||
{ href: '/llm_requests/llm_requests-list', label: 'LLM requests' },
|
||||
{ href: '/model_catalog/model_catalog-list', label: 'Model catalog' },
|
||||
{ href: '/environment_variables/environment_variables-list', label: 'Environment variables' },
|
||||
].map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="rounded-full border border-white/10 bg-white/5 px-3 py-1.5 transition hover:border-cyan-300/40 hover:bg-cyan-300/10"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border border-white/10 bg-white/5 shadow-[0_24px_70px_rgba(2,6,23,0.38)] backdrop-blur">
|
||||
<div>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-cyan-200">Reverse proxy test</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Send a live prompt through the app proxy</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">
|
||||
This uses <span className="font-mono text-cyan-100">/api/public-llm/proxy</span> and returns the extracted model text plus the raw upstream payload.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form className="mt-6 space-y-4" onSubmit={handleProxySubmit}>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-200" htmlFor="model-input">
|
||||
Model
|
||||
</label>
|
||||
<input
|
||||
id="model-input"
|
||||
value={selectedModel}
|
||||
onChange={(event) => setSelectedModel(event.target.value)}
|
||||
className="w-full rounded-2xl border border-white/10 bg-slate-950/40 px-4 py-3 font-mono text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-cyan-300/60 focus:ring-4 focus:ring-cyan-400/20"
|
||||
placeholder="gpt-5-mini"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-slate-200" htmlFor="prompt-input">
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="prompt-input"
|
||||
value={prompt}
|
||||
onChange={(event) => setPrompt(event.target.value)}
|
||||
rows={7}
|
||||
className="w-full rounded-2xl border border-white/10 bg-slate-950/40 px-4 py-3 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-cyan-300/60 focus:ring-4 focus:ring-cyan-400/20"
|
||||
placeholder="Ask the proxy anything…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<BaseButton type="submit" label={isProxying ? 'Sending…' : 'Run proxy test'} color="info" disabled={isProxying} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setPrompt(diagnostics?.defaultPrompt || 'Reply with a concise health check for this LLM proxy.');
|
||||
setSelectedModel(diagnostics?.availableModels[0] || diagnostics?.proxyConfig.defaultModel || 'gpt-5-mini');
|
||||
setProxyError('');
|
||||
setProxyResponse(null);
|
||||
}}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-100 transition hover:border-white/20 hover:bg-white/10 focus:outline-none focus:ring-4 focus:ring-white/10"
|
||||
>
|
||||
Reset sample
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{proxyError && (
|
||||
<div className="mt-5 rounded-2xl border border-rose-300/15 bg-rose-300/10 p-4 text-sm text-rose-100">
|
||||
{proxyError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proxyResponse && (
|
||||
<div className="mt-5 space-y-4">
|
||||
<div className="rounded-2xl border border-emerald-300/15 bg-emerald-300/10 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-emerald-50">Extracted response</p>
|
||||
<p className="mt-1 text-xs text-emerald-50/80">Model: {proxyResponse.model}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(proxyResponse.text, 'Proxy response text')}
|
||||
className="rounded-xl border border-emerald-100/20 bg-emerald-100/10 px-3 py-1.5 text-xs font-medium text-emerald-50 transition hover:border-emerald-100/40 hover:bg-emerald-100/20"
|
||||
>
|
||||
Copy text
|
||||
</button>
|
||||
</div>
|
||||
<pre className="mt-3 whitespace-pre-wrap break-words rounded-2xl bg-slate-950/40 p-4 text-sm text-white">{proxyResponse.text || '(empty text output)'}</pre>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-slate-950/35 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-sm font-medium uppercase tracking-[0.18em] text-slate-300">Raw upstream payload</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(JSON.stringify(proxyResponse.response, null, 2), 'Raw proxy JSON')}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-100 transition hover:border-white/20 hover:bg-white/10"
|
||||
>
|
||||
Copy JSON
|
||||
</button>
|
||||
</div>
|
||||
<pre className="mt-3 max-h-72 overflow-auto whitespace-pre-wrap break-words rounded-2xl bg-slate-950/60 p-4 text-xs text-slate-200">
|
||||
{JSON.stringify(proxyResponse.response, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardBox>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-6 xl:grid-cols-[0.9fr,1.1fr]">
|
||||
<CardBox className="border border-white/10 bg-white/5 shadow-[0_24px_70px_rgba(2,6,23,0.38)] backdrop-blur">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-cyan-200">LLM environment</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Relevant environment parameters discovered at runtime</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(JSON.stringify(diagnostics?.llmEnvVars || [], null, 2), 'LLM environment JSON')}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-100 transition hover:border-white/20 hover:bg-white/10 focus:outline-none focus:ring-4 focus:ring-white/10"
|
||||
>
|
||||
Copy LLM env JSON
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 space-y-3">
|
||||
{(diagnostics?.llmEnvVars || []).map((entry) => (
|
||||
<div key={entry.key} className="rounded-2xl border border-white/10 bg-slate-950/35 p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-200">{entry.key}</div>
|
||||
<div className="mt-2 break-all font-mono text-xs leading-6 text-slate-200">{entry.value}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(entry.value, entry.key)}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-100 transition hover:border-white/20 hover:bg-white/10"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
|
||||
<CardBox className="border border-white/10 bg-white/5 shadow-[0_24px_70px_rgba(2,6,23,0.38)] backdrop-blur">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-cyan-200">Curl commands</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Full commands with URL and key included</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">
|
||||
These commands are regenerated from the current model and prompt, so you can copy them straight into your terminal.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(curlCards.map((item) => `# ${item.title}\n${item.command}`).join('\n\n'), 'All curl commands')}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-4 py-2 text-sm font-medium text-slate-100 transition hover:border-white/20 hover:bg-white/10 focus:outline-none focus:ring-4 focus:ring-white/10"
|
||||
>
|
||||
Copy all curls
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4">
|
||||
{curlCards.map((item) => (
|
||||
<div key={item.id} className="rounded-2xl border border-white/10 bg-slate-950/35 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-white">{item.title}</h3>
|
||||
<p className="mt-1 max-w-2xl text-sm text-slate-300">{item.description}</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(item.command, item.title)}
|
||||
className="rounded-xl border border-cyan-300/30 bg-cyan-300/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:border-cyan-200/60 hover:bg-cyan-300/20 focus:outline-none focus:ring-4 focus:ring-cyan-400/20"
|
||||
>
|
||||
Copy command
|
||||
</button>
|
||||
</div>
|
||||
<pre className="mt-4 overflow-x-auto whitespace-pre-wrap break-words rounded-2xl bg-slate-950/70 p-4 text-xs leading-6 text-slate-100">{item.command}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 pb-10">
|
||||
<CardBox className="border border-white/10 bg-white/5 shadow-[0_24px_70px_rgba(2,6,23,0.38)] backdrop-blur">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium uppercase tracking-[0.2em] text-cyan-200">System environment variables</p>
|
||||
<h2 className="mt-2 text-2xl font-semibold text-white">Full runtime env dump on the homepage</h2>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-300">
|
||||
No masking is applied. Use the search box to filter keys or values, then copy a single value or the complete export.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<input
|
||||
value={envSearch}
|
||||
onChange={(event) => setEnvSearch(event.target.value)}
|
||||
className="min-w-[240px] rounded-2xl border border-white/10 bg-slate-950/40 px-4 py-2 text-sm text-white outline-none transition placeholder:text-slate-500 focus:border-cyan-300/60 focus:ring-4 focus:ring-cyan-400/20"
|
||||
placeholder="Search env key or value"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(envExportText, 'All environment variables')}
|
||||
className="rounded-xl border border-cyan-300/30 bg-cyan-300/10 px-4 py-2 text-sm font-medium text-cyan-100 transition hover:border-cyan-200/60 hover:bg-cyan-300/20 focus:outline-none focus:ring-4 focus:ring-cyan-400/20"
|
||||
>
|
||||
Copy all env vars
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center justify-between gap-4 border-b border-white/10 pb-4 text-sm text-slate-400">
|
||||
<span>Showing {filteredEnvVars.length} of {diagnostics?.envVars.length || 0} variables</span>
|
||||
{isLoadingDiagnostics && <span>Loading latest diagnostics…</span>}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 max-h-[40rem] space-y-3 overflow-auto pr-1">
|
||||
{filteredEnvVars.map((entry) => (
|
||||
<div key={entry.key} className="rounded-2xl border border-white/10 bg-slate-950/35 p-4">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold uppercase tracking-[0.18em] text-cyan-200">{entry.key}</div>
|
||||
<div className="mt-2 break-all font-mono text-xs leading-6 text-slate-200">{entry.value}</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopy(`${entry.key}=${entry.value}`, `${entry.key} line`)}
|
||||
className="rounded-xl border border-white/10 bg-white/5 px-3 py-1.5 text-xs font-medium text-slate-100 transition hover:border-white/20 hover:bg-white/10"
|
||||
>
|
||||
Copy line
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardBox>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</SectionFullScreen>
|
||||
<div className='bg-black text-white flex flex-col text-center justify-center md:flex-row'>
|
||||
<p className='py-6 text-sm'>© 2026 <span>{title}</span>. All rights reserved</p>
|
||||
<Link className='py-6 ml-4 text-sm' href='/privacy-policy/'>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||
return <LayoutGuest>{page}</LayoutGuest>;
|
||||
};
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user