create-volt 0.49.0 → 0.51.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/CHANGELOG.md +20 -0
- package/package.json +1 -1
- package/templates/blog/server.js +39 -20
- package/templates/blog/setup/setup.js +16 -0
- package/templates/default/server.js +39 -20
- package/templates/default/setup/setup.js +16 -0
- package/templates/docs/server.js +39 -20
- package/templates/docs/setup/setup.js +16 -0
- package/templates/starter/server.js +39 -20
- package/templates/starter/setup/setup.js +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,24 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/), and this project adheres to
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.51.0] - 2026-06-30
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **Generate a hosted-AI token from the config.** The wizard's AI section has a
|
|
11
|
+
**Generate a free hosted token** button: it self-registers with the voltjs.com
|
|
12
|
+
gateway (new public, rate-limited `POST /api/register`) and writes
|
|
13
|
+
`VOLT_AI_TOKEN` to `.env` on Apply. One click to the free-capped/pay-as-you-go
|
|
14
|
+
hosted tier when you have no key of your own.
|
|
15
|
+
|
|
16
|
+
## [0.50.0] - 2026-06-30
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- **Config editor AI falls back to the hosted gateway.** With no local provider
|
|
20
|
+
key, `/setup/ai` routes through the voltjs.com gateway via `VOLT_AI_TOKEN`
|
|
21
|
+
(free-capped, then pay-as-you-go on the host's key); a local key still wins
|
|
22
|
+
(BYO). Clear error when neither is set. The gateway now honors the client's
|
|
23
|
+
stream preference, so the editor gets a normal JSON response.
|
|
24
|
+
|
|
7
25
|
## [0.49.0] - 2026-06-29
|
|
8
26
|
|
|
9
27
|
### Added
|
|
@@ -657,6 +675,8 @@ All notable changes to `create-volt` are documented here. The format follows
|
|
|
657
675
|
watching and full-page hot reload. Supports `--skip-install` and `--force`,
|
|
658
676
|
and auto-detects npm / pnpm / yarn / bun for the install step.
|
|
659
677
|
|
|
678
|
+
[0.51.0]: https://github.com/MIR-2025/volt/releases/tag/v0.51.0
|
|
679
|
+
[0.50.0]: https://github.com/MIR-2025/volt/releases/tag/v0.50.0
|
|
660
680
|
[0.49.0]: https://github.com/MIR-2025/volt/releases/tag/v0.49.0
|
|
661
681
|
[0.48.3]: https://github.com/MIR-2025/volt/releases/tag/v0.48.3
|
|
662
682
|
[0.48.2]: https://github.com/MIR-2025/volt/releases/tag/v0.48.2
|
package/package.json
CHANGED
package/templates/blog/server.js
CHANGED
|
@@ -344,9 +344,21 @@ function startSetup() {
|
|
|
344
344
|
}
|
|
345
345
|
return;
|
|
346
346
|
}
|
|
347
|
-
// --- AI
|
|
348
|
-
|
|
349
|
-
|
|
347
|
+
// --- generate a free hosted-AI token from the gateway (self-service) ---
|
|
348
|
+
if (req.method === "POST" && p === "/setup/gen-token") {
|
|
349
|
+
res.setHeader("Content-Type", "application/json");
|
|
350
|
+
const env = readEnvFile();
|
|
351
|
+
const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
|
|
352
|
+
fetch(base + "/api/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ app: env.SITE_NAME || "volt-app" }) })
|
|
353
|
+
.then((r) => r.json())
|
|
354
|
+
.then((j) => res.end(JSON.stringify(j)))
|
|
355
|
+
.catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// --- AI proxy for the in-config editor (RTEPro). Uses a local provider key
|
|
359
|
+
// (BYO) when set; otherwise falls back to the voltjs.com gateway via
|
|
360
|
+
// VOLT_AI_TOKEN (free-capped, then pay-as-you-go on the host's key). The
|
|
361
|
+
// key/token never reaches the browser. ---
|
|
350
362
|
if (req.method === "POST" && p === "/setup/ai") {
|
|
351
363
|
let cbody = "";
|
|
352
364
|
req.on("data", (c) => (cbody += c));
|
|
@@ -357,23 +369,30 @@ function startSetup() {
|
|
|
357
369
|
const body = JSON.parse(cbody || "{}");
|
|
358
370
|
const provider = body._provider || env.AI_PROVIDER || "anthropic";
|
|
359
371
|
delete body._provider;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
} else
|
|
376
|
-
|
|
372
|
+
const localKey = { anthropic: env.ANTHROPIC_API_KEY, openai: env.OPENAI_API_KEY, gemini: env.GEMINI_API_KEY }[provider];
|
|
373
|
+
let url, headers, payload = body;
|
|
374
|
+
if (localKey) {
|
|
375
|
+
if (provider === "anthropic") {
|
|
376
|
+
url = "https://api.anthropic.com/v1/messages";
|
|
377
|
+
headers = { "x-api-key": localKey, "anthropic-version": "2023-06-01", "content-type": "application/json" };
|
|
378
|
+
} else if (provider === "openai") {
|
|
379
|
+
url = "https://api.openai.com/v1/chat/completions";
|
|
380
|
+
headers = { authorization: "Bearer " + localKey, "content-type": "application/json" };
|
|
381
|
+
} else if (provider === "gemini") {
|
|
382
|
+
const model = body.model || "gemini-2.0-flash";
|
|
383
|
+
delete body.model;
|
|
384
|
+
url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${localKey}`;
|
|
385
|
+
headers = { "content-type": "application/json" };
|
|
386
|
+
} else throw new Error("unknown AI provider: " + provider);
|
|
387
|
+
} else if (env.VOLT_AI_TOKEN) {
|
|
388
|
+
// no local key → host gateway (free-capped, then pay-as-you-go)
|
|
389
|
+
url = env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
|
|
390
|
+
headers = { authorization: "Bearer " + env.VOLT_AI_TOKEN, "content-type": "application/json" };
|
|
391
|
+
payload = { messages: body.messages, system: body.system, max_tokens: body.max_tokens, model: body.model };
|
|
392
|
+
} else {
|
|
393
|
+
throw new Error("No AI key in .env and no VOLT_AI_TOKEN — add a provider key, or a gateway token to use the hosted tier.");
|
|
394
|
+
}
|
|
395
|
+
const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
377
396
|
const text = await r.text();
|
|
378
397
|
res.statusCode = r.status;
|
|
379
398
|
res.setHeader("Content-Type", r.headers.get("content-type") || "application/json");
|
|
@@ -34,6 +34,7 @@ const state = signal({
|
|
|
34
34
|
theme: current.THEME || "",
|
|
35
35
|
aiProvider: current.AI_PROVIDER || "anthropic",
|
|
36
36
|
aiKey: current.ANTHROPIC_API_KEY || current.OPENAI_API_KEY || current.GEMINI_API_KEY || "",
|
|
37
|
+
aiToken: current.VOLT_AI_TOKEN || "",
|
|
37
38
|
});
|
|
38
39
|
const set = (patch) => state({ ...state(), ...patch });
|
|
39
40
|
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
@@ -82,6 +83,7 @@ function genEnv(s) {
|
|
|
82
83
|
const keyVar = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", gemini: "GEMINI_API_KEY" }[s.aiProvider] || "ANTHROPIC_API_KEY";
|
|
83
84
|
out.push(`${keyVar}=${clean(s.aiKey)}`);
|
|
84
85
|
}
|
|
86
|
+
if (s.aiToken) out.push(`VOLT_AI_TOKEN=${clean(s.aiToken)}`); // hosted-tier token (used when no local key)
|
|
85
87
|
if (eff.includes("db")) {
|
|
86
88
|
out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
|
|
87
89
|
if (s.dbDriver === "mongodb") {
|
|
@@ -266,6 +268,8 @@ const aiSettings = () =>
|
|
|
266
268
|
</select>
|
|
267
269
|
${() => html`<a class="small d-inline-block mb-1" href=${AI_KEY_URL[state().aiProvider] || AI_KEY_URL.anthropic} target="_blank" rel="noopener">Get a ${state().aiProvider} key → paste it below (stays server-side in .env)</a>`}
|
|
268
270
|
${field("API key", "aiKey", "sk-…")}
|
|
271
|
+
<div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
|
|
272
|
+
${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
|
|
269
273
|
</div>
|
|
270
274
|
</details>`;
|
|
271
275
|
|
|
@@ -304,6 +308,18 @@ async function buyCredits(amountUsd) {
|
|
|
304
308
|
status("Checkout request failed.");
|
|
305
309
|
}
|
|
306
310
|
}
|
|
311
|
+
async function genToken() {
|
|
312
|
+
status("Requesting a hosted token…");
|
|
313
|
+
try {
|
|
314
|
+
const r = await (await fetch("/setup/gen-token", { method: "POST" })).json();
|
|
315
|
+
if (r.ok && r.token) {
|
|
316
|
+
set({ aiToken: r.token });
|
|
317
|
+
status("Hosted token generated — click Apply to save it.");
|
|
318
|
+
} else status("Could not get a token: " + (r.error || "?"));
|
|
319
|
+
} catch {
|
|
320
|
+
status("Token request failed.");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
307
323
|
const items = signal({ pages: [], posts: [] });
|
|
308
324
|
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
309
325
|
let ed = null; // live RTEPro instance for the open editor
|
|
@@ -344,9 +344,21 @@ function startSetup() {
|
|
|
344
344
|
}
|
|
345
345
|
return;
|
|
346
346
|
}
|
|
347
|
-
// --- AI
|
|
348
|
-
|
|
349
|
-
|
|
347
|
+
// --- generate a free hosted-AI token from the gateway (self-service) ---
|
|
348
|
+
if (req.method === "POST" && p === "/setup/gen-token") {
|
|
349
|
+
res.setHeader("Content-Type", "application/json");
|
|
350
|
+
const env = readEnvFile();
|
|
351
|
+
const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
|
|
352
|
+
fetch(base + "/api/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ app: env.SITE_NAME || "volt-app" }) })
|
|
353
|
+
.then((r) => r.json())
|
|
354
|
+
.then((j) => res.end(JSON.stringify(j)))
|
|
355
|
+
.catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// --- AI proxy for the in-config editor (RTEPro). Uses a local provider key
|
|
359
|
+
// (BYO) when set; otherwise falls back to the voltjs.com gateway via
|
|
360
|
+
// VOLT_AI_TOKEN (free-capped, then pay-as-you-go on the host's key). The
|
|
361
|
+
// key/token never reaches the browser. ---
|
|
350
362
|
if (req.method === "POST" && p === "/setup/ai") {
|
|
351
363
|
let cbody = "";
|
|
352
364
|
req.on("data", (c) => (cbody += c));
|
|
@@ -357,23 +369,30 @@ function startSetup() {
|
|
|
357
369
|
const body = JSON.parse(cbody || "{}");
|
|
358
370
|
const provider = body._provider || env.AI_PROVIDER || "anthropic";
|
|
359
371
|
delete body._provider;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
} else
|
|
376
|
-
|
|
372
|
+
const localKey = { anthropic: env.ANTHROPIC_API_KEY, openai: env.OPENAI_API_KEY, gemini: env.GEMINI_API_KEY }[provider];
|
|
373
|
+
let url, headers, payload = body;
|
|
374
|
+
if (localKey) {
|
|
375
|
+
if (provider === "anthropic") {
|
|
376
|
+
url = "https://api.anthropic.com/v1/messages";
|
|
377
|
+
headers = { "x-api-key": localKey, "anthropic-version": "2023-06-01", "content-type": "application/json" };
|
|
378
|
+
} else if (provider === "openai") {
|
|
379
|
+
url = "https://api.openai.com/v1/chat/completions";
|
|
380
|
+
headers = { authorization: "Bearer " + localKey, "content-type": "application/json" };
|
|
381
|
+
} else if (provider === "gemini") {
|
|
382
|
+
const model = body.model || "gemini-2.0-flash";
|
|
383
|
+
delete body.model;
|
|
384
|
+
url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${localKey}`;
|
|
385
|
+
headers = { "content-type": "application/json" };
|
|
386
|
+
} else throw new Error("unknown AI provider: " + provider);
|
|
387
|
+
} else if (env.VOLT_AI_TOKEN) {
|
|
388
|
+
// no local key → host gateway (free-capped, then pay-as-you-go)
|
|
389
|
+
url = env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
|
|
390
|
+
headers = { authorization: "Bearer " + env.VOLT_AI_TOKEN, "content-type": "application/json" };
|
|
391
|
+
payload = { messages: body.messages, system: body.system, max_tokens: body.max_tokens, model: body.model };
|
|
392
|
+
} else {
|
|
393
|
+
throw new Error("No AI key in .env and no VOLT_AI_TOKEN — add a provider key, or a gateway token to use the hosted tier.");
|
|
394
|
+
}
|
|
395
|
+
const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
377
396
|
const text = await r.text();
|
|
378
397
|
res.statusCode = r.status;
|
|
379
398
|
res.setHeader("Content-Type", r.headers.get("content-type") || "application/json");
|
|
@@ -34,6 +34,7 @@ const state = signal({
|
|
|
34
34
|
theme: current.THEME || "",
|
|
35
35
|
aiProvider: current.AI_PROVIDER || "anthropic",
|
|
36
36
|
aiKey: current.ANTHROPIC_API_KEY || current.OPENAI_API_KEY || current.GEMINI_API_KEY || "",
|
|
37
|
+
aiToken: current.VOLT_AI_TOKEN || "",
|
|
37
38
|
});
|
|
38
39
|
const set = (patch) => state({ ...state(), ...patch });
|
|
39
40
|
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
@@ -82,6 +83,7 @@ function genEnv(s) {
|
|
|
82
83
|
const keyVar = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", gemini: "GEMINI_API_KEY" }[s.aiProvider] || "ANTHROPIC_API_KEY";
|
|
83
84
|
out.push(`${keyVar}=${clean(s.aiKey)}`);
|
|
84
85
|
}
|
|
86
|
+
if (s.aiToken) out.push(`VOLT_AI_TOKEN=${clean(s.aiToken)}`); // hosted-tier token (used when no local key)
|
|
85
87
|
if (eff.includes("db")) {
|
|
86
88
|
out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
|
|
87
89
|
if (s.dbDriver === "mongodb") {
|
|
@@ -266,6 +268,8 @@ const aiSettings = () =>
|
|
|
266
268
|
</select>
|
|
267
269
|
${() => html`<a class="small d-inline-block mb-1" href=${AI_KEY_URL[state().aiProvider] || AI_KEY_URL.anthropic} target="_blank" rel="noopener">Get a ${state().aiProvider} key → paste it below (stays server-side in .env)</a>`}
|
|
268
270
|
${field("API key", "aiKey", "sk-…")}
|
|
271
|
+
<div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
|
|
272
|
+
${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
|
|
269
273
|
</div>
|
|
270
274
|
</details>`;
|
|
271
275
|
|
|
@@ -304,6 +308,18 @@ async function buyCredits(amountUsd) {
|
|
|
304
308
|
status("Checkout request failed.");
|
|
305
309
|
}
|
|
306
310
|
}
|
|
311
|
+
async function genToken() {
|
|
312
|
+
status("Requesting a hosted token…");
|
|
313
|
+
try {
|
|
314
|
+
const r = await (await fetch("/setup/gen-token", { method: "POST" })).json();
|
|
315
|
+
if (r.ok && r.token) {
|
|
316
|
+
set({ aiToken: r.token });
|
|
317
|
+
status("Hosted token generated — click Apply to save it.");
|
|
318
|
+
} else status("Could not get a token: " + (r.error || "?"));
|
|
319
|
+
} catch {
|
|
320
|
+
status("Token request failed.");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
307
323
|
const items = signal({ pages: [], posts: [] });
|
|
308
324
|
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
309
325
|
let ed = null; // live RTEPro instance for the open editor
|
package/templates/docs/server.js
CHANGED
|
@@ -344,9 +344,21 @@ function startSetup() {
|
|
|
344
344
|
}
|
|
345
345
|
return;
|
|
346
346
|
}
|
|
347
|
-
// --- AI
|
|
348
|
-
|
|
349
|
-
|
|
347
|
+
// --- generate a free hosted-AI token from the gateway (self-service) ---
|
|
348
|
+
if (req.method === "POST" && p === "/setup/gen-token") {
|
|
349
|
+
res.setHeader("Content-Type", "application/json");
|
|
350
|
+
const env = readEnvFile();
|
|
351
|
+
const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
|
|
352
|
+
fetch(base + "/api/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ app: env.SITE_NAME || "volt-app" }) })
|
|
353
|
+
.then((r) => r.json())
|
|
354
|
+
.then((j) => res.end(JSON.stringify(j)))
|
|
355
|
+
.catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
// --- AI proxy for the in-config editor (RTEPro). Uses a local provider key
|
|
359
|
+
// (BYO) when set; otherwise falls back to the voltjs.com gateway via
|
|
360
|
+
// VOLT_AI_TOKEN (free-capped, then pay-as-you-go on the host's key). The
|
|
361
|
+
// key/token never reaches the browser. ---
|
|
350
362
|
if (req.method === "POST" && p === "/setup/ai") {
|
|
351
363
|
let cbody = "";
|
|
352
364
|
req.on("data", (c) => (cbody += c));
|
|
@@ -357,23 +369,30 @@ function startSetup() {
|
|
|
357
369
|
const body = JSON.parse(cbody || "{}");
|
|
358
370
|
const provider = body._provider || env.AI_PROVIDER || "anthropic";
|
|
359
371
|
delete body._provider;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
} else
|
|
376
|
-
|
|
372
|
+
const localKey = { anthropic: env.ANTHROPIC_API_KEY, openai: env.OPENAI_API_KEY, gemini: env.GEMINI_API_KEY }[provider];
|
|
373
|
+
let url, headers, payload = body;
|
|
374
|
+
if (localKey) {
|
|
375
|
+
if (provider === "anthropic") {
|
|
376
|
+
url = "https://api.anthropic.com/v1/messages";
|
|
377
|
+
headers = { "x-api-key": localKey, "anthropic-version": "2023-06-01", "content-type": "application/json" };
|
|
378
|
+
} else if (provider === "openai") {
|
|
379
|
+
url = "https://api.openai.com/v1/chat/completions";
|
|
380
|
+
headers = { authorization: "Bearer " + localKey, "content-type": "application/json" };
|
|
381
|
+
} else if (provider === "gemini") {
|
|
382
|
+
const model = body.model || "gemini-2.0-flash";
|
|
383
|
+
delete body.model;
|
|
384
|
+
url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${localKey}`;
|
|
385
|
+
headers = { "content-type": "application/json" };
|
|
386
|
+
} else throw new Error("unknown AI provider: " + provider);
|
|
387
|
+
} else if (env.VOLT_AI_TOKEN) {
|
|
388
|
+
// no local key → host gateway (free-capped, then pay-as-you-go)
|
|
389
|
+
url = env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
|
|
390
|
+
headers = { authorization: "Bearer " + env.VOLT_AI_TOKEN, "content-type": "application/json" };
|
|
391
|
+
payload = { messages: body.messages, system: body.system, max_tokens: body.max_tokens, model: body.model };
|
|
392
|
+
} else {
|
|
393
|
+
throw new Error("No AI key in .env and no VOLT_AI_TOKEN — add a provider key, or a gateway token to use the hosted tier.");
|
|
394
|
+
}
|
|
395
|
+
const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
377
396
|
const text = await r.text();
|
|
378
397
|
res.statusCode = r.status;
|
|
379
398
|
res.setHeader("Content-Type", r.headers.get("content-type") || "application/json");
|
|
@@ -34,6 +34,7 @@ const state = signal({
|
|
|
34
34
|
theme: current.THEME || "",
|
|
35
35
|
aiProvider: current.AI_PROVIDER || "anthropic",
|
|
36
36
|
aiKey: current.ANTHROPIC_API_KEY || current.OPENAI_API_KEY || current.GEMINI_API_KEY || "",
|
|
37
|
+
aiToken: current.VOLT_AI_TOKEN || "",
|
|
37
38
|
});
|
|
38
39
|
const set = (patch) => state({ ...state(), ...patch });
|
|
39
40
|
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
@@ -82,6 +83,7 @@ function genEnv(s) {
|
|
|
82
83
|
const keyVar = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", gemini: "GEMINI_API_KEY" }[s.aiProvider] || "ANTHROPIC_API_KEY";
|
|
83
84
|
out.push(`${keyVar}=${clean(s.aiKey)}`);
|
|
84
85
|
}
|
|
86
|
+
if (s.aiToken) out.push(`VOLT_AI_TOKEN=${clean(s.aiToken)}`); // hosted-tier token (used when no local key)
|
|
85
87
|
if (eff.includes("db")) {
|
|
86
88
|
out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
|
|
87
89
|
if (s.dbDriver === "mongodb") {
|
|
@@ -266,6 +268,8 @@ const aiSettings = () =>
|
|
|
266
268
|
</select>
|
|
267
269
|
${() => html`<a class="small d-inline-block mb-1" href=${AI_KEY_URL[state().aiProvider] || AI_KEY_URL.anthropic} target="_blank" rel="noopener">Get a ${state().aiProvider} key → paste it below (stays server-side in .env)</a>`}
|
|
268
270
|
${field("API key", "aiKey", "sk-…")}
|
|
271
|
+
<div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
|
|
272
|
+
${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
|
|
269
273
|
</div>
|
|
270
274
|
</details>`;
|
|
271
275
|
|
|
@@ -304,6 +308,18 @@ async function buyCredits(amountUsd) {
|
|
|
304
308
|
status("Checkout request failed.");
|
|
305
309
|
}
|
|
306
310
|
}
|
|
311
|
+
async function genToken() {
|
|
312
|
+
status("Requesting a hosted token…");
|
|
313
|
+
try {
|
|
314
|
+
const r = await (await fetch("/setup/gen-token", { method: "POST" })).json();
|
|
315
|
+
if (r.ok && r.token) {
|
|
316
|
+
set({ aiToken: r.token });
|
|
317
|
+
status("Hosted token generated — click Apply to save it.");
|
|
318
|
+
} else status("Could not get a token: " + (r.error || "?"));
|
|
319
|
+
} catch {
|
|
320
|
+
status("Token request failed.");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
307
323
|
const items = signal({ pages: [], posts: [] });
|
|
308
324
|
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
309
325
|
let ed = null; // live RTEPro instance for the open editor
|
|
@@ -370,9 +370,21 @@ function startSetup() {
|
|
|
370
370
|
}
|
|
371
371
|
return;
|
|
372
372
|
}
|
|
373
|
-
// --- AI
|
|
374
|
-
|
|
375
|
-
|
|
373
|
+
// --- generate a free hosted-AI token from the gateway (self-service) ---
|
|
374
|
+
if (req.method === "POST" && p === "/setup/gen-token") {
|
|
375
|
+
res.setHeader("Content-Type", "application/json");
|
|
376
|
+
const env = readEnvFile();
|
|
377
|
+
const base = (env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai").replace(/\/api\/ai\/?$/, "");
|
|
378
|
+
fetch(base + "/api/register", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ app: env.SITE_NAME || "volt-app" }) })
|
|
379
|
+
.then((r) => r.json())
|
|
380
|
+
.then((j) => res.end(JSON.stringify(j)))
|
|
381
|
+
.catch(() => res.end(JSON.stringify({ ok: false, error: "gateway unreachable" })));
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
// --- AI proxy for the in-config editor (RTEPro). Uses a local provider key
|
|
385
|
+
// (BYO) when set; otherwise falls back to the voltjs.com gateway via
|
|
386
|
+
// VOLT_AI_TOKEN (free-capped, then pay-as-you-go on the host's key). The
|
|
387
|
+
// key/token never reaches the browser. ---
|
|
376
388
|
if (req.method === "POST" && p === "/setup/ai") {
|
|
377
389
|
let cbody = "";
|
|
378
390
|
req.on("data", (c) => (cbody += c));
|
|
@@ -383,23 +395,30 @@ function startSetup() {
|
|
|
383
395
|
const body = JSON.parse(cbody || "{}");
|
|
384
396
|
const provider = body._provider || env.AI_PROVIDER || "anthropic";
|
|
385
397
|
delete body._provider;
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
} else
|
|
402
|
-
|
|
398
|
+
const localKey = { anthropic: env.ANTHROPIC_API_KEY, openai: env.OPENAI_API_KEY, gemini: env.GEMINI_API_KEY }[provider];
|
|
399
|
+
let url, headers, payload = body;
|
|
400
|
+
if (localKey) {
|
|
401
|
+
if (provider === "anthropic") {
|
|
402
|
+
url = "https://api.anthropic.com/v1/messages";
|
|
403
|
+
headers = { "x-api-key": localKey, "anthropic-version": "2023-06-01", "content-type": "application/json" };
|
|
404
|
+
} else if (provider === "openai") {
|
|
405
|
+
url = "https://api.openai.com/v1/chat/completions";
|
|
406
|
+
headers = { authorization: "Bearer " + localKey, "content-type": "application/json" };
|
|
407
|
+
} else if (provider === "gemini") {
|
|
408
|
+
const model = body.model || "gemini-2.0-flash";
|
|
409
|
+
delete body.model;
|
|
410
|
+
url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${localKey}`;
|
|
411
|
+
headers = { "content-type": "application/json" };
|
|
412
|
+
} else throw new Error("unknown AI provider: " + provider);
|
|
413
|
+
} else if (env.VOLT_AI_TOKEN) {
|
|
414
|
+
// no local key → host gateway (free-capped, then pay-as-you-go)
|
|
415
|
+
url = env.VOLT_AI_GATEWAY || "https://voltjs.com/api/ai";
|
|
416
|
+
headers = { authorization: "Bearer " + env.VOLT_AI_TOKEN, "content-type": "application/json" };
|
|
417
|
+
payload = { messages: body.messages, system: body.system, max_tokens: body.max_tokens, model: body.model };
|
|
418
|
+
} else {
|
|
419
|
+
throw new Error("No AI key in .env and no VOLT_AI_TOKEN — add a provider key, or a gateway token to use the hosted tier.");
|
|
420
|
+
}
|
|
421
|
+
const r = await fetch(url, { method: "POST", headers, body: JSON.stringify(payload) });
|
|
403
422
|
const text = await r.text();
|
|
404
423
|
res.statusCode = r.status;
|
|
405
424
|
res.setHeader("Content-Type", r.headers.get("content-type") || "application/json");
|
|
@@ -34,6 +34,7 @@ const state = signal({
|
|
|
34
34
|
theme: current.THEME || "",
|
|
35
35
|
aiProvider: current.AI_PROVIDER || "anthropic",
|
|
36
36
|
aiKey: current.ANTHROPIC_API_KEY || current.OPENAI_API_KEY || current.GEMINI_API_KEY || "",
|
|
37
|
+
aiToken: current.VOLT_AI_TOKEN || "",
|
|
37
38
|
});
|
|
38
39
|
const set = (patch) => state({ ...state(), ...patch });
|
|
39
40
|
const toggle = (n) => state({ ...state(), addons: { ...state().addons, [n]: !state().addons[n] } });
|
|
@@ -82,6 +83,7 @@ function genEnv(s) {
|
|
|
82
83
|
const keyVar = { anthropic: "ANTHROPIC_API_KEY", openai: "OPENAI_API_KEY", gemini: "GEMINI_API_KEY" }[s.aiProvider] || "ANTHROPIC_API_KEY";
|
|
83
84
|
out.push(`${keyVar}=${clean(s.aiKey)}`);
|
|
84
85
|
}
|
|
86
|
+
if (s.aiToken) out.push(`VOLT_AI_TOKEN=${clean(s.aiToken)}`); // hosted-tier token (used when no local key)
|
|
85
87
|
if (eff.includes("db")) {
|
|
86
88
|
out.push(`DB_DRIVER=${clean(s.dbDriver)}`);
|
|
87
89
|
if (s.dbDriver === "mongodb") {
|
|
@@ -266,6 +268,8 @@ const aiSettings = () =>
|
|
|
266
268
|
</select>
|
|
267
269
|
${() => html`<a class="small d-inline-block mb-1" href=${AI_KEY_URL[state().aiProvider] || AI_KEY_URL.anthropic} target="_blank" rel="noopener">Get a ${state().aiProvider} key → paste it below (stays server-side in .env)</a>`}
|
|
268
270
|
${field("API key", "aiKey", "sk-…")}
|
|
271
|
+
<div class="small text-muted mt-2 mb-1">— or — no key? Use the hosted tier (free, capped, then pay-as-you-go):</div>
|
|
272
|
+
${() => (state().aiToken ? html`<div class="small">Hosted token: <code>${state().aiToken.slice(0, 14)}…</code> <button class="btn btn-sm btn-link p-0 ms-1" onclick=${() => set({ aiToken: "" })}>clear</button></div>` : html`<button class="btn btn-sm btn-outline-secondary" onclick=${genToken}>Generate a free hosted token</button>`)}
|
|
269
273
|
</div>
|
|
270
274
|
</details>`;
|
|
271
275
|
|
|
@@ -304,6 +308,18 @@ async function buyCredits(amountUsd) {
|
|
|
304
308
|
status("Checkout request failed.");
|
|
305
309
|
}
|
|
306
310
|
}
|
|
311
|
+
async function genToken() {
|
|
312
|
+
status("Requesting a hosted token…");
|
|
313
|
+
try {
|
|
314
|
+
const r = await (await fetch("/setup/gen-token", { method: "POST" })).json();
|
|
315
|
+
if (r.ok && r.token) {
|
|
316
|
+
set({ aiToken: r.token });
|
|
317
|
+
status("Hosted token generated — click Apply to save it.");
|
|
318
|
+
} else status("Could not get a token: " + (r.error || "?"));
|
|
319
|
+
} catch {
|
|
320
|
+
status("Token request failed.");
|
|
321
|
+
}
|
|
322
|
+
}
|
|
307
323
|
const items = signal({ pages: [], posts: [] });
|
|
308
324
|
const editing = signal(null); // { type, slug, title, isNew } — set only on open/save/close
|
|
309
325
|
let ed = null; // live RTEPro instance for the open editor
|