@ifc-lite/viewer 1.14.2 → 1.14.4
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/CHANGELOG.md +35 -0
- package/dist/assets/{Arrow.dom-CSgnLhN4.js → Arrow.dom-_vGzMMKs.js} +1 -1
- package/dist/assets/basketViewActivator-BZcoCL3V.js +1 -0
- package/dist/assets/{browser-qSKWrKQW.js → browser-Czmf34bo.js} +1 -1
- package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
- package/dist/assets/index-CMQ_Dgkr.css +1 -0
- package/dist/assets/index-D7nEDctQ.js +229 -0
- package/dist/assets/{index-4Y4XaV8N.js → index-DX-Qf5fA.js} +72669 -61673
- package/dist/assets/{native-bridge-CSFDsEkg.js → native-bridge-DAOWftxE.js} +1 -1
- package/dist/assets/{wasm-bridge-Zf90ysEm.js → wasm-bridge-D7jYpn8a.js} +1 -1
- package/dist/index.html +2 -2
- package/package.json +21 -20
- package/src/App.tsx +17 -1
- package/src/components/viewer/BasketPresentationDock.tsx +8 -4
- package/src/components/viewer/ChatPanel.tsx +1402 -0
- package/src/components/viewer/CodeEditor.tsx +70 -4
- package/src/components/viewer/CommandPalette.tsx +1 -0
- package/src/components/viewer/HierarchyPanel.tsx +28 -13
- package/src/components/viewer/MainToolbar.tsx +113 -95
- package/src/components/viewer/ScriptPanel.tsx +351 -184
- package/src/components/viewer/UpgradePage.tsx +69 -0
- package/src/components/viewer/Viewport.tsx +23 -0
- package/src/components/viewer/chat/ChatMessage.tsx +144 -0
- package/src/components/viewer/chat/ExecutableCodeBlock.tsx +416 -0
- package/src/components/viewer/chat/ModelSelector.tsx +102 -0
- package/src/components/viewer/chat/renderTextContent.test.ts +23 -0
- package/src/components/viewer/chat/renderTextContent.ts +19 -0
- package/src/components/viewer/hierarchy/HierarchyNode.tsx +10 -3
- package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +126 -0
- package/src/components/viewer/hierarchy/treeDataBuilder.ts +139 -38
- package/src/components/viewer/hierarchy/types.ts +6 -1
- package/src/components/viewer/hierarchy/useHierarchyTree.ts +27 -12
- package/src/hooks/useIfcCache.ts +1 -2
- package/src/hooks/useSandbox.ts +122 -6
- package/src/index.css +10 -0
- package/src/lib/attachments.ts +46 -0
- package/src/lib/llm/ClerkChatSync.tsx +74 -0
- package/src/lib/llm/clerk-auth.ts +62 -0
- package/src/lib/llm/code-extractor.ts +50 -0
- package/src/lib/llm/context-builder.test.ts +18 -0
- package/src/lib/llm/context-builder.ts +305 -0
- package/src/lib/llm/free-models.test.ts +118 -0
- package/src/lib/llm/message-capabilities.test.ts +131 -0
- package/src/lib/llm/message-capabilities.ts +94 -0
- package/src/lib/llm/models.ts +197 -0
- package/src/lib/llm/repair-loop.test.ts +91 -0
- package/src/lib/llm/repair-loop.ts +76 -0
- package/src/lib/llm/script-diagnostics.ts +445 -0
- package/src/lib/llm/script-edit-ops.test.ts +399 -0
- package/src/lib/llm/script-edit-ops.ts +954 -0
- package/src/lib/llm/script-preflight.test.ts +513 -0
- package/src/lib/llm/script-preflight.ts +990 -0
- package/src/lib/llm/script-preservation.test.ts +128 -0
- package/src/lib/llm/script-preservation.ts +152 -0
- package/src/lib/llm/stream-client.test.ts +97 -0
- package/src/lib/llm/stream-client.ts +410 -0
- package/src/lib/llm/system-prompt.test.ts +181 -0
- package/src/lib/llm/system-prompt.ts +665 -0
- package/src/lib/llm/types.ts +150 -0
- package/src/lib/scripts/templates/bim-globals.d.ts +226 -7
- package/src/lib/scripts/templates/create-building.ts +12 -12
- package/src/main.tsx +10 -1
- package/src/sdk/adapters/export-adapter.test.ts +24 -0
- package/src/sdk/adapters/export-adapter.ts +40 -16
- package/src/sdk/adapters/files-adapter.ts +39 -0
- package/src/sdk/adapters/model-compat.ts +1 -1
- package/src/sdk/adapters/mutate-adapter.ts +20 -6
- package/src/sdk/adapters/mutation-view.ts +112 -0
- package/src/sdk/adapters/query-adapter.ts +100 -4
- package/src/sdk/local-backend.ts +4 -0
- package/src/store/index.ts +15 -1
- package/src/store/slices/chatSlice.test.ts +325 -0
- package/src/store/slices/chatSlice.ts +468 -0
- package/src/store/slices/scriptSlice.test.ts +75 -0
- package/src/store/slices/scriptSlice.ts +256 -9
- package/src/vite-env.d.ts +10 -0
- package/vite.config.ts +21 -2
- package/dist/assets/ifc-lite_bg-BOvNXJA_.wasm +0 -0
- package/dist/assets/index-ByrFvN5A.css +0 -1
- package/dist/assets/index-CN7qDq7G.js +0 -216
|
@@ -0,0 +1,1402 @@
|
|
|
1
|
+
/* This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* ChatPanel — Interactive LLM chat with live 3D model generation.
|
|
7
|
+
*
|
|
8
|
+
* Features:
|
|
9
|
+
* - Streaming responses with blinking cursor
|
|
10
|
+
* - Executable code blocks with "Run" and "Fix this" buttons
|
|
11
|
+
* - Drag-and-drop file upload with visual dropzone
|
|
12
|
+
* - Smart auto-scroll with "scroll to bottom" button
|
|
13
|
+
* - Clickable example prompts in empty state
|
|
14
|
+
* - Auto-execute toggle for hands-free workflow
|
|
15
|
+
* - Keyboard shortcuts (Cmd+L focus, Escape close)
|
|
16
|
+
* - Conversation persistence via localStorage
|
|
17
|
+
* - Clear confirmation dialog
|
|
18
|
+
* - Error-to-LLM feedback loop for failed scripts
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useCallback, useRef, useEffect, useState, type KeyboardEvent, type DragEvent } from 'react';
|
|
22
|
+
import {
|
|
23
|
+
X,
|
|
24
|
+
Send,
|
|
25
|
+
Square,
|
|
26
|
+
Trash2,
|
|
27
|
+
Paperclip,
|
|
28
|
+
Loader2,
|
|
29
|
+
ArrowDown,
|
|
30
|
+
Zap,
|
|
31
|
+
} from 'lucide-react';
|
|
32
|
+
import { SignInButton, SignedIn, SignedOut, UserButton } from '@clerk/clerk-react';
|
|
33
|
+
import { Button } from '@/components/ui/button';
|
|
34
|
+
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
|
35
|
+
import { useViewerStore } from '@/store';
|
|
36
|
+
import { buildErrorFeedbackContent } from '@/store/slices/chatSlice';
|
|
37
|
+
import { ChatMessageComponent } from './chat/ChatMessage';
|
|
38
|
+
import { ModelSelector } from './chat/ModelSelector';
|
|
39
|
+
import { fetchUsageSnapshot, streamChat, type StreamMessage, type TextContentPart, type ImageContentPart, type UsageInfo } from '@/lib/llm/stream-client';
|
|
40
|
+
import { buildStreamMessagesForModel, filterAttachmentsForModel } from '@/lib/llm/message-capabilities';
|
|
41
|
+
import { buildSystemPrompt } from '@/lib/llm/system-prompt';
|
|
42
|
+
import { getModelContext, parseCSV } from '@/lib/llm/context-builder';
|
|
43
|
+
import { collectActiveFileAttachments } from '@/lib/attachments';
|
|
44
|
+
import { extractCodeBlocks } from '@/lib/llm/code-extractor';
|
|
45
|
+
import { extractScriptEditOps, filterUnappliedScriptOps } from '@/lib/llm/script-edit-ops';
|
|
46
|
+
import { createPatchDiagnostic, getPrimaryRootCause, type RepairScope } from '@/lib/llm/script-diagnostics';
|
|
47
|
+
import type { ScriptDiagnostic } from '@/lib/llm/script-diagnostics';
|
|
48
|
+
import { buildRepairSessionKey, getEscalatedRepairScope, pruneMessagesForRepair } from '@/lib/llm/repair-loop';
|
|
49
|
+
import type { ChatMessage, ChatRepairRequest, FileAttachment } from '@/lib/llm/types';
|
|
50
|
+
import { canUsePlainCodeBlockFallback, type ScriptMutationIntent } from '@/lib/llm/script-preservation';
|
|
51
|
+
import { Image as ImageIcon } from 'lucide-react';
|
|
52
|
+
import { isClerkConfigured } from '@/lib/llm/clerk-auth';
|
|
53
|
+
import { getModelById } from '@/lib/llm/models';
|
|
54
|
+
import { useSandbox } from '@/hooks/useSandbox';
|
|
55
|
+
|
|
56
|
+
// Environment variable for the proxy URL
|
|
57
|
+
const PROXY_URL = import.meta.env.VITE_LLM_PROXY_URL as string || '/api/chat';
|
|
58
|
+
|
|
59
|
+
const EXAMPLE_PROMPTS = [
|
|
60
|
+
'Create a 3-story house with walls, slabs, and a gable roof',
|
|
61
|
+
'Color all IfcWalls by their fire rating',
|
|
62
|
+
'Export a quantity takeoff as CSV',
|
|
63
|
+
'Create a skyscraper with 4x4 column grid, 30x40m, concrete shaft',
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
const CONTINUE_PROMPT = 'Continue from exactly where your last response stopped. Do not repeat previously generated text.';
|
|
67
|
+
const DEFAULT_PRO_MONTHLY_CREDIT_LIMIT = 1000;
|
|
68
|
+
const USAGE_REFRESH_INTERVAL_MS = 15_000;
|
|
69
|
+
const EST_CHARS_PER_TOKEN = 4;
|
|
70
|
+
const IMAGE_TOKEN_COST_EST = 850;
|
|
71
|
+
const INPUT_BUDGET_RATIO = 0.72;
|
|
72
|
+
const OUTPUT_TOKEN_RESERVE = 9_000;
|
|
73
|
+
const MIN_INPUT_BUDGET = 8_000;
|
|
74
|
+
const MAX_RECENT_MESSAGES = 48;
|
|
75
|
+
const SUMMARY_SNIPPET_LEN = 240;
|
|
76
|
+
const MAX_INLINE_IMAGE_DATA_URL_CHARS = 1_200_000;
|
|
77
|
+
const MAX_ATTACHMENTS_PER_MESSAGE = 6;
|
|
78
|
+
const MAX_TEXT_ATTACHMENT_BYTES = 512_000;
|
|
79
|
+
const MAX_IMAGE_ATTACHMENT_BYTES = 8_000_000;
|
|
80
|
+
|
|
81
|
+
function createAttachmentId(): string {
|
|
82
|
+
return crypto.randomUUID();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface ChatSendOptions {
|
|
86
|
+
continuationBase?: string;
|
|
87
|
+
intent?: ScriptMutationIntent;
|
|
88
|
+
repairDiagnostics?: ScriptDiagnostic[];
|
|
89
|
+
requestedRepairScope?: RepairScope;
|
|
90
|
+
rootCauseKey?: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Convert a File to a base64 data URL */
|
|
94
|
+
function fileToBase64(file: File): Promise<string> {
|
|
95
|
+
return new Promise((resolve, reject) => {
|
|
96
|
+
const reader = new FileReader();
|
|
97
|
+
reader.onload = () => resolve(reader.result as string);
|
|
98
|
+
reader.onerror = reject;
|
|
99
|
+
reader.readAsDataURL(file);
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function imageFileToCompressedBase64(file: File): Promise<string> {
|
|
104
|
+
const raw = await fileToBase64(file);
|
|
105
|
+
return compressDataUrlImage(raw);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function compressDataUrlImage(dataUrl: string): Promise<string> {
|
|
109
|
+
return new Promise((resolve) => {
|
|
110
|
+
const img = new Image();
|
|
111
|
+
img.onload = () => {
|
|
112
|
+
const maxSide = 1400;
|
|
113
|
+
const srcW = img.naturalWidth || img.width;
|
|
114
|
+
const srcH = img.naturalHeight || img.height;
|
|
115
|
+
const scale = Math.min(1, maxSide / Math.max(srcW, srcH));
|
|
116
|
+
const outW = Math.max(1, Math.round(srcW * scale));
|
|
117
|
+
const outH = Math.max(1, Math.round(srcH * scale));
|
|
118
|
+
const canvas = document.createElement('canvas');
|
|
119
|
+
canvas.width = outW;
|
|
120
|
+
canvas.height = outH;
|
|
121
|
+
const ctx = canvas.getContext('2d');
|
|
122
|
+
if (!ctx) {
|
|
123
|
+
resolve(dataUrl);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
ctx.drawImage(img, 0, 0, outW, outH);
|
|
127
|
+
resolve(canvas.toDataURL('image/jpeg', 0.72));
|
|
128
|
+
};
|
|
129
|
+
img.onerror = () => resolve(dataUrl);
|
|
130
|
+
img.src = dataUrl;
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function stripContinuationOverlap(previous: string, continuation: string): string {
|
|
135
|
+
const prev = previous.trimEnd();
|
|
136
|
+
const next = continuation.trimStart();
|
|
137
|
+
if (!prev || !next) return continuation;
|
|
138
|
+
|
|
139
|
+
const maxOverlap = Math.min(prev.length, next.length, 1200);
|
|
140
|
+
const minOverlap = Math.min(48, maxOverlap);
|
|
141
|
+
for (let size = maxOverlap; size >= minOverlap; size--) {
|
|
142
|
+
const suffix = prev.slice(-size);
|
|
143
|
+
const prefix = next.slice(0, size);
|
|
144
|
+
if (suffix === prefix) {
|
|
145
|
+
return next.slice(size).trimStart();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return continuation;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function estimateTextTokens(text: string): number {
|
|
152
|
+
return Math.ceil(text.length / EST_CHARS_PER_TOKEN);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function estimateContentTokens(content: string | Array<TextContentPart | ImageContentPart>): number {
|
|
156
|
+
if (typeof content === 'string') return estimateTextTokens(content);
|
|
157
|
+
let tokens = 0;
|
|
158
|
+
for (const part of content) {
|
|
159
|
+
if (part.type === 'text') {
|
|
160
|
+
tokens += estimateTextTokens(part.text);
|
|
161
|
+
} else {
|
|
162
|
+
tokens += IMAGE_TOKEN_COST_EST;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return tokens;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function estimateMessagesTokens(messages: Array<{ role: string; content: string | Array<TextContentPart | ImageContentPart> }>): number {
|
|
169
|
+
return messages.reduce((sum, m) => sum + estimateContentTokens(m.content) + 8, 0);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function summarizeDroppedMessages(messages: ChatMessage[]): string {
|
|
173
|
+
if (messages.length === 0) return '';
|
|
174
|
+
const summaryParts: string[] = [];
|
|
175
|
+
for (const m of messages.slice(-14)) {
|
|
176
|
+
const body = m.content.replace(/\s+/g, ' ').trim().slice(0, SUMMARY_SNIPPET_LEN);
|
|
177
|
+
if (!body) continue;
|
|
178
|
+
summaryParts.push(`${m.role}: ${body}`);
|
|
179
|
+
}
|
|
180
|
+
return summaryParts.join('\n');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
interface ChatPanelProps {
|
|
184
|
+
onClose?: () => void;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function ChatPanel({ onClose }: ChatPanelProps) {
|
|
188
|
+
const messages = useViewerStore((s) => s.chatMessages);
|
|
189
|
+
const status = useViewerStore((s) => s.chatStatus);
|
|
190
|
+
const streamingContent = useViewerStore((s) => s.chatStreamingContent);
|
|
191
|
+
const activeModel = useViewerStore((s) => s.chatActiveModel);
|
|
192
|
+
const autoExecute = useViewerStore((s) => s.chatAutoExecute);
|
|
193
|
+
const error = useViewerStore((s) => s.chatError);
|
|
194
|
+
const attachments = useViewerStore((s) => s.chatAttachments);
|
|
195
|
+
const addMessage = useViewerStore((s) => s.addChatMessage);
|
|
196
|
+
const setChatStatus = useViewerStore((s) => s.setChatStatus);
|
|
197
|
+
const updateStreaming = useViewerStore((s) => s.updateLastAssistantMessage);
|
|
198
|
+
const finalizeAssistant = useViewerStore((s) => s.finalizeAssistantMessage);
|
|
199
|
+
const setChatError = useViewerStore((s) => s.setChatError);
|
|
200
|
+
const setChatAbortController = useViewerStore((s) => s.setChatAbortController);
|
|
201
|
+
const setAutoExecute = useViewerStore((s) => s.setChatAutoExecute);
|
|
202
|
+
const addAttachment = useViewerStore((s) => s.addChatAttachment);
|
|
203
|
+
const removeAttachment = useViewerStore((s) => s.removeChatAttachment);
|
|
204
|
+
const clearAttachments = useViewerStore((s) => s.clearChatAttachments);
|
|
205
|
+
const clearMessages = useViewerStore((s) => s.clearChatMessages);
|
|
206
|
+
const resetScriptEditorForNewChat = useViewerStore((s) => s.resetScriptEditorForNewChat);
|
|
207
|
+
const pendingPrompt = useViewerStore((s) => s.chatPendingPrompt);
|
|
208
|
+
const consumePendingPrompt = useViewerStore((s) => s.consumeChatPendingPrompt);
|
|
209
|
+
const pendingRepairRequest = useViewerStore((s) => s.chatPendingRepairRequest);
|
|
210
|
+
const consumePendingRepairRequest = useViewerStore((s) => s.consumeChatPendingRepairRequest);
|
|
211
|
+
const authToken = useViewerStore((s) => s.chatAuthToken);
|
|
212
|
+
const hasPro = useViewerStore((s) => s.chatHasPro);
|
|
213
|
+
const usage = useViewerStore((s) => s.chatUsage);
|
|
214
|
+
const setChatUsage = useViewerStore((s) => s.setChatUsage);
|
|
215
|
+
const { execute } = useSandbox();
|
|
216
|
+
const displayUsage: UsageInfo | null = usage ?? (hasPro
|
|
217
|
+
? {
|
|
218
|
+
type: 'credits',
|
|
219
|
+
used: 0,
|
|
220
|
+
limit: DEFAULT_PRO_MONTHLY_CREDIT_LIMIT,
|
|
221
|
+
pct: 0,
|
|
222
|
+
resetAt: 0,
|
|
223
|
+
billable: false,
|
|
224
|
+
}
|
|
225
|
+
: null);
|
|
226
|
+
const usageResetLabel = displayUsage?.resetAt && displayUsage.resetAt > 0
|
|
227
|
+
? new Date(displayUsage.resetAt * 1000).toLocaleDateString()
|
|
228
|
+
: '—';
|
|
229
|
+
|
|
230
|
+
const [inputText, setInputText] = useState('');
|
|
231
|
+
const [showClearConfirm, setShowClearConfirm] = useState(false);
|
|
232
|
+
const [isDragging, setIsDragging] = useState(false);
|
|
233
|
+
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
|
234
|
+
const [userScrolledUp, setUserScrolledUp] = useState(false);
|
|
235
|
+
const [lastFinishReason, setLastFinishReason] = useState<string | null>(null);
|
|
236
|
+
|
|
237
|
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
|
238
|
+
const scrollRef = useRef<HTMLDivElement>(null);
|
|
239
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
240
|
+
const dragCounterRef = useRef(0);
|
|
241
|
+
const autoRepairAttemptCountsRef = useRef(new Map<string, { attempts: number; lastScope: RepairScope }>());
|
|
242
|
+
|
|
243
|
+
const resizeInput = useCallback(() => {
|
|
244
|
+
const target = inputRef.current;
|
|
245
|
+
if (!target) return;
|
|
246
|
+
target.style.height = 'auto';
|
|
247
|
+
target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
|
|
248
|
+
}, []);
|
|
249
|
+
|
|
250
|
+
useEffect(() => {
|
|
251
|
+
resizeInput();
|
|
252
|
+
}, [inputText, resizeInput]);
|
|
253
|
+
|
|
254
|
+
// ── Smart auto-scroll ──
|
|
255
|
+
// Only auto-scroll if user hasn't scrolled up to read old messages
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
const el = scrollRef.current;
|
|
258
|
+
if (!el) return;
|
|
259
|
+
if (!userScrolledUp) {
|
|
260
|
+
el.scrollTop = el.scrollHeight;
|
|
261
|
+
}
|
|
262
|
+
}, [messages.length, streamingContent, userScrolledUp]);
|
|
263
|
+
|
|
264
|
+
// Detect whether user has scrolled up from the bottom
|
|
265
|
+
useEffect(() => {
|
|
266
|
+
const el = scrollRef.current;
|
|
267
|
+
if (!el) return;
|
|
268
|
+
|
|
269
|
+
const handleScroll = () => {
|
|
270
|
+
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
|
|
271
|
+
setUserScrolledUp(!isNearBottom);
|
|
272
|
+
setShowScrollBtn(!isNearBottom && (messages.length > 0 || !!streamingContent));
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
el.addEventListener('scroll', handleScroll, { passive: true });
|
|
276
|
+
return () => el.removeEventListener('scroll', handleScroll);
|
|
277
|
+
}, [messages.length, streamingContent]);
|
|
278
|
+
|
|
279
|
+
const scrollToBottom = useCallback(() => {
|
|
280
|
+
const el = scrollRef.current;
|
|
281
|
+
if (el) {
|
|
282
|
+
el.scrollTop = el.scrollHeight;
|
|
283
|
+
setUserScrolledUp(false);
|
|
284
|
+
setShowScrollBtn(false);
|
|
285
|
+
}
|
|
286
|
+
}, []);
|
|
287
|
+
|
|
288
|
+
// Focus input on mount
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
inputRef.current?.focus();
|
|
291
|
+
}, []);
|
|
292
|
+
|
|
293
|
+
// Keep usage meter hydrated even before first prompt and refresh periodically.
|
|
294
|
+
useEffect(() => {
|
|
295
|
+
let cancelled = false;
|
|
296
|
+
const refreshUsage = async () => {
|
|
297
|
+
const snapshot = await fetchUsageSnapshot(PROXY_URL, authToken);
|
|
298
|
+
if (!cancelled && snapshot) {
|
|
299
|
+
setChatUsage(snapshot);
|
|
300
|
+
}
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
void refreshUsage();
|
|
304
|
+
const timer = window.setInterval(() => {
|
|
305
|
+
void refreshUsage();
|
|
306
|
+
}, USAGE_REFRESH_INTERVAL_MS);
|
|
307
|
+
|
|
308
|
+
return () => {
|
|
309
|
+
cancelled = true;
|
|
310
|
+
window.clearInterval(timer);
|
|
311
|
+
};
|
|
312
|
+
}, [authToken, setChatUsage]);
|
|
313
|
+
|
|
314
|
+
// ── Keyboard shortcuts ──
|
|
315
|
+
useEffect(() => {
|
|
316
|
+
const handler = (e: globalThis.KeyboardEvent) => {
|
|
317
|
+
// Cmd+L / Ctrl+L → focus chat input
|
|
318
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
|
|
319
|
+
e.preventDefault();
|
|
320
|
+
inputRef.current?.focus();
|
|
321
|
+
}
|
|
322
|
+
// Escape → close panel (only if chat input isn't focused or is empty)
|
|
323
|
+
if (e.key === 'Escape' && onClose) {
|
|
324
|
+
const isChatFocused = document.activeElement === inputRef.current;
|
|
325
|
+
if (!isChatFocused || !inputText) {
|
|
326
|
+
onClose();
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
};
|
|
330
|
+
window.addEventListener('keydown', handler);
|
|
331
|
+
return () => window.removeEventListener('keydown', handler);
|
|
332
|
+
}, [onClose, inputText]);
|
|
333
|
+
|
|
334
|
+
const buildRepairPromptFromLiveState = useCallback((request: ChatRepairRequest) => {
|
|
335
|
+
const state = useViewerStore.getState();
|
|
336
|
+
return buildErrorFeedbackContent(state.scriptEditorContent, request.error, {
|
|
337
|
+
diagnostics: request.diagnostics ?? state.scriptLastDiagnostics,
|
|
338
|
+
currentRevision: state.scriptEditorRevision,
|
|
339
|
+
currentSelection: request.includeSelection ? state.scriptEditorSelection : undefined,
|
|
340
|
+
staleCodeBlock: request.staleCodeBlock,
|
|
341
|
+
reason: request.reason,
|
|
342
|
+
requestedRepairScope: request.requestedRepairScope,
|
|
343
|
+
});
|
|
344
|
+
}, []);
|
|
345
|
+
|
|
346
|
+
const triggerAutoRepair = (request: ChatRepairRequest) => {
|
|
347
|
+
const state = useViewerStore.getState();
|
|
348
|
+
const diagnostics = request.diagnostics ?? state.scriptLastDiagnostics;
|
|
349
|
+
const primaryRootCause = getPrimaryRootCause(diagnostics);
|
|
350
|
+
const sessionKey = buildRepairSessionKey({
|
|
351
|
+
diagnostics,
|
|
352
|
+
currentCode: state.scriptEditorContent,
|
|
353
|
+
});
|
|
354
|
+
const sessionState = autoRepairAttemptCountsRef.current.get(sessionKey);
|
|
355
|
+
const defaultScope = request.requestedRepairScope ?? primaryRootCause?.repairScope ?? 'local';
|
|
356
|
+
const requestedScope = sessionState
|
|
357
|
+
? sessionState.attempts >= 1 && sessionState.lastScope === defaultScope
|
|
358
|
+
? getEscalatedRepairScope(defaultScope) ?? null
|
|
359
|
+
: defaultScope
|
|
360
|
+
: defaultScope;
|
|
361
|
+
|
|
362
|
+
if (!requestedScope) {
|
|
363
|
+
setChatError('Auto-repair stopped after the same root cause persisted through escalation. Use Fix with LLM after adjusting the script or make a broader manual change.');
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
autoRepairAttemptCountsRef.current.set(sessionKey, {
|
|
368
|
+
attempts: (sessionState?.attempts ?? 0) + 1,
|
|
369
|
+
lastScope: requestedScope,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
void doSend(buildRepairPromptFromLiveState({
|
|
373
|
+
...request,
|
|
374
|
+
diagnostics,
|
|
375
|
+
requestedRepairScope: requestedScope,
|
|
376
|
+
rootCauseKey: primaryRootCause?.rootCauseKey,
|
|
377
|
+
}), {
|
|
378
|
+
intent: 'repair',
|
|
379
|
+
repairDiagnostics: diagnostics,
|
|
380
|
+
requestedRepairScope: requestedScope,
|
|
381
|
+
rootCauseKey: primaryRootCause?.rootCauseKey,
|
|
382
|
+
});
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
// ── Core send logic ──
|
|
386
|
+
const doSend = useCallback(async (text: string, options?: ChatSendOptions) => {
|
|
387
|
+
if (!text.trim() || status === 'streaming' || status === 'sending') return;
|
|
388
|
+
|
|
389
|
+
const continuationBase = options?.continuationBase;
|
|
390
|
+
const responseIntent = options?.intent ?? 'create';
|
|
391
|
+
if (responseIntent !== 'repair') {
|
|
392
|
+
autoRepairAttemptCountsRef.current.clear();
|
|
393
|
+
}
|
|
394
|
+
const liveState = useViewerStore.getState();
|
|
395
|
+
const currentMessages = responseIntent === 'repair'
|
|
396
|
+
? pruneMessagesForRepair(liveState.chatMessages)
|
|
397
|
+
: liveState.chatMessages;
|
|
398
|
+
const liveScriptContext = {
|
|
399
|
+
content: liveState.scriptEditorContent,
|
|
400
|
+
revision: liveState.scriptEditorRevision,
|
|
401
|
+
selection: liveState.scriptEditorSelection,
|
|
402
|
+
};
|
|
403
|
+
const liveDiagnostics = liveState.scriptLastDiagnostics;
|
|
404
|
+
const effectiveDiagnostics = options?.repairDiagnostics ?? liveDiagnostics;
|
|
405
|
+
const primaryRootCause = options?.rootCauseKey
|
|
406
|
+
? { rootCauseKey: options.rootCauseKey, repairScope: options.requestedRepairScope ?? 'local' }
|
|
407
|
+
: getPrimaryRootCause(effectiveDiagnostics);
|
|
408
|
+
setLastFinishReason(null);
|
|
409
|
+
|
|
410
|
+
const activeModelInfo = getModelById(activeModel);
|
|
411
|
+
const supportsImages = activeModelInfo?.supportsImages ?? false;
|
|
412
|
+
const supportsFileAttachments = activeModelInfo?.supportsFileAttachments ?? true;
|
|
413
|
+
const filtered = filterAttachmentsForModel(attachments, supportsImages, supportsFileAttachments);
|
|
414
|
+
const droppedAttachmentWarnings: string[] = [];
|
|
415
|
+
if (filtered.droppedImages > 0) {
|
|
416
|
+
droppedAttachmentWarnings.push('image attachments');
|
|
417
|
+
}
|
|
418
|
+
if (filtered.droppedFiles > 0) {
|
|
419
|
+
droppedAttachmentWarnings.push('file attachments');
|
|
420
|
+
}
|
|
421
|
+
if (droppedAttachmentWarnings.length > 0) {
|
|
422
|
+
setChatError(
|
|
423
|
+
`Selected model does not support ${droppedAttachmentWarnings.join(' and ')}. Unsupported attachments were skipped.`,
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const userMessage: ChatMessage = {
|
|
428
|
+
id: crypto.randomUUID(),
|
|
429
|
+
role: 'user',
|
|
430
|
+
content: text.trim(),
|
|
431
|
+
createdAt: Date.now(),
|
|
432
|
+
attachments: filtered.accepted.length > 0 ? [...filtered.accepted] : undefined,
|
|
433
|
+
};
|
|
434
|
+
addMessage(userMessage);
|
|
435
|
+
setInputText('');
|
|
436
|
+
setChatStatus('sending');
|
|
437
|
+
setUserScrolledUp(false);
|
|
438
|
+
|
|
439
|
+
// Reset textarea height
|
|
440
|
+
resizeInput();
|
|
441
|
+
|
|
442
|
+
// Check for auto-captured viewport screenshot to include
|
|
443
|
+
const pendingViewportScreenshot = useViewerStore.getState().chatViewportScreenshot;
|
|
444
|
+
if (pendingViewportScreenshot) {
|
|
445
|
+
useViewerStore.getState().setChatViewportScreenshot(null);
|
|
446
|
+
}
|
|
447
|
+
let viewportScreenshot: string | null = null;
|
|
448
|
+
if (pendingViewportScreenshot && supportsImages) {
|
|
449
|
+
// Normalize legacy/uncompressed screenshots before attaching.
|
|
450
|
+
const normalized = await compressDataUrlImage(pendingViewportScreenshot);
|
|
451
|
+
if (normalized.length <= MAX_INLINE_IMAGE_DATA_URL_CHARS) {
|
|
452
|
+
viewportScreenshot = normalized;
|
|
453
|
+
} else {
|
|
454
|
+
setChatError('Auto-captured screenshot was too large and was skipped.');
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const allMessages = [...currentMessages, userMessage];
|
|
459
|
+
|
|
460
|
+
const modelContext = getModelContext();
|
|
461
|
+
const fileAttachments = supportsFileAttachments
|
|
462
|
+
? collectActiveFileAttachments(allMessages, filtered.accepted)
|
|
463
|
+
: [];
|
|
464
|
+
const systemPrompt = buildSystemPrompt(modelContext, fileAttachments, {
|
|
465
|
+
content: liveScriptContext.content,
|
|
466
|
+
revision: liveScriptContext.revision,
|
|
467
|
+
selection: liveScriptContext.selection,
|
|
468
|
+
}, {
|
|
469
|
+
userPrompt: text.trim(),
|
|
470
|
+
diagnostics: effectiveDiagnostics,
|
|
471
|
+
});
|
|
472
|
+
const contextWindow = activeModelInfo?.contextWindow ?? 128_000;
|
|
473
|
+
const inputBudget = Math.max(
|
|
474
|
+
MIN_INPUT_BUDGET,
|
|
475
|
+
Math.floor(contextWindow * INPUT_BUDGET_RATIO) - OUTPUT_TOKEN_RESERVE,
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
let compactedMessages = [...allMessages];
|
|
479
|
+
let droppedMessages: ChatMessage[] = [];
|
|
480
|
+
let streamBuild = buildStreamMessagesForModel(compactedMessages, viewportScreenshot, supportsImages);
|
|
481
|
+
let streamMessages = streamBuild.messages;
|
|
482
|
+
let estimatedInputTokens = estimateTextTokens(systemPrompt) + estimateMessagesTokens(streamMessages);
|
|
483
|
+
|
|
484
|
+
while (estimatedInputTokens > inputBudget && compactedMessages.length > 2) {
|
|
485
|
+
const dropCount = Math.min(4, Math.max(1, compactedMessages.length - 2));
|
|
486
|
+
droppedMessages = [...droppedMessages, ...compactedMessages.slice(0, dropCount)];
|
|
487
|
+
compactedMessages = compactedMessages.slice(dropCount);
|
|
488
|
+
if (compactedMessages.length > MAX_RECENT_MESSAGES) {
|
|
489
|
+
const over = compactedMessages.length - MAX_RECENT_MESSAGES;
|
|
490
|
+
droppedMessages = [...droppedMessages, ...compactedMessages.slice(0, over)];
|
|
491
|
+
compactedMessages = compactedMessages.slice(over);
|
|
492
|
+
}
|
|
493
|
+
streamBuild = buildStreamMessagesForModel(compactedMessages, viewportScreenshot, supportsImages);
|
|
494
|
+
streamMessages = streamBuild.messages;
|
|
495
|
+
estimatedInputTokens = estimateTextTokens(systemPrompt) + estimateMessagesTokens(streamMessages);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if (streamBuild.droppedInlineImages > 0 || streamBuild.droppedViewportScreenshot) {
|
|
499
|
+
setChatError(
|
|
500
|
+
'Selected model does not support image input. Screenshot/image payload was omitted.',
|
|
501
|
+
);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (droppedMessages.length > 0) {
|
|
505
|
+
const summary = summarizeDroppedMessages(droppedMessages);
|
|
506
|
+
if (summary) {
|
|
507
|
+
const summaryMessage = {
|
|
508
|
+
role: 'system' as const,
|
|
509
|
+
content: `Conversation summary of earlier turns (for continuity only):\n${summary}`,
|
|
510
|
+
};
|
|
511
|
+
streamMessages = [summaryMessage, ...streamMessages];
|
|
512
|
+
estimatedInputTokens = estimateTextTokens(systemPrompt) + estimateMessagesTokens(streamMessages);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
if (estimatedInputTokens > inputBudget && import.meta.env.DEV) {
|
|
517
|
+
console.info('[llm-budget]', {
|
|
518
|
+
model: activeModel,
|
|
519
|
+
contextWindow,
|
|
520
|
+
inputBudget,
|
|
521
|
+
estimatedInputTokens,
|
|
522
|
+
droppedMessages: droppedMessages.length,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const abortController = new AbortController();
|
|
527
|
+
setChatAbortController(abortController);
|
|
528
|
+
useViewerStore.getState().beginAssistantScriptTurn();
|
|
529
|
+
|
|
530
|
+
let accumulated = '';
|
|
531
|
+
const responseBaseRevision = liveScriptContext.revision;
|
|
532
|
+
const responseBaseContent = liveScriptContext.content;
|
|
533
|
+
const editParseOptions = {
|
|
534
|
+
baseRevision: responseBaseRevision,
|
|
535
|
+
baseContent: responseBaseContent,
|
|
536
|
+
intent: responseIntent,
|
|
537
|
+
requestedRepairScope: options?.requestedRepairScope ?? primaryRootCause?.repairScope,
|
|
538
|
+
targetRootCause: options?.rootCauseKey ?? primaryRootCause?.rootCauseKey,
|
|
539
|
+
} as const;
|
|
540
|
+
const responseEditState = {
|
|
541
|
+
intent: responseIntent,
|
|
542
|
+
appliedOpIds: new Set<string>(),
|
|
543
|
+
acceptedOps: [] as ReturnType<typeof extractScriptEditOps>['operations'],
|
|
544
|
+
appliedAny: false,
|
|
545
|
+
applyFailed: false,
|
|
546
|
+
fallbackApplied: false,
|
|
547
|
+
rolledBack: false,
|
|
548
|
+
applyFailureStatus: null as null | 'revision_conflict' | 'range_error' | 'semantic_error' | 'parse_error',
|
|
549
|
+
applyFailureError: null as string | null,
|
|
550
|
+
applyFailureDiagnostic: null as ReturnType<typeof useViewerStore.getState>['scriptLastDiagnostics'][number] | null,
|
|
551
|
+
};
|
|
552
|
+
let pendingAttachmentsCleared = attachments.length === 0;
|
|
553
|
+
|
|
554
|
+
const clearPendingAttachmentsOnce = () => {
|
|
555
|
+
if (pendingAttachmentsCleared) return;
|
|
556
|
+
clearAttachments();
|
|
557
|
+
pendingAttachmentsCleared = true;
|
|
558
|
+
};
|
|
559
|
+
|
|
560
|
+
const rollbackAssistantTurnIfNeeded = () => {
|
|
561
|
+
if (responseEditState.rolledBack || !responseEditState.appliedAny) return;
|
|
562
|
+
useViewerStore.getState().rollbackAssistantScriptTurn();
|
|
563
|
+
responseEditState.appliedAny = false;
|
|
564
|
+
responseEditState.fallbackApplied = false;
|
|
565
|
+
responseEditState.rolledBack = true;
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
const commitAssistantTurn = () => {
|
|
569
|
+
if (!responseEditState.rolledBack) {
|
|
570
|
+
useViewerStore.getState().commitAssistantScriptTurn();
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
await streamChat({
|
|
575
|
+
proxyUrl: PROXY_URL,
|
|
576
|
+
model: activeModel,
|
|
577
|
+
messages: streamMessages,
|
|
578
|
+
system: systemPrompt,
|
|
579
|
+
authToken,
|
|
580
|
+
signal: abortController.signal,
|
|
581
|
+
onChunk: (chunk) => {
|
|
582
|
+
clearPendingAttachmentsOnce();
|
|
583
|
+
accumulated += chunk;
|
|
584
|
+
if (!responseEditState.applyFailed && responseEditState.intent !== 'repair') {
|
|
585
|
+
const parsed = extractScriptEditOps(accumulated, editParseOptions);
|
|
586
|
+
const freshOps = filterUnappliedScriptOps(parsed.operations, responseEditState.appliedOpIds);
|
|
587
|
+
if (freshOps.length > 0) {
|
|
588
|
+
const applyResult = useViewerStore.getState().applyScriptEditOps(freshOps, {
|
|
589
|
+
acceptedBaseRevision: responseBaseRevision,
|
|
590
|
+
baseContentSnapshot: responseBaseContent,
|
|
591
|
+
priorAcceptedOps: responseEditState.acceptedOps,
|
|
592
|
+
intent: responseEditState.intent,
|
|
593
|
+
});
|
|
594
|
+
if (applyResult.ok) {
|
|
595
|
+
applyResult.appliedOpIds.forEach((id) => responseEditState.appliedOpIds.add(id));
|
|
596
|
+
responseEditState.acceptedOps.push(...freshOps);
|
|
597
|
+
responseEditState.appliedAny = true;
|
|
598
|
+
useViewerStore.getState().setScriptPanelVisible(true);
|
|
599
|
+
} else {
|
|
600
|
+
rollbackAssistantTurnIfNeeded();
|
|
601
|
+
responseEditState.applyFailed = true;
|
|
602
|
+
responseEditState.applyFailureStatus = applyResult.status === 'ok' ? 'semantic_error' : (applyResult.status ?? 'semantic_error');
|
|
603
|
+
responseEditState.applyFailureError = applyResult.error ?? 'unknown error';
|
|
604
|
+
responseEditState.applyFailureDiagnostic = applyResult.diagnostic ?? null;
|
|
605
|
+
setChatError(
|
|
606
|
+
applyResult.status === 'revision_conflict'
|
|
607
|
+
? `Incremental edit apply hit a revision conflict: ${applyResult.error ?? 'unknown error'}`
|
|
608
|
+
: `Incremental edit apply failed: ${applyResult.error ?? 'unknown error'}`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
setChatStatus('streaming');
|
|
614
|
+
updateStreaming(accumulated);
|
|
615
|
+
},
|
|
616
|
+
onComplete: (fullText) => {
|
|
617
|
+
clearPendingAttachmentsOnce();
|
|
618
|
+
const normalizedText = continuationBase
|
|
619
|
+
? stripContinuationOverlap(continuationBase, fullText)
|
|
620
|
+
: fullText;
|
|
621
|
+
const messageId = finalizeAssistant(normalizedText || fullText);
|
|
622
|
+
|
|
623
|
+
if (!responseEditState.applyFailed) {
|
|
624
|
+
const parsed = extractScriptEditOps(fullText, editParseOptions);
|
|
625
|
+
if (parsed.parseErrors.length > 0) {
|
|
626
|
+
if (responseEditState.intent === 'repair') {
|
|
627
|
+
rollbackAssistantTurnIfNeeded();
|
|
628
|
+
responseEditState.applyFailed = true;
|
|
629
|
+
responseEditState.applyFailureDiagnostic = parsed.parseDiagnostics[0] ?? createPatchDiagnostic(
|
|
630
|
+
'patch_semantic_error',
|
|
631
|
+
parsed.parseErrors[0],
|
|
632
|
+
'error',
|
|
633
|
+
{
|
|
634
|
+
failureKind: 'parse_error',
|
|
635
|
+
fixHint: 'Return exactly one valid `ifc-script-edits` block for the current script revision and do not mix it with a `js` fence.',
|
|
636
|
+
},
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
responseEditState.applyFailureStatus = 'parse_error';
|
|
640
|
+
responseEditState.applyFailureError = parsed.parseErrors[0];
|
|
641
|
+
setChatError(parsed.parseErrors[0]);
|
|
642
|
+
}
|
|
643
|
+
const canApplyCompletedOps = !(responseEditState.intent === 'repair' && parsed.parseErrors.length > 0);
|
|
644
|
+
const freshOps = canApplyCompletedOps
|
|
645
|
+
? filterUnappliedScriptOps(parsed.operations, responseEditState.appliedOpIds)
|
|
646
|
+
: [];
|
|
647
|
+
if (freshOps.length > 0) {
|
|
648
|
+
const applyResult = useViewerStore.getState().applyScriptEditOps(freshOps, {
|
|
649
|
+
acceptedBaseRevision: responseBaseRevision,
|
|
650
|
+
baseContentSnapshot: responseBaseContent,
|
|
651
|
+
priorAcceptedOps: responseEditState.acceptedOps,
|
|
652
|
+
intent: responseEditState.intent,
|
|
653
|
+
});
|
|
654
|
+
if (applyResult.ok) {
|
|
655
|
+
applyResult.appliedOpIds.forEach((id) => responseEditState.appliedOpIds.add(id));
|
|
656
|
+
responseEditState.acceptedOps.push(...freshOps);
|
|
657
|
+
responseEditState.appliedAny = true;
|
|
658
|
+
useViewerStore.getState().setScriptPanelVisible(true);
|
|
659
|
+
} else {
|
|
660
|
+
rollbackAssistantTurnIfNeeded();
|
|
661
|
+
responseEditState.applyFailed = true;
|
|
662
|
+
responseEditState.applyFailureStatus = applyResult.status === 'ok' ? 'semantic_error' : (applyResult.status ?? 'semantic_error');
|
|
663
|
+
responseEditState.applyFailureError = applyResult.error ?? 'unknown error';
|
|
664
|
+
responseEditState.applyFailureDiagnostic = applyResult.diagnostic ?? null;
|
|
665
|
+
setChatError(
|
|
666
|
+
applyResult.status === 'revision_conflict'
|
|
667
|
+
? `Incremental edit apply hit a revision conflict: ${applyResult.error ?? 'unknown error'}`
|
|
668
|
+
: `Incremental edit apply failed: ${applyResult.error ?? 'unknown error'}`,
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
if (!responseEditState.appliedAny && !responseEditState.applyFailed && canUsePlainCodeBlockFallback(responseEditState.intent)) {
|
|
675
|
+
const blocks = extractCodeBlocks(fullText);
|
|
676
|
+
if (blocks.length > 0) {
|
|
677
|
+
const lastBlock = blocks[blocks.length - 1];
|
|
678
|
+
const fallbackResult = useViewerStore.getState().replaceScriptContentFallback(lastBlock.code, {
|
|
679
|
+
intent: responseEditState.intent,
|
|
680
|
+
source: 'code_block_fallback',
|
|
681
|
+
});
|
|
682
|
+
if (fallbackResult.ok) {
|
|
683
|
+
useViewerStore.getState().setScriptPanelVisible(true);
|
|
684
|
+
responseEditState.fallbackApplied = true;
|
|
685
|
+
} else {
|
|
686
|
+
responseEditState.applyFailed = true;
|
|
687
|
+
responseEditState.applyFailureStatus = fallbackResult.status === 'ok' ? 'semantic_error' : (fallbackResult.status ?? 'semantic_error');
|
|
688
|
+
responseEditState.applyFailureError = fallbackResult.error ?? 'unknown error';
|
|
689
|
+
responseEditState.applyFailureDiagnostic = fallbackResult.diagnostic ?? null;
|
|
690
|
+
setChatError(`Full-script apply blocked: ${fallbackResult.error ?? 'unknown error'}`);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// Auto-execute if enabled
|
|
696
|
+
const autoExec = useViewerStore.getState().chatAutoExecute;
|
|
697
|
+
if (autoExec) {
|
|
698
|
+
if (responseEditState.appliedAny || responseEditState.fallbackApplied) {
|
|
699
|
+
const currentCode = useViewerStore.getState().scriptEditorContent;
|
|
700
|
+
if (currentCode.trim()) {
|
|
701
|
+
void (async () => {
|
|
702
|
+
const result = await execute(currentCode);
|
|
703
|
+
if (!result) {
|
|
704
|
+
const { scriptLastError, scriptLastDiagnostics, chatStatus } = useViewerStore.getState();
|
|
705
|
+
if (
|
|
706
|
+
scriptLastError &&
|
|
707
|
+
scriptLastError.startsWith('Preflight validation failed:') &&
|
|
708
|
+
chatStatus !== 'sending' &&
|
|
709
|
+
chatStatus !== 'streaming'
|
|
710
|
+
) {
|
|
711
|
+
triggerAutoRepair({
|
|
712
|
+
error: scriptLastError,
|
|
713
|
+
diagnostics: scriptLastDiagnostics,
|
|
714
|
+
reason: 'preflight',
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
})();
|
|
719
|
+
}
|
|
720
|
+
} else if (!responseEditState.applyFailed && responseEditState.intent !== 'repair') {
|
|
721
|
+
const blocks = extractCodeBlocks(fullText);
|
|
722
|
+
if (blocks.length > 0) {
|
|
723
|
+
const lastBlock = blocks[blocks.length - 1];
|
|
724
|
+
useViewerStore.getState().setCodeExecResult(
|
|
725
|
+
messageId,
|
|
726
|
+
lastBlock.index,
|
|
727
|
+
{ status: 'running' },
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
if (responseEditState.applyFailureStatus === 'revision_conflict') {
|
|
734
|
+
const {
|
|
735
|
+
chatStatus,
|
|
736
|
+
} = useViewerStore.getState();
|
|
737
|
+
if (chatStatus !== 'sending' && chatStatus !== 'streaming') {
|
|
738
|
+
triggerAutoRepair({
|
|
739
|
+
error: responseEditState.applyFailureError ?? 'Patch revision conflict.',
|
|
740
|
+
diagnostics: responseEditState.applyFailureDiagnostic ? [responseEditState.applyFailureDiagnostic] : [],
|
|
741
|
+
reason: 'patch-conflict',
|
|
742
|
+
});
|
|
743
|
+
}
|
|
744
|
+
} else if (responseEditState.intent === 'repair' && responseEditState.applyFailed) {
|
|
745
|
+
const {
|
|
746
|
+
chatStatus,
|
|
747
|
+
} = useViewerStore.getState();
|
|
748
|
+
if (chatStatus !== 'sending' && chatStatus !== 'streaming') {
|
|
749
|
+
triggerAutoRepair({
|
|
750
|
+
error: responseEditState.applyFailureError ?? 'Patch apply failed.',
|
|
751
|
+
diagnostics: responseEditState.applyFailureDiagnostic ? [responseEditState.applyFailureDiagnostic] : [],
|
|
752
|
+
reason: 'patch-apply',
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
commitAssistantTurn();
|
|
758
|
+
},
|
|
759
|
+
onUsageInfo: (info: UsageInfo) => {
|
|
760
|
+
setChatUsage(info);
|
|
761
|
+
},
|
|
762
|
+
onFinishReason: (reason) => {
|
|
763
|
+
setLastFinishReason(reason);
|
|
764
|
+
if (reason === 'length') {
|
|
765
|
+
setChatError('Response reached output limit. Click Continue to resume.');
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
onError: (err) => {
|
|
769
|
+
setChatError(err.message);
|
|
770
|
+
setChatAbortController(null);
|
|
771
|
+
commitAssistantTurn();
|
|
772
|
+
},
|
|
773
|
+
});
|
|
774
|
+
if (abortController.signal.aborted) {
|
|
775
|
+
commitAssistantTurn();
|
|
776
|
+
const currentState = useViewerStore.getState();
|
|
777
|
+
if (currentState.chatAbortController === abortController) {
|
|
778
|
+
setChatStatus('idle');
|
|
779
|
+
setChatAbortController(null);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}, [
|
|
783
|
+
status, activeModel, attachments, authToken,
|
|
784
|
+
addMessage, setChatStatus, updateStreaming, finalizeAssistant,
|
|
785
|
+
setChatError, setChatAbortController, clearAttachments, setChatUsage, resizeInput,
|
|
786
|
+
buildRepairPromptFromLiveState, triggerAutoRepair, execute,
|
|
787
|
+
]);
|
|
788
|
+
|
|
789
|
+
const handleSend = useCallback(() => {
|
|
790
|
+
doSend(inputText);
|
|
791
|
+
}, [inputText, doSend]);
|
|
792
|
+
|
|
793
|
+
// Allow other panels (e.g. ScriptPanel errors) to trigger a chat repair turn.
|
|
794
|
+
useEffect(() => {
|
|
795
|
+
if (!pendingPrompt) return;
|
|
796
|
+
if (status === 'sending' || status === 'streaming') return;
|
|
797
|
+
consumePendingPrompt();
|
|
798
|
+
const intent: ScriptMutationIntent | undefined = (
|
|
799
|
+
pendingPrompt.startsWith('The script needs a root-cause repair.')
|
|
800
|
+
|| pendingPrompt.startsWith('The script needs a targeted fix.')
|
|
801
|
+
)
|
|
802
|
+
? 'repair'
|
|
803
|
+
: undefined;
|
|
804
|
+
void doSend(pendingPrompt, { intent });
|
|
805
|
+
}, [pendingPrompt, status, consumePendingPrompt, doSend]);
|
|
806
|
+
|
|
807
|
+
useEffect(() => {
|
|
808
|
+
if (!pendingRepairRequest) return;
|
|
809
|
+
if (status === 'sending' || status === 'streaming') return;
|
|
810
|
+
consumePendingRepairRequest();
|
|
811
|
+
void doSend(buildRepairPromptFromLiveState(pendingRepairRequest), {
|
|
812
|
+
intent: 'repair',
|
|
813
|
+
repairDiagnostics: pendingRepairRequest.diagnostics,
|
|
814
|
+
requestedRepairScope: pendingRepairRequest.requestedRepairScope,
|
|
815
|
+
rootCauseKey: pendingRepairRequest.rootCauseKey,
|
|
816
|
+
});
|
|
817
|
+
}, [pendingRepairRequest, status, consumePendingRepairRequest, buildRepairPromptFromLiveState, doSend]);
|
|
818
|
+
|
|
819
|
+
const handleContinue = useCallback(() => {
|
|
820
|
+
const state = useViewerStore.getState();
|
|
821
|
+
const partial = state.chatStreamingContent.trim();
|
|
822
|
+
const lastAssistant = [...state.chatMessages].reverse().find((m) => m.role === 'assistant');
|
|
823
|
+
const continuationBase = partial || lastAssistant?.content || '';
|
|
824
|
+
if (!continuationBase) return;
|
|
825
|
+
|
|
826
|
+
// Preserve the partial completion in history, then request continuation.
|
|
827
|
+
if (partial) {
|
|
828
|
+
finalizeAssistant(partial);
|
|
829
|
+
}
|
|
830
|
+
setChatError(null);
|
|
831
|
+
doSend(CONTINUE_PROMPT, { continuationBase });
|
|
832
|
+
}, [doSend, finalizeAssistant, setChatError]);
|
|
833
|
+
|
|
834
|
+
const handleStop = useCallback(() => {
|
|
835
|
+
const controller = useViewerStore.getState().chatAbortController;
|
|
836
|
+
if (controller) {
|
|
837
|
+
controller.abort();
|
|
838
|
+
const partial = useViewerStore.getState().chatStreamingContent;
|
|
839
|
+
if (partial) {
|
|
840
|
+
finalizeAssistant(partial);
|
|
841
|
+
} else {
|
|
842
|
+
setChatStatus('idle');
|
|
843
|
+
setChatAbortController(null);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}, [finalizeAssistant, setChatStatus, setChatAbortController]);
|
|
847
|
+
|
|
848
|
+
const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
849
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
850
|
+
e.preventDefault();
|
|
851
|
+
handleSend();
|
|
852
|
+
}
|
|
853
|
+
}, [handleSend]);
|
|
854
|
+
|
|
855
|
+
// ── Error feedback (Fix this) ──
|
|
856
|
+
const handleFixError = useCallback((code: string, errorMsg: string) => {
|
|
857
|
+
const diagnostics = useViewerStore.getState().scriptLastDiagnostics;
|
|
858
|
+
const liveCode = useViewerStore.getState().scriptEditorContent;
|
|
859
|
+
const staleCode = code.trim() !== liveCode.trim() ? code : undefined;
|
|
860
|
+
void doSend(buildRepairPromptFromLiveState({
|
|
861
|
+
error: errorMsg,
|
|
862
|
+
diagnostics,
|
|
863
|
+
staleCodeBlock: staleCode,
|
|
864
|
+
reason: 'runtime',
|
|
865
|
+
}), {
|
|
866
|
+
intent: 'repair',
|
|
867
|
+
repairDiagnostics: diagnostics,
|
|
868
|
+
requestedRepairScope: getPrimaryRootCause(diagnostics)?.repairScope,
|
|
869
|
+
rootCauseKey: getPrimaryRootCause(diagnostics)?.rootCauseKey,
|
|
870
|
+
});
|
|
871
|
+
}, [buildRepairPromptFromLiveState, doSend]);
|
|
872
|
+
|
|
873
|
+
// ── Clickable example prompts ──
|
|
874
|
+
const handleExampleClick = useCallback((prompt: string) => {
|
|
875
|
+
setInputText(prompt);
|
|
876
|
+
inputRef.current?.focus();
|
|
877
|
+
}, []);
|
|
878
|
+
|
|
879
|
+
// ── Clear with confirmation ──
|
|
880
|
+
const handleClearClick = useCallback(() => {
|
|
881
|
+
if (messages.length <= 2) {
|
|
882
|
+
resetScriptEditorForNewChat();
|
|
883
|
+
clearMessages();
|
|
884
|
+
setInputText('');
|
|
885
|
+
setLastFinishReason(null);
|
|
886
|
+
} else {
|
|
887
|
+
setShowClearConfirm(true);
|
|
888
|
+
}
|
|
889
|
+
}, [messages.length, clearMessages, resetScriptEditorForNewChat]);
|
|
890
|
+
|
|
891
|
+
const confirmClear = useCallback(() => {
|
|
892
|
+
resetScriptEditorForNewChat();
|
|
893
|
+
clearMessages();
|
|
894
|
+
setInputText('');
|
|
895
|
+
setLastFinishReason(null);
|
|
896
|
+
setShowClearConfirm(false);
|
|
897
|
+
}, [clearMessages, resetScriptEditorForNewChat]);
|
|
898
|
+
|
|
899
|
+
// ── File upload (button + drag-drop + paste) ──
|
|
900
|
+
const processFiles = useCallback(async (files: FileList | File[]) => {
|
|
901
|
+
const model = getModelById(activeModel);
|
|
902
|
+
const supportsImages = model?.supportsImages ?? false;
|
|
903
|
+
const supportsFileAttachments = model?.supportsFileAttachments ?? true;
|
|
904
|
+
let remainingSlots = Math.max(0, MAX_ATTACHMENTS_PER_MESSAGE - attachments.length);
|
|
905
|
+
|
|
906
|
+
for (const file of Array.from(files)) {
|
|
907
|
+
if (remainingSlots <= 0) {
|
|
908
|
+
setChatError(`You can attach up to ${MAX_ATTACHMENTS_PER_MESSAGE} files per message.`);
|
|
909
|
+
break;
|
|
910
|
+
}
|
|
911
|
+
try {
|
|
912
|
+
// Handle image files
|
|
913
|
+
if (file.type.startsWith('image/')) {
|
|
914
|
+
if (!supportsImages) {
|
|
915
|
+
setChatError('Selected model does not support image input. Switch model to attach images.');
|
|
916
|
+
continue;
|
|
917
|
+
}
|
|
918
|
+
if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) {
|
|
919
|
+
setChatError(`Image attachments must be smaller than ${Math.round(MAX_IMAGE_ATTACHMENT_BYTES / 1_000_000)} MB.`);
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
922
|
+
const base64 = await imageFileToCompressedBase64(file);
|
|
923
|
+
if (base64.length > MAX_INLINE_IMAGE_DATA_URL_CHARS) {
|
|
924
|
+
setChatError('Image attachment is still too large after compression. Please use a smaller image.');
|
|
925
|
+
continue;
|
|
926
|
+
}
|
|
927
|
+
const attachment: FileAttachment = {
|
|
928
|
+
id: createAttachmentId(),
|
|
929
|
+
name: file.name,
|
|
930
|
+
type: 'image/jpeg',
|
|
931
|
+
size: Math.round((base64.length * 3) / 4),
|
|
932
|
+
imageBase64: base64,
|
|
933
|
+
isImage: true,
|
|
934
|
+
};
|
|
935
|
+
addAttachment(attachment);
|
|
936
|
+
remainingSlots -= 1;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
// Only accept text-based files
|
|
940
|
+
if (!file.name.match(/\.(csv|json|txt|tsv)$/i)) continue;
|
|
941
|
+
if (!supportsFileAttachments) {
|
|
942
|
+
setChatError('Selected model does not support file attachments. Switch model to attach files.');
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
if (file.size > MAX_TEXT_ATTACHMENT_BYTES) {
|
|
946
|
+
setChatError(`Text attachments must be smaller than ${Math.round(MAX_TEXT_ATTACHMENT_BYTES / 1024)} KB.`);
|
|
947
|
+
continue;
|
|
948
|
+
}
|
|
949
|
+
const text = await file.text();
|
|
950
|
+
const attachment: FileAttachment = {
|
|
951
|
+
id: createAttachmentId(),
|
|
952
|
+
name: file.name,
|
|
953
|
+
type: file.type || 'text/plain',
|
|
954
|
+
size: file.size,
|
|
955
|
+
textContent: text,
|
|
956
|
+
};
|
|
957
|
+
if (file.name.endsWith('.csv') || file.name.endsWith('.tsv')) {
|
|
958
|
+
const { columns, rows } = parseCSV(text);
|
|
959
|
+
attachment.csvColumns = columns;
|
|
960
|
+
attachment.csvData = rows;
|
|
961
|
+
}
|
|
962
|
+
addAttachment(attachment);
|
|
963
|
+
remainingSlots -= 1;
|
|
964
|
+
} catch (error) {
|
|
965
|
+
setChatError(`Could not read ${file.name}: ${error instanceof Error ? error.message : String(error)}`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}, [activeModel, addAttachment, attachments.length, setChatError]);
|
|
969
|
+
|
|
970
|
+
const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
971
|
+
const files = e.target.files;
|
|
972
|
+
if (!files) return;
|
|
973
|
+
await processFiles(files);
|
|
974
|
+
e.target.value = '';
|
|
975
|
+
}, [processFiles]);
|
|
976
|
+
|
|
977
|
+
// Drag and drop handlers
|
|
978
|
+
const handleDragEnter = useCallback((e: DragEvent) => {
|
|
979
|
+
e.preventDefault();
|
|
980
|
+
e.stopPropagation();
|
|
981
|
+
dragCounterRef.current++;
|
|
982
|
+
if (e.dataTransfer.types.includes('Files')) {
|
|
983
|
+
setIsDragging(true);
|
|
984
|
+
}
|
|
985
|
+
}, []);
|
|
986
|
+
|
|
987
|
+
const handleDragLeave = useCallback((e: DragEvent) => {
|
|
988
|
+
e.preventDefault();
|
|
989
|
+
e.stopPropagation();
|
|
990
|
+
dragCounterRef.current--;
|
|
991
|
+
if (dragCounterRef.current === 0) {
|
|
992
|
+
setIsDragging(false);
|
|
993
|
+
}
|
|
994
|
+
}, []);
|
|
995
|
+
|
|
996
|
+
const handleDragOver = useCallback((e: DragEvent) => {
|
|
997
|
+
e.preventDefault();
|
|
998
|
+
e.stopPropagation();
|
|
999
|
+
}, []);
|
|
1000
|
+
|
|
1001
|
+
const handleDrop = useCallback(async (e: DragEvent) => {
|
|
1002
|
+
e.preventDefault();
|
|
1003
|
+
e.stopPropagation();
|
|
1004
|
+
dragCounterRef.current = 0;
|
|
1005
|
+
setIsDragging(false);
|
|
1006
|
+
|
|
1007
|
+
const files = e.dataTransfer.files;
|
|
1008
|
+
if (files.length > 0) {
|
|
1009
|
+
await processFiles(files);
|
|
1010
|
+
}
|
|
1011
|
+
}, [processFiles]);
|
|
1012
|
+
|
|
1013
|
+
// ── Paste handler for images ──
|
|
1014
|
+
const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
|
|
1015
|
+
const items = e.clipboardData?.items;
|
|
1016
|
+
if (!items) return;
|
|
1017
|
+
|
|
1018
|
+
const imageFiles: File[] = [];
|
|
1019
|
+
for (const item of Array.from(items)) {
|
|
1020
|
+
if (item.type.startsWith('image/')) {
|
|
1021
|
+
const file = item.getAsFile();
|
|
1022
|
+
if (file) imageFiles.push(file);
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
if (imageFiles.length > 0) {
|
|
1027
|
+
e.preventDefault();
|
|
1028
|
+
await processFiles(imageFiles);
|
|
1029
|
+
}
|
|
1030
|
+
}, [processFiles]);
|
|
1031
|
+
|
|
1032
|
+
const isActive = status === 'streaming' || status === 'sending';
|
|
1033
|
+
const modelForUi = getModelById(activeModel);
|
|
1034
|
+
const modelSupportsImages = modelForUi?.supportsImages ?? false;
|
|
1035
|
+
const modelSupportsFiles = modelForUi?.supportsFileAttachments ?? true;
|
|
1036
|
+
const attachmentAccept = [
|
|
1037
|
+
modelSupportsFiles ? '.csv,.json,.txt,.tsv' : '',
|
|
1038
|
+
modelSupportsImages ? 'image/*' : '',
|
|
1039
|
+
].filter(Boolean).join(',');
|
|
1040
|
+
const canAttachInput = modelSupportsFiles || modelSupportsImages;
|
|
1041
|
+
const clerkEnabled = isClerkConfigured();
|
|
1042
|
+
const showUpgradeNudge = Boolean(error && (error.includes('Upgrade to Pro') || error.includes('daily limit')));
|
|
1043
|
+
const showSupportEmail = Boolean(error && error.includes('louis@ltplus.com'));
|
|
1044
|
+
const canContinue = Boolean(
|
|
1045
|
+
!isActive && (streamingContent.trim().length > 0 || lastFinishReason === 'length'),
|
|
1046
|
+
);
|
|
1047
|
+
const openUpgradePage = useCallback(() => {
|
|
1048
|
+
const currentPath = `${window.location.pathname}${window.location.search}`;
|
|
1049
|
+
const target = `/upgrade?returnTo=${encodeURIComponent(currentPath)}`;
|
|
1050
|
+
window.history.pushState({}, '', target);
|
|
1051
|
+
window.dispatchEvent(new PopStateEvent('popstate'));
|
|
1052
|
+
}, []);
|
|
1053
|
+
|
|
1054
|
+
return (
|
|
1055
|
+
<div
|
|
1056
|
+
className="h-full flex flex-col bg-background relative"
|
|
1057
|
+
onDragEnter={handleDragEnter}
|
|
1058
|
+
onDragLeave={handleDragLeave}
|
|
1059
|
+
onDragOver={handleDragOver}
|
|
1060
|
+
onDrop={handleDrop}
|
|
1061
|
+
>
|
|
1062
|
+
{/* Drag overlay */}
|
|
1063
|
+
{isDragging && (
|
|
1064
|
+
<div className="absolute inset-0 z-50 bg-blue-500/10 border-2 border-dashed border-blue-500 rounded-md flex items-center justify-center pointer-events-none">
|
|
1065
|
+
<div className="flex flex-col items-center gap-2 text-blue-500">
|
|
1066
|
+
<Paperclip className="h-8 w-8" />
|
|
1067
|
+
<span className="text-sm font-medium">Drop files or images</span>
|
|
1068
|
+
</div>
|
|
1069
|
+
</div>
|
|
1070
|
+
)}
|
|
1071
|
+
|
|
1072
|
+
{/* Header */}
|
|
1073
|
+
<div className="flex items-center gap-0.5 px-2 py-1 border-b shrink-0">
|
|
1074
|
+
<Tooltip>
|
|
1075
|
+
<TooltipTrigger asChild>
|
|
1076
|
+
<Button
|
|
1077
|
+
variant="ghost"
|
|
1078
|
+
size="icon-xs"
|
|
1079
|
+
onClick={handleClearClick}
|
|
1080
|
+
disabled={messages.length === 0}
|
|
1081
|
+
>
|
|
1082
|
+
<Trash2 className="h-3.5 w-3.5" />
|
|
1083
|
+
</Button>
|
|
1084
|
+
</TooltipTrigger>
|
|
1085
|
+
<TooltipContent>Clear</TooltipContent>
|
|
1086
|
+
</Tooltip>
|
|
1087
|
+
|
|
1088
|
+
<ModelSelector hasPro={hasPro} />
|
|
1089
|
+
<div className="flex-1" />
|
|
1090
|
+
|
|
1091
|
+
<Tooltip>
|
|
1092
|
+
<TooltipTrigger asChild>
|
|
1093
|
+
<Button
|
|
1094
|
+
variant="ghost"
|
|
1095
|
+
size="icon-xs"
|
|
1096
|
+
onClick={() => setAutoExecute(!autoExecute)}
|
|
1097
|
+
className={autoExecute ? 'text-amber-500' : ''}
|
|
1098
|
+
>
|
|
1099
|
+
<Zap className="h-3.5 w-3.5" />
|
|
1100
|
+
</Button>
|
|
1101
|
+
</TooltipTrigger>
|
|
1102
|
+
<TooltipContent>Auto-run: {autoExecute ? 'ON' : 'OFF'}</TooltipContent>
|
|
1103
|
+
</Tooltip>
|
|
1104
|
+
|
|
1105
|
+
{clerkEnabled && (
|
|
1106
|
+
<>
|
|
1107
|
+
<SignedOut>
|
|
1108
|
+
<SignInButton mode="modal">
|
|
1109
|
+
<Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground">
|
|
1110
|
+
Sign in
|
|
1111
|
+
</Button>
|
|
1112
|
+
</SignInButton>
|
|
1113
|
+
</SignedOut>
|
|
1114
|
+
{!hasPro && (
|
|
1115
|
+
<Button
|
|
1116
|
+
variant="ghost"
|
|
1117
|
+
size="sm"
|
|
1118
|
+
className="h-6 px-2 text-xs text-muted-foreground"
|
|
1119
|
+
onClick={openUpgradePage}
|
|
1120
|
+
>
|
|
1121
|
+
Pro
|
|
1122
|
+
</Button>
|
|
1123
|
+
)}
|
|
1124
|
+
<SignedIn>
|
|
1125
|
+
<UserButton afterSignOutUrl="/" />
|
|
1126
|
+
</SignedIn>
|
|
1127
|
+
</>
|
|
1128
|
+
)}
|
|
1129
|
+
|
|
1130
|
+
{onClose && (
|
|
1131
|
+
<Button variant="ghost" size="icon-xs" onClick={onClose}>
|
|
1132
|
+
<X className="h-3.5 w-3.5" />
|
|
1133
|
+
</Button>
|
|
1134
|
+
)}
|
|
1135
|
+
</div>
|
|
1136
|
+
|
|
1137
|
+
{/* Clear confirmation */}
|
|
1138
|
+
{showClearConfirm && (
|
|
1139
|
+
<div className="px-3 py-2 bg-destructive/5 border-b flex items-center gap-2 text-xs">
|
|
1140
|
+
<span className="text-muted-foreground">Clear {messages.length} messages?</span>
|
|
1141
|
+
<Button
|
|
1142
|
+
variant="destructive"
|
|
1143
|
+
size="sm"
|
|
1144
|
+
onClick={confirmClear}
|
|
1145
|
+
className="h-5 px-2 text-xs"
|
|
1146
|
+
>
|
|
1147
|
+
Clear
|
|
1148
|
+
</Button>
|
|
1149
|
+
<Button
|
|
1150
|
+
variant="ghost"
|
|
1151
|
+
size="sm"
|
|
1152
|
+
onClick={() => setShowClearConfirm(false)}
|
|
1153
|
+
className="h-5 px-2 text-xs"
|
|
1154
|
+
>
|
|
1155
|
+
Cancel
|
|
1156
|
+
</Button>
|
|
1157
|
+
</div>
|
|
1158
|
+
)}
|
|
1159
|
+
|
|
1160
|
+
{/* Messages */}
|
|
1161
|
+
<div className="flex-1 min-h-0 overflow-y-auto relative" ref={scrollRef}>
|
|
1162
|
+
{/* Empty state */}
|
|
1163
|
+
{messages.length === 0 && !streamingContent && (
|
|
1164
|
+
<div className="flex flex-col justify-end h-full px-3 pb-2">
|
|
1165
|
+
<p className="text-xs text-muted-foreground/60 mb-2">Try something:</p>
|
|
1166
|
+
<div className="flex flex-col gap-1">
|
|
1167
|
+
{EXAMPLE_PROMPTS.map((prompt) => (
|
|
1168
|
+
<button
|
|
1169
|
+
key={prompt}
|
|
1170
|
+
onClick={() => handleExampleClick(prompt)}
|
|
1171
|
+
className="text-xs text-left px-2.5 py-1.5 rounded border border-transparent hover:border-border hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
|
|
1172
|
+
>
|
|
1173
|
+
{prompt}
|
|
1174
|
+
</button>
|
|
1175
|
+
))}
|
|
1176
|
+
</div>
|
|
1177
|
+
</div>
|
|
1178
|
+
)}
|
|
1179
|
+
|
|
1180
|
+
{/* Message list */}
|
|
1181
|
+
{messages.map((msg) => (
|
|
1182
|
+
<ChatMessageComponent
|
|
1183
|
+
key={msg.id}
|
|
1184
|
+
message={msg}
|
|
1185
|
+
onFixError={handleFixError}
|
|
1186
|
+
/>
|
|
1187
|
+
))}
|
|
1188
|
+
|
|
1189
|
+
{/* Streaming assistant response */}
|
|
1190
|
+
{streamingContent && (
|
|
1191
|
+
<ChatMessageComponent
|
|
1192
|
+
message={{
|
|
1193
|
+
id: 'streaming',
|
|
1194
|
+
role: 'assistant',
|
|
1195
|
+
content: streamingContent,
|
|
1196
|
+
createdAt: Date.now(),
|
|
1197
|
+
codeBlocks: extractCodeBlocks(streamingContent),
|
|
1198
|
+
}}
|
|
1199
|
+
isStreaming
|
|
1200
|
+
/>
|
|
1201
|
+
)}
|
|
1202
|
+
|
|
1203
|
+
{/* Sending indicator */}
|
|
1204
|
+
{status === 'sending' && (
|
|
1205
|
+
<div className="flex items-center gap-2 px-3 py-2 text-muted-foreground">
|
|
1206
|
+
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
1207
|
+
<span className="text-xs">Thinking...</span>
|
|
1208
|
+
</div>
|
|
1209
|
+
)}
|
|
1210
|
+
</div>
|
|
1211
|
+
|
|
1212
|
+
{/* Scroll to bottom button */}
|
|
1213
|
+
{showScrollBtn && (
|
|
1214
|
+
<div className="absolute bottom-[120px] right-4 z-20">
|
|
1215
|
+
<Button
|
|
1216
|
+
variant="outline"
|
|
1217
|
+
size="icon-xs"
|
|
1218
|
+
onClick={scrollToBottom}
|
|
1219
|
+
className="rounded-full shadow-md bg-background"
|
|
1220
|
+
>
|
|
1221
|
+
<ArrowDown className="h-3.5 w-3.5" />
|
|
1222
|
+
</Button>
|
|
1223
|
+
</div>
|
|
1224
|
+
)}
|
|
1225
|
+
|
|
1226
|
+
{/* Error display */}
|
|
1227
|
+
{error && (
|
|
1228
|
+
<div className="px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t flex items-center justify-between gap-2">
|
|
1229
|
+
<span>{error}</span>
|
|
1230
|
+
<div className="flex items-center gap-2">
|
|
1231
|
+
{canContinue && (
|
|
1232
|
+
<Button
|
|
1233
|
+
variant="outline"
|
|
1234
|
+
size="sm"
|
|
1235
|
+
className="h-5 px-2 text-[10px]"
|
|
1236
|
+
onClick={handleContinue}
|
|
1237
|
+
>
|
|
1238
|
+
Continue
|
|
1239
|
+
</Button>
|
|
1240
|
+
)}
|
|
1241
|
+
{showUpgradeNudge && clerkEnabled && (
|
|
1242
|
+
<Button
|
|
1243
|
+
variant="outline"
|
|
1244
|
+
size="sm"
|
|
1245
|
+
className="h-5 px-2 text-[10px]"
|
|
1246
|
+
onClick={openUpgradePage}
|
|
1247
|
+
>
|
|
1248
|
+
Upgrade
|
|
1249
|
+
</Button>
|
|
1250
|
+
)}
|
|
1251
|
+
{showSupportEmail && (
|
|
1252
|
+
<a className="underline text-[10px]" href="mailto:louis@ltplus.com">
|
|
1253
|
+
Contact support
|
|
1254
|
+
</a>
|
|
1255
|
+
)}
|
|
1256
|
+
</div>
|
|
1257
|
+
</div>
|
|
1258
|
+
)}
|
|
1259
|
+
|
|
1260
|
+
{/* Attachments preview */}
|
|
1261
|
+
{attachments.length > 0 && (
|
|
1262
|
+
<div className="px-2 py-1 border-t flex flex-wrap gap-1">
|
|
1263
|
+
{attachments.map((a) => (
|
|
1264
|
+
<span
|
|
1265
|
+
key={a.id}
|
|
1266
|
+
className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-muted text-xs"
|
|
1267
|
+
>
|
|
1268
|
+
{a.isImage ? (
|
|
1269
|
+
<>
|
|
1270
|
+
{a.imageBase64 && (
|
|
1271
|
+
<img
|
|
1272
|
+
src={a.imageBase64}
|
|
1273
|
+
alt={a.name}
|
|
1274
|
+
className="h-6 w-6 object-cover rounded"
|
|
1275
|
+
/>
|
|
1276
|
+
)}
|
|
1277
|
+
<ImageIcon className="h-3 w-3" />
|
|
1278
|
+
</>
|
|
1279
|
+
) : (
|
|
1280
|
+
<Paperclip className="h-3 w-3" />
|
|
1281
|
+
)}
|
|
1282
|
+
{a.name}
|
|
1283
|
+
<button
|
|
1284
|
+
className="ml-0.5 hover:text-destructive"
|
|
1285
|
+
onClick={() => removeAttachment(a.id)}
|
|
1286
|
+
>
|
|
1287
|
+
<X className="h-3 w-3" />
|
|
1288
|
+
</button>
|
|
1289
|
+
</span>
|
|
1290
|
+
))}
|
|
1291
|
+
</div>
|
|
1292
|
+
)}
|
|
1293
|
+
|
|
1294
|
+
{/* Input area */}
|
|
1295
|
+
<div className="shrink-0 border-t p-2">
|
|
1296
|
+
<div className="flex items-end gap-1.5">
|
|
1297
|
+
<input
|
|
1298
|
+
ref={fileInputRef}
|
|
1299
|
+
type="file"
|
|
1300
|
+
accept={attachmentAccept}
|
|
1301
|
+
multiple
|
|
1302
|
+
onChange={handleFileUpload}
|
|
1303
|
+
className="hidden"
|
|
1304
|
+
/>
|
|
1305
|
+
<Tooltip>
|
|
1306
|
+
<TooltipTrigger asChild>
|
|
1307
|
+
<Button
|
|
1308
|
+
variant="ghost"
|
|
1309
|
+
size="icon-xs"
|
|
1310
|
+
onClick={() => fileInputRef.current?.click()}
|
|
1311
|
+
disabled={!canAttachInput}
|
|
1312
|
+
className="shrink-0 mb-0.5"
|
|
1313
|
+
>
|
|
1314
|
+
<Paperclip className="h-3.5 w-3.5" />
|
|
1315
|
+
</Button>
|
|
1316
|
+
</TooltipTrigger>
|
|
1317
|
+
<TooltipContent>
|
|
1318
|
+
{canAttachInput
|
|
1319
|
+
? 'Attach file or image (paste, drag & drop)'
|
|
1320
|
+
: 'Selected model does not support attachments'}
|
|
1321
|
+
</TooltipContent>
|
|
1322
|
+
</Tooltip>
|
|
1323
|
+
|
|
1324
|
+
<textarea
|
|
1325
|
+
ref={inputRef}
|
|
1326
|
+
value={inputText}
|
|
1327
|
+
onChange={(e) => {
|
|
1328
|
+
setInputText(e.target.value);
|
|
1329
|
+
resizeInput();
|
|
1330
|
+
}}
|
|
1331
|
+
onKeyDown={handleKeyDown}
|
|
1332
|
+
onPaste={handlePaste}
|
|
1333
|
+
placeholder="Ask anything..."
|
|
1334
|
+
rows={1}
|
|
1335
|
+
className="flex-1 resize-none rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground px-3 py-1.5 text-sm min-h-[32px] max-h-[120px] focus:outline-none focus:ring-1 focus:ring-ring"
|
|
1336
|
+
style={{ height: 'auto', overflow: 'hidden' }}
|
|
1337
|
+
/>
|
|
1338
|
+
|
|
1339
|
+
{isActive ? (
|
|
1340
|
+
<Tooltip>
|
|
1341
|
+
<TooltipTrigger asChild>
|
|
1342
|
+
<Button
|
|
1343
|
+
variant="ghost"
|
|
1344
|
+
size="icon-xs"
|
|
1345
|
+
onClick={handleStop}
|
|
1346
|
+
className="shrink-0 mb-0.5"
|
|
1347
|
+
>
|
|
1348
|
+
<Square className="h-3.5 w-3.5" />
|
|
1349
|
+
</Button>
|
|
1350
|
+
</TooltipTrigger>
|
|
1351
|
+
<TooltipContent>Stop generating</TooltipContent>
|
|
1352
|
+
</Tooltip>
|
|
1353
|
+
) : (
|
|
1354
|
+
<Tooltip>
|
|
1355
|
+
<TooltipTrigger asChild>
|
|
1356
|
+
<Button
|
|
1357
|
+
variant="default"
|
|
1358
|
+
size="icon-xs"
|
|
1359
|
+
onClick={handleSend}
|
|
1360
|
+
disabled={!inputText.trim()}
|
|
1361
|
+
className="shrink-0 mb-0.5"
|
|
1362
|
+
>
|
|
1363
|
+
<Send className="h-3.5 w-3.5" />
|
|
1364
|
+
</Button>
|
|
1365
|
+
</TooltipTrigger>
|
|
1366
|
+
<TooltipContent>Send (Enter)</TooltipContent>
|
|
1367
|
+
</Tooltip>
|
|
1368
|
+
)}
|
|
1369
|
+
</div>
|
|
1370
|
+
<div className="flex items-center justify-between mt-1 px-0.5">
|
|
1371
|
+
{isActive ? (
|
|
1372
|
+
<span className="text-[10px] text-muted-foreground/50">Streaming...</span>
|
|
1373
|
+
) : displayUsage ? (
|
|
1374
|
+
<Tooltip>
|
|
1375
|
+
<TooltipTrigger asChild>
|
|
1376
|
+
<div className="flex items-center gap-1.5 cursor-default">
|
|
1377
|
+
<div className="w-12 h-1 bg-muted rounded-full overflow-hidden">
|
|
1378
|
+
<div
|
|
1379
|
+
className={`h-full rounded-full transition-all ${displayUsage.pct >= 90 ? 'bg-destructive' : displayUsage.pct >= 70 ? 'bg-amber-500' : 'bg-emerald-500'
|
|
1380
|
+
}`}
|
|
1381
|
+
style={{ width: `${Math.min(100, displayUsage.pct)}%` }}
|
|
1382
|
+
/>
|
|
1383
|
+
</div>
|
|
1384
|
+
<span className="text-[10px] text-muted-foreground/40 tabular-nums">{displayUsage.pct}%</span>
|
|
1385
|
+
</div>
|
|
1386
|
+
</TooltipTrigger>
|
|
1387
|
+
<TooltipContent>
|
|
1388
|
+
{displayUsage.type === 'credits'
|
|
1389
|
+
? `${displayUsage.used}/${displayUsage.limit} credits · resets ${usageResetLabel}`
|
|
1390
|
+
: `${displayUsage.used}/${displayUsage.limit} requests · resets ${usageResetLabel}`
|
|
1391
|
+
}
|
|
1392
|
+
</TooltipContent>
|
|
1393
|
+
</Tooltip>
|
|
1394
|
+
) : (
|
|
1395
|
+
<span className="text-[10px] text-muted-foreground/40">Shift+Enter new line</span>
|
|
1396
|
+
)}
|
|
1397
|
+
<span className="text-[10px] text-muted-foreground/30">⌘L</span>
|
|
1398
|
+
</div>
|
|
1399
|
+
</div>
|
|
1400
|
+
</div>
|
|
1401
|
+
);
|
|
1402
|
+
}
|