vzcode 2.28.0 → 2.29.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.html CHANGED
@@ -20,8 +20,8 @@
20
20
  href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap"
21
21
  rel="stylesheet"
22
22
  />
23
- <script type="module" crossorigin src="/assets/index-CTdYkAQU.js"></script>
24
- <link rel="stylesheet" crossorigin href="/assets/index-Dyt9tpmu.css">
23
+ <script type="module" crossorigin src="/assets/index-JcHUw7hN.js"></script>
24
+ <link rel="stylesheet" crossorigin href="/assets/index-CCmGeE_1.css">
25
25
  </head>
26
26
  <body>
27
27
  <div id="root"></div>
@@ -58,6 +58,44 @@ export const addUserMessage = (shareDBDoc, chatId, content) => {
58
58
  shareDBDoc.submitOp(userMessageOp);
59
59
  return userMessage;
60
60
  };
61
+ /**
62
+ * Persists AI metadata fields on a chat (Phase 3).
63
+ *
64
+ * `baseCommitId` records the commit the current AI attempt is applied on
65
+ * top of. `escalationLevel` records how many times the user has pressed
66
+ * "Try Harder". Persisting these on the chat makes retries idempotent and
67
+ * keeps the level consistent across reloads and clients.
68
+ *
69
+ * Only fields that are explicitly provided are written.
70
+ */
71
+ export const setChatAIMetadata = (shareDBDoc, chatId, metadata) => {
72
+ const chat = shareDBDoc.data.chats[chatId];
73
+ if (!chat) {
74
+ return;
75
+ }
76
+ const updates = {};
77
+ if (metadata.baseCommitId !== undefined) {
78
+ updates.baseCommitId = metadata.baseCommitId;
79
+ }
80
+ if (metadata.escalationLevel !== undefined) {
81
+ updates.escalationLevel = metadata.escalationLevel;
82
+ }
83
+ if (Object.keys(updates).length === 0) {
84
+ return;
85
+ }
86
+ const op = diff(shareDBDoc.data, {
87
+ ...shareDBDoc.data,
88
+ chats: {
89
+ ...shareDBDoc.data.chats,
90
+ [chatId]: {
91
+ ...chat,
92
+ ...updates,
93
+ updatedAt: dateToTimestamp(new Date()),
94
+ },
95
+ },
96
+ });
97
+ shareDBDoc.submitOp(op);
98
+ };
61
99
  const DEBUG = false;
62
100
  /**
63
101
  * Updates AI status in the chat
@@ -162,7 +162,6 @@ enableReasoningTokens = false, model, aiRequestOptions, }) => {
162
162
  model: modelName,
163
163
  messages: [{ role: 'user', content: fullPrompt }],
164
164
  stream: true,
165
- ...aiRequestOptions,
166
165
  };
167
166
  // Only include reasoning configuration if reasoning tokens are enabled
168
167
  // if (enableReasoningTokens) {
@@ -1,5 +1,5 @@
1
1
  import { validateRequest } from '../../llm-streaming-server/validation.js';
2
- import { ensureChatsExist, ensureChatExists, addUserMessage, setAIStatus, } from '../../llm-streaming-server/chatOperations.js';
2
+ import { ensureChatsExist, ensureChatExists, addUserMessage, setAIStatus, setChatAIMetadata, } from '../../llm-streaming-server/chatOperations.js';
3
3
  import { createLLMFunction } from '../../llm-streaming-server/llmStreaming.js';
4
4
  import { performAIEditing } from '../../llm-streaming-server/aiEditing.js';
5
5
  import { handleError, handleBackgroundError, } from '../../llm-streaming-server/errorHandling.js';
@@ -7,7 +7,7 @@ import { createRunCodeFunction } from '../../runCode.js';
7
7
  import { createSubmitOperation } from '../../submitOperation.js';
8
8
  import { getGenerationMetadata } from 'editcodewithai';
9
9
  const DEBUG = false;
10
- export const handleAIChatMessage = ({ shareDBDoc, onCreditDeduction, model, aiRequestOptions, enableReasoningTokens, }) => async (req, res) => {
10
+ export const handleAIChatMessage = ({ shareDBDoc, onCreditDeduction, onGenerationFinished, model, aiRequestOptions, enableReasoningTokens, baseCommitId, escalationLevel, }) => async (req, res) => {
11
11
  const { content, chatId } = req.body;
12
12
  if (DEBUG) {
13
13
  console.log('[handleAIChatMessage] content:', content, 'chatId:', chatId, 'shareDBDoc:', shareDBDoc);
@@ -22,6 +22,12 @@ export const handleAIChatMessage = ({ shareDBDoc, onCreditDeduction, model, aiRe
22
22
  ensureChatExists(shareDBDoc, chatId);
23
23
  // Add user message to chat
24
24
  addUserMessage(shareDBDoc, chatId, content);
25
+ // Persist explicit escalation metadata on the chat, so that
26
+ // retries are idempotent and the level survives reloads.
27
+ setChatAIMetadata(shareDBDoc, chatId, {
28
+ baseCommitId,
29
+ escalationLevel,
30
+ });
25
31
  // Return success immediately - AI generation continues in background
26
32
  res.status(200).json('success');
27
33
  // Continue AI processing in background (don't await)
@@ -33,6 +39,7 @@ export const handleAIChatMessage = ({ shareDBDoc, onCreditDeduction, model, aiRe
33
39
  aiRequestOptions,
34
40
  enableReasoningTokens,
35
41
  onCreditDeduction,
42
+ onGenerationFinished,
36
43
  }).catch((error) => {
37
44
  console.error('Background AI processing error:', error);
38
45
  // Handle error without HTTP response
@@ -46,7 +53,7 @@ export const handleAIChatMessage = ({ shareDBDoc, onCreditDeduction, model, aiRe
46
53
  /**
47
54
  * Processes the AI request asynchronously in the background
48
55
  */
