From a7d542a5c7a6dd2ff25b0f4db7fe213fce15ef22 Mon Sep 17 00:00:00 2001 From: Julien Bouquillon Date: Mon, 30 Dec 2024 19:25:34 +0100 Subject: [PATCH] feat: use static build and ask user token --- Dockerfile | 30 +----- csp.config.mjs | 2 +- next.config.mjs | 13 ++- package.json | 4 +- scripts/{prebuild.ts => prebuild.mjs} | 5 +- src/components/InputAlbertToken.tsx | 64 ++++++++++++ src/lib/albert.ts | 64 ++++++------ src/pages/_app.tsx | 21 +++- src/pages/api/albert/[[...path]].ts | 85 ---------------- src/pages/api/sentry-example-api.js | 5 - .../{collection/[id].tsx => collection.tsx} | 79 ++++++++------- src/pages/index.tsx | 97 +++++++++++-------- tsconfig.json | 12 ++- yarn.lock | 7 ++ 14 files changed, 247 insertions(+), 241 deletions(-) rename scripts/{prebuild.ts => prebuild.mjs} (73%) create mode 100644 src/components/InputAlbertToken.tsx delete mode 100644 src/pages/api/albert/[[...path]].ts delete mode 100644 src/pages/api/sentry-example-api.js rename src/pages/{collection/[id].tsx => collection.tsx} (80%) diff --git a/Dockerfile b/Dockerfile index b935d3e..209a7f5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,33 +51,9 @@ RUN \ else echo "Lockfile not found." && exit 1; \ fi -# Production image, copy all the files and run next -FROM base AS runner -WORKDIR /app - -ENV NODE_ENV=production -# Uncomment the following line in case you want to disable telemetry during runtime. -# ENV NEXT_TELEMETRY_DISABLED=1 - -RUN addgroup --system --gid 1001 nodejs -RUN adduser --system --uid 1001 nextjs - -COPY --from=builder /app/public ./public -# Automatically leverage output traces to reduce image size -# https://nextjs.org/docs/advanced-features/output-file-tracing -COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ -COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static - -USER nextjs - -EXPOSE 3000 - -ENV PORT=3000 -ENV NEXT_TELEMETRY_DISABLED 1 +# static production image -# server.js is created by next build from the standalone output -# https://nextjs.org/docs/pages/api-reference/next-config-js/output -ENV HOSTNAME="0.0.0.0" -CMD ["node", "server.js"] +FROM nginx:1.27.3-alpine +COPY --from=builder /app/out /usr/share/nginx/html/ diff --git a/csp.config.mjs b/csp.config.mjs index 4b889a9..339fd36 100644 --- a/csp.config.mjs +++ b/csp.config.mjs @@ -4,7 +4,7 @@ const ContentSecurityPolicy = ` script-src 'self' *.gouv.fr ${ process.env.NODE_ENV !== "production" && "'unsafe-eval' 'unsafe-inline'" }; - connect-src 'self' *.gouv.fr https://sentry.incubateur.net; + connect-src 'self' *.gouv.fr https://sentry.incubateur.net *.incubateur.net; frame-src 'self' *.gouv.fr; style-src 'self' 'unsafe-inline'; font-src 'self' data: blob:; diff --git a/next.config.mjs b/next.config.mjs index c077a45..19b827c 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -19,13 +19,12 @@ const moduleExports = { //basePath: process.env.NEXT_PUBLIC_BASE_PATH, pageExtensions: ["js", "jsx", "mdx", "ts", "tsx"], reactStrictMode: true, - output: "standalone", - experimental: { - serverActions: { - bodySizeLimit: "10mb", - }, - }, - //output: "export", + output: "export", + // experimental: { + // serverActions: { + // bodySizeLimit: "10mb", + // }, + // }, webpack: (config) => { config.module.rules.push({ test: /\.(woff2|webmanifest)$/, diff --git a/package.json b/package.json index 0b888b4..2e7d514 100644 --- a/package.json +++ b/package.json @@ -6,9 +6,10 @@ "engines": { "node": ">=18 || >=20" }, + "type": "module", "scripts": { "predev": "only-include-used-icons && cp -a node_modules/@gouvfr/dsfr-chart/Charts ./public/", - "prebuild": "node -r @swc-node/register scripts/prebuild.ts && yarn only-include-used-icons && cp -a node_modules/@gouvfr/dsfr-chart/Charts ./public/", + "prebuild": "node scripts/prebuild.mjs && yarn only-include-used-icons && cp -a node_modules/@gouvfr/dsfr-chart/Charts ./public/ && yarn type-check", "only-include-used-icons": "node node_modules/@codegouvfr/react-dsfr/bin/only-include-used-icons.js", "dev": "next dev", "build": "next build", @@ -51,6 +52,7 @@ "react-markdown": "^9.0.1", "remark-gfm": "^4.0.0", "tss-react": "^4.9.14", + "usehooks-ts": "^3.1.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/scripts/prebuild.ts b/scripts/prebuild.mjs similarity index 73% rename from scripts/prebuild.ts rename to scripts/prebuild.mjs index 0afc78c..d23f075 100644 --- a/scripts/prebuild.ts +++ b/scripts/prebuild.mjs @@ -1,9 +1,8 @@ -import path from "path"; import fs from "fs"; -export const filePath = path.join(__dirname, "../public/robots.txt"); +export const filePath = "./public/robots.txt"; -export const generateRobotsTxt = (isOnProduction: boolean, host: string) => { +export const generateRobotsTxt = (isOnProduction, host) => { const robotsDev = ["User-agent: *", "Disallow: /"].join("\n"); const robotsProd = ["User-agent: *", "Allow: /"].join("\n"); diff --git a/src/components/InputAlbertToken.tsx b/src/components/InputAlbertToken.tsx new file mode 100644 index 0000000..9905728 --- /dev/null +++ b/src/components/InputAlbertToken.tsx @@ -0,0 +1,64 @@ +"use client"; + +import { useSessionStorage } from "usehooks-ts"; + +import { fr } from "@codegouvfr/react-dsfr"; + +import { albertApi } from "../lib/albert"; +import { Input, InputProps } from "@codegouvfr/react-dsfr/Input"; +import Button from "@codegouvfr/react-dsfr/Button"; +import { useRef, useState } from "react"; + +export const InputAlbertToken = () => { + const input = useRef(null); + const [value, setValue] = useSessionStorage("albert-api-key", ""); + const [status, setStatus] = useState("default"); + const messages = { + error: "Le token semble invalide", + success: "Le token est valide", + info: "", + default: "", + }; + return value ? null : ( +
+

