Skip to content

Commit 4787f4e

Browse files
feat: enhance notes collaboration, security, and map stability
- Fixed map zooming loop by clearing targetPosition and removing redundant updates. - Improved user management to handle existing Supabase Auth accounts and trigger invitations. - Secured AI prompts and database queries against injection and LIKE wildcard manipulation. - Implemented row-level filtered Supabase Realtime for collaborative notes. - Resolved build errors and synchronized branch with latest main via rebase. - Added namespaced 'noteMarkers' to preserve map state during note taking. Co-authored-by: ngoiyaeric <115367894+ngoiyaeric@users.noreply.github.com>
1 parent 40e4b9b commit 4787f4e

24 files changed

Lines changed: 745 additions & 461 deletions

app/actions.tsx

Lines changed: 61 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
getMutableAIState
88
} from 'ai/rsc'
99
import { CoreMessage, ToolResultPart } from 'ai'
10-
import { nanoid } from 'nanoid'
10+
import { nanoid } from '@/lib/utils'
1111
import type { FeatureCollection } from 'geojson'
1212
import { Spinner } from '@/components/ui/spinner'
1313
import { Section } from '@/components/section'
@@ -21,6 +21,7 @@ import { BotMessage } from '@/components/message'
2121
import { SearchSection } from '@/components/search-section'
2222
import SearchRelated from '@/components/search-related'
2323
import { GeoJsonLayer } from '@/components/map/geojson-layer'
24+
import { ResolutionCarousel } from '@/components/resolution-carousel'
2425
import { ResolutionImage } from '@/components/resolution-image'
2526
import { CopilotDisplay } from '@/components/copilot-display'
2627
import RetrieveSection from '@/components/retrieve-section'
@@ -50,18 +51,29 @@ async function submit(formData?: FormData, skip?: boolean) {
5051
}
5152

