vibespot 0.6.0 → 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
@@ -1643,7 +1643,7 @@ import { join as join7, basename as basename3 } from "path";
1643
1643
  import { readdirSync as readdirSync5 } from "fs";
1644
1644
  var ClaudeAPIEngine = class {
1645
1645
  client;
1646
- model = "claude-sonnet-4-20250514";
1646
+ model = "claude-sonnet-4-6";
1647
1647
  constructor(apiKey) {
1648
1648
  this.client = new Anthropic({
1649
1649
  apiKey: apiKey || process.env.ANTHROPIC_API_KEY
@@ -3085,15 +3085,15 @@ import chalk2 from "chalk";
3085
3085
 
3086
3086
  // src/server/server.ts
3087
3087
  import { createServer } from "http";
3088
- 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";
3089
3089
  import { join as join15, extname as extname2, basename as basename7 } from "path";
3090
3090
  import { homedir as homedir4 } from "os";
3091
3091
  import { execSync as execSync4 } from "child_process";
3092
3092
  import { WebSocketServer } from "ws";
3093
3093
 
3094
3094
  // src/server/session.ts
3095
- import { readFileSync as readFileSync4, readdirSync as readdirSync10, existsSync as existsSync4, writeFileSync as writeFileSync4, mkdirSync as mkdirSync3, rmSync as rmSync4 } from "fs";
3096
- 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";
3097
3097
  import { homedir as homedir3 } from "os";
3098
3098
 
3099
3099
  // src/server/project-git.ts
@@ -3147,6 +3147,29 @@ function commitThemeState(themePath, message) {
3147
3147
  const hashResult = run("git rev-parse --short HEAD", { cwd: themePath });
3148
3148
  return hashResult.success ? hashResult.stdout : null;
3149
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
+ }
3150
3173
  function getHistory(themePath, limit = 50) {
3151
3174
  if (!isGitAvailable()) return [];
3152
3175
  if (!existsSync3(join13(themePath, ".git"))) return [];
@@ -3170,6 +3193,30 @@ function getHistory(themePath, limit = 50) {
3170
3193
  }
3171
3194
  return commits;
3172
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
+ }
3173
3220
  function rollbackToCommit(themePath, commitHash) {
3174
3221
  if (!isGitAvailable()) return { success: false, error: "Git not available" };
3175
3222
  if (!existsSync3(join13(themePath, ".git"))) return { success: false, error: "Not a git repo" };
@@ -3187,6 +3234,29 @@ function rollbackToCommit(themePath, commitHash) {
3187
3234
  run(`git commit -m "${rollbackMsg.replace(/"/g, '\\"')}"`, { cwd: themePath });
3188
3235
  return { success: true };
3189
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
+ }
3190
3260
 
3191
3261
  // src/server/session.ts
3192
3262
  var SESSIONS_DIR = join14(homedir3(), ".vibespot", "sessions");
@@ -3330,6 +3400,14 @@ function addTemplate(pageType, label) {
3330
3400
  activeSession.updatedAt = Date.now();
3331
3401
  return entry;
3332
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
+ }
3333
3411
  function removeTemplate(templateId) {
3334
3412
  if (!activeSession) return false;
3335
3413
  const idx = activeSession.templates.findIndex((t) => t.id === templateId);
@@ -3591,6 +3669,71 @@ function deleteSession(sessionId, deleteFiles = false) {
3591
3669
  activeSession = null;
3592
3670
  }
3593
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
+ }
3594
3737
  function writeModulesToDisk() {
3595
3738
  if (!activeSession) return;
3596
3739
  const themePath = activeSession.themePath;
@@ -3701,6 +3844,37 @@ function reloadModulesFromDisk() {
3701
3844
  activeSession.updatedAt = Date.now();
3702
3845
  syncFlatFieldsToTemplate();
3703
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
+ }
3704
3878
  function patchBaseTemplate() {
3705
3879
  if (!activeSession) return;
3706
3880
  const basePath = join14(activeSession.themePath, "templates", "layouts", "base.html");
@@ -4495,51 +4669,60 @@ ${conversionGuide}`;
4495
4669
  async function handleGenerateStream(userMessage, onChunk, onStatus) {
4496
4670
  const session = getSession();
4497
4671
  if (!session) throw new Error("No active session");
4498
- const config = loadConfig();
4499
- const engine = config.aiEngine || detectDefaultEngine();
4500
- switch (engine) {
4501
- case "anthropic-api":
4502
- case "api": {
4503
- const apiKey = getApiKeyForEngine("anthropic-api", config);
4504
- if (!apiKey) throw new Error("Anthropic API key not configured. Open Settings to add one.");
4505
- await streamWithAnthropicAPI(
4506
- userMessage,
4507
- apiKey,
4508
- session.themeName,
4509
- config.anthropicApiModel || "claude-sonnet-4-20250514",
4510
- onChunk
4511
- );
4512
- break;
4513
- }
4514
- case "openai-api": {
4515
- const apiKey = getApiKeyForEngine("openai-api", config);
4516
- if (!apiKey) throw new Error("OpenAI API key not configured. Open Settings to add one.");
4517
- await streamWithOpenAIAPI(
4518
- userMessage,
4519
- apiKey,
4520
- session.themeName,
4521
- config.openaiApiModel || "gpt-4o",
4522
- onChunk
4523
- );
4524
- break;
4525
- }
4526
- case "gemini-api": {
4527
- const apiKey = getApiKeyForEngine("gemini-api", config);
4528
- if (!apiKey) throw new Error("Gemini API key not configured. Open Settings to add one.");
4529
- await streamWithGeminiAPI(userMessage, apiKey, session.themeName, onChunk);
4530
- 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.`);
4531
4722
  }
4532
- case "claude-code":
4533
- await generateWithClaudeCode(userMessage, session.themeName, onChunk, onStatus);
4534
- break;
4535
- case "gemini-cli":
4536
- await generateWithCLI("gemini", userMessage, session.themeName, onChunk, onStatus);
4537
- break;
4538
- case "codex-cli":
4539
- await generateWithCLI("codex", userMessage, session.themeName, onChunk, onStatus);
4540
- break;
4541
- default:
4542
- throw new Error(`Unknown AI engine: ${engine}. Open Settings to configure one.`);
4723
+ } finally {
4724
+ generatingSessionId = null;
4725
+ parseWarningCallback = null;
4543
4726
  }
4544
4727
  }
4545
4728
  function detectDefaultEngine() {
@@ -4645,33 +4828,73 @@ function setParseWarningCallback(cb) {
4645
4828
  parseWarningCallback = cb;
4646
4829
  }
4647
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
+ }
4648
4838
  addMessage("assistant", fullResponse);
4649
4839
  parseAndApplyModules(fullResponse);
4650
4840
  saveSession();
4651
4841
  }
4652
- 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) {
4653
4848
  const client = new Anthropic2({ apiKey });
4654
4849
  const conversionGuide = getConversionGuide();
4655
4850
  const session = getSession();
4656
4851
  const editMode = session.modules.length > 0;
4657
4852
  const messages = buildMessagesWithContext(userMessage);
4658
- let fullResponse = "";
4659
- const stream = client.messages.stream({
4660
- model,
4661
- max_tokens: 16384,
4662
- system: buildVibeSystemPrompt(conversionGuide, themeName, editMode, getPromptContext().pageType, getPromptContext().brandAssets),
4663
- messages
4664
- });
4665
- for await (const event of stream) {
4666
- if (event.type === "content_block_delta" && event.delta.type === "text_delta") {
4667
- const text3 = event.delta.text;
4668
- fullResponse += text3;
4669
- 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...");
4670
4894
  }
4671
4895
  }
4672
- finishResponse(fullResponse);
4673
4896
  }
4674
- async function streamWithOpenAIAPI(userMessage, apiKey, themeName, model, onChunk) {
4897
+ async function streamWithOpenAIAPI(userMessage, apiKey, themeName, model, onChunk, onStatus) {
4675
4898
  const conversionGuide = getConversionGuide();
4676
4899
  const editMode = getSession().modules.length > 0;
4677
4900
  const messages = buildMessagesWithContext(userMessage);
@@ -4683,7 +4906,7 @@ async function streamWithOpenAIAPI(userMessage, apiKey, themeName, model, onChun
4683
4906
  },
4684
4907
  body: JSON.stringify({
4685
4908
  model,
4686
- max_tokens: 16384,
4909
+ max_tokens: 48e3,
4687
4910
  stream: true,
4688
4911
  messages: [
4689
4912
  { role: "system", content: buildVibeSystemPrompt(conversionGuide, themeName, editMode, getPromptContext().pageType, getPromptContext().brandAssets) },
@@ -4695,34 +4918,46 @@ async function streamWithOpenAIAPI(userMessage, apiKey, themeName, model, onChun
4695
4918
  const err = await response.text();
4696
4919
  throw new Error(`OpenAI API error (${response.status}): ${err}`);
4697
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);
4698
4929
  let fullResponse = "";
4699
4930
  const reader = response.body.getReader();
4700
4931
  const decoder = new TextDecoder();
4701
4932
  let buffer = "";
4702
- while (true) {
4703
- const { done, value } = await reader.read();
4704
- if (done) break;
4705
- buffer += decoder.decode(value, { stream: true });
4706
- const lines = buffer.split("\n");
4707
- buffer = lines.pop() || "";
4708
- for (const line of lines) {
4709
- if (!line.startsWith("data: ")) continue;
4710
- const data = line.slice(6).trim();
4711
- if (data === "[DONE]") break;
4712
- try {
4713
- const parsed = JSON.parse(data);
4714
- const delta = parsed.choices?.[0]?.delta?.content;
4715
- if (delta) {
4716
- fullResponse += delta;
4717
- 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 {
4718
4952
  }
4719
- } catch {
4720
4953
  }
4721
4954
  }
4955
+ } finally {
4956
+ clearInterval(heartbeat);
4722
4957
  }
4723
4958
  finishResponse(fullResponse);
4724
4959
  }
4725
- async function streamWithGeminiAPI(userMessage, apiKey, themeName, onChunk) {
4960
+ async function streamWithGeminiAPI(userMessage, apiKey, themeName, onChunk, onStatus) {
4726
4961
  const conversionGuide = getConversionGuide();
4727
4962
  const session = getSession();
4728
4963
  const editMode = session.modules.length > 0;
@@ -4739,7 +4974,7 @@ async function streamWithGeminiAPI(userMessage, apiKey, themeName, onChunk) {
4739
4974
  ---
4740
4975
  ${stateContext}` : userMessage;
4741
4976
  contents.push({ role: "user", parts: [{ text: userContent }] });
4742
- const model = "gemini-2.0-flash";
4977
+ const model = "gemini-2.5-flash";
4743
4978
  const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:streamGenerateContent?alt=sse&key=${apiKey}`;
4744
4979
  const response = await fetch(url, {
4745
4980
  method: "POST",
@@ -4747,36 +4982,48 @@ ${stateContext}` : userMessage;
4747
4982
  body: JSON.stringify({
4748
4983
  systemInstruction: { parts: [{ text: buildVibeSystemPrompt(conversionGuide, themeName, editMode, getPromptContext().pageType, getPromptContext().brandAssets) }] },
4749
4984
  contents,
4750
- generationConfig: { maxOutputTokens: 16384 }
4985
+ generationConfig: { maxOutputTokens: 48e3 }
4751
4986
  })
4752
4987
  });
4753
4988
  if (!response.ok) {
4754
4989
  const err = await response.text();
4755
4990
  throw new Error(`Gemini API error (${response.status}): ${err}`);
4756
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);
4757
5000
  let fullResponse = "";
4758
5001
  const reader = response.body.getReader();
4759
5002
  const decoder = new TextDecoder();
4760
5003
  let buffer = "";
4761
- while (true) {
4762
- const { done, value } = await reader.read();
4763
- if (done) break;
4764
- buffer += decoder.decode(value, { stream: true });
4765
- const lines = buffer.split("\n");
4766
- buffer = lines.pop() || "";
4767
- for (const line of lines) {
4768
- if (!line.startsWith("data: ")) continue;
4769
- const data = line.slice(6).trim();
4770
- try {
4771
- const parsed = JSON.parse(data);
4772
- const text3 = parsed.candidates?.[0]?.content?.parts?.[0]?.text;
4773
- if (text3) {
4774
- fullResponse += text3;
4775
- 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 {
4776
5022
  }
4777
- } catch {
4778
5023
  }
4779
5024
  }
5025
+ } finally {
5026
+ clearInterval(heartbeat);
4780
5027
  }
4781
5028
  finishResponse(fullResponse);
4782
5029
  }
@@ -4921,14 +5168,58 @@ function tryParseJSON(raw) {
4921
5168
  }
4922
5169
  return null;
4923
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
+ }
4924
5209
  function parseAndApplyModules(response) {
4925
5210
  let modulesApplied = false;
4926
- const blockPattern = /```vibespot-modules\s*\n([\s\S]*?)```/g;
5211
+ const blockPattern = /```vibespot-modules\s*\n?([\s\S]*?)```/g;
4927
5212
  let match;
4928
5213
  while ((match = blockPattern.exec(response)) !== null) {
4929
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)));
4930
5218
  const data = tryParseJSON(match[1]);
4931
- 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
+ }
4932
5223
  const obj = data;
4933
5224
  if (obj.modules && Array.isArray(obj.modules)) {
4934
5225
  const modules = obj.modules.map((m) => ({
@@ -4952,8 +5243,9 @@ function parseAndApplyModules(response) {
4952
5243
  }
4953
5244
  }
4954
5245
  if (!modulesApplied) {
4955
- const jsonPattern = /```(?:json)?\s*\n(\{[\s\S]*?"modules"\s*:\s*\[[\s\S]*?\})\s*```/g;
5246
+ const jsonPattern = /```(?:json)?\s*\n([\s\S]*?)```/g;
4956
5247
  while ((match = jsonPattern.exec(response)) !== null) {
5248
+ if (!match[1].includes('"modules"')) continue;
4957
5249
  try {
4958
5250
  const data = tryParseJSON(match[1]);
4959
5251
  if (!data || typeof data !== "object") throw new Error("Invalid JSON after repair");
@@ -4980,6 +5272,44 @@ function parseAndApplyModules(response) {
4980
5272
  }
4981
5273
  }
4982
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));
4983
5313
  const hasModuleRef = response.includes("vibespot-modules") || response.includes('"modules"');
4984
5314
  const describesProse = /\bmodule|modul/i.test(response) && (/\bcreated?\b|\berstellt\b|\bgenerat/i.test(response) || /\|.*\|.*\|/m.test(response));
4985
5315
  if (hasModuleRef || describesProse) {
@@ -5293,8 +5623,12 @@ function handleApiRoute(method, path, req, res) {
5293
5623
  if (method === "POST") handleDeleteLocalThemeRoute(req, res);
5294
5624
  else jsonResponse(res, 405, { error: "Method not allowed" });
5295
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;
5296
5630
  case "/api/history":
5297
- if (method === "GET") handleHistoryRoute(res);
5631
+ if (method === "GET") handleHistoryRoute(req, res);
5298
5632
  else jsonResponse(res, 405, { error: "Method not allowed" });
5299
5633
  break;
5300
5634
  case "/api/rollback":
@@ -5313,6 +5647,10 @@ function handleApiRoute(method, path, req, res) {
5313
5647
  if (method === "POST") handleTemplateActivateRoute(req, res);
5314
5648
  else jsonResponse(res, 405, { error: "Method not allowed" });
5315
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;
5316
5654
  case "/api/module-library":
5317
5655
  if (method === "GET") handleModuleLibraryRoute(res);
5318
5656
  else jsonResponse(res, 405, { error: "Method not allowed" });
@@ -5320,6 +5658,10 @@ function handleApiRoute(method, path, req, res) {
5320
5658
  case "/api/brand-assets":
5321
5659
  handleBrandAssetsRoute(method, req, res);
5322
5660
  break;
5661
+ case "/api/download-zip":
5662
+ if (method === "GET") handleDownloadZipRoute(res);
5663
+ else jsonResponse(res, 405, { error: "Method not allowed" });
5664
+ break;
5323
5665
  default:
5324
5666
  if (path.startsWith("/api/settings/job/") && method === "GET") {
5325
5667
  handleSettingsJobRoute(path, res);
@@ -5520,6 +5862,10 @@ function handleSetupInfoRoute(res) {
5520
5862
  function handleSetupCreateRoute(req, res) {
5521
5863
  readBody(req, (body) => {
5522
5864
  try {
5865
+ if (isGenerating()) {
5866
+ jsonResponse(res, 409, { error: "Cannot switch projects while AI is generating.", generating: true });
5867
+ return;
5868
+ }
5523
5869
  const { name } = JSON.parse(body);
5524
5870
  if (!name || typeof name !== "string") {
5525
5871
  jsonResponse(res, 400, { error: "Theme name is required" });
@@ -5543,7 +5889,7 @@ function handleSetupCreateRoute(req, res) {
5543
5889
  if (newDir) createdAt = join15(process.cwd(), newDir);
5544
5890
  }
5545
5891
  if (createdAt !== themePath && existsSync5(createdAt)) {
5546
- renameSync2(createdAt, themePath);
5892
+ renameSync3(createdAt, themePath);
5547
5893
  }
5548
5894
  const tplDir = join15(themePath, "templates");
5549
5895
  if (existsSync5(tplDir)) {
@@ -5566,6 +5912,10 @@ function handleSetupCreateRoute(req, res) {
5566
5912
  function handleSetupFetchRoute(req, res) {
5567
5913
  readBody(req, (body) => {
5568
5914
  try {
5915
+ if (isGenerating()) {
5916
+ jsonResponse(res, 409, { error: "Cannot switch projects while AI is generating.", generating: true });
5917
+ return;
5918
+ }
5569
5919
  const { name } = JSON.parse(body);
5570
5920
  if (!name || typeof name !== "string") {
5571
5921
  jsonResponse(res, 400, { error: "Theme name is required" });
@@ -5596,6 +5946,10 @@ function handleSetupFetchRoute(req, res) {
5596
5946
  function handleSetupOpenRoute(req, res) {
5597
5947
  readBody(req, (body) => {
5598
5948
  try {
5949
+ if (isGenerating()) {
5950
+ jsonResponse(res, 409, { error: "Cannot switch projects while AI is generating.", generating: true });
5951
+ return;
5952
+ }
5599
5953
  const { path: themePath } = JSON.parse(body);
5600
5954
  if (!themePath || typeof themePath !== "string") {
5601
5955
  jsonResponse(res, 400, { error: "Theme path is required" });
@@ -5627,6 +5981,10 @@ function handleSetupOpenRoute(req, res) {
5627
5981
  function handleSetupResumeRoute(req, res) {
5628
5982
  readBody(req, (body) => {
5629
5983
  try {
5984
+ if (isGenerating()) {
5985
+ jsonResponse(res, 409, { error: "Cannot switch projects while AI is generating.", generating: true });
5986
+ return;
5987
+ }
5630
5988
  const { sessionId } = JSON.parse(body);
5631
5989
  if (!sessionId || typeof sessionId !== "string") {
5632
5990
  jsonResponse(res, 400, { error: "Session ID is required" });
@@ -5664,17 +6022,112 @@ function handleSetupApiKeyRoute(req, res) {
5664
6022
  }
5665
6023
  });
5666
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
+ }
5667
6106
  function handleSettingsStatusRoute(res) {
5668
6107
  const env = detectEnvironment();
5669
6108
  const config = loadConfig();
5670
- jsonResponse(res, 200, {
5671
- environment: env,
5672
- config: {
5673
- aiEngine: config.aiEngine || null,
5674
- claudeCodeModel: config.claudeCodeModel || null,
5675
- anthropicApiModel: config.anthropicApiModel || null,
5676
- openaiApiModel: config.openaiApiModel || null
5677
- }
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
+ });
5678
6131
  });
5679
6132
  }
5680
6133
  function handleSettingsEngineRoute(req, res) {
@@ -6069,7 +6522,31 @@ function handleDeleteLocalThemeRoute(req, res) {
6069
6522
  }
6070
6523
  });
6071
6524
  }
6072
- 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) {
6073
6550
  const session = getSession();
6074
6551
  if (!session) {
6075
6552
  jsonResponse(res, 404, { error: "No active session" });
@@ -6079,8 +6556,10 @@ function handleHistoryRoute(res) {
6079
6556
  jsonResponse(res, 200, { available: false, commits: [] });
6080
6557
  return;
6081
6558
  }
6082
- const commits = getHistory(session.themePath, 50);
6083
- 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 });
6084
6563
  }
6085
6564
  function handleRollbackRoute(req, res) {
6086
6565
  readBody(req, (body) => {
@@ -6090,18 +6569,34 @@ function handleRollbackRoute(req, res) {
6090
6569
  jsonResponse(res, 404, { error: "No active session" });
6091
6570
  return;
6092
6571
  }
6093
- const { hash } = JSON.parse(body);
6572
+ const { hash, templateId } = JSON.parse(body);
6094
6573
  if (!hash || typeof hash !== "string") {
6095
6574
  jsonResponse(res, 400, { error: "Commit hash is required" });
6096
6575
  return;
6097
6576
  }
6098
6577
  addMessage("assistant", `Rolled back to version ${hash.slice(0, 7)}.`);
6099
- const result = rollbackToCommit(session.themePath, hash);
6100
- if (!result.success) {
6101
- jsonResponse(res, 500, { error: result.error || "Rollback failed" });
6102
- 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();
6103
6599
  }
6104
- reloadModulesFromDisk();
6105
6600
  saveSession();
6106
6601
  jsonResponse(res, 200, {
6107
6602
  ok: true,
@@ -6141,6 +6636,41 @@ function handleDashboardRoute(res) {
6141
6636
  }
6142
6637
  });
6143
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
+ }
6144
6674
  function handleTemplatesRoute(method, req, res) {
6145
6675
  const session = getSession();
6146
6676
  if (!session) {
@@ -6236,6 +6766,26 @@ function handleTemplateActivateRoute(req, res) {
6236
6766
  }
6237
6767
  });
6238
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
+ }
6239
6789
  function handleModuleLibraryRoute(res) {
6240
6790
  const library = getModuleLibrary();
6241
6791
  jsonResponse(res, 200, {
@@ -6384,7 +6934,17 @@ function handleWsConnection(ws) {
6384
6934
  const currentSession = getSession();
6385
6935
  if (currentSession) {
6386
6936
  writeModulesToDisk();
6387
- 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
+ }
6388
6948
  if (commitHash) {
6389
6949
  ws.send(JSON.stringify({ type: "version_created", hash: commitHash }));
6390
6950
  }
@@ -6529,6 +7089,7 @@ ${errorContext}`;
6529
7089
  const activeTpl = getActiveTemplate();
6530
7090
  ws.send(JSON.stringify({
6531
7091
  type: "init",
7092
+ sessionId: session.id,
6532
7093
  themeName: session.themeName,
6533
7094
  modules: getOrderedModules().map((m) => m.moduleName),
6534
7095
  messageCount: session.messages.length,