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