vzcode 2.2.0 → 2.4.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/assets/{buildWorker-Dsx6rOdK.js → buildWorker-wLkQ0yz1.js} +64 -64
- package/dist/assets/{index-BM8tDh6M.css → index-BoFeK4GL.css} +1 -1
- package/dist/assets/{index-CpxqGhXj.js → index-DuUjtrt6.js} +150 -150
- package/dist/assets/{worker-BeBZGG1n.js → worker-DHxSPM7N.js} +78 -78
- package/dist/cli.js +113 -0
- package/dist/index.html +2 -2
- package/dist/ot.js +12 -0
- package/dist/randomId.js +14 -0
- package/dist/runCode.js +20 -0
- package/dist/server/aiChatHandler/aiEditing.js +89 -0
- package/dist/server/aiChatHandler/chatOperations.js +360 -0
- package/dist/server/aiChatHandler/errorHandling.js +49 -0
- package/dist/server/aiChatHandler/index.js +112 -0
- package/dist/server/aiChatHandler/llmStreaming.js +284 -0
- package/dist/server/aiChatHandler/validation.js +19 -0
- package/dist/server/computeInitialDocument.js +146 -0
- package/dist/server/config.js +21 -0
- package/dist/server/featureFlags.js +5 -0
- package/dist/server/generateAIResponse.js +129 -0
- package/dist/server/handleAIAssist.js +39 -0
- package/dist/server/handleAIChatMessage.js +2 -0
- package/dist/server/handleAICopilot.js +81 -0
- package/dist/server/index.js +297 -0
- package/dist/server/isDirectory.js +1 -0
- package/dist/server/livekit.js +17 -0
- package/dist/server/prettier.js +90 -0
- package/dist/server/setupEnv.js +7 -0
- package/dist/submitOperation.js +14 -0
- package/dist/types.js +1 -0
- package/dist/utils/fileDiff.js +66 -0
- package/package.json +21 -20
- package/src/cli.ts +208 -0
- package/src/client/App/index.tsx +0 -1
- package/src/client/VZCodeContext/types.ts +3 -4
- package/src/client/VZCodeContext/useVZCodeState.ts +24 -6
- package/src/client/VZSidebar/AIChat/ChatInput.tsx +4 -0
- package/src/client/VZSidebar/AIChat/Message.tsx +2 -66
- package/src/client/VZSidebar/AIChat/MessageList.tsx +0 -15
- package/src/client/VZSidebar/AIChat/index.tsx +91 -7
- package/src/client/VZSidebar/AIChat/styles.scss +19 -0
- package/src/server/aiChatHandler/aiEditing.ts +0 -1
- package/src/server/aiChatHandler/chatOperations.ts +0 -34
- package/src/server/aiChatHandler/index.ts +0 -1
- package/src/server/aiChatHandler/llmStreaming.ts +3 -3
- package/src/server/index.ts +1 -10
- package/src/server/aiChatHandler/undoHandler.ts +0 -97
- package/src/server/handleAIChatUndo.ts +0 -2
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { validateRequest } from './validation.js';
|
|
2
|
+
import { ensureChatsExist, ensureChatExists, addUserMessage, addDiffToAIMessage, setAIStatus, } from './chatOperations.js';
|
|
3
|
+
import { createLLMFunction } from './llmStreaming.js';
|
|
4
|
+
import { performAIEditing, performAIChat, } from './aiEditing.js';
|
|
5
|
+
import { handleError, handleBackgroundError, } from './errorHandling.js';
|
|
6
|
+
import { createRunCodeFunction } from '../../runCode.js';
|
|
7
|
+
import { createSubmitOperation } from '../../submitOperation.js';
|
|
8
|
+
const DEBUG = false;
|
|
9
|
+
export const handleAIChatMessage = ({ shareDBDoc, createAIEditLocalPresence, onCreditDeduction, getCurrentCommitId, model, aiRequestOptions, }) => async (req, res) => {
|
|
10
|
+
const { content, chatId, mode = 'edit' } = req.body;
|
|
11
|
+
if (DEBUG) {
|
|
12
|
+
console.log('[handleAIChatMessage] content:', content, 'chatId:', chatId, 'shareDBDoc:', shareDBDoc);
|
|
13
|
+
}
|
|
14
|
+
// Validate request
|
|
15
|
+
if (!validateRequest(req, res)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
try {
|
|
19
|
+
// Ensure chats structure exists
|
|
20
|
+
ensureChatsExist(shareDBDoc);
|
|
21
|
+
ensureChatExists(shareDBDoc, chatId);
|
|
22
|
+
// Add user message to chat
|
|
23
|
+
addUserMessage(shareDBDoc, chatId, content);
|
|
24
|
+
// Return success immediately - AI generation continues in background
|
|
25
|
+
res.status(200).json('success');
|
|
26
|
+
// Set AI status to indicate generation is starting
|
|
27
|
+
setAIStatus(shareDBDoc, chatId, 'generating');
|
|
28
|
+
// Continue AI processing in background (don't await)
|
|
29
|
+
processAIRequestAsync({
|
|
30
|
+
shareDBDoc,
|
|
31
|
+
chatId,
|
|
32
|
+
content,
|
|
33
|
+
mode,
|
|
34
|
+
createAIEditLocalPresence,
|
|
35
|
+
getCurrentCommitId,
|
|
36
|
+
model,
|
|
37
|
+
aiRequestOptions,
|
|
38
|
+
onCreditDeduction,
|
|
39
|
+
}).catch((error) => {
|
|
40
|
+
console.error('Background AI processing error:', error);
|
|
41
|
+
// Handle error without HTTP response
|
|
42
|
+
handleBackgroundError(shareDBDoc, chatId, error);
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
handleError(shareDBDoc, chatId, error, res);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* Processes the AI request asynchronously in the background
|
|
51
|
+
*/
|
|
52
|
+
const processAIRequestAsync = async ({ shareDBDoc, chatId, content, mode, createAIEditLocalPresence, getCurrentCommitId, model, aiRequestOptions, onCreditDeduction, }) => {
|
|
53
|
+
try {
|
|
54
|
+
// Capture the current commit ID before making changes (for VizHub integration)
|
|
55
|
+
const beforeCommitId = getCurrentCommitId
|
|
56
|
+
? getCurrentCommitId()
|
|
57
|
+
: null;
|
|
58
|
+
// Create LLM function for streaming
|
|
59
|
+
const llmFunction = createLLMFunction({
|
|
60
|
+
shareDBDoc,
|
|
61
|
+
createAIEditLocalPresence,
|
|
62
|
+
chatId,
|
|
63
|
+
model,
|
|
64
|
+
aiRequestOptions,
|
|
65
|
+
});
|
|
66
|
+
// Create server-side runCode function using shareDBDoc
|
|
67
|
+
const submitOperation = createSubmitOperation(shareDBDoc);
|
|
68
|
+
const runCode = createRunCodeFunction(submitOperation);
|
|
69
|
+
// Perform AI editing or chat based on mode
|
|
70
|
+
const editResult = mode === 'ask'
|
|
71
|
+
? await performAIChat({
|
|
72
|
+
prompt: content,
|
|
73
|
+
shareDBDoc,
|
|
74
|
+
llmFunction,
|
|
75
|
+
})
|
|
76
|
+
: await performAIEditing({
|
|
77
|
+
prompt: content,
|
|
78
|
+
shareDBDoc,
|
|
79
|
+
llmFunction,
|
|
80
|
+
runCode,
|
|
81
|
+
});
|
|
82
|
+
// Add diff data to the AI message if there are changes
|
|
83
|
+
if (editResult.diffData &&
|
|
84
|
+
Object.keys(editResult.diffData).length > 0) {
|
|
85
|
+
addDiffToAIMessage(shareDBDoc, chatId, editResult.diffData, beforeCommitId);
|
|
86
|
+
}
|
|
87
|
+
// Handle credit deduction if callback is provided
|
|
88
|
+
if (onCreditDeduction &&
|
|
89
|
+
editResult.upstreamCostCents) {
|
|
90
|
+
try {
|
|
91
|
+
await onCreditDeduction({
|
|
92
|
+
upstreamCostCents: editResult
|
|
93
|
+
.upstreamCostCents,
|
|
94
|
+
provider: editResult.provider,
|
|
95
|
+
inputTokens: editResult.inputTokens,
|
|
96
|
+
outputTokens: editResult.outputTokens,
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
catch (creditError) {
|
|
100
|
+
console.error('Credit deduction error:', creditError);
|
|
101
|
+
// Don't fail the request if credit deduction fails
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
// Clear the AI status to indicate completion
|
|
105
|
+
setAIStatus(shareDBDoc, chatId, undefined);
|
|
106
|
+
}
|
|
107
|
+
catch (error) {
|
|
108
|
+
// Set error status and add error message to chat
|
|
109
|
+
setAIStatus(shareDBDoc, chatId, 'error');
|
|
110
|
+
handleBackgroundError(shareDBDoc, chatId, error);
|
|
111
|
+
}
|
|
112
|
+
};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { parseMarkdownFiles, StreamingMarkdownParser, } from 'llm-code-format';
|
|
2
|
+
import OpenAI from 'openai';
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import { generateRunId } from '@vizhub/viz-utils';
|
|
5
|
+
import { updateAIStatus, createAIMessage, updateAIMessageContent, finalizeAIMessage, ensureFileExists, clearFileContent, appendLineToFile, updateFiles, updateAIScratchpad, } from './chatOperations.js';
|
|
6
|
+
import { mergeFileChanges } from 'editcodewithai';
|
|
7
|
+
import { diff } from '../../ot.js';
|
|
8
|
+
const DEBUG = false;
|
|
9
|
+
// Useful for testing/debugging the streaming behavior
|
|
10
|
+
const slowMode = false;
|
|
11
|
+
// Throttle the streaming updates, so that we don't
|
|
12
|
+
// overwhelm the ShareDB server with too many updates.
|
|
13
|
+
// It happened actually, before adding this.
|
|
14
|
+
// MongoDB VizHub server got in fact overloaded with
|
|
15
|
+
// too many updates from the AI streaming response, with
|
|
16
|
+
// warning: "Replication Oplog Window has gone below 1 hour"
|
|
17
|
+
const THROTTLE_INTERVAL_MS = 100;
|
|
18
|
+
// Feature flag to enable/disable streaming editing.
|
|
19
|
+
// * If `true`, the AI streaming response will be used to
|
|
20
|
+
// edit files in real-time by submitting ShareDB ops.
|
|
21
|
+
// * If `false`, the updates to code files will be applied
|
|
22
|
+
// only after the AI has finished generating the entire response.
|
|
23
|
+
//
|
|
24
|
+
// Current status: there's a tricky bug with the streaming
|
|
25
|
+
// where the AI edits sometimes don't apply correctly in CodeMirror.
|
|
26
|
+
// It seems that sometimes the op that clears the file content
|
|
27
|
+
// is not applied correctly in the front end, leading to
|
|
28
|
+
// a situation where the AI streaming edits are concatenated into the middle
|
|
29
|
+
// of the file instead of replacing it.
|
|
30
|
+
// See https://github.com/codemirror/codemirror.next/issues/1234
|
|
31
|
+
const enableStreamingEditing = false;
|
|
32
|
+
/**
|
|
33
|
+
* Creates and configures the LLM function for streaming with reasoning tokens
|
|
34
|
+
*/
|
|
35
|
+
export const createLLMFunction = ({ shareDBDoc, createAIEditLocalPresence, chatId,
|
|
36
|
+
// Feature flag to enable/disable reasoning tokens.
|
|
37
|
+
// When false, reasoning tokens are not requested from the API
|
|
38
|
+
// and reasoning content is not processed in the streaming response.
|
|
39
|
+
enableReasoningTokens = false, model, aiRequestOptions, }) => {
|
|
40
|
+
return async (fullPrompt) => {
|
|
41
|
+
const localPresence = enableStreamingEditing
|
|
42
|
+
? createAIEditLocalPresence()
|
|
43
|
+
: null;
|
|
44
|
+
// Create OpenRouter client for reasoning token support
|
|
45
|
+
const openRouterClient = new OpenAI({
|
|
46
|
+
apiKey: process.env.VZCODE_EDIT_WITH_AI_API_KEY,
|
|
47
|
+
baseURL: process.env.VZCODE_EDIT_WITH_AI_BASE_URL ||
|
|
48
|
+
'https://openrouter.ai/api/v1',
|
|
49
|
+
defaultHeaders: {
|
|
50
|
+
'HTTP-Referer': 'https://vizhub.com',
|
|
51
|
+
'X-Title': 'VizHub',
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
let fullContent = '';
|
|
55
|
+
let generationId = '';
|
|
56
|
+
let currentEditingFileId = null;
|
|
57
|
+
let currentEditingFileName = null;
|
|
58
|
+
// Create initial AI message for streaming
|
|
59
|
+
const aiMessageId = createAIMessage(shareDBDoc, chatId);
|
|
60
|
+
// --- throttle wrapper ---
|
|
61
|
+
function makeThrottledUpdater() {
|
|
62
|
+
let lastCall = 0;
|
|
63
|
+
let latestContent = '';
|
|
64
|
+
let timer = null;
|
|
65
|
+
function invoke() {
|
|
66
|
+
updateAIMessageContent(shareDBDoc, chatId, aiMessageId, latestContent);
|
|
67
|
+
lastCall = Date.now();
|
|
68
|
+
}
|
|
69
|
+
const fn = (content) => {
|
|
70
|
+
latestContent = content;
|
|
71
|
+
const now = Date.now();
|
|
72
|
+
if (now - lastCall >= THROTTLE_INTERVAL_MS) {
|
|
73
|
+
// safe to call immediately
|
|
74
|
+
invoke();
|
|
75
|
+
}
|
|
76
|
+
else if (!timer) {
|
|
77
|
+
// schedule for later
|
|
78
|
+
timer = setTimeout(() => {
|
|
79
|
+
timer = null;
|
|
80
|
+
invoke();
|
|
81
|
+
}, THROTTLE_INTERVAL_MS - (now - lastCall));
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
// expose a flush() helper to force an immediate write
|
|
85
|
+
fn.flush = () => {
|
|
86
|
+
if (timer) {
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
timer = null;
|
|
89
|
+
}
|
|
90
|
+
invoke();
|
|
91
|
+
};
|
|
92
|
+
return fn;
|
|
93
|
+
}
|
|
94
|
+
const throttledUpdateAIMessageContent = makeThrottledUpdater();
|
|
95
|
+
// Function to report file edited
|
|
96
|
+
// This is called when the AI has finished editing a file
|
|
97
|
+
// and we want to update the message content with the file name.
|
|
98
|
+
const reportFileEdited = () => {
|
|
99
|
+
if (currentEditingFileName) {
|
|
100
|
+
fullContent += ` * Edited ${currentEditingFileName}\n`;
|
|
101
|
+
throttledUpdateAIMessageContent(fullContent);
|
|
102
|
+
currentEditingFileName = null;
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
// Define callbacks for streaming parser
|
|
106
|
+
const callbacks = {
|
|
107
|
+
onFileNameChange: async (fileName, format) => {
|
|
108
|
+
DEBUG &&
|
|
109
|
+
console.log(`File changed to: ${fileName} (${format})`);
|
|
110
|
+
// Find existing file or create new one
|
|
111
|
+
currentEditingFileId = ensureFileExists(shareDBDoc, fileName);
|
|
112
|
+
reportFileEdited();
|
|
113
|
+
currentEditingFileName = fileName;
|
|
114
|
+
// Clear the file content to start fresh
|
|
115
|
+
// (AI will regenerate the entire file content)
|
|
116
|
+
clearFileContent(shareDBDoc, currentEditingFileId);
|
|
117
|
+
// Update AI status
|
|
118
|
+
updateAIStatus(shareDBDoc, chatId, 'Editing ' + fileName);
|
|
119
|
+
},
|
|
120
|
+
onCodeLine: async (line) => {
|
|
121
|
+
DEBUG && console.log(`Code line: ${line}`);
|
|
122
|
+
// If streaming is enabled, we apply the line immediately
|
|
123
|
+
if (currentEditingFileId) {
|
|
124
|
+
// Apply OT operation for this line immediately
|
|
125
|
+
appendLineToFile(shareDBDoc, currentEditingFileId, line);
|
|
126
|
+
// Update AI presence to show cursor at the end of the file
|
|
127
|
+
const currentFile = shareDBDoc.data.files[currentEditingFileId];
|
|
128
|
+
if (currentFile && currentFile.text) {
|
|
129
|
+
const textLength = currentFile.text.length;
|
|
130
|
+
const filePresence = {
|
|
131
|
+
username: 'AI Editor',
|
|
132
|
+
start: [
|
|
133
|
+
'files',
|
|
134
|
+
currentEditingFileId,
|
|
135
|
+
'text',
|
|
136
|
+
textLength,
|
|
137
|
+
],
|
|
138
|
+
end: [
|
|
139
|
+
'files',
|
|
140
|
+
currentEditingFileId,
|
|
141
|
+
'text',
|
|
142
|
+
textLength,
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
if (localPresence) {
|
|
146
|
+
localPresence.submit(filePresence, (error) => {
|
|
147
|
+
if (error) {
|
|
148
|
+
console.warn('AI Editor line presence submission error:', error);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
},
|
|
155
|
+
onNonCodeLine: async (line) => {
|
|
156
|
+
// We want to report a file edited only if the line is not empty,
|
|
157
|
+
// because sometimes the LLMs leave a newline between the file name
|
|
158
|
+
// declaration and th
|
|
159
|
+
if (line.trim() !== '') {
|
|
160
|
+
reportFileEdited();
|
|
161
|
+
}
|
|
162
|
+
fullContent += line + '\n';
|
|
163
|
+
throttledUpdateAIMessageContent(fullContent);
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
const parser = new StreamingMarkdownParser(callbacks);
|
|
167
|
+
const chunks = [];
|
|
168
|
+
let reasoningContent = '';
|
|
169
|
+
// Stream the response with reasoning tokens
|
|
170
|
+
const modelName = model ||
|
|
171
|
+
process.env.VZCODE_EDIT_WITH_AI_MODEL_NAME ||
|
|
172
|
+
'anthropic/claude-3.5-sonnet';
|
|
173
|
+
// Configure reasoning tokens based on enableReasoningTokens flag
|
|
174
|
+
const requestConfig = {
|
|
175
|
+
model: modelName,
|
|
176
|
+
messages: [{ role: 'user', content: fullPrompt }],
|
|
177
|
+
usage: { include: true },
|
|
178
|
+
stream: true,
|
|
179
|
+
...aiRequestOptions,
|
|
180
|
+
};
|
|
181
|
+
// Only include reasoning configuration if reasoning tokens are enabled
|
|
182
|
+
if (enableReasoningTokens) {
|
|
183
|
+
requestConfig.reasoning = {
|
|
184
|
+
effort: 'low',
|
|
185
|
+
exclude: false,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const stream = await openRouterClient.chat.completions.create(requestConfig);
|
|
189
|
+
let reasoningStarted = false;
|
|
190
|
+
let contentStarted = false;
|
|
191
|
+
for await (const chunk of stream) {
|
|
192
|
+
if (slowMode) {
|
|
193
|
+
await new Promise((resolve) => setTimeout(resolve, 500));
|
|
194
|
+
}
|
|
195
|
+
const delta = chunk.choices[0]?.delta; // Type assertion for OpenRouter-specific reasoning fields
|
|
196
|
+
if (delta?.reasoning && enableReasoningTokens) {
|
|
197
|
+
// Handle reasoning tokens (thinking) - only if enabled
|
|
198
|
+
if (!reasoningStarted) {
|
|
199
|
+
reasoningStarted = true;
|
|
200
|
+
updateAIStatus(shareDBDoc, chatId, 'Thinking...');
|
|
201
|
+
}
|
|
202
|
+
reasoningContent += delta.reasoning;
|
|
203
|
+
updateAIScratchpad(shareDBDoc, chatId, reasoningContent);
|
|
204
|
+
}
|
|
205
|
+
else if (delta?.content) {
|
|
206
|
+
// Handle regular content tokens
|
|
207
|
+
if (reasoningStarted && !contentStarted) {
|
|
208
|
+
// Clear reasoning when content starts
|
|
209
|
+
contentStarted = true;
|
|
210
|
+
updateAIScratchpad(shareDBDoc, chatId, '');
|
|
211
|
+
updateAIStatus(shareDBDoc, chatId, 'Generating response...');
|
|
212
|
+
}
|
|
213
|
+
const chunkContent = delta.content;
|
|
214
|
+
chunks.push(chunkContent);
|
|
215
|
+
if (enableStreamingEditing) {
|
|
216
|
+
await parser.processChunk(chunkContent);
|
|
217
|
+
}
|
|
218
|
+
else {
|
|
219
|
+
fullContent += chunkContent;
|
|
220
|
+
throttledUpdateAIMessageContent(fullContent);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
else if (chunk.usage) {
|
|
224
|
+
// Handle usage information
|
|
225
|
+
DEBUG && console.log('Usage:', chunk.usage);
|
|
226
|
+
}
|
|
227
|
+
if (!generationId && chunk.id) {
|
|
228
|
+
generationId = chunk.id;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
await parser.flushRemaining();
|
|
232
|
+
reportFileEdited();
|
|
233
|
+
// Final cleanup - clear scratchpad and set final status
|
|
234
|
+
updateAIScratchpad(shareDBDoc, chatId, '');
|
|
235
|
+
updateAIStatus(shareDBDoc, chatId, 'Done editing.');
|
|
236
|
+
throttledUpdateAIMessageContent(fullContent);
|
|
237
|
+
// Flush to ensure the final content is written immediately
|
|
238
|
+
throttledUpdateAIMessageContent.flush();
|
|
239
|
+
// Finalize the AI message by clearing temporary fields
|
|
240
|
+
finalizeAIMessage(shareDBDoc, chatId);
|
|
241
|
+
// Clear AI Editor presence when done
|
|
242
|
+
DEBUG &&
|
|
243
|
+
console.log('AI editing done, clearing AI Editor presence');
|
|
244
|
+
if (localPresence) {
|
|
245
|
+
localPresence.submit(null, (error) => {
|
|
246
|
+
DEBUG && console.log('AI Editor presence cleared');
|
|
247
|
+
if (error) {
|
|
248
|
+
console.warn('AI Editor presence cleanup error:', error);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
// If streaming editing is not enabled, we need to
|
|
253
|
+
// apply all the edits at once
|
|
254
|
+
if (!enableStreamingEditing) {
|
|
255
|
+
updateFiles(shareDBDoc, mergeFileChanges(shareDBDoc.data.files, parseMarkdownFiles(fullContent, 'bold').files));
|
|
256
|
+
}
|
|
257
|
+
// Generate a new runId to trigger a run when AI finishes editing
|
|
258
|
+
// This will trigger a re-run without hot reloading
|
|
259
|
+
const newRunId = generateRunId();
|
|
260
|
+
const runIdOp = diff(shareDBDoc.data, {
|
|
261
|
+
...shareDBDoc.data,
|
|
262
|
+
runId: newRunId,
|
|
263
|
+
});
|
|
264
|
+
shareDBDoc.submitOp(runIdOp, (error) => {
|
|
265
|
+
if (error) {
|
|
266
|
+
console.warn('Error setting runId after AI editing:', error);
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
DEBUG &&
|
|
270
|
+
console.log('Set new runId after AI editing:', newRunId);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
// Write chunks file for debugging
|
|
274
|
+
if (DEBUG) {
|
|
275
|
+
const chunksFileJSONpath = `./ai-chunks-${chatId}.json`;
|
|
276
|
+
fs.writeFileSync(chunksFileJSONpath, JSON.stringify(chunks, null, 2));
|
|
277
|
+
console.log(`AI chunks written to ${chunksFileJSONpath}`);
|
|
278
|
+
}
|
|
279
|
+
return {
|
|
280
|
+
content: fullContent,
|
|
281
|
+
generationId: generationId,
|
|
282
|
+
};
|
|
283
|
+
};
|
|
284
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validates incoming request data for AI chat messages
|
|
3
|
+
*/
|
|
4
|
+
export const validateRequest = (req, res) => {
|
|
5
|
+
const { content, chatId } = req.body;
|
|
6
|
+
if (!content || typeof content !== 'string') {
|
|
7
|
+
res.status(400).json({
|
|
8
|
+
error: 'Invalid request: content is required and must be a string',
|
|
9
|
+
});
|
|
10
|
+
return false;
|
|
11
|
+
}
|
|
12
|
+
if (!chatId || typeof chatId !== 'string') {
|
|
13
|
+
res.status(400).json({
|
|
14
|
+
error: 'Invalid request: chatId is required and must be a string',
|
|
15
|
+
});
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import fs from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { randomId } from '../randomId.js';
|
|
4
|
+
import { enableDirectories, debugDirectories, debugIgnore, } from './featureFlags.js';
|
|
5
|
+
import createIgnore from 'ignore';
|
|
6
|
+
import { ignoreFilePattern, baseIgnore } from './config.js';
|
|
7
|
+
import { isDirectory } from './isDirectory.js';
|
|
8
|
+
// Import the image file utility
|
|
9
|
+
const isImageFile = (fileName) => {
|
|
10
|
+
return (fileName.match(/\.(png|jpg|jpeg|gif|bmp|svg|webp)$/i) !== null);
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} fullPath - absolut path of the workspace root
|
|
14
|
+
* @param {string} currentDirectoryPath - path where the ignore file is found, relative to fullPath
|
|
15
|
+
* @param {string} fileName - name of the ignore file
|
|
16
|
+
* @returns {string[]} parsed lines
|
|
17
|
+
*/
|
|
18
|
+
const parseIgnoreFile = (fullPath, currentDirectory, fileName) => {
|
|
19
|
+
const filePath = path.join(fullPath, currentDirectory, fileName);
|
|
20
|
+
const content = fs.readFileSync(filePath, 'utf8');
|
|
21
|
+
const globs = content
|
|
22
|
+
.split(/[\n\r]+/)
|
|
23
|
+
.filter(
|
|
24
|
+
// remove blank line and comments
|
|
25
|
+
(line) => line.length > 0 && !line.startsWith('#'))
|
|
26
|
+
.map((line) => {
|
|
27
|
+
const { bang, slash, glob } = line.match(/^(?<bang>!?)(?<slash>\/?)(?<glob>.*)$/).groups;
|
|
28
|
+
const hasSlash = Boolean(slash) || /\/.*\S/.test(glob);
|
|
29
|
+
const relativeGlob = path.posix.join(currentDirectory.replace(
|
|
30
|
+
// escape characters with special meaning in glob expressions
|
|
31
|
+
/[*?!# \[\]\\]/g, (char) => '\\' + char),
|
|
32
|
+
// a pattern that doesn't include a slash (not counting a trailing one) matches files in any descendant directory od the current one
|
|
33
|
+
hasSlash ? '' : '**', glob);
|
|
34
|
+
// preserve leading `!` and `/` characters
|
|
35
|
+
return bang + slash + relativeGlob;
|
|
36
|
+
});
|
|
37
|
+
if (debugIgnore) {
|
|
38
|
+
console.debug('at', currentDirectory, 'parsing', fileName, 'obtained globs', globs);
|
|
39
|
+
}
|
|
40
|
+
return globs;
|
|
41
|
+
};
|
|
42
|
+
// Lists files from the file system,
|
|
43
|
+
// converts them into the VZCode internal
|
|
44
|
+
// ShareDB-compatible data structure.
|
|
45
|
+
export const computeInitialDocument = ({ fullPath }) => {
|
|
46
|
+
// Isolate files, not directories.
|
|
47
|
+
// Inspired by https://stackoverflow.com/questions/41472161/fs-readdir-ignore-directories
|
|
48
|
+
// Initialize the document using our data structure for representing files.
|
|
49
|
+
const initialDocument = {
|
|
50
|
+
// `files`
|
|
51
|
+
// * Keys are file ids, which are random numbers.
|
|
52
|
+
// * Values are objects with properties:
|
|
53
|
+
// * text - the text content of the file
|
|
54
|
+
// * name - the file name
|
|
55
|
+
files: {},
|
|
56
|
+
// `isInteracting`
|
|
57
|
+
// * Whether the user is currently interacting with the document
|
|
58
|
+
// using an interactive code widget such as number dragger.
|
|
59
|
+
// * When true, the auto-save to the file system is changed to be
|
|
60
|
+
// more frequent (throttled not debounced).
|
|
61
|
+
// isInteracting: false,
|
|
62
|
+
// This is `undefined` initially.
|
|
63
|
+
};
|
|
64
|
+
/**
|
|
65
|
+
* Stack for recursively traversing directories.
|
|
66
|
+
* @type {string[]}
|
|
67
|
+
*/
|
|
68
|
+
let files = [];
|
|
69
|
+
const ignoreFileMatcher = createIgnore().add(ignoreFilePattern);
|
|
70
|
+
const isIgnoreFile = (fileName) => ignoreFileMatcher.ignores(fileName);
|
|
71
|
+
const unsearchedDirectories = [
|
|
72
|
+
{
|
|
73
|
+
currentDirectory: '.',
|
|
74
|
+
ignore: createIgnore().add(baseIgnore),
|
|
75
|
+
},
|
|
76
|
+
];
|
|
77
|
+
while (unsearchedDirectories.length !== 0) {
|
|
78
|
+
const { currentDirectory, ignore: parentIgnore } = unsearchedDirectories.pop();
|
|
79
|
+
const currentDirectoryPath = path.join(fullPath, currentDirectory);
|
|
80
|
+
const dirEntries = fs
|
|
81
|
+
.readdirSync(currentDirectoryPath, {
|
|
82
|
+
withFileTypes: true,
|
|
83
|
+
})
|
|
84
|
+
.filter((dirent) => enableDirectories ? true : dirent.isFile());
|
|
85
|
+
// find .ignore or .gitignore files in the current directory
|
|
86
|
+
const ignoreFiles = dirEntries
|
|
87
|
+
.filter((dirent) => dirent.isFile() && isIgnoreFile(dirent.name))
|
|
88
|
+
.map((file) => file.name);
|
|
89
|
+
let ignore = parentIgnore;
|
|
90
|
+
if (ignoreFiles.length > 0) {
|
|
91
|
+
const globs = ignoreFiles.flatMap((fileName) => parseIgnoreFile(fullPath, currentDirectory, fileName));
|
|
92
|
+
ignore = createIgnore().add(parentIgnore).add(globs);
|
|
93
|
+
}
|
|
94
|
+
const newFiles = dirEntries
|
|
95
|
+
.filter((dirent) => {
|
|
96
|
+
const relativePath = path.posix.join(currentDirectory, dirent.name) +
|
|
97
|
+
(dirent.isDirectory() ? '/' : '');
|
|
98
|
+
const keep = !ignore.ignores(relativePath);
|
|
99
|
+
if (debugIgnore && !keep) {
|
|
100
|
+
console.debug('at', currentDirectory, 'ignoring', relativePath);
|
|
101
|
+
}
|
|
102
|
+
return keep;
|
|
103
|
+
})
|
|
104
|
+
// Add a trailing slash for directories
|
|
105
|
+
.map((dirent) => {
|
|
106
|
+
const relativePath = path.posix.join(currentDirectory, dirent.name);
|
|
107
|
+
if (!dirent.isDirectory()) {
|
|
108
|
+
return relativePath;
|
|
109
|
+
}
|
|
110
|
+
unsearchedDirectories.push({
|
|
111
|
+
currentDirectory: relativePath,
|
|
112
|
+
ignore,
|
|
113
|
+
});
|
|
114
|
+
return relativePath + '/';
|
|
115
|
+
});
|
|
116
|
+
// console.log(currentDirectory);
|
|
117
|
+
files.push(...newFiles);
|
|
118
|
+
}
|
|
119
|
+
files.forEach((file) => {
|
|
120
|
+
const id = randomId();
|
|
121
|
+
let text = null;
|
|
122
|
+
if (!isDirectory(file)) {
|
|
123
|
+
const filePath = path.join(fullPath, file);
|
|
124
|
+
if (isImageFile(file)) {
|
|
125
|
+
// Read image files as binary and convert to base64
|
|
126
|
+
const buffer = fs.readFileSync(filePath);
|
|
127
|
+
text = buffer.toString('base64');
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
// Read non-image files as UTF-8 text
|
|
131
|
+
text = fs.readFileSync(filePath, 'utf-8');
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
initialDocument.files[id] = {
|
|
135
|
+
text,
|
|
136
|
+
name: file,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
if (debugDirectories) {
|
|
140
|
+
console.log('files:');
|
|
141
|
+
console.log(files);
|
|
142
|
+
console.log('initialDocument:');
|
|
143
|
+
console.log(JSON.stringify(initialDocument, null, 2));
|
|
144
|
+
}
|
|
145
|
+
return initialDocument;
|
|
146
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
*patterns describing which files should e comsidered as ignore files when creating the file tree.
|
|
3
|
+
*
|
|
4
|
+
* Format: one pattern per line, in gitignore format as defimed by https://git-scm.com/docs/gitignore#_pattern_format
|
|
5
|
+
*/
|
|
6
|
+
export const ignoreFilePattern = process.env.IGNORE_FILE_PATTERN ??
|
|
7
|
+
`
|
|
8
|
+
.vzignore
|
|
9
|
+
.ignore
|
|
10
|
+
.gitignore
|
|
11
|
+
`;
|
|
12
|
+
/**
|
|
13
|
+
* the base ignore patterns, that should be applied before any ignore file found when creating the file tree.
|
|
14
|
+
*
|
|
15
|
+
* These patterns apply even in the absence of an ignore file.
|
|
16
|
+
*/
|
|
17
|
+
export const baseIgnore = process.env.BASE_IGNORE ??
|
|
18
|
+
`
|
|
19
|
+
.git/
|
|
20
|
+
node_modules/
|
|
21
|
+
`;
|