Initial state: - ia_dev was historically referenced as ./ia_dev in docs and integrations, while the vendored module lives under services/ia_dev. - AnythingLLM sync and hook installation had error masking / weak exit signaling. - Proxy layers did not validate proxy path segments, allowing path normalization tricks. Motivation: - Make the IDE-oriented workflow usable (sync -> act -> deploy/preview) with explicit errors. - Reduce security footguns in proxying and script automation. Resolution: - Standardize IA_DEV_ROOT usage and documentation to services/ia_dev. - Add SSH remote data mirroring + optional AnythingLLM ingestion. - Extend AnythingLLM pull sync to support upload-all/prefix and fail on upload errors. - Harden smart-ide-sso-gateway and smart-ide-global-api proxying with safe-path checks and non-leaking error responses. - Improve ia-dev-gateway runner validation and reduce sensitive path leakage. - Add site scaffold tool (Vite/React) with OIDC + chat via sso-gateway -> orchestrator. Root cause: - Historical layout changes (submodule -> vendored tree) and missing central contracts for path resolution. - Missing validation for proxy path traversal patterns. - Overuse of silent fallbacks (|| true, exit 0 on partial failures) in automation scripts. Impacted features: - Project sync: git pull + AnythingLLM sync + remote data mirror ingestion. - Site frontends: SSO gateway proxy and orchestrator intents (rag.query, chat.local). - Agent execution: ia-dev-gateway script runner and SSE output. Code modified: - scripts/remote-data-ssh-sync.sh - scripts/anythingllm-pull-sync/sync.mjs - scripts/install-anythingllm-post-merge-hook.sh - cron/git-pull-project-clones.sh - services/smart-ide-sso-gateway/src/server.ts - services/smart-ide-global-api/src/server.ts - services/smart-ide-orchestrator/src/server.ts - services/ia-dev-gateway/src/server.ts - services/ia_dev/tools/site-generate.sh Documentation modified: - docs/** (architecture, API docs, ia_dev module + integration, scripts) Configurations modified: - config/services.local.env.example - services/*/.env.example Files in deploy modified: - services/ia_dev/deploy/* Files in logs impacted: - logs/ia_dev.log (runtime only) - .logs/* (runtime only) Databases and other sources modified: - None Off-project modifications: - None Files in .smartIde modified: - .smartIde/agents/*.md - services/ia_dev/.smartIde/** Files in .secrets modified: - None New patch version in VERSION: - 0.0.5 CHANGELOG.md updated: - yes
700 lines
20 KiB
Bash
Executable File
700 lines
20 KiB
Bash
Executable File
#!/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 <target_dir> [--name <app_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 <target_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'
|
|
<!doctype html>
|
|
<html lang="fr">
|
|
<head>
|
|
<meta charset="UTF-8" />
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
<title>Smart IDE — Site</title>
|
|
</head>
|
|
<body>
|
|
<div id="root"></div>
|
|
<script type="module" src="/src/main.tsx"></script>
|
|
</body>
|
|
</html>
|
|
EOF
|
|
|
|
mkdir -p src/auth src/api src/components
|
|
|
|
cat > src/i18n.ts <<'EOF'
|
|
type Dict = Record<string, string>;
|
|
|
|
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, string>): 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<void> => {
|
|
await userManager.signinRedirect();
|
|
};
|
|
|
|
export const signout = async (): Promise<void> => {
|
|
await userManager.signoutRedirect();
|
|
};
|
|
|
|
export const completeSignin = async (): Promise<User> => {
|
|
return await userManager.signinRedirectCallback();
|
|
};
|
|
|
|
export const getUser = async (): Promise<User | null> => {
|
|
return await userManager.getUser();
|
|
};
|
|
EOF
|
|
|
|
cat > src/api/orchestrator.ts <<'EOF'
|
|
export type OrchestratorExecuteBody = {
|
|
intent: string;
|
|
projectId?: string;
|
|
context?: Record<string, unknown>;
|
|
};
|
|
|
|
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<unknown> => {
|
|
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<User | null>(null);
|
|
const [authError, setAuthError] = useState<string>("");
|
|
|
|
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 (
|
|
<div className="page">
|
|
<header className="header" role="banner">
|
|
<div className="title">{t("app.title")}</div>
|
|
<div className="auth">
|
|
{user ? (
|
|
<>
|
|
<div className="authStatus" aria-live="polite">
|
|
{t("auth.status.signedInAs", { email: email || user.profile.sub })}
|
|
</div>
|
|
<button className="button" type="button" onClick={() => void signout()}>
|
|
{t("auth.signOut")}
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="authStatus" aria-live="polite">
|
|
{t("auth.status.signedOut")}
|
|
</div>
|
|
<button className="button" type="button" onClick={() => void signin()}>
|
|
{t("auth.signIn")}
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</header>
|
|
|
|
{authError ? (
|
|
<main className="main" role="main">
|
|
<div className="panel" role="alert">
|
|
<div className="panelTitle">{t("auth.error.title")}</div>
|
|
<div className="muted">{t("auth.error.message")}</div>
|
|
{import.meta.env.DEV ? (
|
|
<details className="details">
|
|
<summary>{t("auth.error.details")}</summary>
|
|
<pre className="mono">{authError}</pre>
|
|
</details>
|
|
) : null}
|
|
</div>
|
|
</main>
|
|
) : (
|
|
<main className="main" role="main">
|
|
<ChatPanel user={user} />
|
|
</main>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
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<ChatMode>("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 (
|
|
<section className="panel" aria-label={t("chat.title")}>
|
|
<div className="panelHeader">
|
|
<div className="panelTitle">{t("chat.title")}</div>
|
|
<div className="tabs" role="tablist" aria-label={t("chat.mode.aria")}>
|
|
<button
|
|
className={mode === "rag" ? "tab tabActive" : "tab"}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={mode === "rag"}
|
|
onClick={() => setMode("rag")}
|
|
>
|
|
{t("chat.mode.rag")}
|
|
</button>
|
|
<button
|
|
className={mode === "local" ? "tab tabActive" : "tab"}
|
|
type="button"
|
|
role="tab"
|
|
aria-selected={mode === "local"}
|
|
onClick={() => setMode("local")}
|
|
>
|
|
{t("chat.mode.local")}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="chatLog" role="log" aria-live="polite">
|
|
{messages.map((m, idx) => (
|
|
<div key={idx} className={m.role === "user" ? "msg msgUser" : "msg msgAssistant"}>
|
|
<div className="msgRole">{m.role === "user" ? t("chat.role.you") : t("chat.role.assistant")}</div>
|
|
<div className="msgText">{m.content}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{error ? (
|
|
<div className="error" role="alert">
|
|
{error}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="chatComposer">
|
|
<label className="srOnly" htmlFor="chatInput">
|
|
{t("chat.input.placeholder")}
|
|
</label>
|
|
<input
|
|
id="chatInput"
|
|
className="input"
|
|
type="text"
|
|
value={input}
|
|
placeholder={t("chat.input.placeholder")}
|
|
onChange={(e) => setInput(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
if (!busy) void send();
|
|
}
|
|
}}
|
|
disabled={busy}
|
|
/>
|
|
<button className="button" type="button" onClick={() => void send()} disabled={busy}>
|
|
{t("chat.send")}
|
|
</button>
|
|
</div>
|
|
</section>
|
|
);
|
|
};
|
|
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(
|
|
<React.StrictMode>
|
|
<App />
|
|
</React.StrictMode>,
|
|
);
|
|
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"
|
|
)
|
|
|