Initial SFERA platform baseline
This commit is contained in:
@@ -0,0 +1,79 @@
|
||||
import { resolveApiUrl } from "@/lib/api";
|
||||
|
||||
type RouteContext = {
|
||||
params: Promise<{ path: string[] }>;
|
||||
};
|
||||
|
||||
export async function GET(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function POST(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function PUT(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function PATCH(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function DELETE(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function OPTIONS(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
export async function HEAD(request: Request, context: RouteContext) {
|
||||
return proxy(request, context);
|
||||
}
|
||||
|
||||
async function proxy(request: Request, context: RouteContext) {
|
||||
const { path } = await context.params;
|
||||
const apiUrl = resolveApiUrl(request.headers.get("host"));
|
||||
const sourceUrl = new URL(request.url);
|
||||
const target = `${apiUrl}/${path.join("/")}${sourceUrl.search}`;
|
||||
const method = request.method;
|
||||
const hasBody = method !== "GET" && method !== "HEAD" && request.body !== null;
|
||||
const body = hasBody ? await request.arrayBuffer() : undefined;
|
||||
const contentType = request.headers.get("Content-Type");
|
||||
const accept = request.headers.get("Accept") ?? "application/json";
|
||||
const publicHost = request.headers.get("x-forwarded-host") ?? request.headers.get("host");
|
||||
const publicProto = request.headers.get("x-forwarded-proto") ?? "http";
|
||||
const publicOrigin = publicHost ? `${publicProto}://${publicHost}` : sourceUrl.origin;
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Accept: accept,
|
||||
"X-Sfera-Public-Origin": publicOrigin
|
||||
};
|
||||
if (hasBody) {
|
||||
headers["Content-Type"] = contentType ?? "application/json";
|
||||
}
|
||||
|
||||
const response = await fetch(target, {
|
||||
method,
|
||||
cache: "no-store",
|
||||
headers,
|
||||
body
|
||||
});
|
||||
const responseHeaders: Record<string, string> = {
|
||||
"Content-Type": response.headers.get("Content-Type") ?? "application/json"
|
||||
};
|
||||
const contentDisposition = response.headers.get("Content-Disposition");
|
||||
if (contentDisposition) {
|
||||
responseHeaders["Content-Disposition"] = contentDisposition;
|
||||
}
|
||||
const cacheControl = response.headers.get("Cache-Control");
|
||||
if (cacheControl) {
|
||||
responseHeaders["Cache-Control"] = cacheControl;
|
||||
}
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
headers: responseHeaders
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { AlertTriangle } from "lucide-react";
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { IdeWorkspace } from "@/components/editor/ide-workspace";
|
||||
import { AppShell } from "@/components/layout/app-shell";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import {
|
||||
getApiHealth,
|
||||
getProjects,
|
||||
getProjectWorkspaceData,
|
||||
resolveApiUrl,
|
||||
type ProjectSummary
|
||||
} from "@/lib/api";
|
||||
import { messages, normalizeLanguage, type UiLanguage } from "@/lib/i18n";
|
||||
|
||||
export default async function EditorPage({
|
||||
searchParams
|
||||
}: Readonly<{
|
||||
searchParams?: Promise<{ lang?: string; mode?: string; project?: string; routine?: string }>;
|
||||
}>) {
|
||||
const params = await searchParams;
|
||||
const language = normalizeLanguage(params?.lang);
|
||||
const requestHeaders = await headers();
|
||||
const apiUrl = resolveApiUrl(requestHeaders.get("host"));
|
||||
const bootstrap = await loadEditorBootstrap(apiUrl, language, params?.project);
|
||||
const projectData =
|
||||
bootstrap.status === "ok" && bootstrap.projectId ? await getProjectWorkspaceData(bootstrap.projectId, apiUrl, params?.routine, params?.mode) : null;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
apiStatus={bootstrap.status}
|
||||
language={language}
|
||||
projectId={bootstrap.status === "ok" ? bootstrap.projectId : undefined}
|
||||
projectOptions={bootstrap.status === "ok" ? bootstrap.projects : []}
|
||||
>
|
||||
<main className="pb-8">
|
||||
{bootstrap.status === "error" ? (
|
||||
<ErrorState language={language} message={bootstrap.error} />
|
||||
) : projectData ? (
|
||||
<IdeWorkspace
|
||||
key={projectData.projectId}
|
||||
data={projectData}
|
||||
initialMode={params?.mode}
|
||||
language={language}
|
||||
routineName={params?.routine}
|
||||
/>
|
||||
) : (
|
||||
<ErrorState language={language} message={messages[language].noProjectDataDescription} />
|
||||
)}
|
||||
</main>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
async function loadEditorBootstrap(apiUrl: string, language: UiLanguage, projectId?: string): Promise<
|
||||
| { status: "ok"; projectId: string | undefined; projects: ProjectSummary[] }
|
||||
| { status: "error"; error: string }
|
||||
> {
|
||||
try {
|
||||
const [projectsResponse] = await Promise.all([getProjects(apiUrl), getApiHealth(apiUrl)]);
|
||||
const projects = uniqueProjects(projectsResponse);
|
||||
return { status: "ok", projectId: projectId || pickDefaultProject(projectsResponse), projects };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "error",
|
||||
error: error instanceof Error ? error.message : messages[language].unknownApiError
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function ErrorState({
|
||||
message,
|
||||
language
|
||||
}: Readonly<{
|
||||
message: string;
|
||||
language: UiLanguage;
|
||||
}>) {
|
||||
const t = messages[language];
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex min-h-[70vh] max-w-xl items-center justify-center">
|
||||
<Card className="w-full">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-1 h-5 w-5 text-destructive" aria-hidden="true" />
|
||||
<div>
|
||||
<h1 className="text-base font-semibold">{t.apiUnavailable}</h1>
|
||||
<p className="mt-2 text-sm leading-6 text-muted-foreground">{message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function pickDefaultProject(projects: ProjectSummary[]) {
|
||||
return (
|
||||
projects.find((project) => project.project_id === "ifcm-upo")?.project_id ??
|
||||
projects.find((project) => project.project_id !== "demo")?.project_id ??
|
||||
projects.at(-1)?.project_id ??
|
||||
projects.find((project) => project.project_id === "demo")?.project_id
|
||||
);
|
||||
}
|
||||
|
||||
function uniqueProjects(projects: ProjectSummary[]) {
|
||||
const byId = new Map<string, ProjectSummary>();
|
||||
for (const project of projects) {
|
||||
const projectId = project.project_id.trim();
|
||||
if (projectId) {
|
||||
byId.set(projectId, { ...project, project_id: projectId, name: project.name || projectId });
|
||||
}
|
||||
}
|
||||
return Array.from(byId.values()).sort((left, right) => (left.name || left.project_id).localeCompare(right.name || right.project_id, "ru"));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: 210 20% 98%;
|
||||
--foreground: 222 47% 11%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222 47% 11%;
|
||||
--muted: 210 17% 94%;
|
||||
--muted-foreground: 215 16% 47%;
|
||||
--border: 214 20% 88%;
|
||||
--input: 214 20% 88%;
|
||||
--primary: 198 93% 32%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 168 42% 39%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--destructive: 0 72% 45%;
|
||||
--warning: 38 92% 48%;
|
||||
--success: 142 54% 36%;
|
||||
--info: 214 84% 56%;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 220 24% 10%;
|
||||
--foreground: 210 20% 96%;
|
||||
--card: 220 20% 14%;
|
||||
--card-foreground: 210 20% 96%;
|
||||
--muted: 220 16% 20%;
|
||||
--muted-foreground: 215 15% 66%;
|
||||
--border: 220 16% 24%;
|
||||
--input: 220 16% 24%;
|
||||
--primary: 194 80% 46%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 164 52% 43%;
|
||||
--secondary-foreground: 0 0% 100%;
|
||||
--destructive: 0 72% 51%;
|
||||
--warning: 38 92% 52%;
|
||||
--success: 142 54% 42%;
|
||||
--info: 214 84% 62%;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
body {
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family:
|
||||
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: hsl(var(--primary) / 0.18);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export async function GET() {
|
||||
return Response.json({ status: "ok", service: "sfera-web" });
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import type React from "react";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "SFERA",
|
||||
description: "Semantic 1C operating workspace"
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="ru">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function HomePage({
|
||||
searchParams
|
||||
}: Readonly<{
|
||||
searchParams?: Promise<{ lang?: string; mode?: string; project?: string; routine?: string }>;
|
||||
}>) {
|
||||
const params = await searchParams;
|
||||
const nextParams = new URLSearchParams();
|
||||
if (params?.lang === "en") {
|
||||
nextParams.set("lang", "en");
|
||||
}
|
||||
for (const key of ["project", "mode", "routine"] as const) {
|
||||
const value = params?.[key];
|
||||
if (value) {
|
||||
nextParams.set(key, value);
|
||||
}
|
||||
}
|
||||
const suffix = nextParams.size > 0 ? `?${nextParams.toString()}` : "";
|
||||
|
||||
redirect(`/editor${suffix}`);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { headers } from "next/headers";
|
||||
|
||||
import { ProjectSetupClient, type ProjectSetup } from "@/components/project-setup/project-setup-client";
|
||||
import { getProjects, resolveApiUrl } from "@/lib/api";
|
||||
|
||||
export default async function ProjectSettingsPage({
|
||||
searchParams
|
||||
}: Readonly<{ searchParams?: Promise<{ project?: string; new?: string }> }>) {
|
||||
const requestHeaders = await headers();
|
||||
const params = searchParams ? await searchParams : {};
|
||||
if (params.new === "1") {
|
||||
return <ProjectSetupClient initialSetup={newProjectSetup()} />;
|
||||
}
|
||||
const apiUrl = resolveApiUrl(requestHeaders.get("host"));
|
||||
const projectId = normalizeProjectId(params.project);
|
||||
const setup = await loadSetup(apiUrl, projectId);
|
||||
|
||||
return <ProjectSetupClient initialSetup={setup} />;
|
||||
}
|
||||
|
||||
function newProjectSetup(): ProjectSetup {
|
||||
return {
|
||||
project_id: "__new__",
|
||||
status: "NOT_CONFIGURED",
|
||||
message: "Новый проект. Заполните основные параметры и сохраните.",
|
||||
settings: defaultProjectSettings(""),
|
||||
current_source: null,
|
||||
last_import: null,
|
||||
import_history: [],
|
||||
import_sources: []
|
||||
};
|
||||
}
|
||||
|
||||
async function loadSetup(apiUrl: string, projectId: string): Promise<ProjectSetup> {
|
||||
try {
|
||||
const response = await fetch(`${apiUrl}/projects/${encodeURIComponent(projectId)}/setup`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
} catch {
|
||||
const projects = await getProjects(apiUrl).catch(() => []);
|
||||
const fallbackProjectId = projects.at(0)?.project_id ?? "default";
|
||||
if (fallbackProjectId !== projectId) {
|
||||
return loadSetup(apiUrl, fallbackProjectId);
|
||||
}
|
||||
return {
|
||||
project_id: fallbackProjectId,
|
||||
status: "NOT_CONFIGURED",
|
||||
message: "Проект не проиндексирован. Выберите способ получения структуры конфигурации.",
|
||||
settings: defaultProjectSettings("SFERA Project"),
|
||||
current_source: null,
|
||||
last_import: null,
|
||||
import_history: [],
|
||||
import_sources: []
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function defaultProjectSettings(name = "SFERA Project") {
|
||||
return {
|
||||
name,
|
||||
configuration_source: null,
|
||||
structure_source: null,
|
||||
platform_version: null,
|
||||
compatibility_mode: null,
|
||||
extensions: [],
|
||||
environments: {},
|
||||
agent: {},
|
||||
server_import: {},
|
||||
privacy_mode: "METADATA_ONLY",
|
||||
knowledge_sources: [],
|
||||
task_session_policy: {}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectId(projectId?: string) {
|
||||
const trimmed = projectId?.trim();
|
||||
return trimmed || "default";
|
||||
}
|
||||
Reference in New Issue
Block a user