vzcode 1.37.0 → 1.39.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/src/runCode.ts ADDED
@@ -0,0 +1,58 @@
1
+ import { SubmitOperation } from './types';
2
+ import { VizContent } from '@vizhub/viz-types';
3
+ import { createSubmitOperation } from './submitOperation';
4
+
5
+ /**
6
+ * Creates a runCode function that triggers code execution by flashing `isInteracting` to `true`.
7
+ * This works for both client-side (with submitOperation) and server-side (with ShareDB document).
8
+ */
9
+ export const createRunCodeFunction = (
10
+ submitOperationOrDoc:
11
+ | SubmitOperation
12
+ | { data: any; submitOp: (ops: any) => void },
13
+ ) => {
14
+ return () => {
15
+ let submitOperation: SubmitOperation;
16
+
17
+ // Check if this is a client-side submitOperation or server-side ShareDB document
18
+ if (typeof submitOperationOrDoc === 'function') {
19
+ // Client-side: already a submitOperation function
20
+ submitOperation =
21
+ submitOperationOrDoc as SubmitOperation;
22
+ } else {
23
+ // Server-side: create submitOperation from ShareDB document
24
+ submitOperation = createSubmitOperation(
25
+ submitOperationOrDoc,
26
+ );
27
+ }
28
+
29
+ // Use the unified submitOperation approach for both client and server
30
+ submitOperation((content: VizContent) => ({
31
+ ...content,
32
+ isInteracting: true,
33
+ }));
34
+
35
+ setTimeout(() => {
36
+ // This somewhat cryptic logic
37
+ // deletes the `isInteracting` property
38
+ // from the document.
39
+ submitOperation(
40
+ ({ isInteracting, ...newDocument }) => newDocument,
41
+ );
42
+ }, 0);
43
+ };
44
+ };
45
+
46
+ /**
47
+ * Creates a runCodeRef object compatible with React refs.
48
+ * This is useful for maintaining compatibility with existing code that expects a ref.
49
+ */
50
+ export const createRunCodeRef = (
51
+ submitOperationOrDoc:
52
+ | SubmitOperation
53
+ | { data: any; submitOp: (ops: any) => void },
54
+ ) => {
55
+ return {
56
+ current: createRunCodeFunction(submitOperationOrDoc),
57
+ };
58
+ };
@@ -1,42 +1,44 @@
1
- import { performAiEdit } from 'editcodewithai';
2
1
  import {
3
- clearAIScratchpadAndStatus,
4
- updateFiles,
5
- setIsInteracting,
6
- } from './chatOperations.js';
2
+ assembleFullPrompt,
3
+ prepareFilesForPrompt,
4
+ } from 'editcodewithai';
5
+ import { formatMarkdownFiles } from 'llm-code-format';
7
6
 
8
7
  /**
9
- * Performs AI editing operations on the files
8
+ * Performs AI editing operations using streaming with incremental OT operations
10
9
  */
