vzcode
Version:
Multiplayer code editor system
371 lines (336 loc) • 10.6 kB
text/typescript
import {
parseMarkdownFiles,
StreamingMarkdownParser,
} from 'llm-code-format';
import OpenAI from 'openai';
import fs from 'fs';
import { generateRunId } from '@vizhub/viz-utils';
import {
updateAIStatus,
createAIMessage,
updateAIMessageContent,
finalizeAIMessage,
ensureFileExists,
clearFileContent,
appendLineToFile,
updateFiles,
updateAIScratchpad,
} from './chatOperations.js';
import { mergeFileChanges } from 'editcodewithai';
import { diff } from '../../ot.js';
import { VizChatId, VizContent } from '@vizhub/viz-types';
import { ShareDBDoc } from '../../types.js';
const DEBUG = false;
// Feature flag to enable/disable streaming editing.
// * If `true`, the AI streaming response will be used to
// edit files in real-time by submitting ShareDB ops.
// * If `false`, the updates to code files will be applied
// only after the AI has finished generating the entire response.
//
// Current status: there's a tricky bug with the streaming
// where the AI edits sometimes don't apply correctly in CodeMirror.
// It seems that sometimes the op that clears the file content
// is not applied correctly in the front end, leading to
// a situation where the AI streaming edits are concatenated into the middle
// of the file instead of replacing it.
// See https://github.com/codemirror/codemirror.next/issues/1234
const enableStreamingEditing = false;
/**
* Creates and configures the LLM function for streaming with reasoning tokens
*/
export const createLLMFunction = ({
shareDBDoc,
createAIEditLocalPresence,
chatId,
// Feature flag to enable/disable reasoning tokens.
// When false, reasoning tokens are not requested from the API
// and reasoning content is not processed in the streaming response.
enableReasoningTokens = false,
model,
}: {
shareDBDoc: ShareDBDoc<VizContent>;
createAIEditLocalPresence: () => any;
chatId: VizChatId;
enableReasoningTokens?: boolean;
model?: string;
}) => {
return async (fullPrompt: string) => {
const localPresence = enableStreamingEditing
? createAIEditLocalPresence()
: null;
// Create OpenRouter client for reasoning token support
const openRouterClient = new OpenAI({
apiKey: process.env.VIZHUB_EDIT_WITH_AI_API_KEY,
baseURL:
process.env.VIZHUB_EDIT_WITH_AI_BASE_URL ||
'https://openrouter.ai/api/v1',
defaultHeaders: {
'HTTP-Referer': 'https://vizhub.com',
'X-Title': 'VizHub',
},
});
let fullContent = '';
let generationId = '';
let currentEditingFileId = null;
let currentEditingFileName = null;
// Create initial AI message for streaming
const aiMessageId = createAIMessage(shareDBDoc, chatId);
// Function to report file edited
// This is called when the AI has finished editing a file
// and we want to update the message content with the file name.
const reportFileEdited = () => {
if (currentEditingFileName) {
fullContent += ` * Edited ${currentEditingFileName}\n`;
updateAIMessageContent(
shareDBDoc,
chatId,
aiMessageId,
fullContent,
);
currentEditingFileName = null;
}
};
// Define callbacks for streaming parser
const callbacks = {
onFileNameChange: async (
fileName: string,
format: string,
) => {
DEBUG &&
console.log(
`File changed to: ${fileName} (${format})`,
);
// Find existing file or create new one
currentEditingFileId = ensureFileExists(
shareDBDoc,
fileName,
);
reportFileEdited();
currentEditingFileName = fileName;
// Clear the file content to start fresh
// (AI will regenerate the entire file content)
clearFileContent(shareDBDoc, currentEditingFileId);
// Update AI status
updateAIStatus(
shareDBDoc,
chatId,
'Editing ' + fileName,
);
},
onCodeLine: async (line: string) => {
DEBUG && console.log(`Code line: ${line}`);
// If streaming is enabled, we apply the line immediately
if (currentEditingFileId) {
// Apply OT operation for this line immediately
appendLineToFile(
shareDBDoc,
currentEditingFileId,
line,
);
// Update AI presence to show cursor at the end of the file
const currentFile =
shareDBDoc.data.files[currentEditingFileId];
if (currentFile && currentFile.text) {
const textLength = currentFile.text.length;
const filePresence = {
username: 'AI Editor',
start: [
'files',
currentEditingFileId,
'text',
textLength,
],
end: [
'files',
currentEditingFileId,
'text',
textLength,
],
};
if (localPresence) {
localPresence.submit(
filePresence,
(error) => {
if (error) {
console.warn(
'AI Editor line presence submission error:',
error,
);
}
},
);
}
}
}
},
onNonCodeLine: async (line: string) => {
// We want to report a file edited only if the line is not empty,
// because sometimes the LLMs leave a newline between the file name
// declaration and th
if (line.trim() !== '') {
reportFileEdited();
}
fullContent += line + '\n';
updateAIMessageContent(
shareDBDoc,
chatId,
aiMessageId,
fullContent,
);
},
};
const parser = new StreamingMarkdownParser(callbacks);
const chunks = [];
let reasoningContent = '';
// Stream the response with reasoning tokens
const modelName =
model ||
process.env.VIZHUB_EDIT_WITH_AI_MODEL_NAME ||
'anthropic/claude-3.5-sonnet';
// Configure reasoning tokens based on enableReasoningTokens flag
const requestConfig: any = {
model: modelName,
messages: [{ role: 'user', content: fullPrompt }],
provider: {
sort: 'price',
},
usage: { include: true },
stream: true,
};
// Only include reasoning configuration if reasoning tokens are enabled
if (enableReasoningTokens) {
requestConfig.reasoning = {
effort: 'low',
exclude: false,
};
}
const stream = await (
openRouterClient.chat.completions.create as any
)(requestConfig);
let reasoningStarted = false;
let contentStarted = false;
for await (const chunk of stream) {
const delta = chunk.choices[0]?.delta as any; // Type assertion for OpenRouter-specific reasoning fields
if (delta?.reasoning && enableReasoningTokens) {
// Handle reasoning tokens (thinking) - only if enabled
if (!reasoningStarted) {
reasoningStarted = true;
updateAIStatus(shareDBDoc, chatId, 'Thinking...');
}
reasoningContent += delta.reasoning;
updateAIScratchpad(
shareDBDoc,
chatId,
reasoningContent,
);
} else if (delta?.content) {
// Handle regular content tokens
if (reasoningStarted && !contentStarted) {
// Clear reasoning when content starts
contentStarted = true;
updateAIScratchpad(shareDBDoc, chatId, '');
updateAIStatus(
shareDBDoc,
chatId,
'Generating response...',
);
}
const chunkContent = delta.content;
chunks.push(chunkContent);
if (enableStreamingEditing) {
await parser.processChunk(chunkContent);
} else {
fullContent += chunkContent;
updateAIMessageContent(
shareDBDoc,
chatId,
aiMessageId,
fullContent,
);
}
} else if (chunk.usage) {
// Handle usage information
DEBUG && console.log('Usage:', chunk.usage);
}
if (!generationId && chunk.id) {
generationId = chunk.id;
}
}
await parser.flushRemaining();
reportFileEdited();
// Final cleanup - clear scratchpad and set final status
updateAIScratchpad(shareDBDoc, chatId, '');
updateAIStatus(shareDBDoc, chatId, 'Done editing.');
updateAIMessageContent(
shareDBDoc,
chatId,
aiMessageId,
fullContent,
);
// Finalize the AI message by clearing temporary fields
finalizeAIMessage(shareDBDoc, chatId);
// Clear AI Editor presence when done
DEBUG &&
console.log(
'AI editing done, clearing AI Editor presence',
);
if (localPresence) {
localPresence.submit(null, (error) => {
DEBUG && console.log('AI Editor presence cleared');
if (error) {
console.warn(
'AI Editor presence cleanup error:',
error,
);
}
});
}
// If streaming editing is not enabled, we need to
// apply all the edits at once
if (!enableStreamingEditing) {
updateFiles(
shareDBDoc,
mergeFileChanges(
shareDBDoc.data.files,
parseMarkdownFiles(fullContent, 'bold').files,
),
);
}
// Generate a new runId to trigger a run when AI finishes editing
// This will trigger a re-run without hot reloading
const newRunId = generateRunId();
const runIdOp = diff(shareDBDoc.data, {
...shareDBDoc.data,
runId: newRunId,
});
shareDBDoc.submitOp(runIdOp, (error) => {
if (error) {
console.warn(
'Error setting runId after AI editing:',
error,
);
} else {
DEBUG &&
console.log(
'Set new runId after AI editing:',
newRunId,
);
}
});
// Write chunks file for debugging
if (DEBUG) {
const chunksFileJSONpath = `./ai-chunks-${chatId}.json`;
fs.writeFileSync(
chunksFileJSONpath,
JSON.stringify(chunks, null, 2),
);
console.log(
`AI chunks written to ${chunksFileJSONpath}`,
);
}
return {
content: fullContent,
generationId: generationId,
};
};
};