49
- const processAIRequestAsync = async ({ shareDBDoc, chatId, content, model, aiRequestOptions, enableReasoningTokens, onCreditDeduction, }) => {
56
+ const processAIRequestAsync = async ({ shareDBDoc, chatId, content, model, aiRequestOptions, enableReasoningTokens, onCreditDeduction, onGenerationFinished, }) => {
50
57
  try {
51
58
  // Create LLM function for streaming
52
59
  const llmFunction = createLLMFunction({
@@ -66,18 +73,34 @@ const processAIRequestAsync = async ({ shareDBDoc, chatId, content, model, aiReq
66
73
  llmFunction,
67
74
  runCode,
68
75
  });
69
- // Handle credit deduction if callback is provided
76
+ // Billing is best-effort. It must NEVER prevent the edit from
77
+ // being committed. Any metadata failure is logged and swallowed.
78
+ let metrics = null;
70
79
  if (onCreditDeduction && editResult.generationId) {
71
80
  try {
72
- await onCreditDeduction(await getGenerationMetadata({
81
+ metrics = await getGenerationMetadata({
73
82
  apiKey: aiRequestOptions?.apiKey ||
74
83
  process.env.VZCODE_EDIT_WITH_AI_API_KEY,
75
84
  generationId: editResult.generationId,
76
- }));
85
+ });
86
+ await onCreditDeduction(metrics);
77
87
  }
78
88
  catch (creditError) {
79
- console.error('Credit deduction error:', creditError);
80
- // Don't fail the request if credit deduction fails
89
+ console.error('Credit deduction error (edit will still be finalized):', creditError);
90
+ }
91
+ }
92
+ // Always notify settlement on success, regardless of billing
93
+ // outcome, so the caller can commit the edit and release locks.
94
+ if (onGenerationFinished) {
95
+ try {
96
+ await onGenerationFinished({
97
+ success: true,
98
+ editResult,
99
+ metrics,
100
+ });
101
+ }
102
+ catch (settleError) {
103
+ console.error('onGenerationFinished (success) error:', settleError);
81
104
  }
82
105
  }
83
106
  // Clear the AI status to indicate completion
@@ -86,6 +109,19 @@ const processAIRequestAsync = async ({ shareDBDoc, chatId, content, model, aiReq
86
109
  catch (error) {
87
110
  // Set error status and add error message to chat
88
111
  setAIStatus(shareDBDoc, chatId, 'error');
112
+ // Always notify settlement on failure so the caller can roll back
113
+ // the pre-restore snapshot and release locks.
114
+ if (onGenerationFinished) {
115
+ try {
116
+ await onGenerationFinished({
117
+ success: false,
118
+ error,
119
+ });
120
+ }
121
+ catch (settleError) {
122
+ console.error('onGenerationFinished (failure) error:', settleError);
123
+ }
124
+ }
89
125
  handleBackgroundError(shareDBDoc, chatId, error);
90
126
  }
91
127
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vzcode",
3
- "version": "2.28.0",
3
+ "version": "2.29.0",
4
4
  "description": "Multiplayer code editor system",
5
5
  "main": "src/index.ts",
6
6
  "type": "module",
@@ -117,11 +117,11 @@
117
117
  "@codemirror/lang-json": "^6.0.2",
118
118
  "@codemirror/lang-markdown": "^6.5.0",
119
119
  "@codemirror/lint": "^6.9.2",
120
- "@codemirror/state": "^6.5.2",
120
+ "@codemirror/state": "^6.5.3",
121
121
  "@codemirror/theme-one-dark": "^6.1.3",
122
- "@codemirror/view": "^6.38.8",
123
- "@langchain/core": "^1.1.4",
124
- "@langchain/openai": "^1.1.3",
122
+ "@codemirror/view": "^6.39.8",
123
+ "@langchain/core": "^1.1.8",
124
+ "@langchain/openai": "^1.2.0",
125
125
  "@lezer/highlight": "^1.2.3",
126
126
  "@livekit/components-react": "^2.9.17",
127
127
  "@replit/codemirror-indentation-markers": "^6.5.3",
@@ -130,15 +130,15 @@
130
130
  "@replit/codemirror-vscode-keymap": "^6.0.2",
131
131
  "@teamwork/websocket-json-stream": "^2.0.0",
132
132
  "@typescript/vfs": "^1.6.2",
133
- "@uiw/codemirror-theme-abcdef": "^4.25.3",
134
- "@uiw/codemirror-theme-dracula": "^4.25.3",
135
- "@uiw/codemirror-theme-eclipse": "^4.25.3",
136
- "@uiw/codemirror-theme-github": "^4.25.3",
137
- "@uiw/codemirror-theme-material": "^4.25.3",
138
- "@uiw/codemirror-theme-nord": "^4.25.3",
139
- "@uiw/codemirror-theme-okaidia": "^4.25.3",
140
- "@uiw/codemirror-theme-xcode": "^4.25.3",
141
- "@uiw/codemirror-themes": "^4.25.3",
133
+ "@uiw/codemirror-theme-abcdef": "^4.25.4",
134
+ "@uiw/codemirror-theme-dracula": "^4.25.4",
135
+ "@uiw/codemirror-theme-eclipse": "^4.25.4",
136
+ "@uiw/codemirror-theme-github": "^4.25.4",
137
+ "@uiw/codemirror-theme-material": "^4.25.4",
138
+ "@uiw/codemirror-theme-nord": "^4.25.4",
139
+ "@uiw/codemirror-theme-okaidia": "^4.25.4",
140
+ "@uiw/codemirror-theme-xcode": "^4.25.4",
141
+ "@uiw/codemirror-themes": "^4.25.4",
142
142
  "@valtown/codemirror-ts": "^2.3.1",
143
143
  "@vizhub/runtime": "^4.5.0",
144
144
  "@vizhub/viz-types": "^0.5.0",
@@ -154,25 +154,25 @@
154
154
  "d3-color": "^3.1.0",
155
155
  "diff": "^8.0.2",
156
156
  "@dmsnell/diff-match-patch": "^1.1.0",
157
- "diff2html": "^3.4.52",
157
+ "diff2html": "3.4.52",
158
158
  "dotenv": "^17.2.3",
159
159
  "editcodewithai": "^2.4.0",
160
- "eslint-linter-browserify": "^9.39.1",
160
+ "eslint-linter-browserify": "^9.39.2",
161
161
  "express": "^5.2.1",
162
162
  "ignore": "^7.0.5",
163
163
  "json0-ot-diff": "^1.1.2",
164
164
  "jszip": "^3.10.1",
165
- "livekit-server-sdk": "^2.14.2",
165
+ "livekit-server-sdk": "^2.15.0",
166
166
  "llm-code-format": "^3.1.0",
167
- "lucide-react": "^0.556.0",
168
- "npm": "^11.6.4",
167
+ "lucide-react": "^0.562.0",
168
+ "npm": "^11.7.0",
169
169
  "open": "^11.0.0",
170
- "prettier-plugin-svelte": "^3.4.0",
170
+ "prettier-plugin-svelte": "^3.4.1",
171
171
  "react": "^18",
172
172
  "react-bootstrap": "^2.10.10",
173
173
  "react-dom": "^18",
174
174
  "react-markdown": "^10.1.0",
175
- "react-router-dom": "^7.10.1",
175
+ "react-router-dom": "^7.11.0",
176
176
  "remark-gfm": "^4.0.1",
177
177
  "sharedb": "^5.2.2",
178
178
  "sharedb-client-browser": "^5.2.2",
@@ -181,32 +181,32 @@
181
181
  "ws": "^8.18.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@eslint/js": "^9.39.1",
185
- "@tauri-apps/cli": "^2.9.5",
184
+ "@eslint/js": "^9.39.2",
185
+ "@tauri-apps/cli": "^2.9.6",
186
186
  "@types/d3-color": "^3.1.3",
187
187
  "@types/react": "^18",
188
188
  "@types/react-dom": "^18",
189
- "@typescript-eslint/eslint-plugin": "^8.48.1",
190
- "@typescript-eslint/parser": "^8.48.1",
191
- "@vitejs/plugin-react": "^5.1.1",
189
+ "@typescript-eslint/eslint-plugin": "^8.51.0",
190
+ "@typescript-eslint/parser": "^8.51.0",
191
+ "@vitejs/plugin-react": "^5.1.2",
192
192
  "concurrently": "^9.2.1",
193
193
  "cross-env": "^10.1.0",
194
- "eslint": "^9.39.1",
194
+ "eslint": "^9.39.2",
195
195
  "eslint-plugin-jsx-a11y": "^6.10.2",
196
196
  "eslint-plugin-react": "^7.37.5",
197
197
  "eslint-plugin-react-hooks": "^7.0.1",
198
- "globals": "^16.5.0",
199
- "npm-check-updates": "^19.1.2",
198
+ "globals": "^17.0.0",
199
+ "npm-check-updates": "^19.2.0",
200
200
  "prettier": "^3.7.4",
201
- "sass": "^1.94.2",
201
+ "sass": "^1.97.1",
202
202
  "ts-node": "^10.9.2",
203
203
  "tsx": "^4.21.0",
204
204
  "typescript": "^5.9.3",
205
- "vite": "^7.2.6",
206
- "vitest": "^4.0.15"
205
+ "vite": "^7.3.0",
206
+ "vitest": "^4.0.16"
207
207
  },
208
208
  "optionalDependencies": {
209
- "@rollup/rollup-darwin-arm64": "^4.53.3",
210
- "@rollup/rollup-win32-x64-msvc": "^4.53.3"
209
+ "@rollup/rollup-darwin-arm64": "^4.54.0",
210
+ "@rollup/rollup-win32-x64-msvc": "^4.54.0"
211
211
  }
212
212
  }
@@ -178,7 +178,7 @@ export type VZCodeContextValue = {
178
178
  handleSendMessage: (
179
179
  messageToSend?: string,
180
180
  options?: Record<string, string>,
181
- ) => void;
181
+ ) => Promise<any>;
182
182
 
183
183
  // Message history navigation
184
184
  navigateMessageHistoryUp: () => void;
@@ -204,11 +204,18 @@ export type VZCodeContextValue = {
204
204
  handleSendMessage?: (
205
205
  messageToSend?: string,
206
206
  options?: Record<string, string>,
207
- ) => void;
207
+ ) => Promise<any>;
208
208
  }) => React.ReactNode;
209
209
 
210
210
  // Feature flags
211
211
  enableMinimalEditFlow: boolean;
212
+
213
+ // Analytics callback for chat message submissions
214
+ onAIChatMessageSubmitted?: (params: {
215
+ messageLength: number;
216
+ isRetry?: boolean;
217
+ isUndo?: boolean;
218
+ }) => void;
212
219
  };
213
220
 
214
221
  export interface VZCodeProviderProps {
@@ -247,11 +254,16 @@ export interface VZCodeProviderProps {
247
254
  handleSendMessage?: (
248
255
  messageToSend?: string,
249
256
  options?: Record<string, string>,
250
- ) => void;
257
+ ) => Promise<any>;
251
258
  }) => React.ReactNode;
252
259
  iframeRef?: React.MutableRefObject<HTMLIFrameElement>;
253
260
  handleChatError?: (
254
261
  error: string,
255
262
  message?: string,
256
263
  ) => void;
264
+ onAIChatMessageSubmitted?: (params: {
265
+ messageLength: number;
266
+ isRetry?: boolean;
267
+ isUndo?: boolean;
268
+ }) => void;
257
269
  }
