amiguel Cursor commited on
Commit
9b54db2
·
0 Parent(s):

Initial setup: React + Express streaming chat for Qwen 2.5 7B

Browse files

- Vite/React frontend with SSE streaming and direct DOM rendering at 50 tok/s
- Express backend proxy with Qwen ChatML template and HF Inference API
- Multi-stage Dockerfile optimised for Hugging Face Spaces (port 7860)

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (13) hide show
  1. .dockerignore +7 -0
  2. .gitignore +5 -0
  3. Dockerfile +38 -0
  4. index.html +12 -0
  5. package-lock.json +0 -0
  6. package.json +27 -0
  7. server.ts +94 -0
  8. src/App.tsx +187 -0
  9. src/StreamingText.tsx +23 -0
  10. src/main.tsx +9 -0
  11. tsconfig.json +20 -0
  12. tsconfig.node.json +14 -0
  13. vite.config.ts +16 -0
.dockerignore ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ .git
4
+ .gitignore
5
+ *.md
6
+ .env
7
+ .env.*
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ .DS_Store
4
+ .env
5
+ .env.*
Dockerfile ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---- Build stage ----
2
+ FROM node:20-slim AS builder
3
+
4
+ WORKDIR /app
5
+
6
+ # Copy lockfile + manifest first for layer-cache efficiency
7
+ COPY package.json package-lock.json ./
8
+
9
+ # Reproducible install (includes devDeps needed for vite build)
10
+ RUN npm ci
11
+
12
+ # Copy source
13
+ COPY . .
14
+
15
+ # Build the React frontend → dist/
16
+ RUN npm run build
17
+
18
+ # ---- Runtime stage ----
19
+ FROM node:20-slim
20
+
21
+ WORKDIR /app
22
+
23
+ COPY package.json package-lock.json ./
24
+
25
+ # Production + tsx needed for server runtime
26
+ RUN npm ci
27
+
28
+ # Bring in the compiled frontend and server source
29
+ COPY --from=builder /app/dist ./dist
30
+ COPY server.ts ./
31
+
32
+ # Hugging Face Spaces requires port 7860
33
+ EXPOSE 7860
34
+
35
+ # HF_TOKEN must be added as a Repository Secret in Space settings
36
+ # It is read at runtime via process.env.HF_TOKEN
37
+
38
+ CMD ["npm", "run", "server"]
index.html ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Qwen 2.5 7B Chat</title>
7
+ </head>
8
+ <body>
9
+ <div id="root"></div>
10
+ <script type="module" src="/src/main.tsx"></script>
11
+ </body>
12
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "qwen-chat-space",
3
+ "version": "1.0.0",
4
+ "type": "module",
5
+ "scripts": {
6
+ "dev": "vite",
7
+ "build": "vite build",
8
+ "preview": "vite preview",
9
+ "server": "tsx server.ts"
10
+ },
11
+ "dependencies": {
12
+ "express": "^4.18.2",
13
+ "react": "^18.2.0",
14
+ "react-dom": "^18.2.0",
15
+ "react-markdown": "^9.0.1"
16
+ },
17
+ "devDependencies": {
18
+ "@types/express": "^4.17.21",
19
+ "@types/node": "^20.11.0",
20
+ "@types/react": "^18.2.48",
21
+ "@types/react-dom": "^18.2.18",
22
+ "@vitejs/plugin-react": "^4.2.1",
23
+ "tsx": "^4.7.0",
24
+ "typescript": "^5.3.3",
25
+ "vite": "^5.0.12"
26
+ }
27
+ }
server.ts ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import express, { Request, Response } from 'express';
2
+ import path from 'path';
3
+ import { fileURLToPath } from 'url';
4
+
5
+ const app = express();
6
+ const PORT = 7860;
7
+
8
+ const MODEL_ID = "amiguel/qwen2.5-7b-instruct-ai_llm-sft";
9
+ const API_URL = `https://api-inference.huggingface.co/models/${MODEL_ID}`;
10
+
11
+ app.use(express.json());
12
+
13
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
14
+ app.use(express.static(path.join(__dirname, 'dist')));
15
+
16
+ app.post('/api/chat', async (req: Request, res: Response) => {
17
+ const { messages } = req.body;
18
+ const hfToken = process.env.HF_TOKEN;
19
+
20
+ if (!hfToken) {
21
+ return res.status(401).json({ error: "HF_TOKEN environment variable not set." });
22
+ }
23
+
24
+ // Qwen 2.5 ChatML prompt template
25
+ const prompt = messages
26
+ .map((m: { role: string; content: string }) => {
27
+ if (m.role === 'user') return `<|im_start|>user\n${m.content}<|im_end|>\n`;
28
+ if (m.role === 'assistant') return `<|im_start|>assistant\n${m.content}<|im_end|>\n`;
29
+ return '';
30
+ })
31
+ .join('') + '<|im_start|>assistant\n';
32
+
33
+ try {
34
+ const response = await fetch(API_URL, {
35
+ method: 'POST',
36
+ headers: {
37
+ 'Authorization': `Bearer ${hfToken}`,
38
+ 'Content-Type': 'application/json',
39
+ },
40
+ body: JSON.stringify({
41
+ inputs: prompt,
42
+ parameters: {
43
+ max_new_tokens: 1024,
44
+ return_full_text: false,
45
+ do_sample: true,
46
+ temperature: 0.7,
47
+ },
48
+ stream: true,
49
+ options: {
50
+ wait_for_model: true,
51
+ },
52
+ }),
53
+ });
54
+
55
+ if (!response.ok) {
56
+ const errorText = await response.text();
57
+ console.error("HF API Error:", errorText);
58
+ return res.status(response.status).json({ error: errorText });
59
+ }
60
+
61
+ res.setHeader('Content-Type', 'text/event-stream');
62
+ res.setHeader('Cache-Control', 'no-cache');
63
+ res.setHeader('Connection', 'keep-alive');
64
+
65
+ const reader = response.body?.getReader();
66
+ const decoder = new TextDecoder();
67
+
68
+ if (!reader) {
69
+ return res.end();
70
+ }
71
+
72
+ while (true) {
73
+ const { done, value } = await reader.read();
74
+ if (done) break;
75
+
76
+ const chunk = decoder.decode(value, { stream: true });
77
+ res.write(chunk);
78
+ }
79
+
80
+ res.end();
81
+ } catch (error) {
82
+ console.error("Server Error:", error);
83
+ res.status(500).json({ error: "Internal Server Error" });
84
+ }
85
+ });
86
+
87
+ // SPA fallback
88
+ app.get('*', (_req, res) => {
89
+ res.sendFile(path.join(__dirname, 'dist', 'index.html'));
90
+ });
91
+
92
+ app.listen(PORT, () => {
93
+ console.log(`Server running on http://0.0.0.0:${PORT}`);
94
+ });
src/App.tsx ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useState, useRef, FormEvent } from 'react';
2
+ import { StreamingText } from './StreamingText';
3
+
4
+ interface Message {
5
+ role: 'user' | 'assistant';
6
+ content: string;
7
+ }
8
+
9
+ export default function App() {
10
+ const [messages, setMessages] = useState<Message[]>([]);
11
+ const [input, setInput] = useState('');
12
+ const [isStreaming, setIsStreaming] = useState(false);
13
+ const abortControllerRef = useRef<AbortController | null>(null);
14
+
15
+ const handleSubmit = async (e: FormEvent) => {
16
+ e.preventDefault();
17
+ if (!input.trim() || isStreaming) return;
18
+
19
+ const userMessage: Message = { role: 'user', content: input };
20
+ const newMessages = [...messages, userMessage];
21
+
22
+ setMessages(newMessages);
23
+ setInput('');
24
+ setIsStreaming(true);
25
+ setMessages((prev) => [...prev, { role: 'assistant', content: '' }]);
26
+
27
+ abortControllerRef.current = new AbortController();
28
+
29
+ try {
30
+ const response = await fetch('/api/chat', {
31
+ method: 'POST',
32
+ headers: { 'Content-Type': 'application/json' },
33
+ body: JSON.stringify({ messages: newMessages }),
34
+ signal: abortControllerRef.current.signal,
35
+ });
36
+
37
+ if (!response.body) throw new Error('No body in response');
38
+
39
+ const reader = response.body.getReader();
40
+ const decoder = new TextDecoder();
41
+ let buffer = '';
42
+
43
+ while (true) {
44
+ const { done, value } = await reader.read();
45
+ if (done) break;
46
+
47
+ buffer += decoder.decode(value, { stream: true });
48
+
49
+ // Robust SSE parser: handles chunks split across network packets
50
+ const lines = buffer.split('\n');
51
+ buffer = lines.pop() || '';
52
+
53
+ for (const line of lines) {
54
+ if (line.startsWith('data:')) {
55
+ const jsonStr = line.replace('data:', '').trim();
56
+ if (jsonStr === '[DONE]') continue;
57
+
58
+ try {
59
+ const parsed = JSON.parse(jsonStr);
60
+ const token = parsed.token?.text || '';
61
+
62
+ if (token) {
63
+ setMessages((prev) => {
64
+ const updated = [...prev];
65
+ const lastMsg = updated[updated.length - 1];
66
+ if (lastMsg.role === 'assistant') {
67
+ lastMsg.content += token;
68
+ }
69
+ return updated;
70
+ });
71
+ }
72
+ } catch (err) {
73
+ console.error('JSON parse error on line:', line, err);
74
+ }
75
+ }
76
+ }
77
+ }
78
+ } catch (error) {
79
+ if ((error as Error).name === 'AbortError') {
80
+ console.log('Stream aborted by user');
81
+ } else {
82
+ console.error('Chat error:', error);
83
+ }
84
+ } finally {
85
+ setIsStreaming(false);
86
+ abortControllerRef.current = null;
87
+ }
88
+ };
89
+
90
+ const handleStop = () => {
91
+ abortControllerRef.current?.abort();
92
+ };
93
+
94
+ return (
95
+ <div style={{ height: '100vh', display: 'flex', flexDirection: 'column', backgroundColor: '#f5f5f5' }}>
96
+ <header
97
+ style={{
98
+ padding: '1rem',
99
+ backgroundColor: '#fff',
100
+ borderBottom: '1px solid #ddd',
101
+ boxShadow: '0 2px 4px rgba(0,0,0,0.1)',
102
+ }}
103
+ >
104
+ <h1 style={{ margin: 0, fontSize: '1.25rem' }}>Qwen 2.5 7B Chat</h1>
105
+ </header>
106
+
107
+ <main style={{ flex: 1, overflowY: 'auto', padding: '1rem' }}>
108
+ {messages.map((msg, idx) => (
109
+ <div
110
+ key={idx}
111
+ style={{
112
+ marginBottom: '1rem',
113
+ display: 'flex',
114
+ justifyContent: msg.role === 'user' ? 'flex-end' : 'flex-start',
115
+ }}
116
+ >
117
+ <div
118
+ style={{
119
+ maxWidth: '70%',
120
+ padding: '0.75rem',
121
+ borderRadius: '12px',
122
+ backgroundColor: msg.role === 'user' ? '#007bff' : '#fff',
123
+ color: msg.role === 'user' ? '#fff' : '#000',
124
+ boxShadow: '0 1px 2px rgba(0,0,0,0.1)',
125
+ }}
126
+ >
127
+ {msg.role === 'assistant' && idx === messages.length - 1 && isStreaming ? (
128
+ <StreamingText content={msg.content} />
129
+ ) : (
130
+ <div style={{ whiteSpace: 'pre-wrap' }}>{msg.content}</div>
131
+ )}
132
+ </div>
133
+ </div>
134
+ ))}
135
+ </main>
136
+
137
+ <footer style={{ padding: '1rem', backgroundColor: '#fff', borderTop: '1px solid #ddd' }}>
138
+ <form onSubmit={handleSubmit} style={{ display: 'flex', gap: '0.5rem' }}>
139
+ <input
140
+ type="text"
141
+ value={input}
142
+ onChange={(e) => setInput(e.target.value)}
143
+ placeholder="Type your message..."
144
+ style={{
145
+ flex: 1,
146
+ padding: '0.75rem',
147
+ borderRadius: '4px',
148
+ border: '1px solid #ccc',
149
+ }}
150
+ disabled={isStreaming}
151
+ />
152
+ {isStreaming ? (
153
+ <button
154
+ type="button"
155
+ onClick={handleStop}
156
+ style={{
157
+ padding: '0.75rem 1.5rem',
158
+ backgroundColor: '#dc3545',
159
+ color: '#fff',
160
+ border: 'none',
161
+ borderRadius: '4px',
162
+ cursor: 'pointer',
163
+ }}
164
+ >
165
+ Stop
166
+ </button>
167
+ ) : (
168
+ <button
169
+ type="submit"
170
+ disabled={!input.trim()}
171
+ style={{
172
+ padding: '0.75rem 1.5rem',
173
+ backgroundColor: '#007bff',
174
+ color: '#fff',
175
+ border: 'none',
176
+ borderRadius: '4px',
177
+ cursor: 'pointer',
178
+ }}
179
+ >
180
+ Send
181
+ </button>
182
+ )}
183
+ </form>
184
+ </footer>
185
+ </div>
186
+ );
187
+ }
src/StreamingText.tsx ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ interface StreamingTextProps {
4
+ content: string;
5
+ }
6
+
7
+ export const StreamingText = ({ content }: StreamingTextProps) => {
8
+ const containerRef = useRef<HTMLDivElement>(null);
9
+
10
+ useEffect(() => {
11
+ if (containerRef.current) {
12
+ // Direct DOM mutation bypasses VDOM diffing for smooth 50+ tok/s rendering
13
+ containerRef.current.innerText = content;
14
+ }
15
+ }, [content]);
16
+
17
+ return (
18
+ <div
19
+ ref={containerRef}
20
+ style={{ whiteSpace: 'pre-wrap', fontFamily: 'sans-serif', lineHeight: '1.6' }}
21
+ />
22
+ );
23
+ };
src/main.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import React from 'react'
2
+ import ReactDOM from 'react-dom/client'
3
+ import App from './App'
4
+
5
+ ReactDOM.createRoot(document.getElementById('root')!).render(
6
+ <React.StrictMode>
7
+ <App />
8
+ </React.StrictMode>,
9
+ )
tsconfig.json ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "useDefineForClassFields": true,
5
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
6
+ "module": "ESNext",
7
+ "skipLibCheck": true,
8
+ "moduleResolution": "bundler",
9
+ "allowImportingTsExtensions": true,
10
+ "resolveJsonModule": true,
11
+ "isolatedModules": true,
12
+ "noEmit": true,
13
+ "jsx": "react-jsx",
14
+ "strict": true,
15
+ "noUnusedLocals": true,
16
+ "noUnusedParameters": true,
17
+ "noFallthroughCasesInSwitch": true
18
+ },
19
+ "include": ["src"]
20
+ }
tsconfig.node.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2020",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "skipLibCheck": true,
7
+ "allowSyntheticDefaultImports": true,
8
+ "esModuleInterop": true,
9
+ "strict": true,
10
+ "noEmit": true,
11
+ "types": ["node"]
12
+ },
13
+ "include": ["vite.config.ts", "server.ts"]
14
+ }
vite.config.ts ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { defineConfig } from 'vite'
2
+ import react from '@vitejs/plugin-react'
3
+
4
+ export default defineConfig({
5
+ plugins: [react()],
6
+ server: {
7
+ host: '0.0.0.0',
8
+ port: 7860,
9
+ proxy: {
10
+ '/api': {
11
+ target: 'http://localhost:7860',
12
+ changeOrigin: true,
13
+ }
14
+ }
15
+ }
16
+ })