11
- export const performAIEditing = async (
10
+ export const performAIEditing = async ({
11
+ prompt,
12
12
  shareDBDoc,
13
- chatId,
14
- content,
15
- files,
16
13
  llmFunction,
17
- ) => {
18
- const editResult = await performAiEdit({
19
- prompt: content,
20
- files,
21
- llmFunction,
22
- apiKey: process.env.VIZHUB_EDIT_WITH_AI_API_KEY,
23
- });
24
-
25
- // Clear the scratchpad and update status
26
- clearAIScratchpadAndStatus(
27
- shareDBDoc,
28
- chatId,
29
- 'Done editing with AI.',
14
+ runCode,
15
+ }) => {
16
+ const preparedFiles = prepareFilesForPrompt(
17
+ shareDBDoc.data.files,
30
18
  );
19
+ const filesContext = formatMarkdownFiles(preparedFiles);
31
20
 
32
- // Apply AI edits to files
33
- updateFiles(shareDBDoc, editResult.changedFiles);
21
+ // 2. Assemble the final prompt
22
+ const fullPrompt = assembleFullPrompt({
23
+ filesContext,
24
+ prompt,
25
+ editFormat: 'whole',
26
+ });
27
+
28
+ // Call the LLM function which will handle streaming and incremental file updates
29
+ const result = await llmFunction(fullPrompt);
34
30
 
35
- // Wait for propagation
36
- await new Promise((resolve) => setTimeout(resolve, 100));
31
+ // Clear the scratchpad and update status
32
+ // clearAIScratchpadAndStatus(
33
+ // shareDBDoc,
34
+ // chatId,
35
+ // 'Done editing with AI.',
36
+ // );
37
37
 
38
- // Unset isInteracting
39
- setIsInteracting(shareDBDoc, false);
38
+ runCode();
40
39
 
41
- return editResult;
40
+ return {
41
+ content: result.content,
42
+ generationId: result.generationId,
43
+ };
42
44
  };
@@ -1,5 +1,6 @@
1
1
  import { dateToTimestamp } from '@vizhub/viz-utils';
2
2
  import { diff } from '../../client/diff.js';
3
+ import { randomId } from '../../randomId.js';
3
4
 
4
5
  /**
5
6
  * Ensures the chats object exists in the ShareDB document
@@ -108,7 +109,13 @@ export const updateAIScratchpad = (
108
109
  },
109
110
  },
110
111
  });
111
- shareDBDoc.submitOp(op);
112
+
113
+ // op is `null` if there are no changes
114
+ // This can happen if the content is the same as before
115
+ // In that case, we don't need to submit an operation
116
+ if (op) {
117
+ shareDBDoc.submitOp(op);
118
+ }
112
119
  };
113
120
 
114
121
  /**
@@ -174,8 +181,7 @@ export const addAIMessage = (
174
181
  export const updateFiles = (shareDBDoc, files) => {
175
182
  const filesOp = diff(shareDBDoc.data, {
176
183
  ...shareDBDoc.data,
177
- files: files,
178
- isInteracting: true,
184
+ files,
179
185
  });
180
186
  shareDBDoc.submitOp(filesOp);
181
187
  };
@@ -187,9 +193,128 @@ export const setIsInteracting = (
187
193
  shareDBDoc,
188
194
  isInteracting,
189
195
  ) => {
190
- const interactingOp = diff(
191
- { isInteracting: !isInteracting },
192
- { isInteracting: isInteracting },
193
- );
196
+ // Only generate an operation if the value is actually changing
197
+ const currentIsInteracting =
198
+ shareDBDoc.data.isInteracting;
199
+
200
+ if (currentIsInteracting === isInteracting) {
201
+ return;
202
+ }
203
+
204
+ const newState = {
205
+ ...shareDBDoc.data,
206
+ isInteracting: isInteracting,
207
+ };
208
+
209
+ const interactingOp = diff(shareDBDoc.data, newState);
194
210
  shareDBDoc.submitOp(interactingOp);
195
211
  };
212
+
213
+ /**
214
+ * Finds a file ID by searching for a matching file name
215
+ */
216
+ export const resolveFileId = (fileName, shareDBDoc) => {
217
+ const files = shareDBDoc.data.files;
218
+
219
+ // Search through all files to find matching name
220
+ for (const [fileId, file] of Object.entries(files)) {
221
+ if (file.name === fileName) {
222
+ return fileId;
223
+ }
224
+ }
225
+
226
+ // If file doesn't exist, return null
227
+ return null;
228
+ };
229
+
230
+ /**
231
+ * Creates a new file with a random ID
232
+ */
233
+ export const createNewFile = (shareDBDoc, fileName) => {
234
+ // Generate a new random file ID
235
+ const newFileId = randomId();
236
+
237
+ const newState = {
238
+ ...shareDBDoc.data,
239
+ files: {
240
+ ...shareDBDoc.data.files,
241
+ [newFileId]: {
242
+ name: fileName,
243
+ text: '',
244
+ },
245
+ },
246
+ };
247
+
248
+ const op = diff(shareDBDoc.data, newState);
249
+ shareDBDoc.submitOp(op);
250
+ return newFileId;
251
+ };
252
+
253
+ /**
254
+ * Ensures a file exists, creating it if necessary
255
+ */
256
+ export const ensureFileExists = (shareDBDoc, fileName) => {
257
+ let fileId = resolveFileId(fileName, shareDBDoc);
258
+
259
+ if (!fileId) {
260
+ // File doesn't exist, create it
261
+ fileId = createNewFile(shareDBDoc, fileName);
262
+ }
263
+
264
+ return fileId;
265
+ };
266
+
267
+ /**
268
+ * Clears the content of a file
269
+ */
270
+ export const clearFileContent = (shareDBDoc, fileId) => {
271
+ const currentFile = shareDBDoc.data.files[fileId];
272
+
273
+ if (currentFile && currentFile.text) {
274
+ // Clear the file content
275
+ const newState = {
276
+ ...shareDBDoc.data,
277
+ files: {
278
+ ...shareDBDoc.data.files,
279
+ [fileId]: {
280
+ ...currentFile,
281
+ text: '',
282
+ },
283
+ },
284
+ };
285
+
286
+ const op = diff(shareDBDoc.data, newState);
287
+ shareDBDoc.submitOp(op);
288
+ }
289
+ };
290
+
291
+ /**
292
+ * Appends a line to a file using OT operations
293
+ */
294
+ export const appendLineToFile = (
295
+ shareDBDoc,
296
+ fileId,
297
+ line,
298
+ ) => {
299
+ const currentFile = shareDBDoc.data.files[fileId];
300
+ const currentContent = currentFile?.text || '';
301
+ const newContent = currentContent + line + '\n';
302
+
303
+ // Create the new file state
304
+ const newFileState = {
305
+ ...currentFile,
306
+ text: newContent,
307
+ };
308
+
309
+ const newDocState = {
310
+ ...shareDBDoc.data,
311
+ files: {
312
+ ...shareDBDoc.data.files,
313
+ [fileId]: newFileState,
314
+ },
315
+ };
316
+
317
+ // Generate OT operation using the diff utility
318
+ const op = diff(shareDBDoc.data, newDocState);
319
+ shareDBDoc.submitOp(op);
320
+ };
@@ -3,20 +3,20 @@ import {
3
3
  ensureChatsExist,
4
4
  ensureChatExists,
5
5
  addUserMessage,
6
- addAIMessage,
7
6
  } from './chatOperations.js';
8
7
  import { createLLMFunction } from './llmStreaming.js';
9
8
  import { performAIEditing } from './aiEditing.js';
10
9
  import { handleError } from './errorHandling.js';
10
+ import { createRunCodeFunction } from '../../runCode.js';
11
11
 
12
- const debug = false;
12
+ const DEBUG = false;
13
13
 
14
14
  export const handleAIChatMessage =
15
- (shareDBDoc, options = {}) =>
15
+ ({ shareDBDoc, localPresence, onCreditDeduction }) =>
16
16
  async (req, res) => {
17
17
  const { content, chatId } = req.body;
18
18
 
19
- if (debug) {
19
+ if (DEBUG) {
20
20
  console.log(
21
21
  '[handleAIChatMessage] content:',
22
22
  content,
@@ -31,9 +31,6 @@ export const handleAIChatMessage =
31
31
  }
32
32
 
33
33
  try {
34
- // Get existing files from ShareDB doc
35
- const files = shareDBDoc.data.files;
36
-
37
34
  // Ensure chats structure exists
38
35
  ensureChatsExist(shareDBDoc);
39
36
  ensureChatExists(shareDBDoc, chatId);
@@ -42,34 +39,31 @@ export const handleAIChatMessage =
42
39
  addUserMessage(shareDBDoc, chatId, content);
43
40
 
44
41
  // Create LLM function for streaming
45
- const llmFunction = createLLMFunction(
42
+ const llmFunction = createLLMFunction({
46
43
  shareDBDoc,
44
+ localPresence,
47
45
  chatId,
48
- );
46
+ });
47
+
48
+ // Create server-side runCode function using shared module
49
+ const runCode = createRunCodeFunction(shareDBDoc);
49
50
 
50
51
  // Perform AI editing
51
- const editResult = await performAIEditing(
52
+ const editResult = await performAIEditing({
53
+ prompt: content,
52
54
  shareDBDoc,
53
55
  chatId,
54
- content,
55
- files,
56
56
  llmFunction,
57
- );
58
-
59
- // Add AI response message
60
- const aiResponse = addAIMessage(
61
- shareDBDoc,
62
- chatId,
63
- editResult.content,
64
- );
57
+ runCode,
58
+ });
65
59
 
66
60
  // Handle credit deduction if callback is provided
67
61
  if (
68
- options.onCreditDeduction &&
62
+ onCreditDeduction &&
69
63
  editResult.upstreamCostCents
70
64
  ) {
71
65
  try {
72
- await options.onCreditDeduction({
66
+ await onCreditDeduction({
73
67
  upstreamCostCents: editResult.upstreamCostCents,
74
68
  provider: editResult.provider,
75
69
  inputTokens: editResult.inputTokens,
@@ -84,7 +78,7 @@ export const handleAIChatMessage =
84
78
  }
85
79
  }
86
80
 
87
- res.status(200).json(aiResponse);
81
+ res.status(200).json('success');
88
82
  } catch (error) {
89
83
  handleError(shareDBDoc, chatId, error, res);
90
84
  }
@@ -1,17 +1,40 @@
1
1
  import { StreamingMarkdownParser } from 'llm-code-format';
2
2
  import { ChatOpenAI } from '@langchain/openai';
3
+ import fs from 'fs';
3
4
  import {
4
5
  updateAIStatus,
5
6
  updateAIScratchpad,
7
+ ensureFileExists,
8
+ clearFileContent,
9
+ appendLineToFile,
6
10
  } from './chatOperations.js';
7
11
 
8
- const debug = false;
12
+ const DEBUG = false;
9
13
 
10
14
  /**
11
15
  * Creates and configures the LLM function for streaming
12
16
  */
13
- export const createLLMFunction = (shareDBDoc, chatId) => {
17
+ export const createLLMFunction = ({
18
+ shareDBDoc,
19
+ localPresence,
20
+ chatId,
21
+ }) => {
14
22
  return async (fullPrompt) => {
23
+ // Submit initial presence for VizBot
24
+ const vizBotPresence = {
25
+ username: 'VizBot',
26
+ start: ['files'], // Indicate VizBot is working on files
27
+ end: ['files'],
28
+ };
29
+
30
+ localPresence.submit(vizBotPresence, (error) => {
31
+ if (error) {
32
+ console.warn(
33
+ 'VizBot presence submission error:',
34
+ error,
35
+ );
36
+ }
37
+ });
15
38
  const chatModel = new ChatOpenAI({
16
39
  modelName:
17
40
  process.env.VIZHUB_EDIT_WITH_AI_MODEL_NAME ||
@@ -29,22 +52,58 @@ export const createLLMFunction = (shareDBDoc, chatId) => {
29
52
 
30
53
  let fullContent = '';
31
54
  let generationId = '';
55
+ let currentEditingFileId = null;
56
+ let currentEditingFileName = null;
32
57
 
33
- // Track expected state to avoid race conditions
34
- let expectedAiScratchpad =
35
- shareDBDoc.data.chats[chatId]?.aiScratchpad || '';
36
-
37
- // Throttle updates to avoid "op too long" errors
38
- let lastUpdateTime = 0;
39
- const UPDATE_THROTTLE_MS = 100;
58
+ // Function to report file edited
59
+ // This is called when the AI has finished editing a file
60
+ // and we want to update the scratchpad with the file name.
61
+ const reportFileEdited = () => {
62
+ if (currentEditingFileName) {
63
+ fullContent += ` * Edited ${currentEditingFileName}\n`;
64
+ updateAIScratchpad(shareDBDoc, chatId, fullContent);
65
+ currentEditingFileName = null;
66
+ }
67
+ };
40
68
 
41
69
  // Define callbacks for streaming parser
42
70
  const callbacks = {
43
71
  onFileNameChange: (fileName, format) => {
44
- debug &&
72
+ DEBUG &&
45
73
  console.log(
46
74
  `File changed to: ${fileName} (${format})`,
47
75
  );
76
+
77
+ // Find existing file or create new one
78
+ currentEditingFileId = ensureFileExists(
79
+ shareDBDoc,
80
+ fileName,
81
+ );
82
+
83
+ reportFileEdited();
84
+ currentEditingFileName = fileName;
85
+
86
+ // Clear the file content to start fresh
87
+ // (AI will regenerate the entire file content)
88
+ clearFileContent(shareDBDoc, currentEditingFileId);
89
+
90
+ // Update VizBot presence to show it's editing this specific file
91
+ const filePresence = {
92
+ username: 'VizBot',
93
+ start: ['files', currentEditingFileId, 'text', 0],
94
+ end: ['files', currentEditingFileId, 'text', 0],
95
+ };
96
+
97
+ localPresence.submit(filePresence, (error) => {
98
+ if (error) {
99
+ console.warn(
100
+ 'VizBot file presence submission error:',
101
+ error,
102
+ );
103
+ }
104
+ });
105
+
106
+ // Update AI status
48
107
  updateAIStatus(
49
108
  shareDBDoc,
50
109
  chatId,
@@ -52,37 +111,70 @@ export const createLLMFunction = (shareDBDoc, chatId) => {
52
111
  );
53
112
  },
54
113
  onCodeLine: (line) => {
55
- debug && console.log(`Code line: ${line}`);
114
+ DEBUG && console.log(`Code line: ${line}`);
115
+
116
+ if (currentEditingFileId) {
117
+ // Apply OT operation for this line immediately
118
+ appendLineToFile(
119
+ shareDBDoc,
120
+ currentEditingFileId,
121
+ line,
122
+ );
123
+
124
+ // Update VizBot presence to show cursor at the end of the file
125
+ const currentFile =
126
+ shareDBDoc.data.files[currentEditingFileId];
127
+ if (currentFile && currentFile.text) {
128
+ const textLength = currentFile.text.length;
129
+ const filePresence = {
130
+ username: 'VizBot',
131
+ start: [
132
+ 'files',
133
+ currentEditingFileId,
134
+ 'text',
135
+ textLength,
136
+ ],
137
+ end: [
138
+ 'files',
139
+ currentEditingFileId,
140
+ 'text',
141
+ textLength,
142
+ ],
143
+ };
144
+
145
+ localPresence.submit(filePresence, (error) => {
146
+ if (error) {
147
+ console.warn(
148
+ 'VizBot line presence submission error:',
149
+ error,
150
+ );
151
+ }
152
+ });
153
+ }
154
+ }
56
155
  },
57
156
  onNonCodeLine: (line) => {
58
- debug && console.log(`Comment/text: ${line}`);
157
+ // We want to report a file edited only if the line is not empty,
158
+ // because sometimes the LLMs leave a newline between the file name
159
+ // declaration and th
160
+ if (line.trim() !== '') {
161
+ reportFileEdited();
162
+ }
163
+ fullContent += line + '\n';
164
+ updateAIScratchpad(shareDBDoc, chatId, fullContent);
59
165
  },
60
166
  };
61
167
 
62
168
  const parser = new StreamingMarkdownParser(callbacks);
63
169
 
170
+ const chunks = [];
171
+
64
172
  // Stream the response
65
173
  const stream = await chatModel.stream(fullPrompt);
66
174
  for await (const chunk of stream) {
67
175
  if (chunk.content) {
68
176
  const chunkContent = String(chunk.content);
69
- fullContent += chunkContent;
70
-
71
- // Throttle updates
72
- const now = Date.now();
73
- const shouldUpdate =
74
- now - lastUpdateTime >= UPDATE_THROTTLE_MS;
75
-
76
- if (shouldUpdate) {
77
- updateAIScratchpad(
78
- shareDBDoc,
79
- chatId,
80
- fullContent,
81
- );
82
- expectedAiScratchpad = fullContent;
83
- lastUpdateTime = now;
84
- }
85
-
177
+ chunks.push(chunkContent);
86
178
  parser.processChunk(chunkContent);
87
179
  }
88
180
 
@@ -91,10 +183,30 @@ export const createLLMFunction = (shareDBDoc, chatId) => {
91
183
  }
92
184
  }
93
185
  parser.flushRemaining();
186
+ reportFileEdited();
187
+ updateAIStatus(shareDBDoc, chatId, 'Done editing.');
188
+ updateAIScratchpad(shareDBDoc, chatId, fullContent);
189
+
190
+ // Clear VizBot presence when done
191
+ localPresence.destroy((error) => {
192
+ if (error) {
193
+ console.warn(
194
+ 'VizBot presence cleanup error:',
195
+ error,
196
+ );
197
+ }
198
+ });
94
199
 
95
- // Submit final update if there's pending content
96
- if (expectedAiScratchpad !== fullContent) {
97
- updateAIScratchpad(shareDBDoc, chatId, fullContent);
200
+ // Write chunks file for debugging
201
+ if (DEBUG) {
202
+ const chunksFileJSONpath = `./ai-chunks-${chatId}.json`;
203
+ fs.writeFileSync(
204
+ chunksFileJSONpath,
205
+ JSON.stringify(chunks, null, 2),
206
+ );
207
+ console.log(
208
+ `AI chunks written to ${chunksFileJSONpath}`,
209
+ );
98
210
  }
99
211
 
100
212
  return {
@@ -51,13 +51,6 @@ ShareDB.types.register(json1Presence.type);
51
51
 
52
52
  const app = express();
53
53
 
54
- // TODO make this configurable
55
- // See https://github.com/vizhub-core/vzcode/issues/95
56
- app.post('/saveTime', (req, res) => {
57
- //autoSaveDebounceTimeMS = req.body.autoSaveDebounceTimeMS;
58
- console.log('autoSaveDebounceTimeMS', req.body);
59
- });
60
-
61
54
  // Use ShareDB over WebSocket
62
55
  const shareDBBackend = new ShareDB({
63
56
  // Enable presence
@@ -82,7 +75,7 @@ wss.on('connection', (ws) => {
82
75
  });
83
76
 
84
77
  // Handle disconnections
85
- ws.on('close', (code) => {
78
+ ws.on('close', () => {
86
79
  clientStream.end();
87
80
  });
88
81
  });
@@ -97,6 +90,23 @@ app.use(express.static(dir));
97
90
  // which is a representation of files on disk.
98
91
  const shareDBConnection = shareDBBackend.connect();
99
92
  const shareDBDoc = shareDBConnection.get('documents', '1');
93
+
94
+ // Set up presence for VizBot following the same pattern as useShareDB.ts
95
+ const docPresence = shareDBConnection.getDocPresence(
96
+ 'documents',
97
+ '1',
98
+ );
99
+
100
+ // Create local presence for VizBot with a unique ID
101
+ const generateVizBotId = () => {
102
+ const timestamp = Date.now().toString(36);
103
+ return `vizbot-${timestamp}`;
104
+ };
105
+
106
+ const localPresence = docPresence.create(
107
+ generateVizBotId(),
108
+ );
109
+
100
110
  shareDBDoc.create(initialDocument, json1Presence.type.uri);
101
111
 
102
112
  // Handle AI Assist requests.
@@ -117,7 +127,7 @@ app.post(
117
127
  app.post(
118
128
  '/ai-chat-message',
119
129
  bodyParser.json(),
120
- handleAIChatMessage(shareDBDoc),
130
+ handleAIChatMessage({ shareDBDoc, localPresence }),
121
131
  );
122
132
 
123
133
  // Livekit Token Generator
@@ -0,0 +1,16 @@
1
+ import { diff } from './client/diff.js';
2
+ // TODO migrate the server-side code to use TypeScript,
3
+ // and delete this file once the migration is complete (use src/submitOperation.ts instead).
4
+ /**
5
+ * Creates a submitOperation function that can be used to submit diff-based operations to ShareDB.
6
+ * This is the core logic extracted from useSubmitOperation for reuse across client and server.
7
+ */
8
+ export const createSubmitOperation = (shareDBDoc) => {
9
+ return (next) => {
10
+ const data = shareDBDoc.data;
11
+ const op = diff(data, next(data));
12
+ if (op && shareDBDoc) {
13
+ shareDBDoc.submitOp(op);
14
+ }
15
+ };
16
+ };