Skip to content

Commit 6967ec9

Browse files
committed
Chat images
1 parent 45033c7 commit 6967ec9

File tree

8 files changed

+266
-196
lines changed

8 files changed

+266
-196
lines changed

app/api/generate-image/route.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { createClient } from '@supabase/supabase-js'
3+
4+
export async function POST(req: NextRequest) {
5+
const { prompt } = await req.json()
6+
if (!prompt) return NextResponse.json({ error: 'Missing prompt' }, { status: 400 })
7+
8+
// Get JWT from Authorization header
9+
const authHeader = req.headers.get('authorization')
10+
if (!authHeader) return NextResponse.json({ error: 'Missing auth' }, { status: 401 })
11+
const jwt = authHeader.replace('Bearer ', '')
12+
13+
// Use Supabase admin client to verify JWT and get user
14+
const supabase = createClient(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.SUPABASE_SERVICE_ROLE_KEY!)
15+
const { data: { user }, error } = await supabase.auth.getUser(jwt)
16+
if (error || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
17+
18+
// Fetch Together API key from DB
19+
const { data: keyData, error: userError } = await supabase
20+
.from('users')
21+
.select('together_api_key')
22+
.eq('id', user.id)
23+
.single()
24+
if (userError) return NextResponse.json({ error: userError.message }, { status: 500 })
25+
const apiKey = keyData?.together_api_key
26+
if (!apiKey) return NextResponse.json({ error: 'No Together API key set in your account.' }, { status: 400 })
27+
28+
// Use Together's recommended model for now
29+
const model = 'black-forest-labs/FLUX.1-schnell'
30+
const steps = 4
31+
32+
const res = await fetch('https://api.together.xyz/v1/images/generations', {
33+
method: 'POST',
34+
headers: {
35+
'Authorization': `Bearer ${apiKey}`,
36+
'Content-Type': 'application/json',
37+
},
38+
body: JSON.stringify({ prompt, model, steps }),
39+
})
40+
41+
let togetherData;
42+
let text;
43+
try {
44+
text = await res.text();
45+
togetherData = JSON.parse(text);
46+
} catch {
47+
// Not JSON, probably HTML error
48+
console.error('Together API non-JSON error:', text);
49+
return NextResponse.json({ error: text || 'Unknown error' }, { status: res.status });
50+
}
51+
52+
if (!res.ok) {
53+
return NextResponse.json({ error: togetherData?.error?.message || togetherData?.error || text || 'Failed' }, { status: res.status });
54+
}
55+
return NextResponse.json(togetherData);
56+
}

app/api/user-settings/route.ts

Lines changed: 28 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,32 @@
11
import { NextRequest, NextResponse } from 'next/server'
2-
import { createServerComponentClient } from '@supabase/auth-helpers-nextjs'
3-
import { cookies } from 'next/headers'
2+
import { createClient } from '@supabase/supabase-js'
43
import { z } from 'zod'
54

65
const schema = z.object({
76
together_api_key: z.string().min(1).max(128).optional().or(z.literal('')),
87
})
98

10-
export async function GET() {
11-
const supabase = createServerComponentClient({ cookies })
12-
const {
13-
data: { user },
14-
error,
15-
} = await supabase.auth.getUser()
16-
if (error || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
9+
function getSupabaseAdmin() {
10+
return createClient(
11+
process.env.NEXT_PUBLIC_SUPABASE_URL!,
12+
process.env.SUPABASE_SERVICE_ROLE_KEY!
13+
)
14+
}
15+
16+
async function getUserFromAuthHeader(req: NextRequest) {
17+
const authHeader = req.headers.get('authorization')
18+
if (!authHeader) return null
19+
const jwt = authHeader.replace('Bearer ', '')
20+
const supabase = getSupabaseAdmin()
21+
const { data: { user }, error } = await supabase.auth.getUser(jwt)
22+
if (error || !user) return null
23+
return user
24+
}
25+
26+
export async function GET(req: NextRequest) {
27+
const user = await getUserFromAuthHeader(req)
28+
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
29+
const supabase = getSupabaseAdmin()
1730
const { data, error: userError } = await supabase
1831
.from('users')
1932
.select('together_api_key, daily_query_count, last_query_reset')
@@ -28,18 +41,15 @@ export async function GET() {
2841
}
2942

3043
export async function POST(req: NextRequest) {
31-
const supabase = createServerComponentClient({ cookies })
32-
const {
33-
data: { user },
34-
error,
35-
} = await supabase.auth.getUser()
36-
if (error || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
44+
const user = await getUserFromAuthHeader(req)
45+
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
3746
const body = await req.json()
3847
const parsed = schema.safeParse(body)
3948
if (!parsed.success) {
4049
return NextResponse.json({ error: 'Invalid input' }, { status: 400 })
4150
}
4251
const { together_api_key } = parsed.data
52+
const supabase = getSupabaseAdmin()
4353
const { error: updateError } = await supabase
4454
.from('users')
4555
.update({ together_api_key: together_api_key || null })
@@ -49,16 +59,13 @@ export async function POST(req: NextRequest) {
4959
}
5060

5161
export async function PATCH(req: NextRequest) {
52-
const supabase = createServerComponentClient({ cookies })
53-
const {
54-
data: { user },
55-
error,
56-
} = await supabase.auth.getUser()
57-
if (error || !user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
62+
const user = await getUserFromAuthHeader(req)
63+
if (!user) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
5864
const body = await req.json()
5965
const { model } = body
6066
if (!model) return NextResponse.json({ error: 'Model required' }, { status: 400 })
6167
const today = new Date().toISOString().slice(0, 10)
68+
const supabase = getSupabaseAdmin()
6269
// Atomic update: reset if needed, then decrement if premium
6370
const { data, error: userError } = await supabase
6471
.from('users')

components/chat/chat-input.tsx

Lines changed: 110 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import React, { useState, useRef, useEffect } from 'react'
22
import { Button } from '@/components/ui/button'
3-
import { LucideSend, Search, Upload } from 'lucide-react'
3+
import { LucideSend, Search, Upload, Image as LucideImage } from 'lucide-react'
44
import * as Tooltip from '@radix-ui/react-tooltip'
55
import { useActiveConversation } from '@/hooks/use-active-conversation'
66
import { useCreateConversation } from '@/hooks/use-create-conversation'
@@ -39,6 +39,11 @@ type PendingMessage = {
3939
messages: { role: string; content: string }[];
4040
};
4141

42+
const isImageModel = (model: string | null | undefined) => {
43+
if (!model) return false;
44+
return /flux|image|black-forest-labs/i.test(model);
45+
};
46+
4247
const ChatInput = React.memo(function ChatInput({ onOpenSearch, defaultModel }: { onOpenSearch?: () => void, defaultModel?: string }) {
4348
const activeConversationId = useActiveConversation(s => s.activeConversationId)
4449
const setActiveConversationId = useActiveConversation(s => s.setActiveConversationId)
@@ -117,6 +122,52 @@ const ChatInput = React.memo(function ChatInput({ onOpenSearch, defaultModel }:
117122
}
118123
}, [activeConversationId]);
119124

125+
const handleGenerateImage = async () => {
126+
if (!isImageModel(selectedModel)) {
127+
toast.error('Current model cannot generate images.');
128+
return;
129+
}
130+
if (!inputValue.trim()) {
131+
toast.error('Enter a prompt to generate an image.');
132+
return;
133+
}
134+
// Get Supabase JWT for secure auth
135+
const { data: { session } } = await supabase.auth.getSession();
136+
const jwt = session?.access_token;
137+
if (!jwt) {
138+
toast.error('You must be signed in to generate images.');
139+
return;
140+
}
141+
try {
142+
const model = selectedModel ?? '';
143+
if (!model) throw new Error('No model selected');
144+
const res = await fetch('/api/generate-image', {
145+
method: 'POST',
146+
headers: {
147+
'Content-Type': 'application/json',
148+
'Authorization': `Bearer ${jwt}`,
149+
},
150+
body: JSON.stringify({ prompt: inputValue.trim() }),
151+
});
152+
const data = await res.json();
153+
const url = data?.data?.[0]?.url;
154+
if (!url) throw new Error('No image returned');
155+
// Add as a new message in chat (simulate user + assistant)
156+
setInputValue('');
157+
setPendingAttachments([]);
158+
if (!activeConversationId || typeof activeConversationId !== 'string') {
159+
toast.error('No active conversation.');
160+
return;
161+
}
162+
await createMessage.mutateAsync({ conversation_id: activeConversationId, content: inputValue.trim(), role: 'user' });
163+
await createMessage.mutateAsync({ conversation_id: activeConversationId, content: `![generated image](${url})`, role: 'assistant' });
164+
toast.success('Image generated!');
165+
} catch (err: unknown) {
166+
const msg = typeof err === 'object' && err && 'message' in err ? (err as { message?: string }).message : String(err);
167+
toast.error('Image generation failed: ' + (msg || 'Unknown error'));
168+
}
169+
};
170+
120171
return (
121172
<>
122173
{showLimitModal && createPortal(
@@ -433,36 +484,64 @@ const ChatInput = React.memo(function ChatInput({ onOpenSearch, defaultModel }:
433484
</Tooltip.Content>
434485
</Tooltip.Portal>
435486
</Tooltip.Root>
436-
{[Search].map((Icon, i) => (
437-
<Tooltip.Root key={i} delayDuration={200}>
438-
<Tooltip.Trigger asChild>
439-
<Button
440-
type="button"
441-
size="icon"
442-
variant="ghost"
443-
className="w-10 h-10 text-[#ececf1] bg-transparent border-none shadow-none hover:bg-transparent focus:bg-transparent active:bg-transparent"
444-
onClick={i === 0 && onOpenSearch ? onOpenSearch : undefined}
445-
>
446-
<Icon size={18} />
447-
</Button>
448-
</Tooltip.Trigger>
449-
<Tooltip.Portal>
450-
<Tooltip.Content
451-
sideOffset={8}
452-
className="px-3 py-1.5 rounded-md text-xs shadow-lg border z-50"
453-
style={{
454-
background: "var(--popover)",
455-
color: "var(--popover-foreground)",
456-
borderColor: "var(--border)",
457-
fontFamily: "var(--font-sans)",
458-
}}
459-
>
460-
{["Search"][i]}
461-
<Tooltip.Arrow className="fill-[var(--popover)]" />
462-
</Tooltip.Content>
463-
</Tooltip.Portal>
464-
</Tooltip.Root>
465-
))}
487+
<Tooltip.Root delayDuration={200}>
488+
<Tooltip.Trigger asChild>
489+
<Button
490+
type="button"
491+
size="icon"
492+
variant="ghost"
493+
className="w-10 h-10 text-[#ececf1] bg-transparent border-none shadow-none hover:bg-transparent focus:bg-transparent active:bg-transparent"
494+
onClick={onOpenSearch}
495+
aria-label="Search"
496+
>
497+
<Search size={18} />
498+
</Button>
499+
</Tooltip.Trigger>
500+
<Tooltip.Portal>
501+
<Tooltip.Content
502+
sideOffset={8}
503+
className="px-3 py-1.5 rounded-md text-xs shadow-lg border z-50"
504+
style={{
505+
background: "var(--popover)",
506+
color: "var(--popover-foreground)",
507+
borderColor: "var(--border)",
508+
fontFamily: "var(--font-sans)",
509+
}}
510+
>
511+
Search
512+
<Tooltip.Arrow className="fill-[var(--popover)]" />
513+
</Tooltip.Content>
514+
</Tooltip.Portal>
515+
</Tooltip.Root>
516+
<Tooltip.Root delayDuration={200}>
517+
<Tooltip.Trigger asChild>
518+
<Button
519+
type="button"
520+
size="icon"
521+
variant="ghost"
522+
className="w-10 h-10 text-[#ececf1] bg-transparent border-none shadow-none hover:bg-transparent focus:bg-transparent active:bg-transparent"
523+
onClick={handleGenerateImage}
524+
aria-label="Generate image"
525+
>
526+
<LucideImage size={18} />
527+
</Button>
528+
</Tooltip.Trigger>
529+
<Tooltip.Portal>
530+
<Tooltip.Content
531+
sideOffset={8}
532+
className="px-3 py-1.5 rounded-md text-xs shadow-lg border z-50"
533+
style={{
534+
background: "var(--popover)",
535+
color: "var(--popover-foreground)",
536+
borderColor: "var(--border)",
537+
fontFamily: "var(--font-sans)",
538+
}}
539+
>
540+
Generate Image
541+
<Tooltip.Arrow className="fill-[var(--popover)]" />
542+
</Tooltip.Content>
543+
</Tooltip.Portal>
544+
</Tooltip.Root>
466545
</div>
467546
<Tooltip.Root delayDuration={200}>
468547
<Tooltip.Trigger asChild>

components/chat/chat-message.tsx

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,29 @@ export default function ChatMessage({ role, content, highlight, searchTerm, atta
9292
li: (props) => <li className="mb-1" {...props} />,
9393
strong: (props) => <strong className="font-semibold" {...props} />,
9494
code: (props) => <code className="bg-[#f7f7fa] px-1 py-0.5 rounded text-sm text-[#23272f]" {...props} />,
95+
img: ({ src, alt }) => (
96+
<div className="my-3 flex justify-center">
97+
<img
98+
src={src || ''}
99+
alt={alt || 'generated image'}
100+
loading="lazy"
101+
className="rounded-2xl border border-[#353740] shadow-lg max-w-xs max-h-80 object-contain bg-[#23272f]"
102+
style={{ display: 'block', margin: '0 auto' }}
103+
/>
104+
</div>
105+
),
106+
p: ({ children }) => {
107+
// If any child is a div (our custom img wrapper), unwrap all children
108+
if (
109+
Array.isArray(children) &&
110+
children.some(
111+
(child) => React.isValidElement(child) && child.type === 'div'
112+
)
113+
) {
114+
return <>{children}</>;
115+
}
116+
return <p>{children}</p>;
117+
},
95118
}}
96119
>
97120
{typeof content === 'string' ? content : ''}

components/chat/sidebar-footer.tsx

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,17 @@
11
"use client";
22

33
import { useEffect, useState } from 'react'
4-
import useSWR from 'swr'
54
import { usePremiumQueryCountStore } from '@/hooks/use-premium-query-count-store'
65

76
export default function SidebarFooter() {
8-
const fetcher = (url: string) => fetch(url).then(res => res.json())
9-
const { data: userSettings } = useSWR('/api/user-settings', fetcher, { revalidateOnFocus: false })
107
const count = usePremiumQueryCountStore(s => s.count)
11-
const isUnlimited = usePremiumQueryCountStore(s => s.isUnlimited)
12-
const syncFromDb = usePremiumQueryCountStore(s => s.syncFromDb)
8+
const isUnlimited = typeof window !== 'undefined' && !!localStorage.getItem('together_api_key')
139
const [hydrated, setHydrated] = useState(false)
1410

1511
useEffect(() => {
1612
setHydrated(true)
1713
}, [])
1814

19-
useEffect(() => {
20-
if (userSettings && typeof userSettings.dailyQueryCount === 'number') {
21-
syncFromDb(userSettings.dailyQueryCount, !!userSettings.hasTogetherApiKey)
22-
}
23-
}, [userSettings, syncFromDb])
24-
2515
if (!hydrated) return null
2616

2717
return (

0 commit comments

Comments
 (0)