#!/usr/bin/env bash # Scaffold a "lovable-style" front-end website: # - Vite + React + TypeScript # - OIDC login (PKCE) with oidc-client-ts (sessionStorage) # - Chat UI that calls smart-ide-sso-gateway -> proxy/orchestrator -> intents (rag.query, chat.local) # # This script creates files and installs npm dependencies. It does not deploy. set -euo pipefail usage() { cat <<'EOF' Usage: ./tools/site-generate.sh --dir [--name ] [--skip-install] Examples: ./tools/site-generate.sh --dir ../sites/my-site --name my-site Notes: - Requires: node (>=20), npm, git (optional). - Output is intentionally verbose (no filtering). EOF } DIR="" NAME="" SKIP_INSTALL="false" while [[ $# -gt 0 ]]; do case "$1" in -h|--help) usage exit 0 ;; --dir) DIR="${2:-}" shift 2 ;; --name) NAME="${2:-}" shift 2 ;; --skip-install) SKIP_INSTALL="true" shift 1 ;; *) echo "[site-generate][ERROR] Unknown arg: $1" >&2 usage >&2 exit 2 ;; esac done command -v node >/dev/null 2>&1 || { echo "[site-generate][ERROR] Missing dependency: node" >&2; exit 1; } command -v npm >/dev/null 2>&1 || { echo "[site-generate][ERROR] Missing dependency: npm" >&2; exit 1; } if [[ -z "${DIR}" ]]; then echo "[site-generate][ERROR] Missing --dir " >&2 usage >&2 exit 2 fi TARGET="${DIR}" mkdir -p "$(dirname "${TARGET}")" if [[ -e "${TARGET}" && ! -d "${TARGET}" ]]; then echo "[site-generate][ERROR] Target exists and is not a directory: ${TARGET}" >&2 exit 1 fi mkdir -p "${TARGET}" if [[ ! -r "${TARGET}" || ! -x "${TARGET}" ]]; then echo "[site-generate][ERROR] Target directory is not accessible: ${TARGET}" >&2 exit 1 fi shopt -s nullglob dotglob entries=( "${TARGET}"/* ) shopt -u nullglob dotglob if (( ${#entries[@]} > 0 )); then echo "[site-generate][ERROR] Target directory is not empty: ${TARGET}" >&2 exit 1 fi if [[ -z "${NAME}" ]]; then NAME="$(basename "${TARGET}")" fi echo "[site-generate] target=${TARGET}" echo "[site-generate] name=${NAME}" ( cd "${TARGET}" npm init -y if [[ "${SKIP_INSTALL}" != "true" ]]; then # Runtime npm install react react-dom oidc-client-ts # Tooling npm install -D vite typescript @types/react @types/react-dom @vitejs/plugin-react else echo "[site-generate] --skip-install: dependencies will not be installed." fi cat > vite.config.ts <<'EOF' import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; export default defineConfig({ plugins: [react()], server: { port: 5173, strictPort: true }, }); EOF cat > tsconfig.json <<'EOF' { "compilerOptions": { "target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "Bundler", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", "strict": true, "types": ["vite/client"] }, "include": ["src"] } EOF cat > index.html <<'EOF' Smart IDE — Site
EOF mkdir -p src/auth src/api src/components cat > src/i18n.ts <<'EOF' type Dict = Record; const fr: Dict = { "app.title": "Smart IDE — Site", "auth.signIn": "Se connecter", "auth.signOut": "Se déconnecter", "auth.status.signedOut": "Non connecté", "auth.status.signedInAs": "Connecté en tant que {email}", "auth.error.title": "Connexion", "auth.error.message": "Un problème empêche la connexion. Réessayez ou contactez le support.", "auth.error.details": "Détails techniques", "chat.title": "Chat", "chat.mode.rag": "Contexte du projet", "chat.mode.local": "Assistant local", "chat.mode.aria": "Mode de chat", "chat.role.you": "Vous", "chat.role.assistant": "Assistant", "chat.input.placeholder": "Écrivez votre message…", "chat.send": "Envoyer", "chat.error.unauthorized": "Session expirée. Reconnectez-vous.", "chat.error.network": "Le service est indisponible. Réessayez plus tard.", }; export type I18nKey = keyof typeof fr; export const t = (key: I18nKey, vars?: Record): string => { const raw = fr[key] ?? String(key); if (!vars) return raw; return raw.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? ""); }; EOF cat > src/auth/oidc.ts <<'EOF' import { UserManager, WebStorageStateStore, type User } from "oidc-client-ts"; const requiredEnv = (k: string): string => { const v = (import.meta as any).env?.[k] as string | undefined; if (!v || v.trim().length === 0) { throw new Error(`Missing ${k}`); } return v.trim(); }; export const userManager = new UserManager({ authority: requiredEnv("VITE_OIDC_ISSUER"), client_id: requiredEnv("VITE_OIDC_CLIENT_ID"), redirect_uri: requiredEnv("VITE_OIDC_REDIRECT_URI"), post_logout_redirect_uri: requiredEnv("VITE_OIDC_POST_LOGOUT_REDIRECT_URI"), response_type: "code", scope: "openid profile email", userStore: new WebStorageStateStore({ store: window.sessionStorage }), }); export const signin = async (): Promise => { await userManager.signinRedirect(); }; export const signout = async (): Promise => { await userManager.signoutRedirect(); }; export const completeSignin = async (): Promise => { return await userManager.signinRedirectCallback(); }; export const getUser = async (): Promise => { return await userManager.getUser(); }; EOF cat > src/api/orchestrator.ts <<'EOF' export type OrchestratorExecuteBody = { intent: string; projectId?: string; context?: Record; }; const requiredEnv = (k: string): string => { const v = (import.meta as any).env?.[k] as string | undefined; if (!v || v.trim().length === 0) { throw new Error(`Missing ${k}`); } return v.trim().replace(/\/+$/, ""); }; const ssoBase = (): string => requiredEnv("VITE_SSO_GATEWAY_BASE_URL"); export const orchestratorExecute = async (accessToken: string, body: OrchestratorExecuteBody): Promise => { const url = `${ssoBase()}/proxy/orchestrator/v1/execute`; const res = await fetch(url, { method: "POST", headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", Accept: "application/json", }, body: JSON.stringify(body), }); const text = await res.text(); let parsed: unknown = undefined; try { parsed = JSON.parse(text); } catch { parsed = text; } if (!res.ok) { const err = new Error(`HTTP ${res.status}`); (err as any).response = parsed; throw err; } return parsed; }; EOF cat > src/components/App.tsx <<'EOF' import { useEffect, useMemo, useState } from "react"; import type { User } from "oidc-client-ts"; import { completeSignin, getUser, signin, signout, userManager } from "../auth/oidc"; import { t } from "../i18n"; import { ChatPanel } from "./ChatPanel"; import "./styles.css"; export const App = () => { const [user, setUser] = useState(null); const [authError, setAuthError] = useState(""); const email = useMemo(() => (user?.profile?.email as string | undefined) ?? "", [user]); useEffect(() => { const run = async () => { try { if (window.location.pathname.startsWith("/auth/callback")) { await completeSignin(); window.history.replaceState({}, "", "/"); } const u = await getUser(); setUser(u); } catch (e) { const msg = e instanceof Error ? e.message : String(e); setAuthError(msg); } }; void run(); const onUserLoaded = (u: User) => setUser(u); const onUserUnloaded = () => setUser(null); userManager.events.addUserLoaded(onUserLoaded); userManager.events.addUserUnloaded(onUserUnloaded); return () => { userManager.events.removeUserLoaded(onUserLoaded); userManager.events.removeUserUnloaded(onUserUnloaded); }; }, []); return (
{t("app.title")}
{user ? ( <>
{t("auth.status.signedInAs", { email: email || user.profile.sub })}
) : ( <>
{t("auth.status.signedOut")}
)}
{authError ? (
{t("auth.error.title")}
{t("auth.error.message")}
{import.meta.env.DEV ? (
{t("auth.error.details")}
{authError}
) : null}
) : (
)}
); }; EOF cat > src/components/ChatPanel.tsx <<'EOF' import { useMemo, useState } from "react"; import type { User } from "oidc-client-ts"; import { orchestratorExecute } from "../api/orchestrator"; import { t } from "../i18n"; type ChatMode = "rag" | "local"; const requiredEnv = (k: string): string => { const v = (import.meta as any).env?.[k] as string | undefined; if (!v || v.trim().length === 0) { throw new Error(`Missing ${k}`); } return v.trim(); }; export const ChatPanel = ({ user }: { user: User | null }) => { const [mode, setMode] = useState("rag"); const [input, setInput] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [messages, setMessages] = useState<{ role: "user" | "assistant"; content: string }[]>([]); const accessToken = useMemo(() => user?.access_token ?? "", [user]); const send = async () => { if (!accessToken) { setError(t("chat.error.unauthorized")); return; } const content = input.trim(); if (!content) return; setInput(""); setError(""); setMessages((m) => [...m, { role: "user", content }]); setBusy(true); try { if (mode === "local") { const model = requiredEnv("VITE_OLLAMA_MODEL"); const r = await orchestratorExecute(accessToken, { intent: "chat.local", context: { model, prompt: content, stream: false }, }); const text = (r as any)?.response?.response ?? (r as any)?.response?.message ?? JSON.stringify(r); setMessages((m) => [...m, { role: "assistant", content: String(text) }]); } else { const workspace = requiredEnv("VITE_ANYTHINGLLM_WORKSPACE_SLUG"); const r = await orchestratorExecute(accessToken, { intent: "rag.query", context: { model: workspace, messages: [ { role: "system", content: "Vous êtes un assistant utile. Répondez en utilisant le contexte du projet quand c'est pertinent.", }, ...messages.map((m) => ({ role: m.role, content: m.content })), { role: "user", content }, ], }, }); const text = (r as any)?.response?.choices?.[0]?.message?.content ?? (r as any)?.response?.choices?.[0]?.text ?? JSON.stringify(r); setMessages((m) => [...m, { role: "assistant", content: String(text) }]); } } catch (e) { const anyE = e as any; if (anyE?.message === "HTTP 401") { setError(t("chat.error.unauthorized")); } else { setError(t("chat.error.network")); } } finally { setBusy(false); } }; return (
{t("chat.title")}
{messages.map((m, idx) => (
{m.role === "user" ? t("chat.role.you") : t("chat.role.assistant")}
{m.content}
))}
{error ? (
{error}
) : null}
setInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); if (!busy) void send(); } }} disabled={busy} />
); }; EOF cat > src/components/styles.css <<'EOF' :root { --bg: #0b0f17; --panel: #101826; --text: #e6eaf2; --muted: #aab4c5; --border: #1b2940; --accent: #60a5fa; --danger: #f87171; } * { box-sizing: border-box; } html, body { height: 100%; } body { margin: 0; background: radial-gradient(1200px 900px at 20% 10%, #13213b, var(--bg)); color: var(--text); font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif; } .srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } .page { min-height: 100%; display: flex; flex-direction: column; } .header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 16px 18px; border-bottom: 1px solid var(--border); background: rgba(16, 24, 38, 0.7); backdrop-filter: blur(8px); } .title { font-weight: 700; letter-spacing: 0.2px; } .auth { display: flex; align-items: center; gap: 10px; } .authStatus { color: var(--muted); font-size: 13px; } .main { padding: 18px; display: flex; justify-content: center; } .panel { width: min(980px, 100%); border: 1px solid var(--border); background: rgba(16, 24, 38, 0.9); border-radius: 14px; overflow: hidden; box-shadow: 0 18px 60px rgba(0,0,0,0.35); } .panelHeader { padding: 14px 14px 10px; display: flex; align-items: center; justify-content: space-between; gap: 12px; } .panelTitle { font-weight: 700; } .panel[role="alert"] { padding: 14px; } .muted { color: var(--muted); margin-top: 8px; } .details { margin-top: 10px; } .details summary { cursor: pointer; color: var(--muted); } .details summary:focus-visible { outline: 2px solid rgba(96, 165, 250, 0.8); outline-offset: 2px; border-radius: 6px; } .tabs { display: inline-flex; gap: 6px; } .tab { border: 1px solid var(--border); background: transparent; color: var(--muted); padding: 6px 10px; border-radius: 10px; cursor: pointer; } .tabActive { border-color: rgba(96, 165, 250, 0.35); color: var(--text); } .chatLog { padding: 14px; display: flex; flex-direction: column; gap: 10px; max-height: 60vh; overflow: auto; } .msg { border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; } .msgUser { background: rgba(96, 165, 250, 0.07); } .msgAssistant { background: rgba(170, 180, 197, 0.06); } .msgRole { font-size: 12px; color: var(--muted); margin-bottom: 6px; } .msgText { white-space: pre-wrap; line-height: 1.4; } .error { padding: 10px 14px; border-top: 1px solid var(--border); color: var(--danger); } .chatComposer { display: flex; gap: 10px; padding: 14px; border-top: 1px solid var(--border); } .input { flex: 1; border: 1px solid var(--border); background: rgba(11, 15, 23, 0.6); color: var(--text); border-radius: 12px; padding: 10px 12px; outline: none; } .input:focus { border-color: rgba(96, 165, 250, 0.6); } .input:focus-visible { outline: 2px solid rgba(96, 165, 250, 0.8); outline-offset: 2px; } .button { border: 1px solid rgba(96, 165, 250, 0.45); background: rgba(96, 165, 250, 0.18); color: var(--text); border-radius: 12px; padding: 10px 12px; cursor: pointer; } .button:focus-visible { outline: 2px solid rgba(96, 165, 250, 0.8); outline-offset: 2px; } .tab:focus-visible { outline: 2px solid rgba(96, 165, 250, 0.8); outline-offset: 2px; } .button:disabled { opacity: 0.6; cursor: not-allowed; } .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-size: 12px; } EOF cat > src/main.tsx <<'EOF' import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./components/App"; ReactDOM.createRoot(document.getElementById("root")!).render( , ); EOF cat > .env.example <<'EOF' # Public app config (Vite) VITE_SSO_GATEWAY_BASE_URL=https://smart-ide-sso.example.test # OIDC PKCE client config VITE_OIDC_ISSUER=https://docv.example.test VITE_OIDC_CLIENT_ID=smart-ide-site VITE_OIDC_REDIRECT_URI=http://localhost:5173/auth/callback VITE_OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:5173/ # Chat modes VITE_ANYTHINGLLM_WORKSPACE_SLUG=enso-test VITE_OLLAMA_MODEL=llama3.1 EOF # Update package.json APP_NAME="${NAME}" node - <<'EOF' const fs = require("fs"); const p = JSON.parse(fs.readFileSync("package.json", "utf8")); p.name = process.env.APP_NAME || p.name; p.private = true; p.type = "module"; p.scripts = { dev: "vite", build: "tsc -p tsconfig.json && vite build", preview: "vite preview --strictPort --port 4173", }; fs.writeFileSync("package.json", JSON.stringify(p, null, 2) + "\n", "utf8"); EOF cat > README.md <<'EOF' ## Smart IDE — Lovable-style site scaffold This scaffold is a front-end web app that: - logs users in via **OIDC (PKCE)** - calls `smart-ide-sso-gateway` which proxies to the internal services (including the orchestrator) - exposes two chat modes: - **RAG** (`rag.query`) via AnythingLLM OpenAI-compatible endpoint - **Local chat** (`chat.local`) via Ollama ### Setup 1. Copy `.env.example` to `.env.local` and set: - `VITE_SSO_GATEWAY_BASE_URL` - OIDC settings (`VITE_OIDC_*`) - chat settings (`VITE_ANYTHINGLLM_WORKSPACE_SLUG`, `VITE_OLLAMA_MODEL`) 2. Run: ```bash npm install npm run dev ``` ### Runtime notes - Tokens are stored in **sessionStorage** (not localStorage). - The app does not embed service tokens; it only uses the user OIDC access token. EOF echo "[site-generate] Files created." echo "[site-generate] Next steps:" echo " - cd ${TARGET}" echo " - cp .env.example .env.local" echo " - npm run dev" )