test
This commit is contained in:
parent
afdc19bebf
commit
67d9fa33e5
@ -6,7 +6,6 @@ const passport = require('passport');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const bodyParser = require('body-parser');
|
const bodyParser = require('body-parser');
|
||||||
const db = require('./db/models');
|
|
||||||
const config = require('./config');
|
const config = require('./config');
|
||||||
const swaggerUI = require('swagger-ui-express');
|
const swaggerUI = require('swagger-ui-express');
|
||||||
const swaggerJsDoc = require('swagger-jsdoc');
|
const swaggerJsDoc = require('swagger-jsdoc');
|
||||||
@ -16,6 +15,7 @@ const fileRoutes = require('./routes/file');
|
|||||||
const searchRoutes = require('./routes/search');
|
const searchRoutes = require('./routes/search');
|
||||||
const sqlRoutes = require('./routes/sql');
|
const sqlRoutes = require('./routes/sql');
|
||||||
const pexelsRoutes = require('./routes/pexels');
|
const pexelsRoutes = require('./routes/pexels');
|
||||||
|
const publicLlmRoutes = require('./routes/publicLlm');
|
||||||
|
|
||||||
const openaiRoutes = require('./routes/openai');
|
const openaiRoutes = require('./routes/openai');
|
||||||
|
|
||||||
@ -101,7 +101,7 @@ app.use('/api/auth', authRoutes);
|
|||||||
app.use('/api/file', fileRoutes);
|
app.use('/api/file', fileRoutes);
|
||||||
app.use('/api/pexels', pexelsRoutes);
|
app.use('/api/pexels', pexelsRoutes);
|
||||||
app.enable('trust proxy');
|
app.enable('trust proxy');
|
||||||
|
app.use('/api/public-llm', publicLlmRoutes);
|
||||||
|
|
||||||
app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes);
|
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 Link from 'next/link'
|
||||||
import { useState } from 'react'
|
|
||||||
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
import { mdiChevronUp, mdiChevronDown } from '@mdi/js'
|
||||||
import BaseDivider from './BaseDivider'
|
import BaseDivider from './BaseDivider'
|
||||||
import BaseIcon from './BaseIcon'
|
import BaseIcon from './BaseIcon'
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
import React, { ReactNode, useEffect } from 'react'
|
import React, { ReactNode, useEffect, useState } from 'react'
|
||||||
import { useState } from 'react'
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
import { mdiForwardburger, mdiBackburger, mdiMenu } from '@mdi/js'
|
||||||
import menuAside from '../menuAside'
|
import menuAside from '../menuAside'
|
||||||
|
|||||||
@ -1,166 +1,731 @@
|
|||||||
|
import React, { ReactElement, useEffect, useMemo, useState } from 'react';
|
||||||
import React, { useEffect, useState } from 'react';
|
|
||||||
import type { ReactElement } from 'react';
|
|
||||||
import Head from 'next/head';
|
import Head from 'next/head';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import axios from 'axios';
|
||||||
import BaseButton from '../components/BaseButton';
|
import BaseButton from '../components/BaseButton';
|
||||||
import CardBox from '../components/CardBox';
|
import CardBox from '../components/CardBox';
|
||||||
import SectionFullScreen from '../components/SectionFullScreen';
|
import NotificationBar from '../components/NotificationBar';
|
||||||
import LayoutGuest from '../layouts/Guest';
|
|
||||||
import BaseDivider from '../components/BaseDivider';
|
|
||||||
import BaseButtons from '../components/BaseButtons';
|
|
||||||
import { getPageTitle } from '../config';
|
import { getPageTitle } from '../config';
|
||||||
import { useAppSelector } from '../stores/hooks';
|
import LayoutGuest from '../layouts/Guest';
|
||||||
import CardBoxComponentTitle from "../components/CardBoxComponentTitle";
|
|
||||||
import { getPexelsImage, getPexelsVideo } from '../helpers/pexels';
|
|
||||||
|
|
||||||
|
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() {
|
export default function Starter() {
|
||||||
const [illustrationImage, setIllustrationImage] = useState({
|
const [diagnostics, setDiagnostics] = useState<DiagnosticsPayload | null>(null);
|
||||||
src: undefined,
|
const [isLoadingDiagnostics, setIsLoadingDiagnostics] = useState(true);
|
||||||
photographer: undefined,
|
const [diagnosticsError, setDiagnosticsError] = useState('');
|
||||||
photographer_url: undefined,
|
const [prompt, setPrompt] = useState('Reply with a concise health check for this LLM proxy.');
|
||||||
})
|
const [selectedModel, setSelectedModel] = useState('');
|
||||||
const [illustrationVideo, setIllustrationVideo] = useState({video_files: []})
|
const [envSearch, setEnvSearch] = useState('');
|
||||||
const [contentType, setContentType] = useState('video');
|
const [proxyResponse, setProxyResponse] = useState<ProxyResponsePayload | null>(null);
|
||||||
const [contentPosition, setContentPosition] = useState('left');
|
const [proxyError, setProxyError] = useState('');
|
||||||
const textColor = useAppSelector((state) => state.style.linkColor);
|
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
|
try {
|
||||||
useEffect(() => {
|
const response = await axios.get<DiagnosticsPayload>(
|
||||||
async function fetchData() {
|
forceRefresh ? '/public-llm/diagnostics?refresh=true' : '/public-llm/diagnostics',
|
||||||
const image = await getPexelsImage();
|
);
|
||||||
const video = await getPexelsVideo();
|
const payload = response.data;
|
||||||
setIllustrationImage(image);
|
setDiagnostics(payload);
|
||||||
setIllustrationVideo(video);
|
setPrompt((currentPrompt) => currentPrompt || payload.defaultPrompt);
|
||||||
}
|
setSelectedModel((currentModel) => currentModel || payload.availableModels[0] || payload.proxyConfig.defaultModel);
|
||||||
fetchData();
|
} 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) => (
|
useEffect(() => {
|
||||||
<div
|
loadDiagnostics();
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
const videoBlock = (video) => {
|
const environmentMap = useMemo(() => {
|
||||||
if (video?.video_files?.length > 0) {
|
const entries = diagnostics?.envVars || [];
|
||||||
return (
|
return new Map(entries.map((entry) => [entry.key, entry.value]));
|
||||||
<div className='hidden md:flex flex-col justify-end relative flex-grow-0 flex-shrink-0 w-1/3'>
|
}, [diagnostics]);
|
||||||
<video
|
|
||||||
className='absolute top-0 left-0 w-full h-full object-cover'
|
const activeModel = useMemo(() => {
|
||||||
autoPlay
|
if (!diagnostics) {
|
||||||
loop
|
return selectedModel || 'gpt-5-mini';
|
||||||
muted
|
}
|
||||||
>
|
|
||||||
<source src={video?.video_files[0]?.link} type='video/mp4'/>
|
return selectedModel.trim() || diagnostics.availableModels[0] || diagnostics.proxyConfig.defaultModel;
|
||||||
Your browser does not support the video tag.
|
}, [diagnostics, selectedModel]);
|
||||||
</video>
|
|
||||||
<div className='flex justify-center w-full bg-blue-300/20 z-10'>
|
const activePrompt = useMemo(() => {
|
||||||
<a
|
return prompt.trim() || diagnostics?.defaultPrompt || 'Reply with OK only.';
|
||||||
className='text-[8px]'
|
}, [diagnostics?.defaultPrompt, prompt]);
|
||||||
href={video?.user?.url}
|
|
||||||
target='_blank'
|
const filteredEnvVars = useMemo(() => {
|
||||||
rel='noreferrer'
|
const query = envSearch.trim().toLowerCase();
|
||||||
>
|
const entries = diagnostics?.envVars || [];
|
||||||
Video by {video.user.name} on Pexels
|
|
||||||
</a>
|
if (!query) {
|
||||||
</div>
|
return entries;
|
||||||
</div>)
|
}
|
||||||
}
|
|
||||||
};
|
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 (
|
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>
|
<Head>
|
||||||
<title>{getPageTitle('Starter Page')}</title>
|
<title>{getPageTitle('LLM Proxy Console')}</title>
|
||||||
</Head>
|
</Head>
|
||||||
|
|
||||||
<SectionFullScreen bg='violet'>
|
<div className="min-h-screen bg-slate-950 text-slate-50">
|
||||||
<div
|
<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%)]">
|
||||||
className={`flex ${
|
<div className="absolute left-8 top-24 h-40 w-40 rounded-full bg-cyan-400/20 blur-3xl" />
|
||||||
contentPosition === 'right' ? 'flex-row-reverse' : 'flex-row'
|
<div className="absolute right-0 top-0 h-64 w-64 rounded-full bg-blue-500/20 blur-3xl" />
|
||||||
} 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'
|
|
||||||
/>
|
|
||||||
|
|
||||||
</BaseButtons>
|
<main className="relative mx-auto max-w-7xl px-6 py-8 lg:px-8">
|
||||||
</CardBox>
|
<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>
|
||||||
</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) {
|
Starter.getLayout = function getLayout(page: ReactElement) {
|
||||||
return <LayoutGuest>{page}</LayoutGuest>;
|
return <LayoutGuest>{page}</LayoutGuest>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user