Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 30 additions & 6 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -103,23 +103,24 @@ async function submit(formData?: FormData, skip?: boolean) {
try {
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures, location);

let partialObject: any;
let fullSummary = '';
for await (const partialObject of streamResult.partialObjectStream) {
for await (partialObject of streamResult.partialObjectStream) {
if (partialObject.summary) {
fullSummary = partialObject.summary;
summaryStream.update(fullSummary);
}
}

const analysisResult = await streamResult.object;
const analysisResult: any = await streamResult.object;
summaryStream.done(analysisResult.summary || 'Analysis complete.');

// Reconstruct standard GeoJSON from flattened schema if present
let geoJson: FeatureCollection | null = null;
if (analysisResult.geoJson && analysisResult.geoJson.features) {
geoJson = {
type: 'FeatureCollection',
features: analysisResult.geoJson.features.map(f => ({
features: analysisResult.geoJson.features.map( (f: any) => ({
type: 'Feature',
geometry: {
type: f.geometryType as any,
Expand Down Expand Up @@ -334,6 +335,9 @@ async function submit(formData?: FormData, skip?: boolean) {
}
}

const { getChatNotes } = await import('@/lib/actions/calendar')
const calendarNotes = await getChatNotes(aiState.get().chatId)

let filteredImagesCount = 0
let retainedImagesCount = 0
const messages: CoreMessage[] = [...(aiState.get().messages as any[])]
Expand Down Expand Up @@ -525,14 +529,34 @@ async function submit(formData?: FormData, skip?: boolean) {
messages,
mapProvider,
useSpecificAPI,
drawnFeatures
drawnFeatures,
calendarNotes
)
answer = fullResponse
toolOutputs = toolResponses
errorOccurred = hasError

if (toolOutputs.length > 0) {
toolOutputs.map(output => {
await Promise.all(toolOutputs.map(async output => {
const result = output.result as any

if (output.toolName === 'calendarTool' && result.type === 'CALENDAR_ACTION') {
const { saveNote, getChatNotes } = await import('@/lib/actions/calendar')
if (result.action === 'create') {
const saved = await saveNote({
...result.note,
chatId: aiState.get().chatId,
userId: '', // set in saveNote
userTags: null,
mapFeatureId: null
})
output.result = { success: true, note: saved }
} else if (result.action === 'list') {
Comment on lines +546 to +554

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Do not emit success: true when note persistence fails.

Line 542 can return null from saveNote, but Line 549 always reports success. That creates false confirmations and inconsistent chat/calendar state.

Suggested fix
              if (result.action === 'create') {
                 const saved = await saveNote({
                   ...result.note,
                   chatId: aiState.get().chatId,
                   userId: '', // set in saveNote
                   userTags: null,
                   mapFeatureId: null
                 })
-                output.result = { success: true, note: saved }
+                output.result = saved
+                  ? { success: true, note: saved }
+                  : { success: false, error: 'Failed to save calendar note' }
              } else if (result.action === 'list') {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/actions.tsx` around lines 542 - 550, The code unconditionally sets
output.result = { success: true, note: saved } even though saveNote(...) can
return null; update the block that calls saveNote (using result.note and
aiState.get().chatId) to check the returned value (saved) and only set success:
true and include the note when saved is non-null; if saved is null, set
output.result = { success: false } (and optionally include an error message) and
avoid mutating chat/calendar state or treating it as persisted. Ensure you
reference the saveNote call and output.result assignment so the conditional
wraps only the successful-persistence path.

const notes = await getChatNotes(aiState.get().chatId)
output.result = { success: true, notes }
}
}

aiState.update({
...aiState.get(),
messages: [
Expand All @@ -546,7 +570,7 @@ async function submit(formData?: FormData, skip?: boolean) {
}
]
})
})
}))
}
}

Expand Down
1,421 changes: 969 additions & 452 deletions bun.lock

Large diffs are not rendered by default.

4 changes: 1 addition & 3 deletions components/calendar-toggle-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,7 @@ export const CalendarToggleProvider = ({ children }: { children: ReactNode }) =>
const [isCalendarOpen, setIsCalendarOpen] = useState(false)

const toggleCalendar = () => {
startTransition(() => {
setIsCalendarOpen(prevState => !prevState)
})
setIsCalendarOpen(prevState => !prevState)
}

return (
Expand Down
6 changes: 3 additions & 3 deletions components/map/mapbox-map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number
map.current.off('draw.create', updateMeasurementLabels)
map.current.off('draw.delete', updateMeasurementLabels)
map.current.off('draw.update', updateMeasurementLabels)
map.current.removeControl(drawRef.current)
map.current.removeControl(drawRef.current as any)
drawRef.current = null

// Clean up any existing labels
Expand Down Expand Up @@ -282,7 +282,7 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number
})

// Add control to map
map.current.addControl(drawRef.current, 'top-right')
map.current.addControl(drawRef.current as any, 'top-right')

// Add navigation control only on desktop
if (window.innerWidth > 768) {
Expand Down Expand Up @@ -509,7 +509,7 @@ export const Mapbox: React.FC<{ position?: { latitude: number; longitude: number
map.current.off('draw.create', updateMeasurementLabels)
map.current.off('draw.delete', updateMeasurementLabels)
map.current.off('draw.update', updateMeasurementLabels)
map.current.removeControl(drawRef.current)
map.current.removeControl(drawRef.current as any)
drawRef.current = null

// Clean up any existing labels
Expand Down
3 changes: 2 additions & 1 deletion components/message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ export function BotMessage({ content }: { content: StreamableValue<string> }) {

return (
<div className="overflow-x-auto">
<div className="prose-sm prose-neutral prose-a:text-accent-foreground/50">
<MemoizedReactMarkdown
rehypePlugins={[[rehypeExternalLinks, { target: '_blank' }], rehypeKatex]}
remarkPlugins={[remarkGfm, remarkMath]}
className="prose-sm prose-neutral prose-a:text-accent-foreground/50"
>
{processedData}
</MemoizedReactMarkdown>
</div>
</div>
)
}
Expand Down
2 changes: 1 addition & 1 deletion components/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import * as React from 'react'
import { ThemeProvider as NextThemesProvider } from 'next-themes'
import { type ThemeProviderProps } from 'next-themes/dist/types'
import { type ThemeProviderProps } from 'next-themes'

export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
Expand Down
3 changes: 1 addition & 2 deletions components/ui/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,5 @@ import ReactMarkdown, { Options } from 'react-markdown'
export const MemoizedReactMarkdown: FC<Options> = memo(
ReactMarkdown,
(prevProps, nextProps) =>
prevProps.children === nextProps.children &&
prevProps.className === nextProps.className
prevProps.children === nextProps.children
Comment on lines 6 to +7

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify where MemoizedReactMarkdown is used and whether non-children props vary.
rg -n -C3 '\bMemoizedReactMarkdown\b'
rg -n -C3 '<MemoizedReactMarkdown[^>]*className=|<MemoizedReactMarkdown[^>]*components=|<MemoizedReactMarkdown[^>]*remarkPlugins=|<MemoizedReactMarkdown[^>]*rehypePlugins='

Repository: QueueLab/QCX

Length of output: 1627


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== components/ui/markdown.tsx (lines 1-120) ==="
nl -ba components/ui/markdown.tsx | sed -n '1,120p'

echo "=== components/message.tsx (around MemoizedReactMarkdown usage) ==="
nl -ba components/message.tsx | sed -n '1,120p'

Repository: QueueLab/QCX

Length of output: 166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "=== components/ui/markdown.tsx (first 160 lines) ==="
cat -n components/ui/markdown.tsx | sed -n '1,160p'

echo "=== components/message.tsx (first 220 lines) ==="
cat -n components/message.tsx | sed -n '1,220p'

Repository: QueueLab/QCX

Length of output: 2287


🏁 Script executed:

#!/bin/bash
set -euo pipefail

rg -n "MemoizedReactMarkdown" -S

Repository: QueueLab/QCX

Length of output: 332


Custom memo comparator only checks children—brittle for future props, but not a current issue here.

MemoizedReactMarkdown is only used in components/message.tsx, and that call site always passes the same remarkPlugins/rehypePlugins values (even though the arrays/options are re-created each render). Since markdown content changes drive children, the component still updates when needed. The main risk is for future call sites where non-children props change while children stays the same; in that case this comparator could suppress updates. Consider removing the custom comparator or using a comparator that accounts for the relevant props.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/ui/markdown.tsx` around lines 6 - 7, The custom memo comparator
currently used for MemoizedReactMarkdown only compares prevProps.children ===
nextProps.children which can suppress updates if other props change; update the
memo usage by either removing the custom comparator so React.memo uses default
shallow prop comparison, or replace the comparator with a shallow equality that
also checks remarkPlugins and rehypePlugins (and any other passed props) by
comparing prevProps.remarkPlugins === nextProps.remarkPlugins &&
prevProps.rehypePlugins === nextProps.rehypePlugins && prevProps.children ===
nextProps.children; locate the memo wrapper around the ReactMarkdown component
(the function using (prevProps, nextProps) => prevProps.children ===
nextProps.children and the exported MemoizedReactMarkdown) and apply one of
these fixes.

)
36 changes: 35 additions & 1 deletion lib/actions/calendar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,33 @@ export async function getNotes(date: Date, chatId: string | null): Promise<Calen
}
}

/**
* Retrieves all notes for a specific chat session.
* @param chatId - The ID of the chat session.
* @returns A promise that resolves to an array of notes.
*/
export async function getChatNotes(chatId: string): Promise<CalendarNote[]> {
const userId = await getCurrentUserIdOnServer()
if (!userId) {
console.error('getChatNotes: User not authenticated')
return []
}

try {
const notes = await db
.select()
.from(calendarNotes)
.where(and(eq(calendarNotes.userId, userId), eq(calendarNotes.chatId, chatId)))
.orderBy(desc(calendarNotes.date))
.execute()

return notes
} catch (error) {
console.error('Error fetching chat notes:', error)
return []
}
}

/**
* Saves a new note or updates an existing one.
* @param noteData - The note data to save.
Expand Down Expand Up @@ -84,9 +111,16 @@ export async function saveNote(noteData: NewCalendarNote | CalendarNote): Promis
} else {
// Create new note
try {
// Parse @mentions from content
const mentions = noteData.content.match(/@(\w+)/g)?.map(m => m.substring(1)) || null;
const updatedNoteData = {
...noteData,
userTags: mentions
};
Comment on lines +114 to +119

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Deduplicate extracted @mentions.

The mention extraction correctly parses @username patterns and removes the @ prefix, but it doesn't deduplicate. If a note contains @alice @bob @alice``, all three mentions will be stored. Based on learnings, collaborator tags should be deduplicated to avoid redundant entries.

🔧 Proposed fix
             // Parse `@mentions` from content
-            const mentions = noteData.content.match(/@(\w+)/g)?.map(m => m.substring(1)) || null;
+            const matches = noteData.content.match(/@(\w+)/g);
+            const mentions = matches ? [...new Set(matches.map(m => m.substring(1)))] : null;
             const updatedNoteData = {
                 ...noteData,
                 userTags: mentions
             };
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@lib/actions/calendar.ts` around lines 114 - 119, The mention extraction
stores duplicate tags; update the logic that builds mentions (currently reading
noteData.content and creating the mentions variable) to deduplicate entries
before assigning to updatedNoteData.userTags — e.g., collect matches via
noteData.content.match(/@(\w+)/g), strip the '@' as you already do, then create
a unique list (use a Set or Array.from(new Set(...)) to preserve insertion
order) and keep null when there are no matches; ensure the transformed variable
name mentions is the deduplicated array assigned into updatedNoteData.userTags.

Source: Learnings


const [newNote] = await db
.insert(calendarNotes)
.values({ ...noteData, userId })
.values({ ...updatedNoteData, userId })
.returning();

if (newNote && newNote.chatId) {
Expand Down
2 changes: 1 addition & 1 deletion lib/actions/suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ export async function getSuggestions(

;(async () => {
const result = await streamObject({
model: (await getModel()) as LanguageModel,
model: (await getModel()) as any,
system: systemPrompt,
messages: [{ role: 'user', content: query }],
schema: relatedSchema
Expand Down
2 changes: 1 addition & 1 deletion lib/agents/inquire.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export async function inquire(

let finalInquiry: PartialInquiry = {};
const result = await streamObject({
model: (await getModel()) as LanguageModel,
model: (await getModel()) as any,
system: `You are a helpful assistant that gathers clarifying information from the user.
Generate a structured inquiry with a clear question, multiple choice options, and optionally allow free-text input.
Ensure the inquiry is concise and helps narrow down the user's intent.`,
Expand Down
2 changes: 1 addition & 1 deletion lib/agents/query-suggestor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export async function querySuggestor(

// OPTIMIZATION: Use a more concise system prompt to reduce token usage
const result = await streamObject({
model: (await getModel()) as LanguageModel,
model: (await getModel()) as any,
system: `Generate 3 follow-up queries that explore the subject matter deeper. Format as JSON with an "items" array containing objects with "query" fields. Keep queries concise and relevant.`,
messages,
schema: relatedSchema,
Expand Down
19 changes: 15 additions & 4 deletions lib/agents/researcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,12 @@ import { getTools } from './tools'
import { getModel } from '../utils'
import { MapProvider } from '@/lib/store/settings'
import { DrawnFeature } from './resolution-search'
import { CalendarNote } from '@/lib/types'

// This magic tag lets us write raw multi-line strings with backticks, arrows, etc.
const raw = String.raw

const getDefaultSystemPrompt = (date: string, drawnFeatures?: DrawnFeature[]) => raw`
const getDefaultSystemPrompt = (date: string, drawnFeatures?: DrawnFeature[], calendarNotes?: CalendarNote[]) => raw`
As a comprehensive AI assistant, your primary directive is **Exploration Efficiency**. You must use the provided tools judiciously to gather information and formulate a response.

Current date and time: ${date}.
Expand All @@ -26,6 +27,11 @@ ${drawnFeatures && drawnFeatures.length > 0 ? `The user has drawn the following
${drawnFeatures.map(f => `- ${f.type} with measurement ${f.measurement}`).join('\n')}
Use these user-drawn areas/lines as primary areas of interest for your analysis if applicable to the query.` : ''}

${calendarNotes && calendarNotes.length > 0 ? `### **Calendar Context**
The following notes are relevant to this session:
${calendarNotes.map(n => `- [${new Date(n.date).toLocaleDateString()}]: ${n.content}`).join('\n')}
` : ''}

**Exploration Efficiency Directives:**
1. **Tool First:** Always check if a tool can directly or partially answer the user's query. Use the most specific tool available.
2. **Geospatial Priority:** For any query involving locations, places, addresses, geographical features, finding businesses, distances, or directions → you **MUST** use the 'geospatialQueryTool'.
Expand Down Expand Up @@ -86,7 +92,8 @@ export async function researcher(
messages: CoreMessage[],
mapProvider: MapProvider,
useSpecificModel?: boolean,
drawnFeatures?: DrawnFeature[]
drawnFeatures?: DrawnFeature[],
calendarNotes?: CalendarNote[]
) {
let fullResponse = ''
let hasError = false
Expand All @@ -102,7 +109,7 @@ export async function researcher(
const systemPromptToUse =
dynamicSystemPrompt?.trim()
? dynamicSystemPrompt
: getDefaultSystemPrompt(currentDate, drawnFeatures)
: getDefaultSystemPrompt(currentDate, drawnFeatures, calendarNotes)

// Check if any message contains an image
const hasImage = messages.some(message =>
Expand Down Expand Up @@ -133,7 +140,7 @@ export async function researcher(
})

const result = await nonexperimental_streamText({
model: (await getModel(hasImage)) as LanguageModel,
model: (await getModel(hasImage)) as any,
maxTokens: 4096,
system: systemPromptToUse,
messages,
Expand Down Expand Up @@ -185,5 +192,9 @@ export async function researcher(
messages.push({ role: 'tool', content: toolResponses })
}

// Log token usage
const usage = await result.usage;
console.log('AI Generation Usage:', usage);

return { result, fullResponse, hasError, toolResponses }
}
2 changes: 1 addition & 1 deletion lib/agents/resolution-search.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ Analyze the user's prompt and the image to provide a holistic understanding of t

// Use streamObject to get partial results.
return streamObject({
model: await getModel(hasImage),
model: (await getModel(hasImage)) as any,
system: systemPrompt,
messages: filteredMessages,
schema: resolutionSearchSchema,
Expand Down
2 changes: 1 addition & 1 deletion lib/agents/task-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ export async function taskManager(messages: CoreMessage[]) {
}

const result = await generateObject({
model: (await getModel()) as LanguageModel,
model: (await getModel()) as any,
system: `As a planet computer, your primary objective is to act as an efficient **Task Manager** for the user's query. Your goal is to minimize unnecessary steps and maximize the efficiency of the subsequent exploration phase (researcher agent).

You must first analyze the user's input and determine the optimal course of action. You have two options at your disposal:
Expand Down
44 changes: 44 additions & 0 deletions lib/agents/tools/calendar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { tool } from 'ai'
import { calendarSchema } from '@/lib/schema/calendar'
import { saveNote, getChatNotes } from '@/lib/actions/calendar'
import { ToolProps } from '.'
import { Section } from '@/components/section'
import { BotMessage } from '@/components/message'
import { nanoid } from '@/lib/utils'

export const calendarTool = ({ uiStream }: ToolProps) =>
tool({
description: 'Create reminders, add notes to the calendar, or list existing notes for the current chat.',
parameters: calendarSchema,
execute: async ({ action, content, date, location }) => {
// In a real execution, we need the chatId.
// Since tools are called within the submit action context,
// we might need to find a way to pass chatId here if it's not readily available.
// For this implementation, we'll assume the AI state provides it or we handle it in app/actions.

if (action === 'create_note' && content) {
// We'll return a result that the caller (submit action) can use to actually save
return {
type: 'CALENDAR_ACTION',
action: 'create',
note: {
content,
date: date ? new Date(date) : new Date(),
locationTags: location ? {
type: 'Point',
coordinates: [location.longitude, location.latitude]
} : null
}
}
}

if (action === 'list_notes') {
return {
type: 'CALENDAR_ACTION',
action: 'list'
}
}

return { error: 'Invalid calendar action' }
}
})
5 changes: 5 additions & 0 deletions lib/agents/tools/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { retrieveTool } from './retrieve'
import { searchTool } from './search'
import { videoSearchTool } from './video-search'
import { geospatialTool } from './geospatial' // Removed useGeospatialToolMcp import
import { calendarTool } from './calendar'

import { MapProvider } from '@/lib/store/settings'

Expand All @@ -21,6 +22,10 @@ export const getTools = ({ uiStream, fullResponse, mapProvider }: ToolProps) =>
geospatialQueryTool: geospatialTool({
uiStream,
mapProvider
}),
calendarTool: calendarTool({
uiStream,
fullResponse
})
}

Expand Down
2 changes: 1 addition & 1 deletion lib/agents/writer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export async function writer(
const systemToUse = dynamicSystemPrompt && dynamicSystemPrompt.trim() !== '' ? dynamicSystemPrompt : default_system_prompt;

const result = await nonexperimental_streamText({
model: (await getModel()) as LanguageModel,
model: (await getModel()) as any,
maxTokens: 2500,
system: systemToUse, // Use the dynamic or default system prompt
messages
Expand Down
Loading