5253
if (action === 'resolution_search') {
53-
const file = formData?.get('file') as File;
54+
const file_mapbox = formData?.get('file_mapbox') as File;
55+
const file_google = formData?.get('file_google') as File;
56+
const file = (formData?.get('file') as File) || file_mapbox || file_google;
5457
const timezone = (formData?.get('timezone') as string) || 'UTC';
58+
const lat = formData?.get('latitude') ? parseFloat(formData.get('latitude') as string) : undefined;
59+
const lng = formData?.get('longitude') ? parseFloat(formData.get('longitude') as string) : undefined;
60+
const location = (lat !== undefined && lng !== undefined) ? { lat, lng } : undefined;
5561

5662
if (!file) {
5763
throw new Error('No file provided for resolution search.');
5864
}
5965

66+
const mapboxBuffer = file_mapbox ? await file_mapbox.arrayBuffer() : null;
67+
const mapboxDataUrl = mapboxBuffer ? `data:${file_mapbox.type};base64,${Buffer.from(mapboxBuffer).toString('base64')}` : null;
68+
69+
const googleBuffer = file_google ? await file_google.arrayBuffer() : null;
70+
const googleDataUrl = googleBuffer ? `data:${file_google.type};base64,${Buffer.from(googleBuffer).toString('base64')}` : null;
71+
6072
const buffer = await file.arrayBuffer();
6173
const dataUrl = `data:${file.type};base64,${Buffer.from(buffer).toString('base64')}`;
6274

6375
const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
64-
message =>
76+
(message: any) =>
6577
message.role !== 'tool' &&
6678
message.type !== 'followup' &&
6779
message.type !== 'related' &&
@@ -89,7 +101,7 @@ async function submit(formData?: FormData, skip?: boolean) {
89101

90102
async function processResolutionSearch() {
91103
try {
92-
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures);
104+
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures, location);
93105

94106
let fullSummary = '';
95107
for await (const partialObject of streamResult.partialObjectStream) {
@@ -113,7 +125,7 @@ async function submit(formData?: FormData, skip?: boolean) {
113125

114126
messages.push({ role: 'assistant', content: analysisResult.summary || 'Analysis complete.' });
115127

116-
const sanitizedMessages: CoreMessage[] = messages.map(m => {
128+
const sanitizedMessages: CoreMessage[] = messages.map((m: any) => {
117129
if (Array.isArray(m.content)) {
118130
return {
119131
...m,
@@ -124,7 +136,7 @@ async function submit(formData?: FormData, skip?: boolean) {
124136
})
125137

126138
const currentMessages = aiState.get().messages;
127-
const sanitizedHistory = currentMessages.map(m => {
139+
const sanitizedHistory = currentMessages.map((m: any) => {
128140
if (m.role === "user" && Array.isArray(m.content)) {
129141
return {
130142
...m,
@@ -159,7 +171,9 @@ async function submit(formData?: FormData, skip?: boolean) {
159171
role: 'assistant',
160172
content: JSON.stringify({
161173
...analysisResult,
162-
image: dataUrl
174+
image: dataUrl,
175+
mapboxImage: mapboxDataUrl,
176+
googleImage: googleDataUrl
163177
}),
164178
type: 'resolution_search_result'
165179
},
@@ -190,7 +204,11 @@ async function submit(formData?: FormData, skip?: boolean) {
190204

191205
uiStream.update(
192206
<Section title="response">
193-
<ResolutionImage src={dataUrl} />
207+
<ResolutionCarousel
208+
mapboxImage={mapboxDataUrl || undefined}
209+
googleImage={googleDataUrl || undefined}
210+
initialImage={dataUrl}
211+
/>
194212
<BotMessage content={summaryStream.value} />
195213
</Section>
196214
);
@@ -203,43 +221,20 @@ async function submit(formData?: FormData, skip?: boolean) {
203221
};
204222
}
205223

206-
const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
207-
message =>
208-
message.role !== 'tool' &&
209-
message.type !== 'followup' &&
210-
message.type !== 'related' &&
211-
message.type !== 'end' &&
212-
message.type !== 'resolution_search_result'
213-
).map(m => {
214-
if (Array.isArray(m.content)) {
215-
return {
216-
...m,
217-
content: m.content.filter((part: any) =>
218-
part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:"))
219-
)
220-
} as any
221-
}
222-
return m
223-
})
224-
225-
const groupeId = nanoid()
226-
const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true'
227-
const maxMessages = useSpecificAPI ? 5 : 10
228-
messages.splice(0, Math.max(messages.length - maxMessages, 0))
229-
224+
const file = !skip ? (formData?.get('file') as File) : undefined
230225
const userInput = skip
231226
? `{"action": "skip"}`
232227
: ((formData?.get('related_query') as string) ||
233228
(formData?.get('input') as string))
234229

235-
if (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?') {
230+
if (userInput && (userInput.toLowerCase().trim() === 'what is a planet computer?' || userInput.toLowerCase().trim() === 'what is qcx-terra?')) {
236231
const definition = userInput.toLowerCase().trim() === 'what is a planet computer?'
237232
? `A planet computer is a proprietary environment aware system that interoperates weather forecasting, mapping and scheduling using cutting edge multi-agents to streamline automation and exploration on a planet. Available for our Pro and Enterprise customers. [QCX Pricing](https://www.queue.cx/#pricing)`
238-
239233
: `QCX-Terra is a model garden of pixel level precision geospatial foundational models for efficient land feature predictions from satellite imagery. Available for our Pro and Enterprise customers. [QCX Pricing] (https://www.queue.cx/#pricing)`;
240234

241235
const content = JSON.stringify(Object.fromEntries(formData!));
242236
const type = 'input';
237+
const groupeId = nanoid();
243238

244239
aiState.update({
245240
...aiState.get(),
@@ -299,10 +294,9 @@ async function submit(formData?: FormData, skip?: boolean) {
299294
id: nanoid(),
300295
isGenerating: isGenerating.value,
301296
component: uiStream.value,
302-
isCollapsed: isCollapsed.value,
297+
isCollapsed: isCollapsed.value
303298
};
304299
}
305-
const file = !skip ? (formData?.get('file') as File) : undefined
306300

307301
if (!userInput && !file) {
308302
isGenerating.done(false)
@@ -314,6 +308,30 @@ async function submit(formData?: FormData, skip?: boolean) {
314308
}
315309
}
316310

311+
const messages: CoreMessage[] = [...(aiState.get().messages as any[])].filter(
312+
(message: any) =>
313+
message.role !== 'tool' &&
314+
message.type !== 'followup' &&
315+
message.type !== 'related' &&
316+
message.type !== 'end' &&
317+
message.type !== 'resolution_search_result'
318+
).map((m: any) => {
319+
if (Array.isArray(m.content)) {
320+
return {
321+
...m,
322+
content: m.content.filter((part: any) =>
323+
part.type !== "image" || (typeof part.image === "string" && part.image.startsWith("data:"))
324+
)
325+
} as any
326+
}
327+
return m
328+
})
329+
330+
const groupeId = nanoid()
331+
const useSpecificAPI = process.env.USE_SPECIFIC_API_FOR_WRITER === 'true'
332+
const maxMessages = useSpecificAPI ? 5 : 10
333+
messages.splice(0, Math.max(messages.length - maxMessages, 0))
334+
317335
const messageParts: {
318336
type: 'text' | 'image'
319337
text?: string
@@ -725,12 +743,18 @@ export const getUIStateFromAIState = (aiState: AIState): UIState => {
725743
const analysisResult = JSON.parse(content as string);
726744
const geoJson = analysisResult.geoJson as FeatureCollection;
727745
const image = analysisResult.image as string;
746+
const mapboxImage = analysisResult.mapboxImage as string;
747+
const googleImage = analysisResult.googleImage as string;
728748

729749
return {
730750
id,
731751
component: (
732752
<>
733-
{image && <ResolutionImage src={image} />}
753+
<ResolutionCarousel
754+
mapboxImage={mapboxImage}
755+
googleImage={googleImage}
756+
initialImage={image}
757+
/>
734758
{geoJson && (
735759
<GeoJsonLayer id={id} data={geoJson} />
736760
)}

app/layout.tsx

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ export default function RootLayout({
112112
<Header />
113113
<ConditionalLottie />
114114
{children}
115-
<Sidebar />
116115
<HistorySidebar />
117116
<Footer />
118117
<Toaster />

app/page.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Chat } from '@/components/chat'
2-
import {nanoid } from 'nanoid'
2+
import { nanoid } from '@/lib/utils'
33
import { AI } from './actions'
44

55
export const maxDuration = 60

chat-panel.patch

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<<<<<<< SEARCH
2+
// New chat button (appears when there are messages)
3+
if (messages.length > 0 && !isMobile) {
4+
return (
5+
<div
6+
className={cn(
7+
'fixed bottom-2 left-2 flex justify-start items-center pointer-events-none',
8+
isMobile ? 'w-full px-2' : 'md:bottom-8'
9+
)}
10+
>
11+
<Button
12+
type="button"
13+
variant={'secondary'}
14+
className="rounded-full bg-secondary/80 group transition-all hover:scale-105 pointer-events-auto"
15+
onClick={() => handleClear()}
16+
data-testid="new-chat-button"
17+
>
18+
<span className="text-sm mr-2 group-hover:block hidden animate-in fade-in duration-300">
19+
New
20+
</span>
21+
<Plus size={18} className="group-hover:rotate-90 transition-all" />
22+
</Button>
23+
</div>
24+
)
25+
}
26+
=======
27+
// New chat button (appears when there are messages)
28+
if (messages.length > 0 && !isMobile) {
29+
return (
30+
<div
31+
className={cn(
32+
'fixed bottom-4 left-4 flex justify-start items-center pointer-events-none z-50'
33+
)}
34+
>
35+
<Button
36+
type="button"
37+
variant={'ghost'}
38+
size={'icon'}
39+
className="rounded-full transition-all hover:scale-110 pointer-events-auto text-primary"
40+
onClick={() => handleClear()}
41+
data-testid="new-chat-button"
42+
title="New Chat"
43+
>
44+
<Sprout size={28} className="fill-primary/20" />
45+
</Button>
46+
</div>
47+
)
48+
}
49+
>>>>>>> REPLACE

components/chat-panel.tsx

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,9 @@ import { useUIState, useActions, readStreamableValue } from 'ai/rsc'
66
import { cn } from '@/lib/utils'
77
import { UserMessage } from './user-message'
88
import { Button } from './ui/button'
9-
import { ArrowRight, Plus, Paperclip, X } from 'lucide-react'
9+
import { ArrowRight, Plus, Paperclip, X, Sprout } from 'lucide-react'
1010
import Textarea from 'react-textarea-autosize'
11-
import { nanoid } from 'nanoid'
11+
import { nanoid } from '@/lib/utils'
1212
import { useSettingsStore } from '@/lib/store/settings'
1313
import { PartialRelated } from '@/lib/schema/related'
1414
import { getSuggestions } from '@/lib/actions/suggest'
@@ -166,21 +166,19 @@ export const ChatPanel = forwardRef<ChatPanelRef, ChatPanelProps>(({ messages, i
166166
return (
167167
<div
168168
className={cn(
169-
'fixed bottom-2 left-2 flex justify-start items-center pointer-events-none',
170-
isMobile ? 'w-full px-2' : 'md:bottom-8'
169+
'fixed bottom-4 left-4 flex justify-start items-center pointer-events-none z-50'
171170
)}
172171
>
173172
<Button
174173
type="button"
175-
variant={'secondary'}
176-
className="rounded-full bg-secondary/80 group transition-all hover:scale-105 pointer-events-auto"
174+
variant={'ghost'}
175+
size={'icon'}
176+
className="rounded-full transition-all hover:scale-110 pointer-events-auto text-primary"
177177
onClick={() => handleClear()}
178178
data-testid="new-chat-button"
179+
title="New Chat"
179180
>
180-
<span className="text-sm mr-2 group-hover:block hidden animate-in fade-in duration-300">
181-
New
182-
</span>
183-
<Plus size={18} className="group-hover:rotate-90 transition-all" />
181+
<Sprout size={28} className="fill-primary/20" />
184182
</Button>
185183
</div>
186184
)

0 commit comments

Comments
 (0)