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