Skip to content

Commit ffbe0dd

Browse files
committed
Refactor project frontend API and fix backend project API
This commit refactors the project frontend API by adding tests and fixes an issue with the backend project API where the user ID should be a number. Note: Please remove any meta information like issue references, tags, or author names from the commit message.
1 parent 666d159 commit ffbe0dd

File tree

6 files changed

+274
-253
lines changed

6 files changed

+274
-253
lines changed

frontend/src/app/HomeContent.tsx

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
'use client';
2+
3+
import React, { useEffect, useRef, useState } from 'react';
4+
import { ChatLayout } from '@/components/chat/chat-layout';
5+
import {
6+
Dialog,
7+
DialogContent,
8+
DialogHeader,
9+
DialogTitle,
10+
DialogDescription,
11+
} from '@/components/ui/dialog';
12+
import UsernameForm from '@/components/username-form';
13+
import { getSelectedModel } from '@/lib/model-helper';
14+
import { toast } from 'sonner';
15+
import { v4 as uuidv4 } from 'uuid';
16+
import useChatStore from './hooks/useChatStore';
17+
import { Message } from '@/components/types';
18+
19+
export default function HomeContent() {
20+
const [messages, setMessages] = useState<Message[]>([]);
21+
const [input, setInput] = useState('');
22+
const [isLoading, setIsLoading] = useState(false);
23+
const [error, setError] = useState<Error | null>(null);
24+
const [chatId, setChatId] = useState<string>('');
25+
const [selectedModel, setSelectedModel] =
26+
useState<string>(getSelectedModel());
27+
const [open, setOpen] = useState(false);
28+
const [loadingSubmit, setLoadingSubmit] = useState(false);
29+
const formRef = useRef<HTMLFormElement>(null);
30+
31+
const base64Images = useChatStore((state) => state.base64Images);
32+
const setBase64Images = useChatStore((state) => state.setBase64Images);
33+
const ws = useRef<WebSocket | null>(null);
34+
35+
useEffect(() => {
36+
if (messages.length < 1) {
37+
const id = uuidv4();
38+
setChatId(id);
39+
}
40+
}, [messages]);
41+
42+
useEffect(() => {
43+
if (!isLoading && !error && chatId && messages.length > 0) {
44+
localStorage.setItem(`chat_${chatId}`, JSON.stringify(messages));
45+
window.dispatchEvent(new Event('storage'));
46+
}
47+
}, [chatId, isLoading, error, messages]);
48+
49+
useEffect(() => {
50+
ws.current = new WebSocket('ws://localhost:8080/graphql');
51+
52+
ws.current.onopen = () => {
53+
console.log('WebSocket connected');
54+
};
55+
56+
ws.current.onerror = (error) => {
57+
console.error('WebSocket error:', error);
58+
toast.error('Connection error. Retrying...');
59+
};
60+
61+
if (!localStorage.getItem('ollama_user')) {
62+
setOpen(true);
63+
}
64+
65+
return () => {
66+
if (ws.current) {
67+
ws.current.close();
68+
}
69+
};
70+
}, []);
71+
72+
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
73+
setInput(e.target.value);
74+
};
75+
76+
const stop = () => {
77+
if (ws.current) {
78+
ws.current.send(
79+
JSON.stringify({
80+
type: 'stop',
81+
id: chatId,
82+
})
83+
);
84+
}
85+
};
86+
87+
const onSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
88+
e.preventDefault();
89+
if (
90+
!input.trim() ||
91+
!ws.current ||
92+
ws.current.readyState !== WebSocket.OPEN
93+
) {
94+
return;
95+
}
96+
97+
setLoadingSubmit(true);
98+
99+
const newMessage: Message = {
100+
id: uuidv4(),
101+
role: 'user',
102+
content: input,
103+
createdAt: new Date().toISOString(),
104+
};
105+
106+
setMessages((prev) => [...prev, newMessage]);
107+
setInput('');
108+
109+
const attachments = base64Images
110+
? base64Images.map((image) => ({
111+
contentType: 'image/base64',
112+
url: image,
113+
}))
114+
: [];
115+
116+
const subscriptionMsg = {
117+
type: 'start',
118+
id: Date.now().toString(),
119+
payload: {
120+
query: `
121+
subscription ChatStream($input: ChatInputType!) {
122+
chatStream(input: $input) {
123+
choices {
124+
delta {
125+
content
126+
}
127+
finish_reason
128+
index
129+
}
130+
created
131+
id
132+
model
133+
object
134+
}
135+
}
136+
`,
137+
variables: {
138+
input: {
139+
message: input,
140+
chatId,
141+
model: selectedModel,
142+
attachments,
143+
},
144+
},
145+
},
146+
};
147+
148+
try {
149+
ws.current.onmessage = (event) => {
150+
const response = JSON.parse(event.data);
151+
152+
if (response.type === 'data' && response.payload.data) {
153+
const chunk = response.payload.data.chatStream;
154+
const content = chunk.choices[0]?.delta?.content;
155+
156+
if (content) {
157+
setMessages((prev) => {
158+
const lastMsg = prev[prev.length - 1];
159+
if (lastMsg?.role === 'assistant') {
160+
return [
161+
...prev.slice(0, -1),
162+
{ ...lastMsg, content: lastMsg.content + content },
163+
];
164+
} else {
165+
return [
166+
...prev,
167+
{
168+
id: chunk.id,
169+
role: 'assistant',
170+
content,
171+
createdAt: new Date(chunk.created * 1000).toISOString(),
172+
},
173+
];
174+
}
175+
});
176+
}
177+
178+
if (chunk.choices[0]?.finish_reason === 'stop') {
179+
setLoadingSubmit(false);
180+
// 保存到本地存储
181+
localStorage.setItem(`chat_${chatId}`, JSON.stringify(messages));
182+
window.dispatchEvent(new Event('storage'));
183+
}
184+
}
185+
};
186+
187+
// 发送订阅请求
188+
ws.current.send(JSON.stringify(subscriptionMsg));
189+
setBase64Images(null);
190+
} catch (error) {
191+
console.error('Error:', error);
192+
toast.error('An error occurred. Please try again.');
193+
setLoadingSubmit(false);
194+
}
195+
};
196+
197+
const onOpenChange = (isOpen: boolean) => {
198+
const username = localStorage.getItem('ollama_user');
199+
if (username) return setOpen(isOpen);
200+
201+
localStorage.setItem('ollama_user', 'Anonymous');
202+
window.dispatchEvent(new Event('storage'));
203+
setOpen(isOpen);
204+
};
205+
206+
return (
207+
<main className="flex h-[calc(100dvh)] flex-col items-center">
208+
<Dialog open={open} onOpenChange={onOpenChange}>
209+
<ChatLayout
210+
chatId={chatId}
211+
setSelectedModel={setSelectedModel}
212+
messages={messages}
213+
input={input}
214+
handleInputChange={handleInputChange}
215+
handleSubmit={onSubmit}
216+
isLoading={isLoading}
217+
loadingSubmit={loadingSubmit}
218+
error={error}
219+
stop={stop}
220+
navCollapsedSize={10}
221+
defaultLayout={[30, 160]}
222+
formRef={formRef}
223+
setMessages={setMessages}
224+
setInput={setInput}
225+
/>
226+
<DialogContent className="flex flex-col space-y-4">
227+
<DialogHeader className="space-y-2">
228+
<DialogTitle>Welcome to Ollama!</DialogTitle>
229+
<DialogDescription>
230+
Enter your name to get started. This is just to personalize your
231+
experience.
232+
</DialogDescription>
233+
<UsernameForm setOpen={setOpen} />
234+
</DialogHeader>
235+
</DialogContent>
236+
</Dialog>
237+
</main>
238+
);
239+
}

frontend/src/app/layout.tsx

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import type { Metadata } from 'next';
22
import { Inter } from 'next/font/google';
33
import './globals.css';
4-
import { ThemeProvider } from '@/providers/theme-provider';
5-
import { Toaster } from '@/components/ui/sonner';
4+
import { RootProvider } from './provider';
65

76
const inter = Inter({ subsets: ['latin'] });
87

@@ -26,10 +25,7 @@ export default function RootLayout({
2625
return (
2726
<html lang="en">
2827
<body className={inter.className}>
29-
<ThemeProvider attribute="class" defaultTheme="dark">
30-
{children}
31-
<Toaster />
32-
</ThemeProvider>
28+
<RootProvider>{children}</RootProvider>
3329
</body>
3430
</html>
3531
);

0 commit comments

Comments
 (0)