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.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
|
-
|
|
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 & 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", "
|
|
4680
|
-
npx create-lacspace-app add pricing faq testimonials
|
|
4681
|
-
${c("dim", "
|
|
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
|
-
|
|
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
|
|
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
|
|
4947
|
-
if (
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
|
|
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
|
-
|
|
4955
|
-
|
|
4956
|
-
|
|
4957
|
-
|
|
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
|
-
|
|
4962
|
-
|
|
4963
|
-
|
|
4964
|
-
|
|
4965
|
-
|
|
4966
|
-
|
|
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 (
|
|
4970
|
-
|
|
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
|
-
|
|
5568
|
+
for (const n of created) process$1.stdout.write(` ${c("cyan", `import { ${pascal(n)}Section } from "@/components/sections/${n}";`)}
|
|
4974
5569
|
`);
|
|
4975
|
-
|
|
5570
|
+
process$1.stdout.write(`
|
|
4976
5571
|
Then drop ${c("cyan", "<" + pascal(created[0]) + "Section />")} into your JSX.
|
|
4977
5572
|
`);
|
|
4978
|
-
|
|
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
|
-
|
|
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
|
|
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;
|