Compare commits
10 Commits
fbf5ad5561
...
d4fe82e9a4
| Author | SHA1 | Date |
|---|---|---|
|
|
d4fe82e9a4 | |
|
|
f7db9937b7 | |
|
|
0aaa393a6d | |
|
|
a4f9df6906 | |
|
|
2d35f2d498 | |
|
|
2e7db6a952 | |
|
|
10ed6a395a | |
|
|
5195901f01 | |
|
|
5eb6f9ea31 | |
|
|
10eae505ef |
25
Dockerfile
25
Dockerfile
|
|
@ -1,4 +1,4 @@
|
||||||
# ---------- Build stage (Keep this the same) ----------
|
# Build stage
|
||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
|
|
@ -6,18 +6,11 @@ RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ---------- Diagnostic Production stage ----------
|
# Production stage
|
||||||
FROM python:3.11-slim
|
FROM node:20-alpine
|
||||||
|
WORKDIR /app
|
||||||
# Set the working directory to where the React files live
|
COPY --from=build /app/dist ./dist
|
||||||
WORKDIR /usr/share/nginx/html
|
COPY server.js .
|
||||||
|
RUN npm install express@4 google-auth-library
|
||||||
# Copy build output from the build stage
|
EXPOSE 8080
|
||||||
COPY --from=build /app/dist .
|
CMD ["node", "server.js"]
|
||||||
|
|
||||||
# Expose port 80 (Cloud Run expects this)
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Run Python's built-in simple HTTP server
|
|
||||||
# This server is very "dumb" and will ignore/accept large IAP headers
|
|
||||||
CMD ["python", "-m", "http.server", "80"]
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
const express = require('express');
|
||||||
|
const path = require('path');
|
||||||
|
const { GoogleAuth } = require('google-auth-library');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json({ limit: '1mb' }));
|
||||||
|
|
||||||
|
const BACKEND_URL = process.env.BACKEND_URL;
|
||||||
|
if (!BACKEND_URL) {
|
||||||
|
console.error('FATAL: BACKEND_URL env var is required');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const auth = new GoogleAuth();
|
||||||
|
|
||||||
|
app.get('/health', (_req, res) => {
|
||||||
|
res.status(200).json({ status: 'ok' });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/chat', async (req, res) => {
|
||||||
|
console.log('🔥🔥🔥 /api/chat ROUTE HIT 🔥🔥🔥');
|
||||||
|
try {
|
||||||
|
// Create an ID-token authenticated client for the backend (audience = BACKEND_URL)
|
||||||
|
const idTokenClient = await auth.getIdTokenClient(BACKEND_URL);
|
||||||
|
|
||||||
|
// Forward request to backend
|
||||||
|
const backendResp = await idTokenClient.request({
|
||||||
|
url: `${BACKEND_URL}/chat`,
|
||||||
|
method: 'POST',
|
||||||
|
data: req.body,
|
||||||
|
});
|
||||||
|
|
||||||
|
res.status(backendResp.status).json(backendResp.data);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Proxy error FULL:', {
|
||||||
|
message: err.message,
|
||||||
|
responseStatus: err.response?.status,
|
||||||
|
responseData: err.response?.data,
|
||||||
|
stack: err.stack,
|
||||||
|
});
|
||||||
|
const status = err.response?.status || 500;
|
||||||
|
const message = err.response?.data || { error: err.message };
|
||||||
|
res.status(err.response?.status || 500).json({
|
||||||
|
error: err.response?.data || err.message || 'Unknown proxy error',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve static assets
|
||||||
|
app.use(express.static(path.join(__dirname, 'dist')));
|
||||||
|
|
||||||
|
// SPA fallback
|
||||||
|
app.get('*', (_req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'dist', 'index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = parseInt(process.env.PORT || '8080', 10);
|
||||||
|
app.listen(port, '0.0.0.0', () => {
|
||||||
|
console.log(`Frontend proxy listening on port ${port}`);
|
||||||
|
});
|
||||||
|
|
@ -24,47 +24,21 @@ export const useChat = (mode: ChatMode) => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Create placeholder assistant message
|
// Call the chat API and get the response
|
||||||
const assistantMessageId = (Date.now() + 1).toString()
|
const response = await apiClient.chat(question, mode)
|
||||||
let assistantContent = ''
|
|
||||||
|
|
||||||
setMessages((prev) => [
|
// Add assistant message with the response
|
||||||
...prev,
|
const assistantMessage: Message = {
|
||||||
{
|
id: (Date.now() + 1).toString(),
|
||||||
id: assistantMessageId,
|
role: 'assistant',
|
||||||
role: 'assistant',
|
content: response.response,
|
||||||
content: '',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
// Process SSE stream
|
|
||||||
const streamGenerator = apiClient.chatStream(question, mode)
|
|
||||||
|
|
||||||
for await (const chunk of streamGenerator) {
|
|
||||||
if (chunk.type === 'chunk') {
|
|
||||||
// Append chunk content
|
|
||||||
assistantContent += chunk.data
|
|
||||||
|
|
||||||
// Update assistant message
|
|
||||||
setMessages((prev) =>
|
|
||||||
prev.map((msg) =>
|
|
||||||
msg.id === assistantMessageId
|
|
||||||
? { ...msg, content: assistantContent }
|
|
||||||
: msg
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else if (chunk.type === 'error') {
|
|
||||||
toast.error(chunk.data)
|
|
||||||
console.error('Stream error:', chunk.data)
|
|
||||||
} else if (chunk.type === 'done') {
|
|
||||||
setIsLoading(false)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setIsLoading(false)
|
setMessages((prev) => [...prev, assistantMessage])
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error sending message:', error)
|
console.error('Error sending message:', error)
|
||||||
toast.error('Failed to send message. Please try again.')
|
toast.error(error instanceof Error ? error.message : 'Failed to send message. Please try again.')
|
||||||
|
} finally {
|
||||||
setIsLoading(false)
|
setIsLoading(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
143
src/lib/api.ts
143
src/lib/api.ts
|
|
@ -1,7 +1,8 @@
|
||||||
import type { ChatMode } from '@/types/chat'
|
import type { ChatMode } from '@/types/chat'
|
||||||
|
|
||||||
const API_BASE_URL = import.meta.env.VITE_API_URL || 'http://localhost:5000'
|
// In production, use relative URL to route through the proxy server
|
||||||
const USE_MOCK_DATA = true // Set to false when backend is ready
|
const API_BASE_URL = '/api'
|
||||||
|
const USE_MOCK_DATA = false // Set to true to use mock data for testing
|
||||||
|
|
||||||
// Session management
|
// Session management
|
||||||
export const getChatSessionId = (): string | null => {
|
export const getChatSessionId = (): string | null => {
|
||||||
|
|
@ -32,39 +33,34 @@ const MOCK_RESPONSES = {
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mock streaming function
|
// Chat response type from backend
|
||||||
async function* mockChatStream(
|
export interface ChatResponse {
|
||||||
|
conversation_id: string
|
||||||
|
response: string
|
||||||
|
mode: string
|
||||||
|
sources: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mock chat function
|
||||||
|
async function mockChat(
|
||||||
_question: string,
|
_question: string,
|
||||||
mode: ChatMode
|
mode: ChatMode
|
||||||
): AsyncGenerator<StreamEvent> {
|
): Promise<ChatResponse> {
|
||||||
const sessionId = crypto.randomUUID()
|
|
||||||
yield { type: 'session_id', data: sessionId }
|
|
||||||
|
|
||||||
// Select a random response based on mode
|
// Select a random response based on mode
|
||||||
const responses = MOCK_RESPONSES[mode]
|
const responses = MOCK_RESPONSES[mode]
|
||||||
const response = responses[Math.floor(Math.random() * responses.length)]
|
const response = responses[Math.floor(Math.random() * responses.length)]
|
||||||
|
|
||||||
// Simulate streaming by yielding words with delays
|
// Simulate network delay
|
||||||
const words = response.split(' ')
|
await new Promise(resolve => setTimeout(resolve, 500))
|
||||||
for (let i = 0; i < words.length; i++) {
|
|
||||||
// Add space before word (except first word)
|
|
||||||
const chunk = i === 0 ? words[i] : ' ' + words[i]
|
|
||||||
yield { type: 'chunk', data: chunk }
|
|
||||||
|
|
||||||
// Random delay between 30-80ms to simulate typing
|
return {
|
||||||
await new Promise(resolve => setTimeout(resolve, Math.random() * 50 + 30))
|
conversation_id: crypto.randomUUID(),
|
||||||
|
response,
|
||||||
|
mode,
|
||||||
|
sources: [],
|
||||||
}
|
}
|
||||||
|
|
||||||
yield { type: 'done', data: null }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SSE stream event types
|
|
||||||
export type StreamEvent =
|
|
||||||
| { type: 'chunk'; data: string }
|
|
||||||
| { type: 'done'; data: null }
|
|
||||||
| { type: 'error'; data: string }
|
|
||||||
| { type: 'session_id'; data: string }
|
|
||||||
|
|
||||||
class ApiClient {
|
class ApiClient {
|
||||||
private sessionId: string | null = null
|
private sessionId: string | null = null
|
||||||
|
|
||||||
|
|
@ -82,95 +78,40 @@ class ApiClient {
|
||||||
clearChatSessionId()
|
clearChatSessionId()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Streaming chat endpoint (SSE)
|
// Simple JSON chat endpoint
|
||||||
async *chatStream(
|
async chat(
|
||||||
question: string,
|
question: string,
|
||||||
mode: ChatMode,
|
mode: ChatMode,
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
): AsyncGenerator<StreamEvent> {
|
): Promise<ChatResponse> {
|
||||||
// Use mock data if enabled
|
// Use mock data if enabled
|
||||||
if (USE_MOCK_DATA) {
|
if (USE_MOCK_DATA) {
|
||||||
yield* mockChatStream(question, mode)
|
return mockChat(question, mode)
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate or reuse session ID
|
// Generate or reuse session ID
|
||||||
const actualSessionId = sessionId || this.getSessionId() || crypto.randomUUID()
|
const actualSessionId = sessionId || this.getSessionId() || crypto.randomUUID()
|
||||||
this.setSessionId(actualSessionId)
|
this.setSessionId(actualSessionId)
|
||||||
|
|
||||||
// Yield session ID first
|
const response = await fetch(`${API_BASE_URL}/chat`, {
|
||||||
yield { type: 'session_id', data: actualSessionId }
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
message: question,
|
||||||
|
conversation_id: actualSessionId,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
if (!response.ok) {
|
||||||
const response = await fetch(`${API_BASE_URL}/api/chat/stream`, {
|
const errorData = await response.json().catch(() => ({}))
|
||||||
method: 'POST',
|
throw new Error(errorData.detail || `HTTP error! status: ${response.status}`)
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
question,
|
|
||||||
mode,
|
|
||||||
sessionId: actualSessionId,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
yield { type: 'error', data: `HTTP error! status: ${response.status}` }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!response.body) {
|
|
||||||
yield { type: 'error', data: 'Response body is null' }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const reader = response.body.getReader()
|
|
||||||
const decoder = new TextDecoder()
|
|
||||||
let buffer = ''
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const { done, value } = await reader.read()
|
|
||||||
|
|
||||||
if (done) break
|
|
||||||
|
|
||||||
buffer += decoder.decode(value, { stream: true })
|
|
||||||
const lines = buffer.split('\n')
|
|
||||||
buffer = lines.pop() || ''
|
|
||||||
|
|
||||||
for (const line of lines) {
|
|
||||||
if (line.startsWith('data: ')) {
|
|
||||||
const data = line.slice(6)
|
|
||||||
|
|
||||||
if (data === '[DONE]') {
|
|
||||||
yield { type: 'done', data: null }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(data)
|
|
||||||
if (parsed.type === 'error') {
|
|
||||||
yield { type: 'error', data: parsed.message }
|
|
||||||
} else if (parsed.content) {
|
|
||||||
yield { type: 'chunk', data: parsed.content }
|
|
||||||
} else {
|
|
||||||
// Raw string chunk
|
|
||||||
yield { type: 'chunk', data: data }
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Raw string chunk (not JSON)
|
|
||||||
yield { type: 'chunk', data: data }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
yield { type: 'done', data: null }
|
|
||||||
} catch (error) {
|
|
||||||
yield {
|
|
||||||
type: 'error',
|
|
||||||
data: error instanceof Error ? error.message : 'Network error',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const data: ChatResponse = await response.json()
|
||||||
|
this.setSessionId(data.conversation_id)
|
||||||
|
return data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,14 @@ export default defineConfig({
|
||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 3000,
|
||||||
host: true
|
host: true,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:8000',
|
||||||
|
changeOrigin: true,
|
||||||
|
rewrite: (path) => path.replace(/^\/api/, ''),
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
build: {
|
build: {
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue