vibespot 0.5.2 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1096,60 +1096,49 @@ import { join as join6, basename as basename2 } from "path";
1096
1096
  import { readdirSync as readdirSync4, statSync as statSync2, writeFileSync as writeFileSync2 } from "fs";
1097
1097
 
1098
1098
  // src/ai/prompts.ts
1099
- function getConversionGuide() {
1099
+ var guideCache = /* @__PURE__ */ new Map();
1100
+ function cachedAsset(name) {
1101
+ let val = guideCache.get(name);
1102
+ if (val !== void 0) return val;
1100
1103
  try {
1101
- return readFile(resolveAsset("conversion-guide.md"));
1104
+ val = readFile(resolveAsset(name));
1102
1105
  } catch {
1103
- return "Conversion guide not found. Using built-in rules.";
1106
+ val = "";
1104
1107
  }
1108
+ guideCache.set(name, val);
1109
+ return val;
1110
+ }
1111
+ function getConversionGuide() {
1112
+ return cachedAsset("conversion-guide.md") || "Conversion guide not found. Using built-in rules.";
1105
1113
  }
1106
1114
  function getDesignGuide() {
1107
- try {
1108
- return readFile(resolveAsset("design-guide.md"));
1109
- } catch {
1110
- return "";
1111
- }
1115
+ return cachedAsset("design-guide.md");
1112
1116
  }
1113
1117
  function getContentGuide() {
1114
- try {
1115
- return readFile(resolveAsset("content-guide.md"));
1116
- } catch {
1117
- return "";
1118
- }
1118
+ return cachedAsset("content-guide.md");
1119
1119
  }
1120
1120
  function getHubspotRules() {
1121
- try {
1122
- return readFile(resolveAsset("hubspot-rules.md"));
1123
- } catch {
1124
- return "";
1125
- }
1121
+ return cachedAsset("hubspot-rules.md");
1126
1122
  }
1127
1123
  function getHumanifyGuide() {
1128
- try {
1129
- return readFile(resolveAsset("humanify-guide.md"));
1130
- } catch {
1131
- return "";
1132
- }
1124
+ return cachedAsset("humanify-guide.md");
1133
1125
  }
1134
1126
  function getPageTypeGuide(pageType) {
1135
- try {
1136
- const fullGuide = readFile(resolveAsset("page-types.md"));
1137
- const sectionHeaders = {
1138
- landing_page: "## Landing Page",
1139
- blog_post: "## Blog Post",
1140
- website_page: "## Website Page",
1141
- module_only: "## Module Only"
1142
- };
1143
- const header = sectionHeaders[pageType];
1144
- if (!header) return "";
1145
- const startIdx = fullGuide.indexOf(header);
1146
- if (startIdx < 0) return "";
1147
- const afterHeader = fullGuide.indexOf("\n## ", startIdx + header.length);
1148
- const section = afterHeader >= 0 ? fullGuide.slice(startIdx, afterHeader).trim() : fullGuide.slice(startIdx).trim();
1149
- return section;
1150
- } catch {
1151
- return "";
1152
- }
1127
+ const fullGuide = cachedAsset("page-types.md");
1128
+ if (!fullGuide) return "";
1129
+ const sectionHeaders = {
1130
+ landing_page: "## Landing Page",
1131
+ blog_post: "## Blog Post",
1132
+ website_page: "## Website Page",
1133
+ module_only: "## Module Only"
1134
+ };
1135
+ const header = sectionHeaders[pageType];
1136
+ if (!header) return "";
1137
+ const startIdx = fullGuide.indexOf(header);
1138
+ if (startIdx < 0) return "";
1139
+ const afterHeader = fullGuide.indexOf("\n## ", startIdx + header.length);
1140
+ const section = afterHeader >= 0 ? fullGuide.slice(startIdx, afterHeader).trim() : fullGuide.slice(startIdx).trim();
1141
+ return section;
1153
1142
  }
1154
1143
  function buildSystemPrompt(conversionGuide) {
1155
1144
  return `You are a HubSpot CMS expert converting React/Tailwind pages to native HubSpot modules.
@@ -1654,7 +1643,7 @@ import { join as join7, basename as basename3 } from "path";
1654
1643
  import { readdirSync as readdirSync5 } from "fs";
1655
1644
  var ClaudeAPIEngine = class {
1656
1645
  client;
1657
- model = "claude-sonnet-4-20250514";
1646
+ model = "claude-sonnet-4-6";
1658
1647
  constructor(apiKey) {
1659
1648
  this.client = new Anthropic({
1660
1649
  apiKey: apiKey || process.env.ANTHROPIC_API_KEY
@@ -3096,15 +3085,15 @@ import chalk2 from "chalk";
3096
3085
 
3097
3086
  // src/server/server.ts
3098
3087
  import { createServer } from "http";
3099
- import { readFileSync as readFileSync5, existsSync as existsSync5, readdirSync as readdirSync11, appendFileSync, rmSync as rmSync5, renameSync as renameSync2 } from "fs";
3088
+ import { readFileSync as readFileSync5, existsSync as existsSync5, readdirSync as readdirSync11, appendFileSync, rmSync as rmSync5, renameSync as renameSync3 } from "fs";
3100
3089
  import { join as join15, extname as extname2, basename as basename7 } from "path";
3101
3090
  import { homedir as homedir4 } from "os";
3102
3091
  import { execSync as execSync4 } from "child_process";
3103
3092
  import { WebSocketServer } from "ws";
3104
3093
 
3105
3094
  // src/server/session.ts
3106
- import { readFileSync as readFileSync4, readdirSync as readdirSync10, existsSync as existsSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync3, rmSync as rmSync4 } from "fs";
3107
- import { join as join14 } from "path";
3095
+ import { readFileSync as readFileSync4, readdirSync as readdirSync10, existsSync as existsSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync3, rmSync as rmSync4, renameSync as renameSync2 } from "fs";
3096
+ import { join as join14, dirname as dirname2 } from "path";
3108
3097
  import { homedir as homedir3 } from "os";
3109
3098
 
3110
3099
  // src/server/project-git.ts
@@ -3158,6 +3147,29 @@ function commitThemeState(themePath, message) {
3158
3147
  const hashResult = run("git rev-parse --short HEAD", { cwd: themePath });
3159
3148
  return hashResult.success ? hashResult.stdout : null;
3160
3149
  }
3150
+ function commitTemplateState(themePath, templateId, message, filePaths) {
3151
+ if (!isGitAvailable()) return null;
3152
+ if (!existsSync3(join13(themePath, ".git"))) return null;
3153
+ for (const fp of filePaths) {
3154
+ const fullPath = join13(themePath, fp);
3155
+ if (existsSync3(fullPath)) {
3156
+ run(`git add "${fp}"`, { cwd: themePath });
3157
+ }
3158
+ }
3159
+ const diff = run("git diff --cached --quiet", { cwd: themePath });
3160
+ if (diff.success) return null;
3161
+ const prefix = `[${templateId}] `;
3162
+ const maxMsg = 72 - prefix.length;
3163
+ const truncated = message.length > maxMsg ? message.slice(0, maxMsg - 3) + "..." : message;
3164
+ const fullMessage = prefix + truncated;
3165
+ const commitResult = run(`git commit -m "${fullMessage.replace(/"/g, '\\"')}"`, { cwd: themePath });
3166
+ if (!commitResult.success) {
3167
+ console.warn(`[project-git] template commit failed: ${commitResult.stderr}`);
3168
+ return null;
3169
+ }
3170
+ const hashResult = run("git rev-parse --short HEAD", { cwd: themePath });
3171
+ return hashResult.success ? hashResult.stdout : null;
3172
+ }
3161
3173
  function getHistory(themePath, limit = 50) {
3162
3174
  if (!isGitAvailable()) return [];
3163
3175
  if (!existsSync3(join13(themePath, ".git"))) return [];
@@ -3181,6 +3193,30 @@ function getHistory(themePath, limit = 50) {
3181
3193
  }
3182
3194
  return commits;
3183
3195
  }
3196
+ function getTemplateHistory(themePath, templateId, limit = 50) {
3197
+ if (!isGitAvailable()) return [];
3198
+ if (!existsSync3(join13(themePath, ".git"))) return [];
3199
+ const escapedId = templateId.replace(/[[\]\\]/g, "\\$&");
3200
+ const result = run(
3201
+ `git log --grep="\\[${escapedId}\\]" --pretty=format:"%h|%H|%s|%at" -n ${limit}`,
3202
+ { cwd: themePath }
3203
+ );
3204
+ if (!result.success || !result.stdout.trim()) return [];
3205
+ const commits = [];
3206
+ for (const line of result.stdout.split("\n")) {
3207
+ const parts = line.split("|");
3208
+ if (parts.length < 4) continue;
3209
+ const timestamp = parseInt(parts[3], 10) * 1e3;
3210
+ commits.push({
3211
+ hash: parts[0],
3212
+ fullHash: parts[1],
3213
+ message: parts[2],
3214
+ timestamp,
3215
+ date: new Date(timestamp).toISOString()
3216
+ });
3217
+ }
3218
+ return commits;
3219
+ }
3184
3220
  function rollbackToCommit(themePath, commitHash) {
3185
3221
  if (!isGitAvailable()) return { success: false, error: "Git not available" };
3186
3222
  if (!existsSync3(join13(themePath, ".git"))) return { success: false, error: "Not a git repo" };
@@ -3198,9 +3234,91 @@ function rollbackToCommit(themePath, commitHash) {
3198
3234
  run(`git commit -m "${rollbackMsg.replace(/"/g, '\\"')}"`, { cwd: themePath });
3199
3235
  return { success: true };
3200
3236
  }
3237
+ function rollbackTemplateToCommit(themePath, templateId, commitHash, filePaths) {
3238
+ if (!isGitAvailable()) return { success: false, error: "Git not available" };
3239
+ if (!existsSync3(join13(themePath, ".git"))) return { success: false, error: "Not a git repo" };
3240
+ const verify = run(`git cat-file -t ${commitHash}`, { cwd: themePath });
3241
+ if (!verify.success || verify.stdout.trim() !== "commit") {
3242
+ return { success: false, error: `Commit ${commitHash} not found` };
3243
+ }
3244
+ const msgResult = run(`git log --format="%s" -1 ${commitHash}`, { cwd: themePath });
3245
+ const origMessage = msgResult.success ? msgResult.stdout : commitHash;
3246
+ let restored = 0;
3247
+ for (const fp of filePaths) {
3248
+ const checkout = run(`git checkout ${commitHash} -- "${fp}"`, { cwd: themePath });
3249
+ if (checkout.success) restored++;
3250
+ }
3251
+ if (restored === 0) {
3252
+ return { success: false, error: "No files could be restored from that commit" };
3253
+ }
3254
+ run("git add -A", { cwd: themePath });
3255
+ const prefix = `[${templateId}] `;
3256
+ const rollbackMsg = `${prefix}Rollback to: ${origMessage}`.slice(0, 72);
3257
+ run(`git commit -m "${rollbackMsg.replace(/"/g, '\\"')}"`, { cwd: themePath });
3258
+ return { success: true };
3259
+ }
3201
3260
 
3202
3261
  // src/server/session.ts
3203
3262
  var SESSIONS_DIR = join14(homedir3(), ".vibespot", "sessions");
3263
+ var INDEX_PATH = join14(SESSIONS_DIR, "_index.json");
3264
+ function readIndex() {
3265
+ try {
3266
+ if (!existsSync4(INDEX_PATH)) return rebuildIndex();
3267
+ return JSON.parse(readFileSync4(INDEX_PATH, "utf-8"));
3268
+ } catch {
3269
+ return rebuildIndex();
3270
+ }
3271
+ }
3272
+ function writeIndex(entries) {
3273
+ try {
3274
+ mkdirSync3(SESSIONS_DIR, { recursive: true });
3275
+ writeFileSync4(INDEX_PATH, JSON.stringify(entries), "utf-8");
3276
+ } catch {
3277
+ }
3278
+ }
3279
+ function rebuildIndex() {
3280
+ if (!existsSync4(SESSIONS_DIR)) return [];
3281
+ const entries = [];
3282
+ for (const f of readdirSync10(SESSIONS_DIR).filter((f2) => f2.endsWith(".json") && f2 !== "_index.json")) {
3283
+ try {
3284
+ const data = JSON.parse(readFileSync4(join14(SESSIONS_DIR, f), "utf-8"));
3285
+ const templates = data.templates || [];
3286
+ entries.push({
3287
+ id: data.id,
3288
+ themeName: data.themeName,
3289
+ updatedAt: data.updatedAt,
3290
+ moduleCount: templates.reduce((n, t) => n + (t.modules?.length || 0), 0),
3291
+ templateCount: templates.length
3292
+ });
3293
+ } catch {
3294
+ }
3295
+ }
3296
+ writeIndex(entries);
3297
+ return entries;
3298
+ }
3299
+ function upsertIndex(session) {
3300
+ const entries = readIndex();
3301
+ const templates = session.templates || [];
3302
+ const entry = {
3303
+ id: session.id,
3304
+ themeName: session.themeName,
3305
+ updatedAt: session.updatedAt,
3306
+ moduleCount: templates.reduce((n, t) => n + (t.modules?.length || 0), 0),
3307
+ templateCount: templates.length
3308
+ };
3309
+ const idx = entries.findIndex((e) => e.id === session.id);
3310
+ if (idx >= 0) entries[idx] = entry;
3311
+ else entries.push(entry);
3312
+ writeIndex(entries);
3313
+ }
3314
+ function removeFromIndex(sessionId) {
3315
+ const entries = readIndex().filter((e) => e.id !== sessionId);
3316
+ writeIndex(entries);
3317
+ }
3318
+ function removeFromIndexByTheme(themeName) {
3319
+ const entries = readIndex().filter((e) => e.themeName !== themeName);
3320
+ writeIndex(entries);
3321
+ }
3204
3322
  var activeSession = null;
3205
3323
  function createSession(themePath, themeName) {
3206
3324
  const session = {
@@ -3282,6 +3400,14 @@ function addTemplate(pageType, label) {
3282
3400
  activeSession.updatedAt = Date.now();
3283
3401
  return entry;
3284
3402
  }
3403
+ function renameTemplate(templateId, newLabel) {
3404
+ if (!activeSession) return false;
3405
+ const tpl = activeSession.templates.find((t) => t.id === templateId);
3406
+ if (!tpl) return false;
3407
+ tpl.label = newLabel;
3408
+ activeSession.updatedAt = Date.now();
3409
+ return true;
3410
+ }
3285
3411
  function removeTemplate(templateId) {
3286
3412
  if (!activeSession) return false;
3287
3413
  const idx = activeSession.templates.findIndex((t) => t.id === templateId);
@@ -3482,6 +3608,7 @@ function saveSession() {
3482
3608
  mkdirSync3(SESSIONS_DIR, { recursive: true });
3483
3609
  const filePath = join14(SESSIONS_DIR, `${activeSession.id}.json`);
3484
3610
  writeFileSync4(filePath, JSON.stringify(activeSession, null, 2), "utf-8");
3611
+ upsertIndex(activeSession);
3485
3612
  }
3486
3613
  function loadSession(sessionId) {
3487
3614
  const filePath = join14(SESSIONS_DIR, sessionId + ".json");
@@ -3499,21 +3626,7 @@ function loadSession(sessionId) {
3499
3626
  }
3500
3627
  function listSessions() {
3501
3628
  if (!existsSync4(SESSIONS_DIR)) return [];
3502
- return readdirSync10(SESSIONS_DIR).filter((f) => f.endsWith(".json")).map((f) => {
3503
- try {
3504
- const data = JSON.parse(readFileSync4(join14(SESSIONS_DIR, f), "utf-8"));
3505
- const templates = data.templates || [];
3506
- return {
3507
- id: data.id,
3508
- themeName: data.themeName,
3509
- updatedAt: data.updatedAt,
3510
- moduleCount: templates.reduce((n, t) => n + (t.modules?.length || 0), 0),
3511
- templateCount: templates.length
3512
- };
3513
- } catch {
3514
- return null;
3515
- }
3516
- }).filter(Boolean);
3629
+ return readIndex();
3517
3630
  }
3518
3631
  function deleteSession(sessionId, deleteFiles = false) {
3519
3632
  const filePath = join14(SESSIONS_DIR, sessionId + ".json");
@@ -3539,7 +3652,7 @@ function deleteSession(sessionId, deleteFiles = false) {
3539
3652
  } catch {
3540
3653
  }
3541
3654
  if (themeName && existsSync4(SESSIONS_DIR)) {
3542
- for (const f of readdirSync10(SESSIONS_DIR).filter((f2) => f2.endsWith(".json"))) {
3655
+ for (const f of readdirSync10(SESSIONS_DIR).filter((f2) => f2.endsWith(".json") && f2 !== "_index.json")) {
3543
3656
  try {
3544
3657
  const data = JSON.parse(readFileSync4(join14(SESSIONS_DIR, f), "utf-8"));
3545
3658
  if (data.themeName === themeName) {
@@ -3548,11 +3661,79 @@ function deleteSession(sessionId, deleteFiles = false) {
3548
3661
  } catch {
3549
3662
  }
3550
3663
  }
3664
+ removeFromIndexByTheme(themeName);
3665
+ } else {
3666
+ removeFromIndex(sessionId);
3551
3667
  }
3552
3668
  if (activeSession?.id === sessionId) {
3553
3669
  activeSession = null;
3554
3670
  }
3555
3671
  }
3672
+ function renameSession(sessionId, newName) {
3673
+ const filePath = join14(SESSIONS_DIR, sessionId + ".json");
3674
+ if (!existsSync4(filePath)) return { ok: false, error: "Session not found" };
3675
+ let session;
3676
+ try {
3677
+ session = JSON.parse(readFileSync4(filePath, "utf-8"));
3678
+ } catch {
3679
+ return { ok: false, error: "Failed to read session" };
3680
+ }
3681
+ const oldName = session.themeName;
3682
+ if (oldName === newName) return { ok: true };
3683
+ const oldPath = session.themePath;
3684
+ const newPath = join14(dirname2(oldPath), newName);
3685
+ if (existsSync4(oldPath)) {
3686
+ if (existsSync4(newPath)) return { ok: false, error: "A project with that name already exists" };
3687
+ try {
3688
+ renameSync2(oldPath, newPath);
3689
+ } catch (err) {
3690
+ return { ok: false, error: `Failed to rename folder: ${err instanceof Error ? err.message : String(err)}` };
3691
+ }
3692
+ const cssOld = join14(newPath, "css", `${oldName}-theme.css`);
3693
+ const cssNew = join14(newPath, "css", `${newName}-theme.css`);
3694
+ if (existsSync4(cssOld)) try {
3695
+ renameSync2(cssOld, cssNew);
3696
+ } catch {
3697
+ }
3698
+ const jsOld = join14(newPath, "js", `${oldName}-animations.js`);
3699
+ const jsNew = join14(newPath, "js", `${newName}-animations.js`);
3700
+ if (existsSync4(jsOld)) try {
3701
+ renameSync2(jsOld, jsNew);
3702
+ } catch {
3703
+ }
3704
+ const themeJsonPath = join14(newPath, "theme.json");
3705
+ if (existsSync4(themeJsonPath)) {
3706
+ try {
3707
+ const themeData = JSON.parse(readFileSync4(themeJsonPath, "utf-8"));
3708
+ themeData.label = newName;
3709
+ themeData.name = newName;
3710
+ writeFileSync4(themeJsonPath, JSON.stringify(themeData, null, 2), "utf-8");
3711
+ } catch {
3712
+ }
3713
+ }
3714
+ }
3715
+ if (existsSync4(SESSIONS_DIR)) {
3716
+ for (const f of readdirSync10(SESSIONS_DIR).filter((f2) => f2.endsWith(".json") && f2 !== "_index.json")) {
3717
+ try {
3718
+ const data = JSON.parse(readFileSync4(join14(SESSIONS_DIR, f), "utf-8"));
3719
+ if (data.themeName === oldName) {
3720
+ data.themeName = newName;
3721
+ data.themePath = newPath;
3722
+ data.updatedAt = Date.now();
3723
+ writeFileSync4(join14(SESSIONS_DIR, f), JSON.stringify(data, null, 2), "utf-8");
3724
+ }
3725
+ } catch {
3726
+ }
3727
+ }
3728
+ }
3729
+ if (activeSession && activeSession.themeName === oldName) {
3730
+ activeSession.themeName = newName;
3731
+ activeSession.themePath = newPath;
3732
+ activeSession.updatedAt = Date.now();
3733
+ }
3734
+ rebuildIndex();
3735
+ return { ok: true };
3736
+ }
3556
3737
  function writeModulesToDisk() {
3557
3738
  if (!activeSession) return;
3558
3739
  const themePath = activeSession.themePath;
@@ -3663,6 +3844,37 @@ function reloadModulesFromDisk() {
3663
3844
  activeSession.updatedAt = Date.now();
3664
3845
  syncFlatFieldsToTemplate();
3665
3846
  }
3847
+ function reloadActiveTemplateFromDisk() {
3848
+ if (!activeSession) return;
3849
+ const tpl = getActiveTemplate();
3850
+ if (!tpl) return;
3851
+ const themePath = activeSession.themePath;
3852
+ const modulesDir = join14(themePath, "modules");
3853
+ tpl.modules = [];
3854
+ for (const name of tpl.moduleOrder) {
3855
+ const modDir = join14(modulesDir, `${name}.module`);
3856
+ if (!existsSync4(modDir)) continue;
3857
+ const mod = {
3858
+ moduleName: name,
3859
+ fieldsJson: safeRead(join14(modDir, "fields.json")),
3860
+ metaJson: safeRead(join14(modDir, "meta.json")),
3861
+ moduleHtml: safeRead(join14(modDir, "module.html")),
3862
+ moduleCss: safeRead(join14(modDir, "module.css")),
3863
+ moduleJs: safeRead(join14(modDir, "module.js")) || void 0
3864
+ };
3865
+ if (mod.fieldsJson && mod.moduleHtml) {
3866
+ tpl.modules.push(mod);
3867
+ }
3868
+ }
3869
+ if (tpl.templateFile) {
3870
+ const tplPath = join14(themePath, tpl.templateFile);
3871
+ if (existsSync4(tplPath)) {
3872
+ tpl.template = safeRead(tplPath);
3873
+ }
3874
+ }
3875
+ syncFlatFieldsFromTemplate(tpl);
3876
+ activeSession.updatedAt = Date.now();
3877
+ }
3666
3878
  function patchBaseTemplate() {
3667
3879
  if (!activeSession) return;
3668
3880
  const basePath = join14(activeSession.themePath, "templates", "layouts", "base.html");
@@ -4457,51 +4669,60 @@ ${conversionGuide}`;
4457
4669
  async function handleGenerateStream(userMessage, onChunk, onStatus) {
4458
4670
  const session = getSession();
4459
4671
  if (!session) throw new Error("No active session");
4460
- const config = loadConfig();
4461
- const engine = config.aiEngine || detectDefaultEngine();
4462
- switch (engine) {
4463
- case "anthropic-api":
4464
- case "api": {
4465
- const apiKey = getApiKeyForEngine("anthropic-api", config);
4466
- if (!apiKey) throw new Error("Anthropic API key not configured. Open Settings to add one.");
4467
- await streamWithAnthropicAPI(
4468
- userMessage,
4469
- apiKey,
4470
- session.themeName,
4471
- config.anthropicApiModel || "claude-sonnet-4-20250514",
4472
- onChunk
4473
- );
4474
- break;
4475
- }
4476
- case "openai-api": {
4477
- const apiKey = getApiKeyForEngine("openai-api", config);
4478
- if (!apiKey) throw new Error("OpenAI API key not configured. Open Settings to add one.");
4479
- await streamWithOpenAIAPI(
4480
- userMessage,
4481
- apiKey,
4482
- session.themeName,
4483
- config.openaiApiModel || "gpt-4o",
4484
- onChunk
4485
- );
4486
- break;
4487
- }
4488
- case "gemini-api": {
4489
- const apiKey = getApiKeyForEngine("gemini-api", config);
4490
- if (!apiKey) throw new Error("Gemini API key not configured. Open Settings to add one.");
4491
- await streamWithGeminiAPI(userMessage, apiKey, session.themeName, onChunk);
4492
- break;
4672
+ const capturedSessionId = session.id;
4673
+ generatingSessionId = capturedSessionId;
4674
+ try {
4675
+ const config = loadConfig();
4676
+ const engine = config.aiEngine || detectDefaultEngine();
4677
+ switch (engine) {
4678
+ case "anthropic-api":
4679
+ case "api": {
4680
+ const apiKey = getApiKeyForEngine("anthropic-api", config);
4681
+ if (!apiKey) throw new Error("Anthropic API key not configured. Open Settings to add one.");
4682
+ await streamWithAnthropicAPI(
4683
+ userMessage,
4684
+ apiKey,
4685
+ session.themeName,
4686
+ config.anthropicApiModel || "claude-sonnet-4-6",
4687
+ onChunk,
4688
+ onStatus
4689
+ );
4690
+ break;
4691
+ }
4692
+ case "openai-api": {
4693
+ const apiKey = getApiKeyForEngine("openai-api", config);
4694
+ if (!apiKey) throw new Error("OpenAI API key not configured. Open Settings to add one.");
4695
+ await streamWithOpenAIAPI(
4696
+ userMessage,
4697
+ apiKey,
4698
+ session.themeName,
4699
+ config.openaiApiModel || "gpt-4o",
4700
+ onChunk,
4701
+ onStatus
4702
+ );
4703
+ break;
4704
+ }
4705
+ case "gemini-api": {
4706
+ const apiKey = getApiKeyForEngine("gemini-api", config);
4707
+ if (!apiKey) throw new Error("Gemini API key not configured. Open Settings to add one.");
4708
+ await streamWithGeminiAPI(userMessage, apiKey, session.themeName, onChunk, onStatus);
4709
+ break;
4710
+ }
4711
+ case "claude-code":
4712
+ await generateWithClaudeCode(userMessage, session.themeName, onChunk, onStatus);
4713
+ break;
4714
+ case "gemini-cli":
4715
+ await generateWithCLI("gemini", userMessage, session.themeName, onChunk, onStatus);
4716
+ break;
4717
+ case "codex-cli":
4718
+ await generateWithCLI("codex", userMessage, session.themeName, onChunk, onStatus);
4719
+ break;
4720
+ default:
4721
+ throw new Error(`Unknown AI engine: ${engine}. Open Settings to configure one.`);
4493
4722
  }
4494
- case "claude-code":
4495
- await generateWithClaudeCode(userMessage, session.themeName, onChunk, onStatus);
4496
- break;
4497
- case "gemini-cli":
4498
- await generateWithCLI("gemini", userMessage, session.themeName, onChunk, onStatus);
4499
- break;
4500
- case "codex-cli":
4501
- await generateWithCLI("codex", userMessage, session.themeName, onChunk, onStatus);
4502
- break;
4503
- default:
4504
- throw new Error(`Unknown AI engine: ${engine}. Open Settings to configure one.`);
4723
+ } finally {
4724
+ generatingSessionId = null;
4725
+ parseWarningCallback = null;
4505
4726
  }
4506
4727
  }
4507
4728
  function detectDefaultEngine() {
@@ -4607,33 +4828,73 @@ function setParseWarningCallback(cb) {
4607
4828
  parseWarningCallback = cb;
4608
4829
  }
4609
4830
  function finishResponse(fullResponse) {
4831
+ if (generatingSessionId) {
4832
+ const current = getSession();
4833
+ if (!current || current.id !== generatingSessionId) {
4834
+ console.warn("[ai-handler] Session changed during generation \u2014 discarding AI output");
4835
+ return;
4836
+ }
4837
+ }
4610
4838
  addMessage("assistant", fullResponse);
4611
4839
  parseAndApplyModules(fullResponse);
4612
4840
  saveSession();
4613
4841
  }
4614
- async function streamWithAnthropicAPI(userMessage, apiKey, themeName, model, onChunk) {
4842
+ var generatingSessionId = null;
4843
+ function isGenerating() {
4844
+ return generatingSessionId !== null;
4845
+ }
4846
+ var RATE_LIMIT_DELAYS = [10, 20, 40, 60, 120];
4847
+ async function streamWithAnthropicAPI(userMessage, apiKey, themeName, model, onChunk, onStatus) {
4615
4848
  const client = new Anthropic2({ apiKey });
4616
4849
  const conversionGuide = getConversionGuide();
4617
4850
  const session = getSession();
4618
4851
  const editMode = session.modules.length > 0;
4619
4852
  const messages = buildMessagesWithContext(userMessage);
4620
- let fullResponse = "";
4621
- const stream = client.messages.stream({
4622
- model,
4623
- max_tokens: 16384,
4624
- system: buildVibeSystemPrompt(conversionGuide, themeName, editMode, getPromptContext().pageType, getPromptContext().brandAssets),
4625
- messages
4626
- });
4627
- for await (const event of stream) {
4628
- if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
4629
- const text3 = event.delta.text;
4630
- fullResponse += text3;
4631
- onChunk(text3);
4853
+ const systemPrompt = buildVibeSystemPrompt(conversionGuide, themeName, editMode, getPromptContext().pageType, getPromptContext().brandAssets);
4854
+ for (let attempt = 0; ; attempt++) {
4855
+ try {
4856
+ let fullResponse = "";
4857
+ let statusIndex = 0;
4858
+ const sendStatus = onStatus || (() => {
4859
+ });
4860
+ sendStatus(CLI_STATUS_MESSAGES[0]);
4861
+ const heartbeat = setInterval(() => {
4862
+ statusIndex++;
4863
+ sendStatus(CLI_STATUS_MESSAGES[Math.min(statusIndex, CLI_STATUS_MESSAGES.length - 1)]);
4864
+ }, 6e3);
4865
+ try {
4866
+ const stream = client.messages.stream({
4867
+ model,
4868
+ max_tokens: 48e3,
4869
+ system: systemPrompt,
4870
+ messages
4871
+ });
4872
+ for await (const event of stream) {
4873
+ if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
4874
+ const text3 = event.delta.text;
4875
+ fullResponse += text3;
4876
+ onChunk(text3);
4877
+ }
4878
+ }
4879
+ } finally {
4880
+ clearInterval(heartbeat);
4881
+ }
4882
+ finishResponse(fullResponse);
4883
+ return;
4884
+ } catch (err) {
4885
+ const status = err.status;
4886
+ const errType = err.error?.type;
4887
+ const is429 = status === 429 || errType === "rate_limit_error" || err instanceof Error && err.message.includes("429");
4888
+ if (!is429 || attempt >= RATE_LIMIT_DELAYS.length) throw err;
4889
+ const wait = RATE_LIMIT_DELAYS[attempt];
4890
+ console.warn(`[ai-handler] Rate limited (429), attempt ${attempt + 1}/${RATE_LIMIT_DELAYS.length} \u2014 waiting ${wait}s`);
4891
+ if (onStatus) onStatus(`Rate limited by Anthropic API \u2014 retrying in ${wait}s...`);
4892
+ await new Promise((r) => setTimeout(r, wait * 1e3));
4893
+ if (onStatus) onStatus("Retrying...");
4632
4894
  }
4633
4895
  }
4634
- finishResponse(fullResponse);
4635
4896
  }
4636
- async function streamWithOpenAIAPI(userMessage, apiKey, themeName, model, onChunk) {
4897
+ async function streamWithOpenAIAPI(userMessage, apiKey, themeName, model, onChunk, onStatus) {
4637
4898
  const conversionGuide = getConversionGuide();
4638
4899
  const editMode = getSession().modules.length > 0;
4639
4900
  const messages = buildMessagesWithContext(userMessage);
@@ -4645,7 +4906,7 @@ async function streamWithOpenAIAPI(userMessage, apiKey, themeName, model, onChun
4645
4906
  },
4646
4907
  body: JSON.stringify({
4647
4908
  model,
4648
- max_tokens: 16384,
4909
+ max_tokens: 48e3,
4649
4910
  stream: true,
4650
4911
  messages: [
4651
4912
  { role: "system", content: buildVibeSystemPrompt(conversionGuide, themeName, editMode, getPromptContext().pageType, getPromptContext().brandAssets) },
@@ -4657,34 +4918,46 @@ async function streamWithOpenAIAPI(userMessage, apiKey, themeName, model, onChun
4657
4918
  const err = await response.text();
4658
4919
  throw new Error(`OpenAI API error (${response.status}): ${err}`);
4659
4920
  }
4921
+ let statusIndex = 0;
4922
+ const sendStatus = onStatus || (() => {
4923
+ });
4924
+ sendStatus(CLI_STATUS_MESSAGES[0]);
4925
+ const heartbeat = setInterval(() => {
4926
+ statusIndex++;
4927
+ sendStatus(CLI_STATUS_MESSAGES[Math.min(statusIndex, CLI_STATUS_MESSAGES.length - 1)]);
4928
+ }, 6e3);
4660
4929
  let fullResponse = "";
4661
4930
  const reader = response.body.getReader();
4662
4931
  const decoder = new TextDecoder();
4663
4932
  let buffer = "";
4664
- while (true) {
4665
- const { done, value } = await reader.read();
4666
- if (done) break;
4667
- buffer += decoder.decode(value, { stream: true });
4668
- const lines = buffer.split("\n");
4669
- buffer = lines.pop() || "";
4670
- for (const line of lines) {
4671
- if (!line.startsWith("data: ")) continue;
4672
- const data = line.slice(6).trim();
4673
- if (data === "[DONE]") break;
4674
- try {
4675
- const parsed = JSON.parse(data);
4676
- const delta = parsed.choices?.[0]?.delta?.content;
4677
- if (delta) {
4678
- fullResponse += delta;
4679
- onChunk(delta);
4933
+ try {
4934
+ while (true) {
4935
+ const { done, value } = await reader.read();
4936
+ if (done) break;
4937
+ buffer += decoder.decode(value, { stream: true });
4938
+ const lines = buffer.split("\n");
4939
+ buffer = lines.pop() || "";
4940
+ for (const line of lines) {
4941
+ if (!line.startsWith("data: ")) continue;
4942
+ const data = line.slice(6).trim();
4943
+ if (data === "[DONE]") break;
4944
+ try {
4945
+ const parsed = JSON.parse(data);
4946
+ const delta = parsed.choices?.[0]?.delta?.content;
4947
+ if (delta) {
4948
+ fullResponse += delta;
4949
+ onChunk(delta);
4950
+ }
4951
+ } catch {
4680
4952
  }
4681
- } catch {
4682
4953
  }
4683
4954
  }
4955
+ } finally {
4956
+ clearInterval(heartbeat);
4684
4957
  }
4685
4958
  finishResponse(fullResponse);
4686
4959
  }
4687
- async function streamWithGeminiAPI(userMessage, apiKey, themeName, onChunk) {
4960
+ async function streamWithGeminiAPI(userMessage, apiKey, themeName, onChunk, onStatus) {
4688
4961
  const conversionGuide = getConversionGuide();
4689
4962
  const session = getSession();
4690
4963
  const editMode = session.modules.length > 0;
@@ -4701,7 +4974,7 @@ async function streamWithGeminiAPI(userMessage, apiKey, themeName, onChunk) {
4701
4974
  ---
4702
4975
  ${stateContext}` : userMessage;
4703
4976
  contents.push({ role: "user", parts: [{ text: userContent }] });
4704
- const model = "gemini-2.0-flash";
4977
+ const model = "gemini-2.5-flash";
4705
4978
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`;
4706
4979
  const response = await fetch(url, {
4707
4980
  method: "POST",
@@ -4709,36 +4982,48 @@ ${stateContext}` : userMessage;
4709
4982
  body: JSON.stringify({
4710
4983
  systemInstruction: { parts: [{ text: buildVibeSystemPrompt(conversionGuide, themeName, editMode, getPromptContext().pageType, getPromptContext().brandAssets) }] },
4711
4984
  contents,
4712
- generationConfig: { maxOutputTokens: 16384 }
4985
+ generationConfig: { maxOutputTokens: 48e3 }
4713
4986
  })
4714
4987
  });
4715
4988
  if (!response.ok) {
4716
4989
  const err = await response.text();
4717
4990
  throw new Error(`Gemini API error (${response.status}): ${err}`);
4718
4991
  }
4992
+ let statusIndex = 0;
4993
+ const sendStatus = onStatus || (() => {
4994
+ });
4995
+ sendStatus(CLI_STATUS_MESSAGES[0]);
4996
+ const heartbeat = setInterval(() => {
4997
+ statusIndex++;
4998
+ sendStatus(CLI_STATUS_MESSAGES[Math.min(statusIndex, CLI_STATUS_MESSAGES.length - 1)]);
4999
+ }, 6e3);
4719
5000
  let fullResponse = "";
4720
5001
  const reader = response.body.getReader();
4721
5002
  const decoder = new TextDecoder();
4722
5003
  let buffer = "";
4723
- while (true) {
4724
- const { done, value } = await reader.read();
4725
- if (done) break;
4726
- buffer += decoder.decode(value, { stream: true });
4727
- const lines = buffer.split("\n");
4728
- buffer = lines.pop() || "";
4729
- for (const line of lines) {
4730
- if (!line.startsWith("data: ")) continue;
4731
- const data = line.slice(6).trim();
4732
- try {
4733
- const parsed = JSON.parse(data);
4734
- const text3 = parsed.candidates?.[0]?.content?.parts?.[0]?.text;
4735
- if (text3) {
4736
- fullResponse += text3;
4737
- onChunk(text3);
5004
+ try {
5005
+ while (true) {
5006
+ const { done, value } = await reader.read();
5007
+ if (done) break;
5008
+ buffer += decoder.decode(value, { stream: true });
5009
+ const lines = buffer.split("\n");
5010
+ buffer = lines.pop() || "";
5011
+ for (const line of lines) {
5012
+ if (!line.startsWith("data: ")) continue;
5013
+ const data = line.slice(6).trim();
5014
+ try {
5015
+ const parsed = JSON.parse(data);
5016
+ const text3 = parsed.candidates?.[0]?.content?.parts?.[0]?.text;
5017
+ if (text3) {
5018
+ fullResponse += text3;
5019
+ onChunk(text3);
5020
+ }
5021
+ } catch {
4738
5022
  }
4739
- } catch {
4740
5023
  }
4741
5024
  }
5025
+ } finally {
5026
+ clearInterval(heartbeat);
4742
5027
  }
4743
5028
  finishResponse(fullResponse);
4744
5029
  }
@@ -4883,14 +5168,58 @@ function tryParseJSON(raw) {
4883
5168
  }
4884
5169
  return null;
4885
5170
  }
5171
+ function tryRepairTruncatedJSON(raw) {
5172
+ const modulesIdx = raw.indexOf('"modules"');
5173
+ if (modulesIdx === -1) return null;
5174
+ const arrayStart = raw.indexOf("[", modulesIdx);
5175
+ if (arrayStart === -1) return null;
5176
+ let lastCompleteModule = -1;
5177
+ let braceDepth = 0;
5178
+ let inString = false;
5179
+ let escaped = false;
5180
+ for (let i = arrayStart + 1; i < raw.length; i++) {
5181
+ const ch = raw[i];
5182
+ if (escaped) {
5183
+ escaped = false;
5184
+ continue;
5185
+ }
5186
+ if (ch === "\\") {
5187
+ escaped = true;
5188
+ continue;
5189
+ }
5190
+ if (ch === '"') {
5191
+ inString = !inString;
5192
+ continue;
5193
+ }
5194
+ if (inString) continue;
5195
+ if (ch === "{") braceDepth++;
5196
+ if (ch === "}") {
5197
+ braceDepth--;
5198
+ if (braceDepth === 0) {
5199
+ lastCompleteModule = i;
5200
+ }
5201
+ }
5202
+ }
5203
+ if (lastCompleteModule === -1) return null;
5204
+ const upToLastModule = raw.slice(0, lastCompleteModule + 1);
5205
+ const repaired = upToLastModule + "]}";
5206
+ const jsonStr = repaired.trimStart().startsWith("{") ? repaired : "{" + repaired;
5207
+ return tryParseJSON(jsonStr);
5208
+ }
4886
5209
  function parseAndApplyModules(response) {
4887
5210
  let modulesApplied = false;
4888
- const blockPattern = /```vibespot-modules\s*\n([\s\S]*?)```/g;
5211
+ const blockPattern = /```vibespot-modules\s*\n?([\s\S]*?)```/g;
4889
5212
  let match;
4890
5213
  while ((match = blockPattern.exec(response)) !== null) {
4891
5214
  try {
5215
+ console.log("[parse] Found vibespot-modules block, length:", match[1].length);
5216
+ console.log("[parse] Block start:", JSON.stringify(match[1].slice(0, 100)));
5217
+ console.log("[parse] Block end:", JSON.stringify(match[1].slice(-100)));
4892
5218
  const data = tryParseJSON(match[1]);
4893
- if (!data || typeof data !== "object") throw new Error("Invalid JSON after repair");
5219
+ if (!data || typeof data !== "object") {
5220
+ console.warn("[parse] tryParseJSON returned:", data);
5221
+ throw new Error("Invalid JSON after repair");
5222
+ }
4894
5223
  const obj = data;
4895
5224
  if (obj.modules && Array.isArray(obj.modules)) {
4896
5225
  const modules = obj.modules.map((m) => ({
@@ -4914,8 +5243,9 @@ function parseAndApplyModules(response) {
4914
5243
  }
4915
5244
  }
4916
5245
  if (!modulesApplied) {
4917
- const jsonPattern = /```(?:json)?\s*\n(\{[\s\S]*?"modules"\s*:\s*\[[\s\S]*?\})\s*```/g;
5246
+ const jsonPattern = /```(?:json)?\s*\n([\s\S]*?)```/g;
4918
5247
  while ((match = jsonPattern.exec(response)) !== null) {
5248
+ if (!match[1].includes('"modules"')) continue;
4919
5249
  try {
4920
5250
  const data = tryParseJSON(match[1]);
4921
5251
  if (!data || typeof data !== "object") throw new Error("Invalid JSON after repair");
@@ -4942,6 +5272,44 @@ function parseAndApplyModules(response) {
4942
5272
  }
4943
5273
  }
4944
5274
  if (!modulesApplied) {
5275
+ const fenceCount = (response.match(/```/g) || []).length;
5276
+ if (fenceCount % 2 !== 0 && response.includes('"modules"')) {
5277
+ console.log("[parse] Detected truncated response (odd fence count), attempting salvage...");
5278
+ const lastFenceIdx = response.lastIndexOf("```");
5279
+ let truncated = response.slice(lastFenceIdx + 3);
5280
+ truncated = truncated.replace(/^[\w-]*\s*\n?/, "");
5281
+ const salvaged = tryRepairTruncatedJSON(truncated);
5282
+ if (salvaged) {
5283
+ const obj = salvaged;
5284
+ if (obj.modules && Array.isArray(obj.modules) && obj.modules.length > 0) {
5285
+ console.log("[parse] Salvaged", obj.modules.length, "modules from truncated response");
5286
+ const modules = obj.modules.map((m) => ({
5287
+ moduleName: String(m.moduleName || ""),
5288
+ fieldsJson: typeof m.fieldsJson === "string" ? m.fieldsJson : JSON.stringify(m.fieldsJson, null, 2),
5289
+ metaJson: typeof m.metaJson === "string" ? m.metaJson : JSON.stringify(m.metaJson, null, 2),
5290
+ moduleHtml: String(m.moduleHtml || ""),
5291
+ moduleCss: String(m.moduleCss || ""),
5292
+ moduleJs: m.moduleJs ? String(m.moduleJs) : void 0
5293
+ }));
5294
+ updateModules({
5295
+ modules,
5296
+ sharedCss: obj.sharedCss !== void 0 ? String(obj.sharedCss) : void 0,
5297
+ sharedJs: obj.sharedJs !== void 0 ? String(obj.sharedJs) : void 0
5298
+ });
5299
+ modulesApplied = true;
5300
+ if (parseWarningCallback) {
5301
+ parseWarningCallback("Response was truncated \u2014 some modules may be incomplete. Try sending your request again for the full set.");
5302
+ }
5303
+ }
5304
+ }
5305
+ }
5306
+ }
5307
+ if (!modulesApplied) {
5308
+ console.log("[parse] No modules applied. Response length:", response.length);
5309
+ console.log("[parse] Contains 'vibespot-modules':", response.includes("vibespot-modules"));
5310
+ console.log(`[parse] Contains '"modules"':`, response.includes('"modules"'));
5311
+ console.log("[parse] Fence count:", (response.match(/```/g) || []).length);
5312
+ console.log("[parse] Response preview:", response.slice(0, 500));
4945
5313
  const hasModuleRef = response.includes("vibespot-modules") || response.includes('"modules"');
4946
5314
  const describesProse = /\bmodule|modul/i.test(response) && (/\bcreated?\b|\berstellt\b|\bgenerat/i.test(response) || /\|.*\|.*\|/m.test(response));
4947
5315
  if (hasModuleRef || describesProse) {
@@ -5255,8 +5623,12 @@ function handleApiRoute(method, path, req, res) {
5255
5623
  if (method === "POST") handleDeleteLocalThemeRoute(req, res);
5256
5624
  else jsonResponse(res, 405, { error: "Method not allowed" });
5257
5625
  break;
5626
+ case "/api/themes/rename":
5627
+ if (method === "POST") handleRenameThemeRoute(req, res);
5628
+ else jsonResponse(res, 405, { error: "Method not allowed" });
5629
+ break;
5258
5630
  case "/api/history":
5259
- if (method === "GET") handleHistoryRoute(res);
5631
+ if (method === "GET") handleHistoryRoute(req, res);
5260
5632
  else jsonResponse(res, 405, { error: "Method not allowed" });
5261
5633
  break;
5262
5634
  case "/api/rollback":
@@ -5275,6 +5647,10 @@ function handleApiRoute(method, path, req, res) {
5275
5647
  if (method === "POST") handleTemplateActivateRoute(req, res);
5276
5648
  else jsonResponse(res, 405, { error: "Method not allowed" });
5277
5649
  break;
5650
+ case "/api/templates/rename":
5651
+ if (method === "POST") handleTemplateRenameRoute(req, res);
5652
+ else jsonResponse(res, 405, { error: "Method not allowed" });
5653
+ break;
5278
5654
  case "/api/module-library":
5279
5655
  if (method === "GET") handleModuleLibraryRoute(res);
5280
5656
  else jsonResponse(res, 405, { error: "Method not allowed" });
@@ -5282,6 +5658,10 @@ function handleApiRoute(method, path, req, res) {
5282
5658
  case "/api/brand-assets":
5283
5659
  handleBrandAssetsRoute(method, req, res);
5284
5660
  break;
5661
+ case "/api/download-zip":
5662
+ if (method === "GET") handleDownloadZipRoute(res);
5663
+ else jsonResponse(res, 405, { error: "Method not allowed" });
5664
+ break;
5285
5665
  default:
5286
5666
  if (path.startsWith("/api/settings/job/") && method === "GET") {
5287
5667
  handleSettingsJobRoute(path, res);
@@ -5482,6 +5862,10 @@ function handleSetupInfoRoute(res) {
5482
5862
  function handleSetupCreateRoute(req, res) {
5483
5863
  readBody(req, (body) => {
5484
5864
  try {
5865
+ if (isGenerating()) {
5866
+ jsonResponse(res, 409, { error: "Cannot switch projects while AI is generating.", generating: true });
5867
+ return;
5868
+ }
5485
5869
  const { name } = JSON.parse(body);
5486
5870
  if (!name || typeof name !== "string") {
5487
5871
  jsonResponse(res, 400, { error: "Theme name is required" });
@@ -5505,7 +5889,7 @@ function handleSetupCreateRoute(req, res) {
5505
5889
  if (newDir) createdAt = join15(process.cwd(), newDir);
5506
5890
  }
5507
5891
  if (createdAt !== themePath && existsSync5(createdAt)) {
5508
- renameSync2(createdAt, themePath);
5892
+ renameSync3(createdAt, themePath);
5509
5893
  }
5510
5894
  const tplDir = join15(themePath, "templates");
5511
5895
  if (existsSync5(tplDir)) {
@@ -5528,6 +5912,10 @@ function handleSetupCreateRoute(req, res) {
5528
5912
  function handleSetupFetchRoute(req, res) {
5529
5913
  readBody(req, (body) => {
5530
5914
  try {
5915
+ if (isGenerating()) {
5916
+ jsonResponse(res, 409, { error: "Cannot switch projects while AI is generating.", generating: true });
5917
+ return;
5918
+ }
5531
5919
  const { name } = JSON.parse(body);
5532
5920
  if (!name || typeof name !== "string") {
5533
5921
  jsonResponse(res, 400, { error: "Theme name is required" });
@@ -5558,6 +5946,10 @@ function handleSetupFetchRoute(req, res) {
5558
5946
  function handleSetupOpenRoute(req, res) {
5559
5947
  readBody(req, (body) => {
5560
5948
  try {
5949
+ if (isGenerating()) {
5950
+ jsonResponse(res, 409, { error: "Cannot switch projects while AI is generating.", generating: true });
5951
+ return;
5952
+ }
5561
5953
  const { path: themePath } = JSON.parse(body);
5562
5954
  if (!themePath || typeof themePath !== "string") {
5563
5955
  jsonResponse(res, 400, { error: "Theme path is required" });
@@ -5589,6 +5981,10 @@ function handleSetupOpenRoute(req, res) {
5589
5981
  function handleSetupResumeRoute(req, res) {
5590
5982
  readBody(req, (body) => {
5591
5983
  try {
5984
+ if (isGenerating()) {
5985
+ jsonResponse(res, 409, { error: "Cannot switch projects while AI is generating.", generating: true });
5986
+ return;
5987
+ }
5592
5988
  const { sessionId } = JSON.parse(body);
5593
5989
  if (!sessionId || typeof sessionId !== "string") {
5594
5990
  jsonResponse(res, 400, { error: "Session ID is required" });
@@ -5626,17 +6022,112 @@ function handleSetupApiKeyRoute(req, res) {
5626
6022
  }
5627
6023
  });
5628
6024
  }
6025
+ var modelCache = { data: {}, ts: 0 };
6026
+ var MODEL_CACHE_TTL = 10 * 60 * 1e3;
6027
+ var STATIC_MODELS = {
6028
+ "claude-code": [
6029
+ { id: "sonnet", label: "Claude Sonnet (default)" },
6030
+ { id: "opus", label: "Claude Opus" },
6031
+ { id: "haiku", label: "Claude Haiku" }
6032
+ ],
6033
+ "codex-cli": [
6034
+ { id: "o4-mini", label: "o4 Mini (default)" },
6035
+ { id: "o3", label: "o3" },
6036
+ { id: "gpt-4o", label: "GPT-4o" }
6037
+ ]
6038
+ };
6039
+ async function fetchAnthropicModels(apiKey) {
6040
+ const resp = await fetch("https://api.anthropic.com/v1/models", {
6041
+ headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" }
6042
+ });
6043
+ if (!resp.ok) return [];
6044
+ const data = await resp.json();
6045
+ return data.data.filter((m) => !m.id.startsWith("claude-3-") && !m.id.startsWith("claude-2")).map((m) => ({ id: m.id, label: m.display_name }));
6046
+ }
6047
+ async function fetchOpenAIModels(apiKey) {
6048
+ const resp = await fetch("https://api.openai.com/v1/models", {
6049
+ headers: { Authorization: `Bearer ${apiKey}` }
6050
+ });
6051
+ if (!resp.ok) return [];
6052
+ const data = await resp.json();
6053
+ const keep = /^(gpt-4o|gpt-4o-mini|o[1-4](-mini)?|o[1-4]-pro)$/;
6054
+ return data.data.filter((m) => keep.test(m.id)).sort((a, b) => a.id.localeCompare(b.id)).map((m) => ({ id: m.id, label: m.id }));
6055
+ }
6056
+ async function fetchGeminiModels(apiKey) {
6057
+ const resp = await fetch(
6058
+ `https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`
6059
+ );
6060
+ if (!resp.ok) return [];
6061
+ const data = await resp.json();
6062
+ return data.models.filter((m) => m.name.includes("gemini-2")).map((m) => ({ id: m.name.replace("models/", ""), label: m.displayName }));
6063
+ }
6064
+ async function getModelCatalog() {
6065
+ if (Date.now() - modelCache.ts < MODEL_CACHE_TTL && Object.keys(modelCache.data).length > 0) {
6066
+ return modelCache.data;
6067
+ }
6068
+ const config = loadConfig();
6069
+ const catalog = { ...STATIC_MODELS };
6070
+ const jobs2 = [];
6071
+ const anthropicKey = getApiKeyForEngine("anthropic-api", config);
6072
+ if (anthropicKey) {
6073
+ jobs2.push(
6074
+ fetchAnthropicModels(anthropicKey).then((models) => {
6075
+ if (models.length) catalog["anthropic-api"] = models;
6076
+ }).catch(() => {
6077
+ })
6078
+ );
6079
+ }
6080
+ const openaiKey = getApiKeyForEngine("openai-api", config);
6081
+ if (openaiKey) {
6082
+ jobs2.push(
6083
+ fetchOpenAIModels(openaiKey).then((models) => {
6084
+ if (models.length) catalog["openai-api"] = models;
6085
+ }).catch(() => {
6086
+ })
6087
+ );
6088
+ }
6089
+ const geminiKey = getApiKeyForEngine("gemini-api", config);
6090
+ if (geminiKey) {
6091
+ jobs2.push(
6092
+ fetchGeminiModels(geminiKey).then((models) => {
6093
+ if (models.length) {
6094
+ catalog["gemini-api"] = models;
6095
+ catalog["gemini-cli"] = models;
6096
+ }
6097
+ }).catch(() => {
6098
+ })
6099
+ );
6100
+ }
6101
+ await Promise.all(jobs2);
6102
+ modelCache.data = catalog;
6103
+ modelCache.ts = Date.now();
6104
+ return catalog;
6105
+ }
5629
6106
  function handleSettingsStatusRoute(res) {
5630
6107
  const env = detectEnvironment();
5631
6108
  const config = loadConfig();
5632
- jsonResponse(res, 200, {
5633
- environment: env,
5634
- config: {
5635
- aiEngine: config.aiEngine || null,
5636
- claudeCodeModel: config.claudeCodeModel || null,
5637
- anthropicApiModel: config.anthropicApiModel || null,
5638
- openaiApiModel: config.openaiApiModel || null
5639
- }
6109
+ getModelCatalog().then((models) => {
6110
+ jsonResponse(res, 200, {
6111
+ environment: env,
6112
+ config: {
6113
+ aiEngine: config.aiEngine || null,
6114
+ claudeCodeModel: config.claudeCodeModel || null,
6115
+ anthropicApiModel: config.anthropicApiModel || null,
6116
+ openaiApiModel: config.openaiApiModel || null
6117
+ },
6118
+ models
6119
+ });
6120
+ }).catch(() => {
6121
+ jsonResponse(res, 200, {
6122
+ environment: env,
6123
+ config: {
6124
+ aiEngine: config.aiEngine || null,
6125
+ claudeCodeModel: config.claudeCodeModel || null,
6126
+ anthropicApiModel: config.anthropicApiModel || null,
6127
+ openaiApiModel: config.openaiApiModel || null
6128
+ },
6129
+ models: STATIC_MODELS
6130
+ });
5640
6131
  });
5641
6132
  }
5642
6133
  function handleSettingsEngineRoute(req, res) {
@@ -6031,7 +6522,31 @@ function handleDeleteLocalThemeRoute(req, res) {
6031
6522
  }
6032
6523
  });
6033
6524
  }
6034
- function handleHistoryRoute(res) {
6525
+ function handleRenameThemeRoute(req, res) {
6526
+ readBody(req, (body) => {
6527
+ try {
6528
+ const { sessionId, newName } = JSON.parse(body);
6529
+ if (!sessionId || !newName || typeof newName !== "string") {
6530
+ jsonResponse(res, 400, { error: "sessionId and newName are required" });
6531
+ return;
6532
+ }
6533
+ const sanitized = newName.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/^-|-$/g, "").replace(/-{2,}/g, "-");
6534
+ if (!sanitized) {
6535
+ jsonResponse(res, 400, { error: "Invalid name" });
6536
+ return;
6537
+ }
6538
+ const result = renameSession(sessionId, sanitized);
6539
+ if (result.ok) {
6540
+ jsonResponse(res, 200, { ok: true, newName: sanitized });
6541
+ } else {
6542
+ jsonResponse(res, 400, { error: result.error });
6543
+ }
6544
+ } catch (err) {
6545
+ jsonResponse(res, 500, { error: err instanceof Error ? err.message : String(err) });
6546
+ }
6547
+ });
6548
+ }
6549
+ function handleHistoryRoute(req, res) {
6035
6550
  const session = getSession();
6036
6551
  if (!session) {
6037
6552
  jsonResponse(res, 404, { error: "No active session" });
@@ -6041,8 +6556,10 @@ function handleHistoryRoute(res) {
6041
6556
  jsonResponse(res, 200, { available: false, commits: [] });
6042
6557
  return;
6043
6558
  }
6044
- const commits = getHistory(session.themePath, 50);
6045
- jsonResponse(res, 200, { available: true, commits });
6559
+ const url = new URL(req.url || "/", "http://localhost");
6560
+ const templateId = url.searchParams.get("templateId");
6561
+ const commits = templateId ? getTemplateHistory(session.themePath, templateId, 50) : getHistory(session.themePath, 50);
6562
+ jsonResponse(res, 200, { available: true, commits, filtered: !!templateId });
6046
6563
  }
6047
6564
  function handleRollbackRoute(req, res) {
6048
6565
  readBody(req, (body) => {
@@ -6052,18 +6569,34 @@ function handleRollbackRoute(req, res) {
6052
6569
  jsonResponse(res, 404, { error: "No active session" });
6053
6570
  return;
6054
6571
  }
6055
- const { hash } = JSON.parse(body);
6572
+ const { hash, templateId } = JSON.parse(body);
6056
6573
  if (!hash || typeof hash !== "string") {
6057
6574
  jsonResponse(res, 400, { error: "Commit hash is required" });
6058
6575
  return;
6059
6576
  }
6060
6577
  addMessage("assistant", `Rolled back to version ${hash.slice(0, 7)}.`);
6061
- const result = rollbackToCommit(session.themePath, hash);
6062
- if (!result.success) {
6063
- jsonResponse(res, 500, { error: result.error || "Rollback failed" });
6064
- return;
6578
+ if (templateId) {
6579
+ const tpl = session.templates.find((t) => t.id === templateId);
6580
+ if (!tpl) {
6581
+ jsonResponse(res, 404, { error: "Template not found" });
6582
+ return;
6583
+ }
6584
+ const filePaths = tpl.moduleOrder.map((n) => `modules/${n}.module`);
6585
+ if (tpl.templateFile) filePaths.push(tpl.templateFile);
6586
+ const result = rollbackTemplateToCommit(session.themePath, templateId, hash, filePaths);
6587
+ if (!result.success) {
6588
+ jsonResponse(res, 500, { error: result.error || "Rollback failed" });
6589
+ return;
6590
+ }
6591
+ reloadActiveTemplateFromDisk();
6592
+ } else {
6593
+ const result = rollbackToCommit(session.themePath, hash);
6594
+ if (!result.success) {
6595
+ jsonResponse(res, 500, { error: result.error || "Rollback failed" });
6596
+ return;
6597
+ }
6598
+ reloadModulesFromDisk();
6065
6599
  }
6066
- reloadModulesFromDisk();
6067
6600
  saveSession();
6068
6601
  jsonResponse(res, 200, {
6069
6602
  ok: true,
@@ -6103,6 +6636,41 @@ function handleDashboardRoute(res) {
6103
6636
  }
6104
6637
  });
6105
6638
  }
6639
+ function handleDownloadZipRoute(res) {
6640
+ const session = getSession();
6641
+ if (!session) {
6642
+ jsonResponse(res, 404, { error: "No active session" });
6643
+ return;
6644
+ }
6645
+ const themePath = session.themePath;
6646
+ if (!existsSync5(themePath)) {
6647
+ jsonResponse(res, 404, { error: "Theme directory not found" });
6648
+ return;
6649
+ }
6650
+ const themeName = session.themeName || "theme";
6651
+ const parentDir = join15(themePath, "..");
6652
+ const folderName = basename7(themePath);
6653
+ try {
6654
+ const zipFileName = `${themeName}.zip`;
6655
+ const tmpZip = join15(parentDir, zipFileName);
6656
+ if (existsSync5(tmpZip)) rmSync5(tmpZip);
6657
+ execSync4(
6658
+ `zip -r "${zipFileName}" "${folderName}" -x "${folderName}/.git/*" "${folderName}/.vibespot/*" "${folderName}/node_modules/*"`,
6659
+ { cwd: parentDir, timeout: 3e4 }
6660
+ );
6661
+ const zipData = readFileSync5(tmpZip);
6662
+ rmSync5(tmpZip);
6663
+ res.writeHead(200, {
6664
+ "Content-Type": "application/zip",
6665
+ "Content-Disposition": `attachment; filename="${zipFileName}"`,
6666
+ "Content-Length": zipData.length
6667
+ });
6668
+ res.end(zipData);
6669
+ } catch (err) {
6670
+ console.warn("[download-zip] Failed:", err.message);
6671
+ jsonResponse(res, 500, { error: "Failed to create zip archive" });
6672
+ }
6673
+ }
6106
6674
  function handleTemplatesRoute(method, req, res) {
6107
6675
  const session = getSession();
6108
6676
  if (!session) {
@@ -6198,6 +6766,26 @@ function handleTemplateActivateRoute(req, res) {
6198
6766
  }
6199
6767
  });
6200
6768
  }
6769
+ function handleTemplateRenameRoute(req, res) {
6770
+ readBody(req, (body) => {
6771
+ try {
6772
+ const { templateId, newLabel } = JSON.parse(body);
6773
+ if (!templateId || !newLabel || typeof newLabel !== "string") {
6774
+ jsonResponse(res, 400, { error: "templateId and newLabel are required" });
6775
+ return;
6776
+ }
6777
+ const success = renameTemplate(templateId, newLabel.trim());
6778
+ if (!success) {
6779
+ jsonResponse(res, 404, { error: "Template not found" });
6780
+ return;
6781
+ }
6782
+ saveSession();
6783
+ jsonResponse(res, 200, { ok: true, newLabel: newLabel.trim() });
6784
+ } catch (err) {
6785
+ jsonResponse(res, 500, { error: err instanceof Error ? err.message : String(err) });
6786
+ }
6787
+ });
6788
+ }
6201
6789
  function handleModuleLibraryRoute(res) {
6202
6790
  const library = getModuleLibrary();
6203
6791
  jsonResponse(res, 200, {
@@ -6346,7 +6934,17 @@ function handleWsConnection(ws) {
6346
6934
  const currentSession = getSession();
6347
6935
  if (currentSession) {
6348
6936
  writeModulesToDisk();
6349
- const commitHash = commitThemeState(currentSession.themePath, userMessage);
6937
+ const activeTpl = getActiveTemplate();
6938
+ let commitHash = null;
6939
+ if (activeTpl) {
6940
+ const filePaths = activeTpl.moduleOrder.map((n) => `modules/${n}.module`);
6941
+ if (activeTpl.templateFile) filePaths.push(activeTpl.templateFile);
6942
+ if (activeTpl.sharedCss) filePaths.push(`css/${currentSession.themeName}-theme.css`);
6943
+ if (activeTpl.sharedJs) filePaths.push(`js/${currentSession.themeName}-animations.js`);
6944
+ commitHash = commitTemplateState(currentSession.themePath, activeTpl.id, userMessage, filePaths);
6945
+ } else {
6946
+ commitHash = commitThemeState(currentSession.themePath, userMessage);
6947
+ }
6350
6948
  if (commitHash) {
6351
6949
  ws.send(JSON.stringify({ type: "version_created", hash: commitHash }));
6352
6950
  }
@@ -6491,6 +7089,7 @@ ${errorContext}`;
6491
7089
  const activeTpl = getActiveTemplate();
6492
7090
  ws.send(JSON.stringify({
6493
7091
  type: "init",
7092
+ sessionId: session.id,
6494
7093
  themeName: session.themeName,
6495
7094
  modules: getOrderedModules().map((m) => m.moduleName),
6496
7095
  messageCount: session.messages.length,