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/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
|
-
|
|
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 & 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", "
|
|
4676
|
-
npx create-lacspace-app add pricing faq testimonials
|
|
4677
|
-
${c("dim", "
|
|
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
|
-
|
|
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
|
|
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
|
|
4943
|
-
if (
|
|
4944
|
-
|
|
4945
|
-
|
|
4946
|
-
|
|
4947
|
-
|
|
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
|
-
|
|
4951
|
-
|
|
4952
|
-
|
|
4953
|
-
|
|
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
|
-
|
|
4958
|
-
|
|
4959
|
-
|
|
4960
|
-
|
|
4961
|
-
|
|
4962
|
-
|
|
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 (
|
|
4966
|
-
|
|
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
|
-
|
|
5564
|
+
for (const n of created) stdout.write(` ${c("cyan", `import { ${pascal(n)}Section } from "@/components/sections/${n}";`)}
|
|
4970
5565
|
`);
|
|
4971
|
-
|
|
5566
|
+
stdout.write(`
|
|
4972
5567
|
Then drop ${c("cyan", "<" + pascal(created[0]) + "Section />")} into your JSX.
|
|
4973
5568
|
`);
|
|
4974
|
-
|
|
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
|
-
|
|
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
|
|
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 };
|