@@ -54,6 +54,7 @@ export const useVZCodeState = ({
54
54
  additionalWidgets,
55
55
  iframeRef: externalIframeRef,
56
56
  handleChatError,
57
+ onAIChatMessageSubmitted,
57
58
  }: Omit<
58
59
  VZCodeProviderProps,
59
60
  'children'
@@ -312,6 +313,22 @@ export const useVZCodeState = ({
312
313
  [handleChatError],
313
314
  );
314
315
 
316
+ // Phase 5: Restore the prompt into the chat input when the server
317
+ // populates `currentChatDraft` (e.g. after Undo), then clear it so it
318
+ // is applied exactly once.
319
+ useEffect(() => {
320
+ const draft = (content as any)?.currentChatDraft;
321
+ if (typeof draft === 'string' && draft.trim()) {
322
+ setAIChatMessage(draft);
323
+ submitOperation((currentContent) => {
324
+ const next: any = { ...currentContent };
325
+ next.currentChatDraft = undefined;
326
+ return next;
327
+ });
328
+ }
329
+ // eslint-disable-next-line react-hooks/exhaustive-deps
330
+ }, [(content as any)?.currentChatDraft]);
331
+
315
332
  // Message history navigation functions
316
333
  const navigateMessageHistoryUp = useCallback(() => {
317
334
  if (messageHistory.length === 0) return;
@@ -388,6 +405,21 @@ export const useVZCodeState = ({
388
405
 
389
406
  setAIErrorMessage(null); // Clear any previous errors
390
407
 
408
+ // Track AI chat message submission
409
+ const isRetry = options?.isRetry === 'true';
410
+ const isUndo = options?.isUndo === 'true';
411
+ if (
412
+ !DEBUG &&
413
+ onAIChatMessageSubmitted &&
414
+ aiChatOptions?.vizId
415
+ ) {
416
+ onAIChatMessageSubmitted({
417
+ messageLength: messageContent.trim().length,
418
+ isRetry,
419
+ isUndo,
420
+ });
421
+ }
422
+
391
423
  // Generate new chat ID only for fresh user messages
392
424
  // Keep existing chat ID for retries (Try Harder) and undos
393
425
  const isRetryOrUndo =
@@ -493,6 +525,7 @@ export const useVZCodeState = ({
493
525
 
494
526
  // The backend handles all ShareDB operations for successful responses
495
527
  // The loading state is now managed via ShareDB aiStatus
528
+ return responseData;
496
529
  } catch (error) {
497
530
  console.error('Error getting AI response:', error);
498
531
  setAIErrorMessage(
@@ -732,5 +765,8 @@ export const useVZCodeState = ({
732
765
 
733
766
  // Feature flags
734
767
  enableMinimalEditFlow,
768
+
769
+ // Analytics callback for chat message submissions
770
+ onAIChatMessageSubmitted,
735
771
  };
736
772
  };
@@ -81,6 +81,54 @@ export const addUserMessage = (
81
81
  return userMessage;
82
82
  };
83
83
 
84
+ /**
85
+ * Persists AI metadata fields on a chat (Phase 3).
86
+ *
87
+ * `baseCommitId` records the commit the current AI attempt is applied on
88
+ * top of. `escalationLevel` records how many times the user has pressed
89
+ * "Try Harder". Persisting these on the chat makes retries idempotent and
90
+ * keeps the level consistent across reloads and clients.
91
+ *
92
+ * Only fields that are explicitly provided are written.
93
+ */
94
+ export const setChatAIMetadata = (
95
+ shareDBDoc: ShareDBDoc<VizContent>,
96
+ chatId: VizChatId,
97
+ metadata: {
98
+ baseCommitId?: string;
99
+ escalationLevel?: number;
100
+ },
101
+ ) => {
102
+ const chat = shareDBDoc.data.chats[chatId];
103
+ if (!chat) {
104
+ return;
105
+ }
106
+
107
+ const updates: Record<string, unknown> = {};
108
+ if (metadata.baseCommitId !== undefined) {
109
+ updates.baseCommitId = metadata.baseCommitId;
110
+ }
111
+ if (metadata.escalationLevel !== undefined) {
112
+ updates.escalationLevel = metadata.escalationLevel;
113
+ }
114
+ if (Object.keys(updates).length === 0) {
115
+ return;
116
+ }
117
+
118
+ const op = diff(shareDBDoc.data, {
119
+ ...shareDBDoc.data,
120
+ chats: {
121
+ ...shareDBDoc.data.chats,
122
+ [chatId]: {
123
+ ...chat,
124
+ ...updates,
125
+ updatedAt: dateToTimestamp(new Date()),
126
+ },
127
+ },
128
+ });
129
+ shareDBDoc.submitOp(op);
130
+ };
131
+
84
132
  const DEBUG = false;
85
133
 
86
134
  /**
@@ -78,6 +78,7 @@ export const createLLMFunction = ({
78
78
  },
79
79
  });
80
80
 
81
+
81
82
  let fullContent = '';
82
83
  let generationId = '';
83
84
  let currentEditingFileName = null;
@@ -252,12 +253,13 @@ export const createLLMFunction = ({
252
253
  const chunks = [];
253
254
  let reasoningContent = '';
254
255
 
256
+
257
+
255
258
  // Configure reasoning tokens based on enableReasoningTokens flag
256
259
  const requestConfig: any = {
257
260
  model: modelName,
258
261
  messages: [{ role: 'user', content: fullPrompt }],
259
262
  stream: true,
260
- ...aiRequestOptions,
261
263
  };
262
264
 
263
265
  // Only include reasoning configuration if reasoning tokens are enabled
@@ -4,6 +4,7 @@ import {
4
4
  ensureChatExists,
5
5
  addUserMessage,
6
6
  setAIStatus,
7
+ setChatAIMetadata,
7
8
  } from '../../llm-streaming-server/chatOperations.js';
8
9
  import { createLLMFunction } from '../../llm-streaming-server/llmStreaming.js';
9
10
  import { performAIEditing } from '../../llm-streaming-server/aiEditing.js';
@@ -23,15 +24,31 @@ export const handleAIChatMessage =
23
24
  ({
24
25
  shareDBDoc,
25
26
  onCreditDeduction,
27
+ onGenerationFinished,
26
28
  model,
27
29
  aiRequestOptions,
28
30
  enableReasoningTokens,
31
+ baseCommitId,
32
+ escalationLevel,
29
33
  }: {
30
34
  shareDBDoc: ShareDBDoc<VizContent>;
31
35
  onCreditDeduction?: any;
36
+ onGenerationFinished?: (result: {
37
+ success: boolean;
38
+ editResult?: any;
39
+ metrics?: any;
40
+ error?: any;
41
+ }) => Promise<void> | void;
32
42
  model?: string;
33
43
  aiRequestOptions?: any;
34
44
  enableReasoningTokens?: boolean;
45
+ // Phase 3: explicit escalation base. Persisted on the chat so that
46
+ // repeated "Try Harder" clicks are idempotent and do not depend on
47
+ // the fragile `parent(currentCommit)` heuristic.
48
+ baseCommitId?: string;
49
+ // Explicit escalation level, persisted on the chat so it survives
50
+ // reloads and is shared across clients.
51
+ escalationLevel?: number;
35
52
  }) =>
36
53
  async (req: any, res: any) => {
37
54
  const { content, chatId } = req.body;
@@ -60,6 +77,13 @@ export const handleAIChatMessage =
60
77
  // Add user message to chat
61
78
  addUserMessage(shareDBDoc, chatId, content);
62
79
 
80
+ // Persist explicit escalation metadata on the chat, so that
81
+ // retries are idempotent and the level survives reloads.
82
+ setChatAIMetadata(shareDBDoc, chatId, {
83
+ baseCommitId,
84
+ escalationLevel,
85
+ });
86
+
63
87
  // Return success immediately - AI generation continues in background
64
88
  res.status(200).json('success');
65
89
 
@@ -72,6 +96,7 @@ export const handleAIChatMessage =
72
96
  aiRequestOptions,
73
97
  enableReasoningTokens,
74
98
  onCreditDeduction,
99
+ onGenerationFinished,
75
100
  }).catch((error) => {
76
101
  console.error(
77
102
  'Background AI processing error:',
@@ -96,6 +121,7 @@ const processAIRequestAsync = async ({
96
121
  aiRequestOptions,
97
122
  enableReasoningTokens,
98
123
  onCreditDeduction,
124
+ onGenerationFinished,
99
125
  }: {
100
126
  shareDBDoc: ShareDBDoc<VizContent>;
101
127
  chatId: string;
@@ -104,6 +130,12 @@ const processAIRequestAsync = async ({
104
130
  aiRequestOptions?: any;
105
131
  enableReasoningTokens?: boolean;
106
132
  onCreditDeduction?: any;
133
+ onGenerationFinished?: (result: {
134
+ success: boolean;
135
+ editResult?: any;
136
+ metrics?: any;
137
+ error?: any;
138
+ }) => Promise<void> | void;
107
139
  }) => {
108
140
  try {
109
141
  // Create LLM function for streaming
@@ -128,23 +160,40 @@ const processAIRequestAsync = async ({
128
160
  runCode,
129
161
  });
130
162
 
131
- // Handle credit deduction if callback is provided
163
+ // Billing is best-effort. It must NEVER prevent the edit from
164
+ // being committed. Any metadata failure is logged and swallowed.
165
+ let metrics: any = null;
132
166
  if (onCreditDeduction && editResult.generationId) {
133
167
  try {
134
- await onCreditDeduction(
135
- await getGenerationMetadata({
136
- apiKey:
137
- aiRequestOptions?.apiKey ||
138
- process.env.VZCODE_EDIT_WITH_AI_API_KEY,
139
- generationId: editResult.generationId,
140
- }),
141
- );
168
+ metrics = await getGenerationMetadata({
169
+ apiKey:
170
+ aiRequestOptions?.apiKey ||
171
+ process.env.VZCODE_EDIT_WITH_AI_API_KEY,
172
+ generationId: editResult.generationId,
173
+ });
174
+ await onCreditDeduction(metrics);
142
175
  } catch (creditError) {
143
176
  console.error(
144
- 'Credit deduction error:',
177
+ 'Credit deduction error (edit will still be finalized):',
145
178
  creditError,
146
179
  );
147
- // Don't fail the request if credit deduction fails
180
+ }
181
+ }
182
+
183
+ // Always notify settlement on success, regardless of billing
184
+ // outcome, so the caller can commit the edit and release locks.
185
+ if (onGenerationFinished) {
186
+ try {
187
+ await onGenerationFinished({
188
+ success: true,
189
+ editResult,
190
+ metrics,
191
+ });
192
+ } catch (settleError) {
193
+ console.error(
194
+ 'onGenerationFinished (success) error:',
195
+ settleError,
196
+ );
148
197
  }
149
198
  }
150
199
 
@@ -153,6 +202,23 @@ const processAIRequestAsync = async ({
153
202
  } catch (error) {
154
203
  // Set error status and add error message to chat
155
204
  setAIStatus(shareDBDoc, chatId, 'error');
205
+
206
+ // Always notify settlement on failure so the caller can roll back
207
+ // the pre-restore snapshot and release locks.
208
+ if (onGenerationFinished) {
209
+ try {
210
+ await onGenerationFinished({
211
+ success: false,
212
+ error,
213
+ });
214
+ } catch (settleError) {
215
+ console.error(
216
+ 'onGenerationFinished (failure) error:',
217
+ settleError,
218
+ );
219
+ }
220
+ }
221
+
156
222
  handleBackgroundError(shareDBDoc, chatId, error);
157
223
  }
158
224
  };
package/src/types.ts CHANGED
@@ -262,6 +262,9 @@ export interface ExtendedVizChat extends VizChat {
262
262
  currentStatus?: string;
263
263
  isStreaming?: boolean;
264
264
  model?: string; // The LLM model used for this chat
265
+ // Phase 3: explicit escalation metadata persisted on the chat.
266
+ baseCommitId?: string; // Commit the current AI attempt is applied on top of
267
+ escalationLevel?: number; // How many times "Try Harder" has been pressed
265
268
  }
266
269
 
267
270
  // Extended VizChatMessage with progressive rendering support