feat: update dev.js template with streaming support
This commit is contained in:
316
templates/dev.js
316
templates/dev.js
@@ -98,6 +98,26 @@ const anthropic = new Anthropic({
|
|||||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Add a simple loading indicator function
|
||||||
|
function startLoadingIndicator(message) {
|
||||||
|
const frames = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
process.stdout.write(`${message} `);
|
||||||
|
|
||||||
|
return setInterval(() => {
|
||||||
|
readline.cursorTo(process.stdout, message.length + 1);
|
||||||
|
process.stdout.write(frames[i]);
|
||||||
|
i = (i + 1) % frames.length;
|
||||||
|
}, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopLoadingIndicator(interval) {
|
||||||
|
clearInterval(interval);
|
||||||
|
readline.cursorTo(process.stdout, 0);
|
||||||
|
readline.clearLine(process.stdout, 0);
|
||||||
|
}
|
||||||
|
|
||||||
function readJSON(filepath) {
|
function readJSON(filepath) {
|
||||||
if (!fs.existsSync(filepath)) return null;
|
if (!fs.existsSync(filepath)) return null;
|
||||||
const content = fs.readFileSync(filepath, 'utf8');
|
const content = fs.readFileSync(filepath, 'utf8');
|
||||||
@@ -108,10 +128,17 @@ function writeJSON(filepath, data) {
|
|||||||
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8');
|
fs.writeFileSync(filepath, JSON.stringify(data, null, 2), 'utf8');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function callClaude(prdContent, prdPath, numTasks) {
|
async function callClaude(prdContent, prdPath, numTasks, retryCount = 0) {
|
||||||
|
const MAX_RETRIES = 3;
|
||||||
|
const INITIAL_BACKOFF_MS = 1000;
|
||||||
|
|
||||||
log('info', `Starting Claude API call to process PRD from ${prdPath}...`);
|
log('info', `Starting Claude API call to process PRD from ${prdPath}...`);
|
||||||
log('debug', `PRD content length: ${prdContent.length} characters`);
|
log('debug', `PRD content length: ${prdContent.length} characters`);
|
||||||
|
|
||||||
|
// Start loading indicator
|
||||||
|
const loadingMessage = `Waiting for Claude to generate tasks${retryCount > 0 ? ` (retry ${retryCount}/${MAX_RETRIES})` : ''}...`;
|
||||||
|
const loadingIndicator = startLoadingIndicator(loadingMessage);
|
||||||
|
|
||||||
const TASKS_JSON_TEMPLATE = `
|
const TASKS_JSON_TEMPLATE = `
|
||||||
{
|
{
|
||||||
"meta": {
|
"meta": {
|
||||||
@@ -154,26 +181,197 @@ async function callClaude(prdContent, prdPath, numTasks) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
log('debug', "System prompt:", systemPrompt);
|
log('debug', "System prompt:", systemPrompt);
|
||||||
log('info', "Sending request to Claude API...");
|
|
||||||
|
|
||||||
const response = await anthropic.messages.create({
|
try {
|
||||||
max_tokens: CONFIG.maxTokens,
|
// Calculate appropriate max tokens based on PRD size
|
||||||
model: CONFIG.model,
|
let maxTokens = CONFIG.maxTokens;
|
||||||
temperature: CONFIG.temperature,
|
// Rough estimate: 1 token ≈ 4 characters
|
||||||
messages: [
|
const estimatedPrdTokens = Math.ceil(prdContent.length / 4);
|
||||||
{
|
// Ensure we have enough tokens for the response
|
||||||
role: "user",
|
if (estimatedPrdTokens > maxTokens / 2) {
|
||||||
content: prdContent
|
// If PRD is large, increase max tokens if possible
|
||||||
|
const suggestedMaxTokens = Math.min(32000, estimatedPrdTokens * 2);
|
||||||
|
if (suggestedMaxTokens > maxTokens) {
|
||||||
|
log('info', `PRD is large (est. ${estimatedPrdTokens} tokens). Increasing max_tokens to ${suggestedMaxTokens}.`);
|
||||||
|
maxTokens = suggestedMaxTokens;
|
||||||
}
|
}
|
||||||
],
|
}
|
||||||
system: systemPrompt
|
|
||||||
});
|
// Determine if we should use streaming based on PRD size
|
||||||
log('info', "Received response from Claude API!");
|
// For PRDs larger than 20,000 characters (roughly 5,000 tokens), use streaming
|
||||||
|
const useStreaming = prdContent.length > 20000;
|
||||||
|
|
||||||
|
if (useStreaming) {
|
||||||
|
log('info', `Large PRD detected (${prdContent.length} characters). Using streaming API...`);
|
||||||
|
return await handleStreamingRequest(prdContent, prdPath, numTasks, maxTokens, systemPrompt, loadingIndicator);
|
||||||
|
} else {
|
||||||
|
log('info', "Sending request to Claude API...");
|
||||||
|
|
||||||
|
const response = await anthropic.messages.create({
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
model: CONFIG.model,
|
||||||
|
temperature: CONFIG.temperature,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: prdContent
|
||||||
|
}
|
||||||
|
],
|
||||||
|
system: systemPrompt
|
||||||
|
});
|
||||||
|
|
||||||
|
// Stop loading indicator
|
||||||
|
stopLoadingIndicator(loadingIndicator);
|
||||||
|
log('info', "Received response from Claude API!");
|
||||||
|
|
||||||
// Extract the text content from the response
|
// Extract the text content from the response
|
||||||
const textContent = response.content[0].text;
|
const textContent = response.content[0].text;
|
||||||
log('debug', `Response length: ${textContent.length} characters`);
|
return processClaudeResponse(textContent, numTasks, retryCount, prdContent, prdPath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
// Stop loading indicator
|
||||||
|
stopLoadingIndicator(loadingIndicator);
|
||||||
|
|
||||||
|
// Check if this is the streaming recommendation error
|
||||||
|
if (error.message && error.message.includes("Streaming is strongly recommended")) {
|
||||||
|
log('info', "Claude recommends streaming for this large PRD. Switching to streaming mode...");
|
||||||
|
try {
|
||||||
|
// Calculate appropriate max tokens based on PRD size
|
||||||
|
let maxTokens = CONFIG.maxTokens;
|
||||||
|
const estimatedPrdTokens = Math.ceil(prdContent.length / 4);
|
||||||
|
const suggestedMaxTokens = Math.min(32000, estimatedPrdTokens * 2);
|
||||||
|
if (suggestedMaxTokens > maxTokens) {
|
||||||
|
maxTokens = suggestedMaxTokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Restart the loading indicator
|
||||||
|
const newLoadingIndicator = startLoadingIndicator(loadingMessage);
|
||||||
|
return await handleStreamingRequest(prdContent, prdPath, numTasks, maxTokens, systemPrompt, newLoadingIndicator);
|
||||||
|
} catch (streamingError) {
|
||||||
|
log('error', "Error with streaming API call:", streamingError);
|
||||||
|
throw streamingError;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log('error', "Error calling Claude API:", error);
|
||||||
|
|
||||||
|
// Implement exponential backoff for retries
|
||||||
|
if (retryCount < MAX_RETRIES) {
|
||||||
|
const backoffTime = INITIAL_BACKOFF_MS * Math.pow(2, retryCount);
|
||||||
|
log('info', `Retrying in ${backoffTime/1000} seconds (attempt ${retryCount + 1}/${MAX_RETRIES})...`);
|
||||||
|
|
||||||
|
await new Promise(resolve => setTimeout(resolve, backoffTime));
|
||||||
|
|
||||||
|
// If we have a numTasks parameter and it's greater than 3, try again with fewer tasks
|
||||||
|
if (numTasks && numTasks > 3) {
|
||||||
|
const reducedTasks = Math.max(3, Math.floor(numTasks * 0.7)); // Reduce by 30%, minimum 3
|
||||||
|
log('info', `Retrying with reduced task count: ${reducedTasks} (was ${numTasks})`);
|
||||||
|
return callClaude(prdContent, prdPath, reducedTasks, retryCount + 1);
|
||||||
|
} else {
|
||||||
|
// Otherwise, just retry with the same parameters
|
||||||
|
return callClaude(prdContent, prdPath, numTasks, retryCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we've exhausted all retries, ask the user what to do
|
||||||
|
console.log("\nClaude API call failed after multiple attempts.");
|
||||||
|
console.log("Options:");
|
||||||
|
console.log("1. Retry with the same parameters");
|
||||||
|
console.log("2. Retry with fewer tasks (if applicable)");
|
||||||
|
console.log("3. Abort");
|
||||||
|
|
||||||
|
const readline = require('readline').createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
readline.question('Enter your choice (1-3): ', async (choice) => {
|
||||||
|
readline.close();
|
||||||
|
|
||||||
|
switch (choice) {
|
||||||
|
case '1':
|
||||||
|
console.log("Retrying with the same parameters...");
|
||||||
|
resolve(await callClaude(prdContent, prdPath, numTasks, 0)); // Reset retry count
|
||||||
|
break;
|
||||||
|
case '2':
|
||||||
|
if (numTasks && numTasks > 2) {
|
||||||
|
const reducedTasks = Math.max(2, Math.floor(numTasks * 0.5)); // Reduce by 50%, minimum 2
|
||||||
|
console.log(`Retrying with reduced task count: ${reducedTasks} (was ${numTasks})...`);
|
||||||
|
resolve(await callClaude(prdContent, prdPath, reducedTasks, 0)); // Reset retry count
|
||||||
|
} else {
|
||||||
|
console.log("Cannot reduce task count further. Retrying with the same parameters...");
|
||||||
|
resolve(await callClaude(prdContent, prdPath, numTasks, 0)); // Reset retry count
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case '3':
|
||||||
|
default:
|
||||||
|
console.log("Aborting...");
|
||||||
|
reject(new Error("User aborted after multiple failed attempts"));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to handle streaming requests to Claude API
|
||||||
|
async function handleStreamingRequest(prdContent, prdPath, numTasks, maxTokens, systemPrompt, loadingIndicator) {
|
||||||
|
log('info', "Sending streaming request to Claude API...");
|
||||||
|
|
||||||
|
let fullResponse = '';
|
||||||
|
let streamComplete = false;
|
||||||
|
let streamError = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stream = await anthropic.messages.create({
|
||||||
|
max_tokens: maxTokens,
|
||||||
|
model: CONFIG.model,
|
||||||
|
temperature: CONFIG.temperature,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: prdContent
|
||||||
|
}
|
||||||
|
],
|
||||||
|
system: systemPrompt,
|
||||||
|
stream: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update loading indicator to show streaming progress
|
||||||
|
let dotCount = 0;
|
||||||
|
const streamingInterval = setInterval(() => {
|
||||||
|
readline.cursorTo(process.stdout, 0);
|
||||||
|
process.stdout.write(`Receiving streaming response from Claude${'.'.repeat(dotCount)}`);
|
||||||
|
dotCount = (dotCount + 1) % 4;
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
// Process the stream
|
||||||
|
for await (const chunk of stream) {
|
||||||
|
if (chunk.type === 'content_block_delta' && chunk.delta.text) {
|
||||||
|
fullResponse += chunk.delta.text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearInterval(streamingInterval);
|
||||||
|
streamComplete = true;
|
||||||
|
|
||||||
|
// Stop loading indicator
|
||||||
|
stopLoadingIndicator(loadingIndicator);
|
||||||
|
log('info', "Completed streaming response from Claude API!");
|
||||||
|
log('debug', `Streaming response length: ${fullResponse.length} characters`);
|
||||||
|
|
||||||
|
return processClaudeResponse(fullResponse, numTasks, 0, prdContent, prdPath);
|
||||||
|
} catch (error) {
|
||||||
|
clearInterval(streamingInterval);
|
||||||
|
stopLoadingIndicator(loadingIndicator);
|
||||||
|
log('error', "Error during streaming response:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to process Claude's response text
|
||||||
|
function processClaudeResponse(textContent, numTasks, retryCount, prdContent, prdPath) {
|
||||||
try {
|
try {
|
||||||
// Check if the response is wrapped in a Markdown code block and extract the JSON
|
// Check if the response is wrapped in a Markdown code block and extract the JSON
|
||||||
log('info', "Parsing response as JSON...");
|
log('info', "Parsing response as JSON...");
|
||||||
@@ -186,12 +384,40 @@ async function callClaude(prdContent, prdPath, numTasks) {
|
|||||||
|
|
||||||
// Try to parse the response as JSON
|
// Try to parse the response as JSON
|
||||||
const parsedJson = JSON.parse(jsonText);
|
const parsedJson = JSON.parse(jsonText);
|
||||||
|
|
||||||
|
// Check if the response seems incomplete (e.g., missing closing brackets)
|
||||||
|
if (!parsedJson.tasks || parsedJson.tasks.length === 0) {
|
||||||
|
log('warn', "Parsed JSON has no tasks. Response may be incomplete.");
|
||||||
|
|
||||||
|
// If we have a numTasks parameter and it's greater than 5, try again with fewer tasks
|
||||||
|
if (numTasks && numTasks > 5 && retryCount < MAX_RETRIES) {
|
||||||
|
const reducedTasks = Math.max(5, Math.floor(numTasks * 0.7)); // Reduce by 30%, minimum 5
|
||||||
|
log('info', `Retrying with reduced task count: ${reducedTasks} (was ${numTasks})`);
|
||||||
|
return callClaude(prdContent, prdPath, reducedTasks, retryCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
log('info', `Successfully parsed JSON with ${parsedJson.tasks?.length || 0} tasks`);
|
log('info', `Successfully parsed JSON with ${parsedJson.tasks?.length || 0} tasks`);
|
||||||
return parsedJson;
|
return parsedJson;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log('error', "Failed to parse Claude's response as JSON:", error);
|
log('error', "Failed to parse Claude's response as JSON:", error);
|
||||||
log('debug', "Raw response:", textContent);
|
log('debug', "Raw response:", textContent);
|
||||||
throw new Error("Failed to parse Claude's response as JSON. See console for details.");
|
|
||||||
|
// Check if we should retry with different parameters
|
||||||
|
if (retryCount < MAX_RETRIES) {
|
||||||
|
// If we have a numTasks parameter, try again with fewer tasks
|
||||||
|
if (numTasks && numTasks > 3) {
|
||||||
|
const reducedTasks = Math.max(3, Math.floor(numTasks * 0.6)); // Reduce by 40%, minimum 3
|
||||||
|
log('info', `Retrying with reduced task count: ${reducedTasks} (was ${numTasks})`);
|
||||||
|
return callClaude(prdContent, prdPath, reducedTasks, retryCount + 1);
|
||||||
|
} else {
|
||||||
|
// Otherwise, just retry with the same parameters
|
||||||
|
log('info', `Retrying Claude API call (attempt ${retryCount + 1}/${MAX_RETRIES})...`);
|
||||||
|
return callClaude(prdContent, prdPath, numTasks, retryCount + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Failed to parse Claude's response as JSON after multiple attempts. See console for details.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,32 +436,38 @@ async function parsePRD(prdPath, tasksPath, numTasks) {
|
|||||||
|
|
||||||
// call claude to generate the tasks.json
|
// call claude to generate the tasks.json
|
||||||
log('info', "Calling Claude to generate tasks from PRD...");
|
log('info', "Calling Claude to generate tasks from PRD...");
|
||||||
const claudeResponse = await callClaude(prdContent, prdPath, numTasks);
|
|
||||||
let tasks = claudeResponse.tasks || [];
|
try {
|
||||||
log('info', `Claude generated ${tasks.length} tasks from the PRD`);
|
const claudeResponse = await callClaude(prdContent, prdPath, numTasks);
|
||||||
|
let tasks = claudeResponse.tasks || [];
|
||||||
|
log('info', `Claude generated ${tasks.length} tasks from the PRD`);
|
||||||
|
|
||||||
// Limit the number of tasks if specified
|
// Limit the number of tasks if specified
|
||||||
if (numTasks && numTasks > 0 && numTasks < tasks.length) {
|
if (numTasks && numTasks > 0 && numTasks < tasks.length) {
|
||||||
log('info', `Limiting to the first ${numTasks} tasks as specified`);
|
log('info', `Limiting to the first ${numTasks} tasks as specified`);
|
||||||
tasks = tasks.slice(0, numTasks);
|
tasks = tasks.slice(0, numTasks);
|
||||||
|
}
|
||||||
|
|
||||||
|
log('info', "Creating tasks.json data structure...");
|
||||||
|
const data = {
|
||||||
|
meta: {
|
||||||
|
projectName: CONFIG.projectName,
|
||||||
|
version: CONFIG.projectVersion,
|
||||||
|
source: prdPath,
|
||||||
|
description: "Tasks generated from PRD",
|
||||||
|
totalTasksGenerated: claudeResponse.tasks?.length || 0,
|
||||||
|
tasksIncluded: tasks.length
|
||||||
|
},
|
||||||
|
tasks
|
||||||
|
};
|
||||||
|
|
||||||
|
log('info', `Writing ${tasks.length} tasks to ${tasksPath}...`);
|
||||||
|
writeJSON(tasksPath, data);
|
||||||
|
log('info', `Parsed PRD from '${prdPath}' -> wrote ${tasks.length} tasks to '${tasksPath}'.`);
|
||||||
|
} catch (error) {
|
||||||
|
log('error', `Failed to generate tasks from PRD: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
log('info', "Creating tasks.json data structure...");
|
|
||||||
const data = {
|
|
||||||
meta: {
|
|
||||||
projectName: CONFIG.projectName,
|
|
||||||
version: CONFIG.projectVersion,
|
|
||||||
source: prdPath,
|
|
||||||
description: "Tasks generated from PRD",
|
|
||||||
totalTasksGenerated: claudeResponse.tasks?.length || 0,
|
|
||||||
tasksIncluded: tasks.length
|
|
||||||
},
|
|
||||||
tasks
|
|
||||||
};
|
|
||||||
|
|
||||||
log('info', `Writing ${tasks.length} tasks to ${tasksPath}...`);
|
|
||||||
writeJSON(tasksPath, data);
|
|
||||||
log('info', `Parsed PRD from '${prdPath}' -> wrote ${tasks.length} tasks to '${tasksPath}'.`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
//
|
//
|
||||||
|
|||||||
Reference in New Issue
Block a user