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
14 changes: 9 additions & 5 deletions app/actions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ async function submit(formData?: FormData, skip?: boolean) {
const uiStream = createStreamableUI()
const isGenerating = createStreamableValue(true)
const isCollapsed = createStreamableValue(false)
const userId = await getCurrentUserIdOnServer();
if (!userId) return;
const chatId = aiState.get().chatId || nanoid();
Comment on lines +45 to +47

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.

🗄️ Data Integrity & Integration | 🔴 Critical | ⚡ Quick win

Use a UUID chat id and persist it into AI state before passing it downstream.

chats.id and usage_events.chat_id are UUID columns, but this fallback uses nanoid(). New sessions can therefore emit a chat id that does not round-trip through the database, and because it stays local here onSetAIState still reads state.chatId instead of this generated value.

Suggested fix
   const userId = await getCurrentUserIdOnServer();
   if (!userId) return;
-  const chatId = aiState.get().chatId || nanoid();
+  const currentState = aiState.get();
+  const chatId = currentState.chatId ?? crypto.randomUUID();
+  if (!currentState.chatId) {
+    aiState.update({ ...currentState, chatId });
+  }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const userId = await getCurrentUserIdOnServer();
if (!userId) return;
const chatId = aiState.get().chatId || nanoid();
const userId = await getCurrentUserIdOnServer();
if (!userId) return;
const currentState = aiState.get();
const chatId = currentState.chatId ?? crypto.randomUUID();
if (!currentState.chatId) {
aiState.update({ ...currentState, chatId });
}
🤖 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 45 - 47, The chat id generation in the action
flow uses a non-UUID fallback and never stores the generated value back into AI
state, so update the logic around getCurrentUserIdOnServer(), aiState.get(), and
the chatId assignment to create a UUID-compatible id and immediately persist it
into the AI state before any downstream calls. Make sure onSetAIState and any
later consumers read the same persisted chatId value rather than a local-only
fallback.


const action = formData?.get('action') as string;
const drawnFeaturesString = formData?.get('drawnFeatures') as string;
Expand All @@ -59,7 +62,7 @@ async function submit(formData?: FormData, skip?: boolean) {
}
try {
const messages = JSON.parse(messagesString) as AIMessage[];
return await generateReportContext(messages);
return await generateReportContext(userId as string, chatId, messages);
} catch (e) {
console.error('Failed to parse messages for report context:', e);
return { title: 'QCX Intelligence Analysis', summary: 'Automated executive summary is currently unavailable.' };
Expand Down Expand Up @@ -117,7 +120,7 @@ async function submit(formData?: FormData, skip?: boolean) {

async function processResolutionSearch() {
try {
const streamResult = await resolutionSearch(messages, timezone, drawnFeatures, location);
const streamResult = await resolutionSearch(userId as string, chatId, messages, timezone, drawnFeatures, location);

let fullSummary = '';
for await (const partialObject of streamResult.partialObjectStream) {
Expand Down Expand Up @@ -182,7 +185,7 @@ async function submit(formData?: FormData, skip?: boolean) {
}
return m
});
const relatedQueries = await querySuggestor(uiStream, sanitizedMessages);
const relatedQueries = await querySuggestor(userId as string, chatId, uiStream, sanitizedMessages);
uiStream.append(
<Section title="Follow-up">
<FollowupPanel />
Expand Down Expand Up @@ -324,7 +327,6 @@ async function submit(formData?: FormData, skip?: boolean) {
};
}

const userId = await getCurrentUserIdOnServer()
const currentSystemPrompt = userId ? await getSystemPrompt(userId) : null
const maxMessages = 10
const messages = aiState.get().messages.map(message => ({
Expand Down Expand Up @@ -388,6 +390,8 @@ async function submit(formData?: FormData, skip?: boolean) {
) as CoreMessage[]
const latestMessages = modifiedMessages.slice(maxMessages * -1)
const { fullResponse } = await researcher(
userId as string,
chatId,
currentSystemPrompt || '',
uiStream,
streamText,
Expand All @@ -398,7 +402,7 @@ async function submit(formData?: FormData, skip?: boolean) {
)

if (!errorOccurred) {
const relatedQueries = await querySuggestor(uiStream, messages)
const relatedQueries = await querySuggestor(userId as string, chatId, uiStream, messages)
uiStream.append(
<Section title="Follow-up">
<FollowupPanel />
Expand Down
18 changes: 18 additions & 0 deletions app/api/usage/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { NextResponse } from 'next/server';
import { getCurrentUserIdOnServer } from '@/lib/auth/get-current-user';
import { getUserUsageSummary } from '@/lib/actions/usage';

export async function GET() {
const userId = await getCurrentUserIdOnServer();
if (!userId) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}

try {
const summary = await getUserUsageSummary(userId);
return NextResponse.json(summary);
} catch (error) {
console.error('API Error in /api/usage:', error);
return NextResponse.json({ error: 'Internal Server Error' }, { status: 500 });
}
}
64 changes: 34 additions & 30 deletions components/sidebar/chat-history-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { Zap, ChevronDown, ChevronUp } from 'lucide-react';
import { useHistoryToggle } from '../history-toggle-context';
import HistoryItem from '@/components/history-item'; // Adjust path if HistoryItem is moved or renamed
import type { Chat as DrizzleChat } from '@/lib/actions/chat-db'; // Use the Drizzle-based Chat type
import { UsageSummary } from '@/lib/types';

interface ChatHistoryClientProps {
// userId is no longer passed as prop; API route will use authenticated user
Expand All @@ -34,47 +35,45 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) {
const [isClearPending, startClearTransition] = useTransition();
const [isAlertDialogOpen, setIsAlertDialogOpen] = useState(false);
const [isCreditsVisible, setIsCreditsVisible] = useState(false);
const [usageSummary, setUsageSummary] = useState<UsageSummary | null>(null);
const { isHistoryOpen } = useHistoryToggle();
const router = useRouter();

useEffect(() => {
async function fetchChats() {
async function fetchData() {
setIsLoading(true);
setError(null);
try {
// API route /api/chats uses getCurrentUserId internally
const response = await fetch('/api/chats?limit=50&offset=0'); // Example limit/offset
if (!response.ok) {
const errorData = await response.json();
throw new Error(errorData.error || `Failed to fetch chats: ${response.statusText}`);
const [chatsRes, usageRes] = await Promise.all([
fetch('/api/chats?limit=50&offset=0'),
fetch('/api/usage')
]);

if (chatsRes.ok) {
const chatsData = await chatsRes.json();
setChats(chatsData.chats);
}
const data: { chats: DrizzleChat[], nextOffset: number | null } = await response.json();
setChats(data.chats);
} catch (err) {
if (err instanceof Error) {
setError(err.message);
toast.error(`Error fetching chats: ${err.message}`);
} else {
setError('An unknown error occurred.');
toast.error('Error fetching chats: An unknown error occurred.');

if (usageRes.ok) {
const usageData = await usageRes.json();
setUsageSummary(usageData);
}
} catch (err) {
console.error('Error fetching data:', err);
} finally {
setIsLoading(false);
}
}

if (isHistoryOpen) {
fetchChats();
fetchData();
}
}, [isHistoryOpen]);
Comment on lines 42 to 71

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Handle failed fetch responses instead of rendering an empty-success state.

/api/usage returns 401/500 error JSON on failure, but this effect only updates state on ok and otherwise just clears loading. When that happens, error stays null, the history panel renders as if load succeeded, and the credits section falls back to blank/zero values instead of surfacing the failure.

🧰 Tools
🪛 React Doctor (0.5.8)

[warning] 42-42: fetch() inside useEffect can race, double-fire, or leak. Use a data-fetching layer or Server Component instead.

Use a data-fetching layer or Server Component so fetches do not race, double-fire, or leak from useEffect.

(no-fetch-in-effect)

🤖 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/sidebar/chat-history-client.tsx` around lines 42 - 71, The fetch
flow in chat-history-client.tsx’s useEffect/fetchData only handles successful
responses and leaves error state untouched on 401/500, causing the sidebar to
render an empty success state. Update fetchData to detect non-ok responses from
fetch('/api/chats?limit=50&offset=0') and fetch('/api/usage'), parse any error
payload if available, and call setError with a meaningful message instead of
just skipping state updates. Use the existing setError, setChats, and
setUsageSummary paths so failed loads surface in the history panel rather than
falling back to blank credits values.


const handleClearHistory = async () => {
startClearTransition(async () => {
try {
// We need a new API endpoint for clearing history
// Example: DELETE /api/chats (or POST /api/clear-history)
// This endpoint will call clearHistory(userId) from chat-db.ts
const response = await fetch('/api/chats/all', { // Placeholder for the actual clear endpoint
const response = await fetch('/api/chats/all', {
method: 'DELETE',
});

Expand All @@ -86,9 +85,7 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) {
toast.success('History cleared');
setChats([]); // Clear chats from UI
setIsAlertDialogOpen(false);
router.refresh(); // Refresh to reflect changes, potentially redirect if on a chat page
// Consider redirecting to '/' if current page is a chat that got deleted.
// The old clearChats action did redirect('/');
router.refresh();
} catch (err) {
if (err instanceof Error) {
toast.error(err.message);
Expand All @@ -100,6 +97,11 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) {
});
};

const totalCredits = 500;
const usedCredits = usageSummary ? Math.ceil(usageSummary.totalCost * 100) : 0;
const availableCredits = Math.max(0, totalCredits - usedCredits);
const percentage = Math.min(100, (availableCredits / totalCredits) * 100);

if (isLoading) {
return (
<div className="flex flex-col flex-1 space-y-3 h-full items-center justify-center">
Expand All @@ -110,7 +112,6 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) {
}

if (error) {
// Optionally provide a retry button
return (
<div className="flex flex-col flex-1 space-y-3 h-full items-center justify-center text-destructive">
<p>Error loading chat history: {error}</p>
Expand Down Expand Up @@ -138,12 +139,17 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) {
<div className="mt-2 p-3 rounded-lg bg-muted/50 border border-border/50 space-y-2">
<div className="flex justify-between items-center text-xs">
<span>Available Credits</span>
<span className="font-bold">0</span>
<span className="font-bold">{availableCredits}</span>
</div>
<div className="w-full bg-secondary h-1.5 rounded-full overflow-hidden">
<div className="bg-yellow-500 h-full w-[0%]" />
<div
className="bg-yellow-500 h-full transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
</div>
<p className="text-[10px] text-muted-foreground">Upgrade to get more credits</p>
<p className="text-[10px] text-muted-foreground">
{usedCredits} credits used (${usageSummary?.totalCost.toFixed(4)} USD)
</p>
</div>
)}
</div>
Expand All @@ -155,9 +161,7 @@ export function ChatHistoryClient({}: ChatHistoryClientProps) {
</div>
) : (
chats.map((chat) => (
// Assuming HistoryItem is adapted for DrizzleChat and expects chat.id and chat.title
// Also, chat.path will need to be constructed, e.g., `/search/${chat.id}`
<HistoryItem key={chat.id} chat={{...chat, path: `/search/${chat.id}`}} />
<HistoryItem key={chat.id} chat={{...chat, path: `/search/${chat.id}` }} />
))
)}
</div>
Expand Down
97 changes: 69 additions & 28 deletions components/usage-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,35 @@ import { Button } from '@/components/ui/button'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Zap, RefreshCw, LayoutPanelLeft, Minus } from 'lucide-react'
import { useUsageToggle } from './usage-toggle-context'
import { UsageSummary } from '@/lib/types'
import { Spinner } from '@/components/ui/spinner'

export function UsageView() {
const [usage] = useState([
{ details: 'QCX-TERRA Crop yield Analysis', date: 'upcoming', change: 7 },
{ details: 'QCX-TERRA Flood predictions', date: 'upcoming', change: 5 },
{ details: 'Planet computer weather synchronization', date: 'upcoming', change: 3 },
])
const [credits] = useState(0)
const [summary, setSummary] = useState<UsageSummary | null>(null)
const [loading, setLoading] = useState(true)
const { toggleUsage } = useUsageToggle()

useEffect(() => {
async function fetchUsage() {
try {
const response = await fetch('/api/usage')
if (response.ok) {
const data = await response.json()
setSummary(data)
}
} catch (error) {
console.error('Failed to fetch usage:', error)
} finally {
setLoading(false)
}
}
fetchUsage()
}, [])
Comment on lines +16 to +31

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.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Treat non-OK /api/usage responses as errors.

This effect only sets summary on response.ok. On a 401/500, loading flips to false with summary === null, so the page shows full credits and “No usage events recorded yet.” instead of an error state.

🧰 Tools
🪛 React Doctor (0.5.8)

[warning] 16-16: fetch() inside useEffect can race, double-fire, or leak. Use a data-fetching layer or Server Component instead.

Use a data-fetching layer or Server Component so fetches do not race, double-fire, or leak from useEffect.

(no-fetch-in-effect)

🤖 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/usage-view.tsx` around lines 16 - 31, The fetchUsage effect in
usage-view.tsx only handles successful responses, so non-OK /api/usage results
like 401 or 500 fall through to a false “empty” state. Update the fetch logic in
useEffect/fetchUsage to explicitly treat response.ok === false as an error path
(for example by throwing or setting an error state) before setLoading(false),
and make sure the component’s summary/error rendering distinguishes failure from
“no usage data.”


const totalCredits = 500
const usedCredits = summary ? Math.ceil(summary.totalCost * 100) : 0 // Simplified credit model
const availableCredits = Math.max(0, totalCredits - usedCredits)

Comment on lines +33 to +36

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.

🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

This credit balance is using lifetime spend, not the advertised yearly allowance.

getUserUsageSummary() sums all usageEvents.cost rows with no time filter, but this UI says credits “Refresh to 500 every year.” That means both this page and the sidebar will permanently subtract from an all-time total, so the balance never actually refreshes with a new year. This needs a time-bounded aggregate from the backend, or the yearly wording removed.

Also applies to: 79-81

🤖 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/usage-view.tsx` around lines 33 - 36, The credit balance
calculation in usage-view is based on lifetime spend, but the UI promises a
yearly refresh, so update the data flow to use a time-bounded yearly aggregate
instead of the all-time total. Adjust getUserUsageSummary() (and any shared
usage summary logic used by the sidebar) to filter usageEvents.cost by the
current year or expose a backend metric that already does so, then keep
totalCredits/availableCredits derived from that yearly value; if you cannot make
it yearly, remove the “Refresh to 500 every year” wording from the affected UI.

return (
<div className="container py-8 h-full overflow-y-auto">
<div className="flex justify-between items-center mb-8">
Expand Down Expand Up @@ -43,11 +62,11 @@ export function UsageView() {
<Zap size={16} className="text-muted-foreground" />
<span>Credits</span>
</div>
<span className="font-bold">{credits}</span>
<span className="font-bold">{loading ? '...' : availableCredits}</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground pl-6">
<span>Free credits</span>
<span>0</span>
<span>Used credits</span>
<span>{loading ? '...' : usedCredits}</span>
</div>
</div>

Expand All @@ -57,9 +76,9 @@ export function UsageView() {
<RefreshCw size={16} className="text-muted-foreground" />
<span>Yearly refresh credits</span>
</div>
<span className="font-bold">500</span>
<span className="font-bold">{totalCredits}</span>
</div>
<p className="text-[10px] text-muted-foreground pl-6">Refresh to 500 every year.</p>
<p className="text-[10px] text-muted-foreground pl-6">Refresh to {totalCredits} every year.</p>
</div>
</div>

Expand All @@ -71,24 +90,46 @@ export function UsageView() {
</div>
</div>

<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">Details</TableHead>
<TableHead className="text-xs">Date</TableHead>
<TableHead className="text-xs text-right">Credits change</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usage.map((item, i) => (
<TableRow key={i}>
<TableCell className="text-xs font-medium">{item.details}</TableCell>
<TableCell className="text-[10px] text-muted-foreground">{item.date}</TableCell>
<TableCell className="text-xs text-right font-medium">{item.change}</TableCell>
{loading ? (
<div className="flex justify-center py-8">
<Spinner />
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-xs">Source</TableHead>
<TableHead className="text-xs">Date</TableHead>
<TableHead className="text-xs text-right">Cost (USD)</TableHead>
</TableRow>
))}
</TableBody>
</Table>
</TableHeader>
<TableBody>
{summary?.recentEvents.map((item) => (
<TableRow key={item.id}>
<TableCell className="text-xs font-medium">
<div className="flex flex-col">
<span>{item.source}</span>
<span className="text-[10px] text-muted-foreground uppercase">{item.kind}</span>
</div>
</TableCell>
<TableCell className="text-[10px] text-muted-foreground">
{new Date(item.createdAt).toLocaleDateString()}
</TableCell>
<TableCell className="text-xs text-right font-medium">
${parseFloat(item.cost).toFixed(4)}
</TableCell>
</TableRow>
))}
{(!summary || summary.recentEvents.length === 0) && (
<TableRow>
<TableCell colSpan={3} className="text-center py-4 text-muted-foreground italic">
No usage events recorded yet.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
)}
</div>
</div>
</div>
Expand Down
6 changes: 3 additions & 3 deletions lib/actions/chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export async function getCrossSessionContext(userId?: string): Promise<string> {
return combinedText.trim()
}

export async function generateReportContext(messages: AIMessage[]) {
export async function generateReportContext(userId: string, chatId: string, messages: AIMessage[]) {
try {
const crossSessionContext = await getCrossSessionContext()

Comment on lines +84 to 87

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.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Use the threaded userId for cross-session context.

generateReportContext now receives userId, but Line 86 still falls back to ambient auth via getCrossSessionContext(). Pass userId through so the report context matches the same user being billed and attributed.

Proposed fix
-    const crossSessionContext = await getCrossSessionContext()
+    const crossSessionContext = await getCrossSessionContext(userId)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export async function generateReportContext(userId: string, chatId: string, messages: AIMessage[]) {
try {
const crossSessionContext = await getCrossSessionContext()
export async function generateReportContext(userId: string, chatId: string, messages: AIMessage[]) {
try {
const crossSessionContext = await getCrossSessionContext(userId)
🤖 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/chat.ts` around lines 84 - 87, The cross-session context lookup
in generateReportContext is still using ambient auth via
getCrossSessionContext(), which can mismatch the threaded userId. Update
generateReportContext to pass the received userId through to the cross-session
context retrieval path, and make sure the call site uses that user-scoped
identity so the report context, billing, and attribution all align.

Expand All @@ -107,8 +107,8 @@ export async function generateReportContext(messages: AIMessage[]) {
const strategicContent = activeMessages.filter(msg => msg.role === 'assistant')

const [execSummary, strategicSynthesis] = await Promise.all([
executiveSummaryAgent(crossSessionContext, activeMessages),
strategicSynthesisAgent(sensorFusionFindings, strategicContent)
executiveSummaryAgent(userId, chatId, crossSessionContext, activeMessages),
strategicSynthesisAgent(userId, chatId, sensorFusionFindings, strategicContent)
])

return {
Expand Down
3 changes: 2 additions & 1 deletion lib/actions/suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,9 @@ export async function getSuggestions(
Generate three queries that anticipate the user's needs, offering logical next steps for their search. The suggestions should be concise and directly related to the partial query and map context.`

;(async () => {
const { model } = await getModel()
const result = await streamObject({
model: (await getModel()) as LanguageModel,
model: model as LanguageModel,
system: systemPrompt,
messages: [{ role: 'user', content: query }],
schema: relatedSchema
Expand Down
2 changes: 1 addition & 1 deletion lib/actions/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ async function runBackgroundWorker(jobId: string, userId: string, domain: string
throw new Error('Insufficient content scraped from domain');
}

const model = await getModel();
const { model } = await getModel();
const { text } = await generateText({
model,
system: 'You are an expert at creating concise and effective AI system prompts for business copilots.',
Expand Down
Loading