create-lacspace-app 2.2.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -1
- package/dist/index.js +671 -36
- package/dist/lib.cjs +679 -35
- package/dist/lib.d.cts +49 -1
- package/dist/lib.d.ts +49 -1
- package/dist/lib.js +678 -36
- package/package.json +1 -1
package/dist/lib.js
CHANGED
|
@@ -36,7 +36,24 @@ function resolveContext(options = {}) {
|
|
|
36
36
|
const raw = options.name ?? "my-app";
|
|
37
37
|
const seg = raw.split(/[\\/]/).filter(Boolean).pop() ?? "my-app";
|
|
38
38
|
const name = seg.toLowerCase().replace(/[^a-z0-9-_]/g, "-").replace(/^-+|-+$/g, "") || "my-app";
|
|
39
|
-
|
|
39
|
+
const features = normalizeFeatures(options.features);
|
|
40
|
+
return { name, template, features };
|
|
41
|
+
}
|
|
42
|
+
function normalizeFeatures(requested) {
|
|
43
|
+
if (!requested || requested.length === 0) return [];
|
|
44
|
+
const seen = /* @__PURE__ */ new Set();
|
|
45
|
+
const out = [];
|
|
46
|
+
const unknown = [];
|
|
47
|
+
for (const raw of requested) {
|
|
48
|
+
const key = String(raw).toLowerCase().trim();
|
|
49
|
+
if (!key || seen.has(key)) continue;
|
|
50
|
+
seen.add(key);
|
|
51
|
+
const def = FEATURES.find((f) => f.key === key);
|
|
52
|
+
if (def) out.push(def);
|
|
53
|
+
else unknown.push(key);
|
|
54
|
+
}
|
|
55
|
+
if (unknown.length) console.warn(`create-lacspace-app: ignoring unknown feature(s): ${unknown.join(", ")}`);
|
|
56
|
+
return out;
|
|
40
57
|
}
|
|
41
58
|
var pkgJson = (ctx) => JSON.stringify({
|
|
42
59
|
name: ctx.name,
|
|
@@ -1963,8 +1980,529 @@ function buildFiles(ctx) {
|
|
|
1963
1980
|
files["content/docs/installation.md"] = sampleDocInstall();
|
|
1964
1981
|
files["content/docs/writing-content.md"] = sampleDocWriting();
|
|
1965
1982
|
}
|
|
1983
|
+
applyFeatures(files, ctx);
|
|
1966
1984
|
return files;
|
|
1967
1985
|
}
|
|
1986
|
+
function applyFeatures(files, ctx) {
|
|
1987
|
+
const features = ctx.features;
|
|
1988
|
+
if (!features.length) return;
|
|
1989
|
+
for (const feat of features) {
|
|
1990
|
+
for (const [rel, content] of Object.entries(feat.files(ctx))) {
|
|
1991
|
+
files[rel] = content;
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
const addDeps = {};
|
|
1995
|
+
const addScripts = {};
|
|
1996
|
+
for (const feat of features) {
|
|
1997
|
+
Object.assign(addDeps, feat.deps);
|
|
1998
|
+
if (feat.scripts) Object.assign(addScripts, feat.scripts);
|
|
1999
|
+
}
|
|
2000
|
+
if (files["package.json"]) {
|
|
2001
|
+
const pkg = JSON.parse(files["package.json"]);
|
|
2002
|
+
pkg.dependencies = { ...pkg.dependencies ?? {}, ...addDeps };
|
|
2003
|
+
if (Object.keys(addScripts).length) pkg.scripts = { ...pkg.scripts ?? {}, ...addScripts };
|
|
2004
|
+
files["package.json"] = JSON.stringify(pkg, null, 2) + "\n";
|
|
2005
|
+
}
|
|
2006
|
+
const existingEnv = new Set(
|
|
2007
|
+
(files[".env.example"] ?? "").split("\n").map((l) => l.match(/^\s*#?\s*([A-Z0-9_]+)=/)?.[1]).filter(Boolean)
|
|
2008
|
+
);
|
|
2009
|
+
const envLines = [];
|
|
2010
|
+
for (const feat of features) {
|
|
2011
|
+
if (!feat.env || Object.keys(feat.env).length === 0) continue;
|
|
2012
|
+
const fresh = Object.entries(feat.env).filter(([name]) => !existingEnv.has(name));
|
|
2013
|
+
if (fresh.length === 0) continue;
|
|
2014
|
+
envLines.push("", `# --- ${feat.label} (${feat.key}) ---`);
|
|
2015
|
+
for (const [name, comment] of fresh) {
|
|
2016
|
+
if (comment) envLines.push(`# ${comment}`);
|
|
2017
|
+
envLines.push(`${name}=`);
|
|
2018
|
+
existingEnv.add(name);
|
|
2019
|
+
}
|
|
2020
|
+
}
|
|
2021
|
+
if (envLines.length) {
|
|
2022
|
+
const base = files[".env.example"] ?? "";
|
|
2023
|
+
files[".env.example"] = (base.endsWith("\n") || base === "" ? base : base + "\n") + envLines.join("\n") + "\n";
|
|
2024
|
+
}
|
|
2025
|
+
files["LEARN.md"] = learnMd(ctx, features);
|
|
2026
|
+
}
|
|
2027
|
+
function learnMd(ctx, features) {
|
|
2028
|
+
const blocks = features.map((f) => {
|
|
2029
|
+
const steps = f.nextSteps.map((s) => `- [ ] ${s}`).join("\n");
|
|
2030
|
+
const learn = f.learn ? `
|
|
2031
|
+
|
|
2032
|
+
Learn more \u2192 ${f.learn}` : "";
|
|
2033
|
+
return `## ${f.label} \`(${f.key})\`
|
|
2034
|
+
|
|
2035
|
+
${f.description}
|
|
2036
|
+
|
|
2037
|
+
${steps}${learn}`;
|
|
2038
|
+
}).join("\n\n");
|
|
2039
|
+
return `# \u{1F9ED} Learn ${ctx.name}
|
|
2040
|
+
|
|
2041
|
+
You scaffolded **${ctx.template.label}** with ${features.length} feature add-on${features.length === 1 ? "" : "s"}:
|
|
2042
|
+
${features.map((f) => `\`${f.key}\``).join(" \xB7 ")}.
|
|
2043
|
+
|
|
2044
|
+
Work through the checklist below \u2014 each item is a real next step.
|
|
2045
|
+
|
|
2046
|
+
${blocks}
|
|
2047
|
+
|
|
2048
|
+
---
|
|
2049
|
+
|
|
2050
|
+
Every add-on is built from zero-dependency \`@lacspace/*\` packages. Browse them all \u2192 https://lacspace.com/packages
|
|
2051
|
+
`;
|
|
2052
|
+
}
|
|
2053
|
+
var FEATURES = [
|
|
2054
|
+
{
|
|
2055
|
+
key: "ai-chat",
|
|
2056
|
+
label: "AI chat",
|
|
2057
|
+
description: "A streaming AI chat route + UI \u2014 free & local by default (Ollama), keyless.",
|
|
2058
|
+
deps: {
|
|
2059
|
+
"@lacspace/ai": "^1.1.0",
|
|
2060
|
+
"@lacspace/prompt": "^1.1.0",
|
|
2061
|
+
"@lacspace/stream": "^1.1.0",
|
|
2062
|
+
"@lacspace/providers": "^1.0.0",
|
|
2063
|
+
"@lacspace/memory": "^1.0.0",
|
|
2064
|
+
"@lacspace/moderation": "^1.0.0"
|
|
2065
|
+
},
|
|
2066
|
+
env: {
|
|
2067
|
+
LACSPACE_AI_PROVIDER: "Which provider preset to use (default: ollama \u2014 free & local, no key).",
|
|
2068
|
+
LACSPACE_AI_BASE_URL: "Override the provider base URL (default Ollama: http://localhost:11434).",
|
|
2069
|
+
LACSPACE_AI_MODEL: "Chat model id (default: llama3.2).",
|
|
2070
|
+
LACSPACE_AI_API_KEY: "Only needed for a hosted provider \u2014 leave blank for local Ollama."
|
|
2071
|
+
},
|
|
2072
|
+
files: (ctx) => ({
|
|
2073
|
+
"app/api/chat/route.ts": aiChatRoute(),
|
|
2074
|
+
"app/chat/page.tsx": aiChatPage(ctx)
|
|
2075
|
+
}),
|
|
2076
|
+
nextSteps: [
|
|
2077
|
+
"\u{1F193} Free local AI \u2014 install Ollama (https://ollama.com), then run `ollama pull llama3.2`.",
|
|
2078
|
+
"Run `npm run dev` and open http://localhost:3000/chat.",
|
|
2079
|
+
"Prefer a hosted model? Set LACSPACE_AI_* in .env (e.g. LACSPACE_AI_PROVIDER=groq + LACSPACE_AI_API_KEY=\u2026)."
|
|
2080
|
+
],
|
|
2081
|
+
learn: "https://developer.lacspace.com/packages/ai"
|
|
2082
|
+
},
|
|
2083
|
+
{
|
|
2084
|
+
key: "rag",
|
|
2085
|
+
label: "RAG \u2014 chat with your docs",
|
|
2086
|
+
description: "Index your markdown/text and answer grounded questions with cited sources. Keyless & local (Ollama).",
|
|
2087
|
+
deps: {
|
|
2088
|
+
"@lacspace/rag": "^1.0.0",
|
|
2089
|
+
"@lacspace/embeddings": "^1.0.0",
|
|
2090
|
+
"@lacspace/vector": "^1.0.0",
|
|
2091
|
+
"@lacspace/chunk": "^1.1.0",
|
|
2092
|
+
"@lacspace/rerank": "^1.0.0",
|
|
2093
|
+
"@lacspace/providers": "^1.0.0",
|
|
2094
|
+
"@lacspace/ai": "^1.1.0"
|
|
2095
|
+
},
|
|
2096
|
+
scripts: { "rag:index": "node scripts/index-content.mjs" },
|
|
2097
|
+
env: {
|
|
2098
|
+
LACSPACE_AI_PROVIDER: "Which provider preset to use (default: ollama \u2014 free & local, no key).",
|
|
2099
|
+
LACSPACE_AI_BASE_URL: "Override the provider base URL (default Ollama: http://localhost:11434).",
|
|
2100
|
+
LACSPACE_AI_MODEL: "Chat model id used to answer (default: llama3.2).",
|
|
2101
|
+
LACSPACE_AI_API_KEY: "Only needed for a hosted provider \u2014 leave blank for local Ollama.",
|
|
2102
|
+
LACSPACE_EMBED_MODEL: "Embedding model id (default: nomic-embed-text)."
|
|
2103
|
+
},
|
|
2104
|
+
files: (ctx) => ({
|
|
2105
|
+
"content/welcome.md": ragSampleDoc(ctx),
|
|
2106
|
+
"scripts/index-content.mjs": ragIndexScript(),
|
|
2107
|
+
"app/api/ask/route.ts": ragAskRoute(),
|
|
2108
|
+
"app/ask/page.tsx": ragAskPage(ctx)
|
|
2109
|
+
}),
|
|
2110
|
+
nextSteps: [
|
|
2111
|
+
"\u{1F193} Free & local \u2014 install Ollama (https://ollama.com), then `ollama pull nomic-embed-text` and `ollama pull llama3.2`.",
|
|
2112
|
+
"Add markdown/text files to content/, then run `npm run rag:index` to build .rag-index.json.",
|
|
2113
|
+
"Run `npm run dev` and open http://localhost:3000/ask."
|
|
2114
|
+
],
|
|
2115
|
+
learn: "https://developer.lacspace.com/packages/rag"
|
|
2116
|
+
}
|
|
2117
|
+
];
|
|
2118
|
+
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
2119
|
+
import { stream } from "@lacspace/ai";
|
|
2120
|
+
import type { Message } from "@lacspace/ai";
|
|
2121
|
+
import { detectPromptInjection } from "@lacspace/moderation";
|
|
2122
|
+
import { toReadableStream } from "@lacspace/stream";
|
|
2123
|
+
|
|
2124
|
+
// This route runs on Node (it talks to your local Ollama / a hosted LLM).
|
|
2125
|
+
export const runtime = "nodejs";
|
|
2126
|
+
export const dynamic = "force-dynamic";
|
|
2127
|
+
|
|
2128
|
+
export async function POST(req: Request) {
|
|
2129
|
+
// 1. Read the conversation the <ChatUI/> sent us.
|
|
2130
|
+
const { messages } = (await req.json()) as { messages: Message[] };
|
|
2131
|
+
|
|
2132
|
+
// 2. Guard the newest user turn against prompt-injection / jailbreak attempts.
|
|
2133
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/moderation
|
|
2134
|
+
const lastUser = [...messages].reverse().find((m) => m.role === "user");
|
|
2135
|
+
const userText = typeof lastUser?.content === "string" ? lastUser.content : "";
|
|
2136
|
+
const check = detectPromptInjection(userText);
|
|
2137
|
+
if (check.flagged) {
|
|
2138
|
+
return new Response(JSON.stringify({ error: "That message was blocked by the safety guard." }), {
|
|
2139
|
+
status: 400,
|
|
2140
|
+
headers: { "Content-Type": "application/json" },
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
// 3. Resolve provider config from env \u2014 KEYLESS by default (local Ollama).
|
|
2145
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/providers
|
|
2146
|
+
const cfg = resolveConfig(process.env.LACSPACE_AI_PROVIDER ?? "ollama", {
|
|
2147
|
+
baseUrl: process.env.LACSPACE_AI_BASE_URL, // default: http://localhost:11434
|
|
2148
|
+
model: process.env.LACSPACE_AI_MODEL, // default: llama3.2
|
|
2149
|
+
apiKey: process.env.LACSPACE_AI_API_KEY, // only for hosted providers
|
|
2150
|
+
});
|
|
2151
|
+
|
|
2152
|
+
// Ollama speaks its own dialect but also exposes an OpenAI-compatible surface
|
|
2153
|
+
// at /v1 \u2014 so we always call it (and any hosted provider) as "openai-compatible".
|
|
2154
|
+
const baseUrl = cfg.apiStyle === "ollama" ? cfg.baseUrl.replace(/\\/+$/, "") + "/v1" : cfg.baseUrl;
|
|
2155
|
+
const model = cfg.model ?? "llama3.2";
|
|
2156
|
+
|
|
2157
|
+
// 4. Stream the reply. @lacspace/ai yields unified {type:"text", delta} chunks;
|
|
2158
|
+
// we encode each token and hand the stream to the browser via @lacspace/stream.
|
|
2159
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/ai
|
|
2160
|
+
const encoder = new TextEncoder();
|
|
2161
|
+
async function* tokens() {
|
|
2162
|
+
for await (const chunk of stream({
|
|
2163
|
+
provider: "openai-compatible",
|
|
2164
|
+
baseUrl,
|
|
2165
|
+
apiKey: cfg.apiKey,
|
|
2166
|
+
model,
|
|
2167
|
+
headers: cfg.headers,
|
|
2168
|
+
messages,
|
|
2169
|
+
})) {
|
|
2170
|
+
if (chunk.type === "text") yield encoder.encode(chunk.delta);
|
|
2171
|
+
}
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/stream
|
|
2175
|
+
return new Response(toReadableStream(tokens()), {
|
|
2176
|
+
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-cache" },
|
|
2177
|
+
});
|
|
2178
|
+
}
|
|
2179
|
+
`;
|
|
2180
|
+
var aiChatPage = (ctx) => `"use client";
|
|
2181
|
+
|
|
2182
|
+
import { useState, useRef, useEffect } from "react";
|
|
2183
|
+
|
|
2184
|
+
interface Msg { role: "user" | "assistant"; content: string; }
|
|
2185
|
+
|
|
2186
|
+
export default function ChatPage() {
|
|
2187
|
+
const [messages, setMessages] = useState<Msg[]>([]);
|
|
2188
|
+
const [input, setInput] = useState("");
|
|
2189
|
+
const [busy, setBusy] = useState(false);
|
|
2190
|
+
const scroller = useRef<HTMLDivElement>(null);
|
|
2191
|
+
|
|
2192
|
+
useEffect(() => {
|
|
2193
|
+
scroller.current?.scrollTo({ top: scroller.current.scrollHeight, behavior: "smooth" });
|
|
2194
|
+
}, [messages]);
|
|
2195
|
+
|
|
2196
|
+
async function send(e: React.FormEvent) {
|
|
2197
|
+
e.preventDefault();
|
|
2198
|
+
const text = input.trim();
|
|
2199
|
+
if (!text || busy) return;
|
|
2200
|
+
setInput("");
|
|
2201
|
+
setBusy(true);
|
|
2202
|
+
|
|
2203
|
+
const next: Msg[] = [...messages, { role: "user", content: text }];
|
|
2204
|
+
setMessages([...next, { role: "assistant", content: "" }]);
|
|
2205
|
+
|
|
2206
|
+
try {
|
|
2207
|
+
const res = await fetch("/api/chat", {
|
|
2208
|
+
method: "POST",
|
|
2209
|
+
headers: { "Content-Type": "application/json" },
|
|
2210
|
+
body: JSON.stringify({ messages: next }),
|
|
2211
|
+
});
|
|
2212
|
+
if (!res.ok || !res.body) throw new Error("The chat endpoint returned an error.");
|
|
2213
|
+
|
|
2214
|
+
// Read the streamed reply token-by-token and append it live.
|
|
2215
|
+
const reader = res.body.getReader();
|
|
2216
|
+
const decoder = new TextDecoder();
|
|
2217
|
+
let acc = "";
|
|
2218
|
+
for (;;) {
|
|
2219
|
+
const { done, value } = await reader.read();
|
|
2220
|
+
if (done) break;
|
|
2221
|
+
acc += decoder.decode(value, { stream: true });
|
|
2222
|
+
setMessages([...next, { role: "assistant", content: acc }]);
|
|
2223
|
+
}
|
|
2224
|
+
} catch {
|
|
2225
|
+
setMessages([...next, { role: "assistant", content: "\u26A0\uFE0F Couldn't reach the model. Is Ollama running? (ollama.com)" }]);
|
|
2226
|
+
} finally {
|
|
2227
|
+
setBusy(false);
|
|
2228
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
|
|
2231
|
+
return (
|
|
2232
|
+
<section className="mx-auto flex min-h-[calc(100vh-4rem)] max-w-2xl flex-col px-6 py-10">
|
|
2233
|
+
<header className="mb-6">
|
|
2234
|
+
<p className="text-sm font-semibold uppercase tracking-widest gradient-text">AI \xB7 ${ctx.template.siteName}</p>
|
|
2235
|
+
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">Chat</h1>
|
|
2236
|
+
<p className="mt-2 text-muted">Free & local by default \u2014 powered by Ollama and the Lacspace AI Kit. No key required.</p>
|
|
2237
|
+
</header>
|
|
2238
|
+
|
|
2239
|
+
<div ref={scroller} className="flex-1 space-y-4 overflow-y-auto rounded-2xl border border-hairline bg-surface p-4">
|
|
2240
|
+
{messages.length === 0 && (
|
|
2241
|
+
<p className="py-16 text-center text-muted">Ask me anything to get started.</p>
|
|
2242
|
+
)}
|
|
2243
|
+
{messages.map((m, i) => (
|
|
2244
|
+
<div key={i} className={m.role === "user" ? "flex justify-end" : "flex justify-start"}>
|
|
2245
|
+
<div className={
|
|
2246
|
+
"max-w-[85%] whitespace-pre-wrap rounded-2xl px-4 py-2.5 text-sm leading-relaxed " +
|
|
2247
|
+
(m.role === "user" ? "gradient-bg on-accent" : "border border-hairline bg-app")
|
|
2248
|
+
}>
|
|
2249
|
+
{m.content || (busy ? "\u2026" : "")}
|
|
2250
|
+
</div>
|
|
2251
|
+
</div>
|
|
2252
|
+
))}
|
|
2253
|
+
</div>
|
|
2254
|
+
|
|
2255
|
+
<form onSubmit={send} className="mt-4 flex gap-2">
|
|
2256
|
+
<input
|
|
2257
|
+
value={input}
|
|
2258
|
+
onChange={(e) => setInput(e.target.value)}
|
|
2259
|
+
placeholder="Type a message\u2026"
|
|
2260
|
+
className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none focus:border-hairline"
|
|
2261
|
+
/>
|
|
2262
|
+
<button
|
|
2263
|
+
type="submit"
|
|
2264
|
+
disabled={busy || !input.trim()}
|
|
2265
|
+
className="rounded-xl gradient-bg on-accent px-5 py-3 text-sm font-semibold shimmer disabled:opacity-50"
|
|
2266
|
+
>
|
|
2267
|
+
{busy ? "\u2026" : "Send"}
|
|
2268
|
+
</button>
|
|
2269
|
+
</form>
|
|
2270
|
+
</section>
|
|
2271
|
+
);
|
|
2272
|
+
}
|
|
2273
|
+
`;
|
|
2274
|
+
var ragSampleDoc = (ctx) => `# Welcome to ${ctx.template.siteName}
|
|
2275
|
+
|
|
2276
|
+
This markdown file lives in \`content/\`. Run \`npm run rag:index\` to chunk and embed
|
|
2277
|
+
everything under \`content/\` into a local vector index (\`.rag-index.json\`), then open
|
|
2278
|
+
\`/ask\` to ask grounded questions about it.
|
|
2279
|
+
|
|
2280
|
+
## How this works
|
|
2281
|
+
|
|
2282
|
+
- **Chunking** \u2014 \`@lacspace/chunk\` splits each document into overlapping passages.
|
|
2283
|
+
- **Embeddings** \u2014 \`@lacspace/embeddings\` turns each passage into a vector using a
|
|
2284
|
+
local Ollama model (\`nomic-embed-text\`) \u2014 no API key needed.
|
|
2285
|
+
- **Retrieval** \u2014 \`@lacspace/vector\` + \`@lacspace/rag\` find the passages closest to
|
|
2286
|
+
your question, and \`@lacspace/rerank\` re-orders them for relevance.
|
|
2287
|
+
- **Answering** \u2014 \`@lacspace/ai\` writes a grounded answer and cites its sources.
|
|
2288
|
+
|
|
2289
|
+
## Tips
|
|
2290
|
+
|
|
2291
|
+
Add your own \`.md\` or \`.txt\` files here, re-run \`npm run rag:index\`, and ask away.
|
|
2292
|
+
Everything runs on your machine \u2014 free, keyless and private.
|
|
2293
|
+
`;
|
|
2294
|
+
var ragIndexScript = (_ctx) => `// Build a local vector index from everything under content/.
|
|
2295
|
+
// Run: npm run rag:index (needs Ollama: \`ollama pull nomic-embed-text\`)
|
|
2296
|
+
import { readdir, readFile, writeFile, stat } from "node:fs/promises";
|
|
2297
|
+
import { join, extname, relative } from "node:path";
|
|
2298
|
+
import { splitText } from "@lacspace/chunk";
|
|
2299
|
+
import { embed } from "@lacspace/embeddings";
|
|
2300
|
+
import { createVectorStore } from "@lacspace/vector";
|
|
2301
|
+
|
|
2302
|
+
const ROOT = process.cwd();
|
|
2303
|
+
const CONTENT_DIR = join(ROOT, "content");
|
|
2304
|
+
const OUT = join(ROOT, ".rag-index.json");
|
|
2305
|
+
|
|
2306
|
+
// Which files to index (add more extensions if you like).
|
|
2307
|
+
const EXTS = new Set([".md", ".markdown", ".txt", ".mdx"]);
|
|
2308
|
+
|
|
2309
|
+
// Recursively collect readable files under content/.
|
|
2310
|
+
async function walk(dir) {
|
|
2311
|
+
const out = [];
|
|
2312
|
+
let entries = [];
|
|
2313
|
+
try { entries = await readdir(dir, { withFileTypes: true }); }
|
|
2314
|
+
catch { return out; }
|
|
2315
|
+
for (const e of entries) {
|
|
2316
|
+
const full = join(dir, e.name);
|
|
2317
|
+
if (e.isDirectory()) out.push(...(await walk(full)));
|
|
2318
|
+
else if (EXTS.has(extname(e.name).toLowerCase())) out.push(full);
|
|
2319
|
+
}
|
|
2320
|
+
return out;
|
|
2321
|
+
}
|
|
2322
|
+
|
|
2323
|
+
async function main() {
|
|
2324
|
+
const files = await walk(CONTENT_DIR);
|
|
2325
|
+
if (files.length === 0) {
|
|
2326
|
+
console.error("No content found. Add .md/.txt files under content/ and re-run.");
|
|
2327
|
+
process.exit(1);
|
|
2328
|
+
}
|
|
2329
|
+
|
|
2330
|
+
// 1. Chunk every document into overlapping passages via @lacspace/chunk.
|
|
2331
|
+
const records = [];
|
|
2332
|
+
for (const file of files) {
|
|
2333
|
+
const raw = await readFile(file, "utf8");
|
|
2334
|
+
const source = relative(CONTENT_DIR, file);
|
|
2335
|
+
const chunks = splitText(raw, { chunkSize: 900, chunkOverlap: 150 });
|
|
2336
|
+
chunks.forEach((chunk, i) => {
|
|
2337
|
+
records.push({ id: \`\${source}#\${i}\`, text: chunk.text, source });
|
|
2338
|
+
});
|
|
2339
|
+
}
|
|
2340
|
+
|
|
2341
|
+
// 2. Embed all passages with a local Ollama model (keyless) via @lacspace/embeddings.
|
|
2342
|
+
const model = process.env.LACSPACE_EMBED_MODEL ?? "nomic-embed-text";
|
|
2343
|
+
const baseUrl = process.env.LACSPACE_AI_BASE_URL ?? "http://localhost:11434";
|
|
2344
|
+
console.log(\`Embedding \${records.length} chunks with \${model}\u2026\`);
|
|
2345
|
+
const vectors = await embed(records.map((r) => r.text), { provider: "ollama", model, baseUrl });
|
|
2346
|
+
|
|
2347
|
+
// 3. Store them in an in-memory vector store, then serialise to .rag-index.json.
|
|
2348
|
+
const store = createVectorStore({ metric: "cosine" });
|
|
2349
|
+
store.upsert(records.map((r, i) => ({ id: r.id, vector: vectors[i], text: r.text, metadata: { source: r.source } })));
|
|
2350
|
+
|
|
2351
|
+
await writeFile(OUT, JSON.stringify(store.toJSON()));
|
|
2352
|
+
console.log(\`\u2713 Wrote \${OUT} (\${records.length} chunks from \${files.length} files).\`);
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2355
|
+
main().catch((err) => { console.error(err); process.exit(1); });
|
|
2356
|
+
`;
|
|
2357
|
+
var ragAskRoute = (_ctx) => `import { readFile } from "node:fs/promises";
|
|
2358
|
+
import { join } from "node:path";
|
|
2359
|
+
import { fromJSON } from "@lacspace/vector";
|
|
2360
|
+
import { createEmbedder } from "@lacspace/embeddings";
|
|
2361
|
+
import { createRag } from "@lacspace/rag";
|
|
2362
|
+
import { rerank } from "@lacspace/rerank";
|
|
2363
|
+
import { resolveConfig } from "@lacspace/providers";
|
|
2364
|
+
import { chat } from "@lacspace/ai";
|
|
2365
|
+
import type { Message } from "@lacspace/ai";
|
|
2366
|
+
|
|
2367
|
+
export const runtime = "nodejs";
|
|
2368
|
+
export const dynamic = "force-dynamic";
|
|
2369
|
+
|
|
2370
|
+
export async function POST(req: Request) {
|
|
2371
|
+
const { question } = (await req.json()) as { question: string };
|
|
2372
|
+
if (!question || !question.trim()) {
|
|
2373
|
+
return Response.json({ error: "Ask a question." }, { status: 400 });
|
|
2374
|
+
}
|
|
2375
|
+
|
|
2376
|
+
// 1. Load the local index built by \`npm run rag:index\`.
|
|
2377
|
+
let store;
|
|
2378
|
+
try {
|
|
2379
|
+
const raw = await readFile(join(process.cwd(), ".rag-index.json"), "utf8");
|
|
2380
|
+
store = fromJSON(JSON.parse(raw));
|
|
2381
|
+
} catch {
|
|
2382
|
+
return Response.json({ error: "No index found. Run \`npm run rag:index\` first." }, { status: 503 });
|
|
2383
|
+
}
|
|
2384
|
+
|
|
2385
|
+
// 2. Config \u2014 keyless local Ollama by default.
|
|
2386
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/providers
|
|
2387
|
+
const cfg = resolveConfig(process.env.LACSPACE_AI_PROVIDER ?? "ollama", {
|
|
2388
|
+
baseUrl: process.env.LACSPACE_AI_BASE_URL,
|
|
2389
|
+
model: process.env.LACSPACE_AI_MODEL,
|
|
2390
|
+
apiKey: process.env.LACSPACE_AI_API_KEY,
|
|
2391
|
+
});
|
|
2392
|
+
const embedModel = process.env.LACSPACE_EMBED_MODEL ?? "nomic-embed-text";
|
|
2393
|
+
const embedBaseUrl = cfg.apiStyle === "ollama" ? cfg.baseUrl : (process.env.LACSPACE_AI_BASE_URL ?? "http://localhost:11434");
|
|
2394
|
+
|
|
2395
|
+
// 3. Retrieve the closest passages via @lacspace/rag + @lacspace/embeddings.
|
|
2396
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/rag
|
|
2397
|
+
const embedder = createEmbedder({ provider: "ollama", model: embedModel, baseUrl: embedBaseUrl });
|
|
2398
|
+
const rag = createRag({ embed: embedder, store });
|
|
2399
|
+
const hits = await rag.retrieve(question, { k: 8 });
|
|
2400
|
+
|
|
2401
|
+
// 4. Re-rank for relevance and keep the best few. @lacspace/rerank blends the
|
|
2402
|
+
// vector score with a lexical match \u2014 no second model call needed.
|
|
2403
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/rerank
|
|
2404
|
+
const ranked = await rerank(question, hits.map((h) => ({ id: h.id, text: h.text, score: h.score })), { k: 4 });
|
|
2405
|
+
|
|
2406
|
+
// 5. Build a grounded prompt and answer with @lacspace/ai.
|
|
2407
|
+
// Learn more \u2192 https://developer.lacspace.com/packages/ai
|
|
2408
|
+
const context = ranked.map((d, i) => \`[\${i + 1}] \${d.text}\`).join("\\n\\n");
|
|
2409
|
+
const messages: Message[] = [
|
|
2410
|
+
{ role: "system", content: "You answer strictly from the provided context. If the answer isn't there, say you don't know. Cite sources as [n]." },
|
|
2411
|
+
{ role: "user", content: \`Context:\\n\${context}\\n\\nQuestion: \${question}\` },
|
|
2412
|
+
];
|
|
2413
|
+
|
|
2414
|
+
const baseUrl = cfg.apiStyle === "ollama" ? cfg.baseUrl.replace(/\\/+$/, "") + "/v1" : cfg.baseUrl;
|
|
2415
|
+
const res = await chat({
|
|
2416
|
+
provider: "openai-compatible",
|
|
2417
|
+
baseUrl,
|
|
2418
|
+
apiKey: cfg.apiKey,
|
|
2419
|
+
model: cfg.model ?? "llama3.2",
|
|
2420
|
+
headers: cfg.headers,
|
|
2421
|
+
messages,
|
|
2422
|
+
});
|
|
2423
|
+
|
|
2424
|
+
const sources = ranked.map((d, i) => ({ n: i + 1, id: d.id }));
|
|
2425
|
+
return Response.json({ answer: res.text, sources });
|
|
2426
|
+
}
|
|
2427
|
+
`;
|
|
2428
|
+
var ragAskPage = (ctx) => `"use client";
|
|
2429
|
+
|
|
2430
|
+
import { useState } from "react";
|
|
2431
|
+
|
|
2432
|
+
interface Source { n: number; id: string; }
|
|
2433
|
+
|
|
2434
|
+
export default function AskPage() {
|
|
2435
|
+
const [question, setQuestion] = useState("");
|
|
2436
|
+
const [answer, setAnswer] = useState("");
|
|
2437
|
+
const [sources, setSources] = useState<Source[]>([]);
|
|
2438
|
+
const [busy, setBusy] = useState(false);
|
|
2439
|
+
const [error, setError] = useState("");
|
|
2440
|
+
|
|
2441
|
+
async function ask(e: React.FormEvent) {
|
|
2442
|
+
e.preventDefault();
|
|
2443
|
+
const q = question.trim();
|
|
2444
|
+
if (!q || busy) return;
|
|
2445
|
+
setBusy(true); setError(""); setAnswer(""); setSources([]);
|
|
2446
|
+
try {
|
|
2447
|
+
const res = await fetch("/api/ask", {
|
|
2448
|
+
method: "POST",
|
|
2449
|
+
headers: { "Content-Type": "application/json" },
|
|
2450
|
+
body: JSON.stringify({ question: q }),
|
|
2451
|
+
});
|
|
2452
|
+
const data = await res.json();
|
|
2453
|
+
if (!res.ok) throw new Error(data.error || "Something went wrong.");
|
|
2454
|
+
setAnswer(data.answer);
|
|
2455
|
+
setSources(data.sources ?? []);
|
|
2456
|
+
} catch (err) {
|
|
2457
|
+
setError(err instanceof Error ? err.message : "Something went wrong.");
|
|
2458
|
+
} finally {
|
|
2459
|
+
setBusy(false);
|
|
2460
|
+
}
|
|
2461
|
+
}
|
|
2462
|
+
|
|
2463
|
+
return (
|
|
2464
|
+
<section className="mx-auto max-w-2xl px-6 py-16">
|
|
2465
|
+
<p className="text-sm font-semibold uppercase tracking-widest gradient-text">RAG \xB7 ${ctx.template.siteName}</p>
|
|
2466
|
+
<h1 className="mt-2 text-3xl font-bold sm:text-4xl">Ask your content</h1>
|
|
2467
|
+
<p className="mt-2 text-muted">Grounded answers from your own docs \u2014 indexed and answered locally. Run <code className="rounded bg-panel px-1.5 py-0.5 text-sm">npm run rag:index</code> first.</p>
|
|
2468
|
+
|
|
2469
|
+
<form onSubmit={ask} className="mt-8 flex gap-2">
|
|
2470
|
+
<input
|
|
2471
|
+
value={question}
|
|
2472
|
+
onChange={(e) => setQuestion(e.target.value)}
|
|
2473
|
+
placeholder="What is this project about?"
|
|
2474
|
+
className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none focus:border-hairline"
|
|
2475
|
+
/>
|
|
2476
|
+
<button
|
|
2477
|
+
type="submit"
|
|
2478
|
+
disabled={busy || !question.trim()}
|
|
2479
|
+
className="rounded-xl gradient-bg on-accent px-5 py-3 text-sm font-semibold shimmer disabled:opacity-50"
|
|
2480
|
+
>
|
|
2481
|
+
{busy ? "\u2026" : "Ask"}
|
|
2482
|
+
</button>
|
|
2483
|
+
</form>
|
|
2484
|
+
|
|
2485
|
+
{error && <p className="mt-6 rounded-xl border border-red-400/40 bg-red-400/10 px-4 py-3 text-sm text-red-400">{error}</p>}
|
|
2486
|
+
|
|
2487
|
+
{answer && (
|
|
2488
|
+
<div className="mt-8 card p-6">
|
|
2489
|
+
<p className="whitespace-pre-wrap leading-relaxed">{answer}</p>
|
|
2490
|
+
{sources.length > 0 && (
|
|
2491
|
+
<div className="mt-5 border-t border-hairline pt-4">
|
|
2492
|
+
<p className="text-xs font-semibold uppercase tracking-widest text-muted">Sources</p>
|
|
2493
|
+
<ul className="mt-2 space-y-1 text-sm text-muted">
|
|
2494
|
+
{sources.map((s) => (
|
|
2495
|
+
<li key={s.n}>[{s.n}] <code className="text-fg">{s.id}</code></li>
|
|
2496
|
+
))}
|
|
2497
|
+
</ul>
|
|
2498
|
+
</div>
|
|
2499
|
+
)}
|
|
2500
|
+
</div>
|
|
2501
|
+
)}
|
|
2502
|
+
</section>
|
|
2503
|
+
);
|
|
2504
|
+
}
|
|
2505
|
+
`;
|
|
1968
2506
|
function pagesFor(ctx) {
|
|
1969
2507
|
const k = ctx.template.key;
|
|
1970
2508
|
if (k === "dashboard") {
|
|
@@ -4553,8 +5091,9 @@ var faqSection = (ctx) => {
|
|
|
4553
5091
|
</div>
|
|
4554
5092
|
</section>`;
|
|
4555
5093
|
};
|
|
5094
|
+
var splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
4556
5095
|
function parseArgs(list) {
|
|
4557
|
-
const a = { yes: false, install: true, git: true, pm: "npm", help: false };
|
|
5096
|
+
const a = { features: [], yes: false, install: true, git: true, pm: "npm", help: false };
|
|
4558
5097
|
for (let i = 0; i < list.length; i++) {
|
|
4559
5098
|
const arg = list[i];
|
|
4560
5099
|
const next = () => list[++i] ?? "";
|
|
@@ -4564,10 +5103,13 @@ function parseArgs(list) {
|
|
|
4564
5103
|
else if (arg === "--no-git") a.git = false;
|
|
4565
5104
|
else if (arg === "--pm") a.pm = next();
|
|
4566
5105
|
else if (arg === "--theme" || arg === "--accent") a.theme = next();
|
|
5106
|
+
else if (arg === "--with" || arg === "--features") a.features.push(...splitList(next()));
|
|
4567
5107
|
else if (arg === "-h" || arg === "--help") a.help = true;
|
|
4568
5108
|
else if (arg.startsWith("--template=")) a.template = arg.slice(11);
|
|
4569
5109
|
else if (arg.startsWith("--theme=")) a.theme = arg.slice(8);
|
|
4570
5110
|
else if (arg.startsWith("--accent=")) a.theme = arg.slice(9);
|
|
5111
|
+
else if (arg.startsWith("--with=")) a.features.push(...splitList(arg.slice(7)));
|
|
5112
|
+
else if (arg.startsWith("--features=")) a.features.push(...splitList(arg.slice(11)));
|
|
4571
5113
|
else if (!arg.startsWith("-") && !a.name) a.name = arg;
|
|
4572
5114
|
}
|
|
4573
5115
|
return a;
|
|
@@ -4662,6 +5204,7 @@ ${TEMPLATES.map((t) => ` ${t.key.padEnd(10)} ${t.description}`).join("\n")}
|
|
|
4662
5204
|
|
|
4663
5205
|
${c("bold", "Options")}
|
|
4664
5206
|
-t, --template <key> Template (${TEMPLATES.map((t) => t.key).join(" | ")})
|
|
5207
|
+
--with <a,b> Feature add-ons, comma-separated (alias --features)
|
|
4665
5208
|
--theme <name|hex> Accent: a preset, a "#hex", or "from,to" (e.g. --theme lacspace)
|
|
4666
5209
|
--pm <npm|pnpm|yarn|bun> Package manager (default npm)
|
|
4667
5210
|
--no-install Skip installing dependencies
|
|
@@ -4669,13 +5212,18 @@ ${c("bold", "Options")}
|
|
|
4669
5212
|
-y, --yes Accept defaults (needs <name>)
|
|
4670
5213
|
-h, --help Show this help
|
|
4671
5214
|
|
|
5215
|
+
${c("bold", "Feature add-ons")} ${c("dim", "(--with) \u2014 free & keyless, local by default")}
|
|
5216
|
+
${FEATURES.map((f) => ` ${f.key.padEnd(10)} ${f.description}`).join("\n")}
|
|
5217
|
+
${c("dim", "e.g. npx create-lacspace-app my-app --template saas --with ai-chat,rag")}
|
|
5218
|
+
|
|
4672
5219
|
${c("bold", "Themes")} ${c("dim", "(--theme)")}
|
|
4673
5220
|
${Object.keys(THEMES).join(" \xB7 ")}
|
|
4674
5221
|
${c("dim", 'or a custom colour: --theme "#ff6a00" \xB7 --theme "#0bb9d9,#7c3aed"')}
|
|
4675
5222
|
|
|
4676
|
-
${c("bold", "
|
|
4677
|
-
npx create-lacspace-app add pricing faq testimonials
|
|
4678
|
-
${c("dim", "
|
|
5223
|
+
${c("bold", "Grow an existing app")}
|
|
5224
|
+
npx create-lacspace-app add pricing faq testimonials ${c("dim", "# prebuilt sections")}
|
|
5225
|
+
npx create-lacspace-app add ai-chat ${c("dim", "# a feature add-on")}
|
|
5226
|
+
${c("dim", "Drops themed sections / feature files into your project (and the UI kit if missing).")}
|
|
4679
5227
|
`;
|
|
4680
5228
|
var SECTIONS = {
|
|
4681
5229
|
hero: `import Link from "next/link";
|
|
@@ -4915,7 +5463,7 @@ function ensureUiKit(root) {
|
|
|
4915
5463
|
var pascal = (s) => s.split(/[-_]/).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join("");
|
|
4916
5464
|
function runAdd(rawNames) {
|
|
4917
5465
|
stdout.write(`
|
|
4918
|
-
${c("bold", c("magenta", "\u25C6 create-lacspace-app add"))} ${c("dim", "\u2014 drop prebuilt sections into your app")}
|
|
5466
|
+
${c("bold", c("magenta", "\u25C6 create-lacspace-app add"))} ${c("dim", "\u2014 drop prebuilt sections or a feature into your app")}
|
|
4919
5467
|
|
|
4920
5468
|
`);
|
|
4921
5469
|
const root = cwd();
|
|
@@ -4926,56 +5474,108 @@ ${c("bold", c("magenta", "\u25C6 create-lacspace-app add"))} ${c("dim", "\u2014
|
|
|
4926
5474
|
}
|
|
4927
5475
|
const wanted = rawNames.filter((n) => !n.startsWith("-")).map((n) => n.toLowerCase());
|
|
4928
5476
|
if (wanted.length === 0 || rawNames.includes("--help") || rawNames.includes("-h")) {
|
|
4929
|
-
stdout.write(`Usage: ${c("cyan", "npx create-lacspace-app add <section...>")}
|
|
5477
|
+
stdout.write(`Usage: ${c("cyan", "npx create-lacspace-app add <section|feature...>")}
|
|
4930
5478
|
|
|
4931
5479
|
${c("bold", "Available sections")}
|
|
4932
5480
|
`);
|
|
4933
5481
|
stdout.write(" " + Object.keys(SECTIONS).map((k) => c("cyan", k)).join(" ") + "\n");
|
|
4934
5482
|
stdout.write(`
|
|
4935
|
-
|
|
5483
|
+
${c("bold", "Available features")}
|
|
5484
|
+
`);
|
|
5485
|
+
stdout.write(" " + FEATURES.map((f) => c("cyan", f.key)).join(" ") + "\n");
|
|
5486
|
+
stdout.write(`
|
|
5487
|
+
Example: ${c("cyan", "npx create-lacspace-app add pricing faq")} ${c("dim", "or")} ${c("cyan", "add ai-chat")}
|
|
4936
5488
|
|
|
4937
5489
|
`);
|
|
4938
5490
|
return;
|
|
4939
5491
|
}
|
|
4940
|
-
const
|
|
5492
|
+
const featKeys = wanted.filter((n) => FEATURES.some((f) => f.key === n));
|
|
5493
|
+
const sectionNames = wanted.filter((n) => n in SECTIONS);
|
|
5494
|
+
const unknown = wanted.filter((n) => !featKeys.includes(n) && !(n in SECTIONS));
|
|
4941
5495
|
if (unknown.length) stdout.write(c("yellow", `! Unknown: ${unknown.join(", ")} ${c("dim", "(run with no args to list)")}
|
|
4942
5496
|
`));
|
|
4943
|
-
const
|
|
4944
|
-
if (
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
if (kitAdded.length) stdout.write(` ${c("green", "\u2714")} Added the UI kit ${c("dim", "(" + kitAdded.length + " components)")}
|
|
5497
|
+
const addedFeatures = featKeys.map((k) => FEATURES.find((f) => f.key === k));
|
|
5498
|
+
if (addedFeatures.length) {
|
|
5499
|
+
const ctx = resolveContext({ name: basename(root), features: featKeys });
|
|
5500
|
+
for (const feat of addedFeatures) {
|
|
5501
|
+
stdout.write(`
|
|
5502
|
+
${c("bold", `+ ${feat.label}`)} ${c("dim", `(${feat.key})`)}
|
|
4950
5503
|
`);
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
4954
|
-
|
|
4955
|
-
if (existsSync(full)) {
|
|
4956
|
-
stdout.write(c("yellow", ` ~ ${rel} exists \u2014 skipped
|
|
5504
|
+
for (const [rel, content] of Object.entries(feat.files(ctx))) {
|
|
5505
|
+
const full = join(root, rel);
|
|
5506
|
+
if (existsSync(full)) {
|
|
5507
|
+
stdout.write(c("yellow", ` ~ ${rel} exists \u2014 skipped
|
|
4957
5508
|
`));
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
5509
|
+
continue;
|
|
5510
|
+
}
|
|
5511
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
5512
|
+
writeFileSync(full, content);
|
|
5513
|
+
stdout.write(` ${c("green", "\u2714")} ${c("dim", "+ " + rel)}
|
|
5514
|
+
`);
|
|
5515
|
+
}
|
|
5516
|
+
stdout.write(`
|
|
5517
|
+
${c("bold", "Add these dependencies")}:
|
|
5518
|
+
${c("cyan", "npm i " + Object.entries(feat.deps).map(([n, v]) => `${n}@${v}`).join(" "))}
|
|
5519
|
+
`);
|
|
5520
|
+
if (feat.scripts && Object.keys(feat.scripts).length) {
|
|
5521
|
+
stdout.write(`
|
|
5522
|
+
${c("bold", "Add to package.json scripts")}:
|
|
5523
|
+
`);
|
|
5524
|
+
for (const [n, v] of Object.entries(feat.scripts)) stdout.write(` ${c("cyan", `"${n}": "${v}"`)}
|
|
4964
5525
|
`);
|
|
5526
|
+
}
|
|
5527
|
+
if (feat.env && Object.keys(feat.env).length) {
|
|
5528
|
+
stdout.write(`
|
|
5529
|
+
${c("bold", "Add to .env")}:
|
|
5530
|
+
`);
|
|
5531
|
+
for (const [n, comment] of Object.entries(feat.env)) stdout.write(` ${c("cyan", n + "=")} ${c("dim", "# " + comment)}
|
|
5532
|
+
`);
|
|
5533
|
+
}
|
|
5534
|
+
stdout.write(`
|
|
5535
|
+
${c("bold", "Next steps")}:
|
|
5536
|
+
`);
|
|
5537
|
+
for (const s of feat.nextSteps) stdout.write(` ${c("dim", "\u2022")} ${s}
|
|
5538
|
+
`);
|
|
5539
|
+
}
|
|
5540
|
+
stdout.write("\n");
|
|
4965
5541
|
}
|
|
4966
|
-
if (
|
|
4967
|
-
|
|
5542
|
+
if (sectionNames.length) {
|
|
5543
|
+
const kitAdded = ensureUiKit(root);
|
|
5544
|
+
if (kitAdded.length) stdout.write(` ${c("green", "\u2714")} Added the UI kit ${c("dim", "(" + kitAdded.length + " components)")}
|
|
5545
|
+
`);
|
|
5546
|
+
const created = [];
|
|
5547
|
+
for (const name of sectionNames) {
|
|
5548
|
+
const rel = `components/sections/${name}.tsx`;
|
|
5549
|
+
const full = join(root, rel);
|
|
5550
|
+
if (existsSync(full)) {
|
|
5551
|
+
stdout.write(c("yellow", ` ~ ${rel} exists \u2014 skipped
|
|
5552
|
+
`));
|
|
5553
|
+
continue;
|
|
5554
|
+
}
|
|
5555
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
5556
|
+
writeFileSync(full, SECTIONS[name]);
|
|
5557
|
+
created.push(name);
|
|
5558
|
+
stdout.write(` ${c("green", "\u2714")} ${c("dim", "+ " + rel)}
|
|
5559
|
+
`);
|
|
5560
|
+
}
|
|
5561
|
+
if (created.length) {
|
|
5562
|
+
stdout.write(`
|
|
4968
5563
|
${c("bold", "Use them")} \u2014 import into any page:
|
|
4969
5564
|
`);
|
|
4970
|
-
|
|
5565
|
+
for (const n of created) stdout.write(` ${c("cyan", `import { ${pascal(n)}Section } from "@/components/sections/${n}";`)}
|
|
4971
5566
|
`);
|
|
4972
|
-
|
|
5567
|
+
stdout.write(`
|
|
4973
5568
|
Then drop ${c("cyan", "<" + pascal(created[0]) + "Section />")} into your JSX.
|
|
4974
5569
|
`);
|
|
4975
|
-
|
|
5570
|
+
stdout.write(`
|
|
4976
5571
|
${c("dim", "Sections use the @lacspace UI kit + design tokens \u2014 best inside a create-lacspace-app project.")}
|
|
4977
5572
|
|
|
4978
5573
|
`);
|
|
5574
|
+
}
|
|
5575
|
+
}
|
|
5576
|
+
if (!addedFeatures.length && !sectionNames.length) {
|
|
5577
|
+
exit(1);
|
|
5578
|
+
return;
|
|
4979
5579
|
}
|
|
4980
5580
|
}
|
|
4981
5581
|
async function main() {
|
|
@@ -4995,7 +5595,8 @@ ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 a go
|
|
|
4995
5595
|
`);
|
|
4996
5596
|
let name = args.name;
|
|
4997
5597
|
let templateKey = args.template;
|
|
4998
|
-
|
|
5598
|
+
const featureKeys = [...args.features];
|
|
5599
|
+
if (!args.yes && stdin.isTTY) {
|
|
4999
5600
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
5000
5601
|
try {
|
|
5001
5602
|
if (!name) name = (await rl.question(`${c("green", "?")} Project name ${c("dim", "(my-app)")}: `)).trim() || "my-app";
|
|
@@ -5010,6 +5611,22 @@ ${c("green", "?")} Template ${c("dim", "(1)")}: `)).trim() || "1";
|
|
|
5010
5611
|
const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : TEMPLATES.findIndex((t) => t.key === ans);
|
|
5011
5612
|
templateKey = TEMPLATES[idx]?.key ?? "personal";
|
|
5012
5613
|
}
|
|
5614
|
+
if (featureKeys.length === 0) {
|
|
5615
|
+
stdout.write(`
|
|
5616
|
+
Add feature add-ons? ${c("dim", "(free & keyless \u2014 local AI by default)")}
|
|
5617
|
+
`);
|
|
5618
|
+
FEATURES.forEach((f, i) => stdout.write(` ${c("cyan", String(i + 1))}. ${c("bold", f.label)} ${c("dim", "\u2014 " + f.description)}
|
|
5619
|
+
`));
|
|
5620
|
+
const ans = (await rl.question(`
|
|
5621
|
+
${c("green", "?")} Add features? ${c("dim", "(comma-separated numbers, or Enter for none)")}: `)).trim();
|
|
5622
|
+
if (ans) {
|
|
5623
|
+
for (const tok of splitList(ans)) {
|
|
5624
|
+
const idx = /^\d+$/.test(tok) ? parseInt(tok, 10) - 1 : FEATURES.findIndex((f) => f.key === tok);
|
|
5625
|
+
const feat = FEATURES[idx];
|
|
5626
|
+
if (feat && !featureKeys.includes(feat.key)) featureKeys.push(feat.key);
|
|
5627
|
+
}
|
|
5628
|
+
}
|
|
5629
|
+
}
|
|
5013
5630
|
} finally {
|
|
5014
5631
|
rl.close();
|
|
5015
5632
|
}
|
|
@@ -5034,7 +5651,8 @@ ${c("green", "?")} Template ${c("dim", "(1)")}: `)).trim() || "1";
|
|
|
5034
5651
|
exit(1);
|
|
5035
5652
|
return;
|
|
5036
5653
|
}
|
|
5037
|
-
const
|
|
5654
|
+
const features = normalizeFeatures(featureKeys);
|
|
5655
|
+
const files = buildFiles({ name: projectName, template, features });
|
|
5038
5656
|
for (const [rel, content] of Object.entries(files)) {
|
|
5039
5657
|
const full = join(dir, rel);
|
|
5040
5658
|
mkdirSync(dirname(full), { recursive: true });
|
|
@@ -5042,6 +5660,8 @@ ${c("green", "?")} Template ${c("dim", "(1)")}: `)).trim() || "1";
|
|
|
5042
5660
|
}
|
|
5043
5661
|
stdout.write(`
|
|
5044
5662
|
${c("green", "\u2714")} Created ${c("bold", name)} ${c("dim", `(${template.label})`)}
|
|
5663
|
+
`);
|
|
5664
|
+
if (features.length) stdout.write(` ${c("green", "\u2714")} Feature add-ons: ${features.map((f) => c("cyan", f.key)).join(", ")}
|
|
5045
5665
|
`);
|
|
5046
5666
|
for (const rel of Object.keys(files)) stdout.write(` ${c("dim", "+ " + rel)}
|
|
5047
5667
|
`);
|
|
@@ -5074,6 +5694,21 @@ ${c("bold", "Done! Next steps")}
|
|
|
5074
5694
|
stdout.write(`
|
|
5075
5695
|
Edit ${c("cyan", "lib/site.ts")} (your SEO config) and ${c("cyan", "app/page.tsx")}.
|
|
5076
5696
|
`);
|
|
5697
|
+
if (features.length) {
|
|
5698
|
+
const hasAi = features.some((f) => f.key === "ai-chat" || f.key === "rag");
|
|
5699
|
+
stdout.write(`
|
|
5700
|
+
${c("bold", "Your feature add-ons")} ${c("dim", "\u2014 details in LEARN.md")}
|
|
5701
|
+
`);
|
|
5702
|
+
if (hasAi) stdout.write(` ${c("green", "\u{1F193} Free & local AI")} ${c("dim", "\u2014 install Ollama (https://ollama.com); no API key required.")}
|
|
5703
|
+
`);
|
|
5704
|
+
for (const f of features) {
|
|
5705
|
+
stdout.write(`
|
|
5706
|
+
${c("bold", f.label)} ${c("dim", `(${f.key})`)}
|
|
5707
|
+
`);
|
|
5708
|
+
for (const s of f.nextSteps) stdout.write(` ${c("dim", "\u2022")} ${s}
|
|
5709
|
+
`);
|
|
5710
|
+
}
|
|
5711
|
+
}
|
|
5077
5712
|
stdout.write(`
|
|
5078
5713
|
${c("green", "Happy building!")} ${c("dim", "https://lacspace.com/packages")}
|
|
5079
5714
|
|
|
@@ -5106,6 +5741,13 @@ function listSections() {
|
|
|
5106
5741
|
function getSection(name) {
|
|
5107
5742
|
return SECTIONS[name];
|
|
5108
5743
|
}
|
|
5744
|
+
function listFeatures() {
|
|
5745
|
+
return FEATURES.map((f) => ({ ...f }));
|
|
5746
|
+
}
|
|
5747
|
+
function getFeature(key) {
|
|
5748
|
+
const f = FEATURES.find((x) => x.key === key);
|
|
5749
|
+
return f ? { ...f } : void 0;
|
|
5750
|
+
}
|
|
5109
5751
|
function generateProject(options = {}) {
|
|
5110
5752
|
return buildFiles(resolveContext(options));
|
|
5111
5753
|
}
|
|
@@ -5121,4 +5763,4 @@ async function scaffold(options = {}) {
|
|
|
5121
5763
|
return { dir: target, files: Object.keys(files) };
|
|
5122
5764
|
}
|
|
5123
5765
|
|
|
5124
|
-
export { generateProject, getSection, getTemplate, listSections, listTemplates, scaffold, templateKeys, templates };
|
|
5766
|
+
export { generateProject, getFeature, getSection, getTemplate, listFeatures, listSections, listTemplates, scaffold, templateKeys, templates };
|