Token Albert invalide

+ { + // const value = input.current?.value || ""; + const value = input?.current?.querySelector("input")?.value || ""; + console.log("try setAlbertApiKey", value); + setStatus("default"); + const res = await albertApi({ + path: "/models", + method: "GET", + token: value, + }) + .then((r) => { + setStatus("success"); + setTimeout(() => { + console.log("setAlbertApiKey", value); + setValue(value); + }, 1000); + }) + .catch((e) => { + console.log("error", e); + setStatus("error"); + }); + console.log(res); + // setAlbertApiKey(value); + }} + > + Valider + + } + /> +
+ ); +}; diff --git a/src/lib/albert.ts b/src/lib/albert.ts index e627734..3fb8042 100644 --- a/src/lib/albert.ts +++ b/src/lib/albert.ts @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; export const ALBERT_API_KEY = process.env.ALBERT_API_KEY; -export const API_URL = "/api/albert"; //https://albert.api.etalab.gouv.fr"; +export const API_URL = "https://albert-api.kube-dev.incubateur.net"; //https://albert.api.etalab.gouv.fr"; // "/api/albert" export const LANGUAGE_MODEL = "AgentPublic/llama3-instruct-8b"; // see https://albert.api.etalab.gouv.fr/v1/models export const EMBEDDING_MODEL = "BAAI/bge-m3"; @@ -9,21 +9,23 @@ export const albertApi = ({ path, method = "POST", body, + token = ALBERT_API_KEY, }: { path: string; method?: "POST" | "GET"; body?: string; + token?: string; }) => fetch(`${API_URL}/v1${path}`, { method, headers: { - Authorization: `Bearer ${ALBERT_API_KEY}`, + Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body, }).then((r) => r.json()); -type AlbertCollection = { +export type AlbertCollection = { id: string; name: string; type: "public" | "private"; @@ -34,13 +36,17 @@ type AlbertCollection = { documents: null | number; }; -export const useAlbertCollections = () => { +export const useAlbertCollections = (albertToken: string) => { const [collections, setCollections] = useState([]); const reloadCollections = async () => { + if (!albertToken) { + return; + } const collections = await albertApi({ path: "/collections", method: "GET", + token: albertToken, }); setCollections(collections.data || []); }; @@ -49,7 +55,7 @@ export const useAlbertCollections = () => { if (!collections.length) { reloadCollections(); } - }, [reloadCollections]); + }, [reloadCollections, albertToken]); return { collections, reloadCollections }; }; @@ -57,33 +63,28 @@ export const useAlbertCollections = () => { export const createCollection = ({ name, model = EMBEDDING_MODEL, + token = ALBERT_API_KEY, }: { name: string; model?: string; + token?: string; }) => - fetch(`${API_URL}/v1/collections`, { - method: "POST", - headers: { - Authorization: `Bearer ${ALBERT_API_KEY}`, - "Content-Type": "application/json", - }, + albertApi({ + path: "/collections", body: JSON.stringify({ name, model }), - }) - .then((r) => r.json()) - .then((d) => { - console.log(d); - return d; - }) - .then((d) => d.id); + token, + }).then((d) => d.id); export const addFileToCollection = async ({ file, fileName, collectionId, + token = ALBERT_API_KEY, }: { file: File; fileName: string; collectionId: string; + token?: string; }) => { const formData = new FormData(); formData.append("file", file, fileName); @@ -91,7 +92,7 @@ export const addFileToCollection = async ({ return fetch(`${API_URL}/v1/files`, { method: "POST", headers: { - Authorization: `Bearer ${ALBERT_API_KEY}`, + Authorization: `Bearer ${token}`, //"Content-Type": "multipart/form-data", }, body: formData, @@ -125,28 +126,21 @@ export const addFileToCollection = async ({ export const getSearch = ({ collections, query, + token = ALBERT_API_KEY, }: { collections: string[]; query: string; + token?: string; }) => { console.log({ url: `${API_URL}/v1/search`, query }); - return fetch(`${API_URL}/v1/search`, { - cache: "no-cache", - method: "POST", - headers: { - Authorization: `Bearer ${ALBERT_API_KEY}`, - "Content-Type": "application/json", - }, + return albertApi({ + path: "/search", + token, body: JSON.stringify({ collections, k: 6, prompt: query }), - }) - .then((r) => { - console.log(r); - return r.json(); - }) - .catch((r) => { - console.error(r); - throw r; - }); + }).catch((r) => { + console.error(r); + throw r; + }); }; export const getPromptWithRagResults = ({ diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 332e572..6d08e30 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -19,6 +19,8 @@ import { init } from "@socialgouv/matomo-next"; import pkg from "../../package.json"; import "./styles.css"; +import { useSessionStorage } from "usehooks-ts"; +import Button from "@codegouvfr/react-dsfr/Button"; declare module "@codegouvfr/react-dsfr/next-pagesdir" { interface RegisterLink { @@ -126,7 +128,24 @@ const bottomLinks = [ const Layout = ({ children }: { children: ReactNode }) => { const router = useRouter(); + const [albertApiKey, setAlbertApiKey, deleteAlbertApiKey] = useSessionStorage( + "albert-api-key", + "" + ); + const logout = + (albertApiKey && ( + + )) || + null; const contentSecurityPolicy = process.env.CONTENT_SECURITY_POLICY; return ( @@ -192,7 +211,7 @@ const Layout = ({ children }: { children: ReactNode }) => { isActive: router.asPath === "/a-propos", }, ]} - quickAccessItems={[headerFooterDisplayItem]} + quickAccessItems={[logout, headerFooterDisplayItem]} />
-) { - const data = { - path: req.url, - query: req.query, - method: req.method, - headers: req.headers, - body: req.body, - }; - // console.log(data); - // console.log( - // `${API_URL}/${ - // data.query.path && - // Array.isArray(data.query.path) && - // data.query.path.join("/") - // }` - //); - - const fetchOptions = { - method: req.method, - headers: { - Authorization: `Bearer ${ALBERT_API_KEY}`, - } as Record, - body: (req.method === "POST" && req.body) || undefined, - }; - if (req.headers["content-type"]) { - fetchOptions.headers["Content-Type"] = req.headers["content-type"]; - } - - const body = - req.method === "GET" - ? undefined - : fetchOptions.headers["Content-Type"] === "application/json" - ? JSON.stringify(req.body) - : req.body; - fetchOptions.body = body; - - const albertApiResponse = await fetch( - `${API_URL}/${ - data.query.path && - Array.isArray(data.query.path) && - data.query.path.join("/") - }`, - fetchOptions - ).catch((e) => { - console.log("e", e); - res.status(500).write(e.message); - }); - - // allow streaming - const reader = - albertApiResponse && - albertApiResponse.body && - albertApiResponse.body.getReader(); - - while (reader && true) { - const result = await reader.read(); - if (result.done) { - res.end(); - return; - } - res.write(result.value); - } -} diff --git a/src/pages/api/sentry-example-api.js b/src/pages/api/sentry-example-api.js deleted file mode 100644 index ac07eec..0000000 --- a/src/pages/api/sentry-example-api.js +++ /dev/null @@ -1,5 +0,0 @@ -// A faulty API route to test Sentry's error monitoring -export default function handler(_req, res) { - throw new Error("Sentry Example API Route Error"); - res.status(200).json({ name: "John Doe" }); -} diff --git a/src/pages/collection/[id].tsx b/src/pages/collection.tsx similarity index 80% rename from src/pages/collection/[id].tsx rename to src/pages/collection.tsx index 648d206..6351a2f 100644 --- a/src/pages/collection/[id].tsx +++ b/src/pages/collection.tsx @@ -27,9 +27,12 @@ import { ALBERT_API_KEY, API_URL, LANGUAGE_MODEL, -} from "../../lib/albert"; +} from "../lib/albert"; -import { mdxComponents } from "../../../mdx-components"; +import { mdxComponents } from "../../mdx-components"; +import { useQueryState } from "nuqs"; +import { useSessionStorage } from "usehooks-ts"; +import { InputAlbertToken } from "../components/InputAlbertToken"; function MyDropzone({ children, @@ -131,8 +134,8 @@ const CollectionPage: NextPage<{ collectionId: string }> = ({ const [messagesOverrides, setMessagesOverrides] = useState< Record >({}); - - const { collections, reloadCollections } = useAlbertCollections(); + const [albertApiKey] = useSessionStorage("albert-api-key", ""); + const { collections, reloadCollections } = useAlbertCollections(albertApiKey); const collection = collections.find((c) => c.id === collectionId); const overrideMessage = (id: string, data: any) => { @@ -154,6 +157,7 @@ const CollectionPage: NextPage<{ collectionId: string }> = ({ }, ]); const uploaded = await addFileToCollection({ + token: albertApiKey, file, fileName: file.name, collectionId, @@ -179,6 +183,7 @@ const CollectionPage: NextPage<{ collectionId: string }> = ({ const searchResults = await getSearch({ collections: [collectionId], query: input, + token: albertApiKey, }); const prompt = getPromptWithRagResults({ input, results: searchResults }); @@ -213,7 +218,7 @@ const CollectionPage: NextPage<{ collectionId: string }> = ({ } = useChat({ api: `${API_URL}/v1/chat/completions`, headers: { - Authorization: `Bearer ${ALBERT_API_KEY}`, + Authorization: `Bearer ${albertApiKey}`, "Content-Type": "application/json", }, body: { @@ -243,40 +248,44 @@ const CollectionPage: NextPage<{ collectionId: string }> = ({ return (
- -
- ({ - ...m, - ...(messagesOverrides[m.id] || {}), - }))} - input={input} - handleInputChange={handleInputChange} - handleSubmit={myHandleSubmit} - hintText={ - collection && - `Albert cherchera parmi les ${collection.documents} documents de votre collection "${collection.name}"` - } - /> -
-
+ + {albertApiKey && ( + +
+ ({ + ...m, + ...(messagesOverrides[m.id] || {}), + }))} + input={input} + handleInputChange={handleInputChange} + handleSubmit={myHandleSubmit} + hintText={ + collection && + `Albert cherchera parmi les ${collection.documents} documents de votre collection "${collection.name}"` + } + /> +
+
+ )}
); }; -export const getServerSideProps = (async (req) => { - return { - props: { - collectionId: Array.isArray(req.query.id) - ? req.query.id[0] - : req.query.id || "random", - }, - }; -}) satisfies GetServerSideProps<{ collectionId: string }>; +// export const getServerSideProps = (async (req) => { +// return { +// props: { +// collectionId: Array.isArray(req.query.id) +// ? req.query.id[0] +// : req.query.id || "random", +// }, +// }; +// }) satisfies GetServerSideProps<{ collectionId: string }>; -export default function Page({ - collectionId, -}: InferGetServerSidePropsType) { +export default function Page() { + const [collectionId, setCollectionId] = useQueryState("id", { + defaultValue: "", + }); return ; } diff --git a/src/pages/index.tsx b/src/pages/index.tsx index f4fda18..72f191d 100644 --- a/src/pages/index.tsx +++ b/src/pages/index.tsx @@ -1,15 +1,47 @@ +"use client"; + import * as React from "react"; import { NextPage } from "next"; +import { useSessionStorage } from "usehooks-ts"; import { fr } from "@codegouvfr/react-dsfr"; -import Card from "@codegouvfr/react-dsfr/Card"; +import { Card } from "@codegouvfr/react-dsfr/Card"; -import { useAlbertCollections, createCollection } from "../lib/albert"; +import { + useAlbertCollections, + createCollection, + AlbertCollection, +} from "../lib/albert"; import { useRouter } from "next/router"; +import { InputAlbertToken } from "../components/InputAlbertToken"; + +const CollectionCard = ({ collection }: { collection: AlbertCollection }) => ( + + {collection.documents + ? `${collection.documents} documents` + : "Aucun document"} + {collection.description} + + } + linkProps={{ + href: `/collection?id=${collection.id}`, + }} + size="small" + title={collection.name} + titleAs="h3" + /> +); + const Home: NextPage = () => { + const [albertApiKey] = useSessionStorage("albert-api-key", ""); const router = useRouter(); - const { collections } = useAlbertCollections(); + const { collections = [] } = useAlbertCollections(albertApiKey); return ( <> @@ -20,51 +52,38 @@ const Home: NextPage = () => {
- { - const name = prompt("Nom de la collection à créer ?"); - if (name) { - const collectionId = await createCollection({ name }); - router.push(`/collection/${collectionId}`); - } - }, - }} - size="small" - title={"Nouveau"} - titleAs="h3" - /> - {collections - .filter((coll) => coll.type === "private") - .map((coll) => ( + + {albertApiKey && ( + <> - {coll.documents - ? `${coll.documents} documents` - : "Aucun document"} - - {coll.description} - - - } + desc={`Ajouter des fichiers et les intérroger`} linkProps={{ - href: `/collection/${coll.id}`, + href: `#not`, + onClick: async () => { + const name = prompt("Nom de la collection à créer ?"); + if (name) { + const collectionId = await createCollection({ + name, + token: albertApiKey, + }); + router.push(`/collection?id=${collectionId}`); + } + }, }} size="small" - title={coll.name} + title={"Nouveau"} titleAs="h3" /> - ))} + {collections + .filter((coll) => coll.type === "private") + .map((coll) => ( + + ))} + + )}
); diff --git a/tsconfig.json b/tsconfig.json index 5911085..d6aa535 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,11 @@ "compilerOptions": { "target": "es5", "module": "esnext", - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -15,7 +19,11 @@ "jsx": "preserve", "incremental": true }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx" + ], "exclude": [ "node_modules", "**/*.spec.ts", diff --git a/yarn.lock b/yarn.lock index 64b1946..1f8b267 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14333,6 +14333,13 @@ use-sync-external-store@^1.2.0: resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz" integrity sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw== +usehooks-ts@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/usehooks-ts/-/usehooks-ts-3.1.0.tgz#156119f36efc85f1b1952616c02580f140950eca" + integrity sha512-bBIa7yUyPhE1BCc0GmR96VU/15l/9gP1Ch5mYdLcFBaFGQsdmXkvjV0TtOqW1yUd6VjIwDunm+flSciCQXujiw== + dependencies: + lodash.debounce "^4.0.8" + util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz"