Skip to content

Commit cbb40cd

Browse files
committed
chore: release v3.1.0 with per-IP rate limiting
1 parent 33883c6 commit cbb40cd

9 files changed

Lines changed: 146 additions & 16 deletions

File tree

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,6 @@ ehthumbs.db
8686
Thumbs.db
8787

8888
# Local Claude workspace settings
89-
.claude/
89+
.claude/
90+
91+
data/

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ FROM node:18-alpine
22

33
WORKDIR /app
44

5+
RUN mkdir -p /app/data
6+
57
COPY package*.json ./
68

79
RUN npm install

RELEASE_NOTES.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@ This file summarizes the release notes inferred from git tags (tag message/annot
44

55
---
66

7+
## v3.1.0
8+
- Release v3.1.0: Add per-IP daily rate limiting and rate-info endpoint; backend data storage in data/rate-limits.json; client-side usage counter in UI
9+
710
## v3.0.3
811
- Release v3.0.3: Fix thumbnail handling by avoiding ID linkification inside existing URLs
912

app/api/chat/route.js

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/
33
import fs from 'fs'
44
import path from 'path'
55
import axios from 'axios'
6+
import { checkAndIncrement } from '../../../lib/rateLimit.js'
67

78
// GA4 Analytics configuration
89
const GA_MEASUREMENT_ID = process.env.GA_MEASUREMENT_ID || 'G-K7DDZVVXM7'
@@ -738,11 +739,22 @@ STRATEGY:
738739
6. For connectivity queries: If a neuron class (IsClass: true) doesn't have connectivity data, look at individual neuron instances from connectomes.
739740
7. Construct VFB URLs: https://v2.virtualflybrain.org/org.geppetto.frontend/geppetto?id=<id>&i=<template_id>,<image_ids>
740741
741-
DISPLAYING RESULTS:
742-
- Use the human-readable name as markdown link text, not bare IDs. Example: [medulla](https://virtualflybrain.org/reports/FBbt_00003748) not FBbt_00003748.
743-
- When vfb_get_term_info returns thumbnail URLs, include them using markdown image syntax: ![label](thumbnail_url)
744-
- Only use thumbnail URLs actually present in the tool response data. Never invent URLs.
745-
- The chat UI renders thumbnails as compact images that expand on hover.
742+
FORMATTING VFB REFERENCES:
743+
When referencing VFB entities, ALWAYS use markdown links with the descriptive name as link text, NOT bare IDs in parentheses.
744+
- CORRECT: [ME on JRC2018Unisex adult brain](https://virtualflybrain.org/reports/VFB_00102107)
745+
- WRONG: ME on JRC2018Unisex adult brain (VFB_00102107)
746+
- WRONG: ME on JRC2018Unisex adult brain ([VFB_00102107](https://virtualflybrain.org/reports/VFB_00102107))
747+
The user wants to see human-readable names as clickable links, not cryptic IDs. The same applies to FBbt anatomy terms — use the term name as the link text: [medulla](https://virtualflybrain.org/reports/FBbt_00003748), not FBbt_00003748.
748+
749+
DISPLAYING IMAGES:
750+
When vfb_get_term_info returns data with thumbnail URLs, you MUST include them in your response using markdown image syntax.
751+
Format each VFB entity as: thumbnail image followed by a descriptive link — do NOT repeat the name as plain text. Example:
752+
- ![ME on JRC2018Unisex](https://www.virtualflybrain.org/data/VFB/i/0010/2107/thumbnail.png) [ME on JRC2018Unisex adult brain](https://virtualflybrain.org/reports/VFB_00102107)
753+
NOT:
754+
- ME on JRC2018Unisex adult brain ![ME](thumbnail_url) [ME on JRC2018Unisex adult brain](url)
755+
The name should appear ONLY ONCE as the link text next to the thumbnail. Do not write it as plain text before the image.
756+
ONLY use thumbnail URLs that are actually present in the vfb_get_term_info response data. NEVER invent or guess thumbnail URLs.
757+
The user's chat interface renders these as compact thumbnails that expand on hover — they are a key visual feature, so always include them when available.
746758
747759
SUGGESTED FOLLOW-UP QUESTIONS:
748760
At the end of your responses, when appropriate (not always necessary), suggest 2-4 follow-up questions the user might want to ask next. Include these as plain-text URLs in one of these formats:
@@ -773,6 +785,27 @@ export async function POST(request) {
773785
const xForwardedFor = request.headers.get('x-forwarded-for') || ''
774786
const clientIp = (xForwardedFor.split(',')[0] || '').trim() || request.headers.get('x-real-ip') || 'unknown'
775787

788+
const rateCheck = checkAndIncrement(clientIp)
789+
if (!rateCheck.allowed) {
790+
const encoder = new TextEncoder()
791+
const stream = new ReadableStream({
792+
start(controller) {
793+
try {
794+
controller.enqueue(encoder.encode(`event: error\ndata: ${JSON.stringify({ message: `Daily rate limit exceeded (${rateCheck.limit} requests per day). Please try again tomorrow.`, used: rateCheck.used, limit: rateCheck.limit })}\n\n`))
795+
} catch (e) { /* ignore */ }
796+
controller.close()
797+
}
798+
})
799+
800+
return new Response(stream, {
801+
headers: {
802+
'Content-Type': 'text/event-stream',
803+
'Cache-Control': 'no-cache',
804+
'Connection': 'keep-alive'
805+
}
806+
})
807+
}
808+
776809
const { messages, scene } = await request.json()
777810

778811
const message = messages[messages.length - 1].content

app/api/rate-info/route.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { getRateInfo } from '../../../lib/rateLimit.js'
2+
import { NextResponse } from 'next/server'
3+
4+
export async function GET(request) {
5+
const xForwardedFor = request.headers.get('x-forwarded-for') || ''
6+
const clientIp = (xForwardedFor.split(',')[0] || '').trim() || request.headers.get('x-real-ip') || 'unknown'
7+
8+
const info = getRateInfo(clientIp)
9+
return NextResponse.json(info)
10+
}

app/page.js

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ export default function Home() {
8080
const [scene, setScene] = useState({ id: existingId, i: existingI })
8181
const [isThinking, setIsThinking] = useState(false)
8282
const [thinkingDots, setThinkingDots] = useState('.')
83+
const [rateInfo, setRateInfo] = useState({ used: 0, limit: 50, remaining: 50 })
8384
const [thinkingMessage, setThinkingMessage] = useState('Thinking')
8485
const chatEndRef = useRef(null)
8586
const msgIdRef = useRef(0) // stable, incrementing message ID
@@ -93,14 +94,11 @@ export default function Home() {
9394
if (!text) return text
9495

9596
// Strip OpenAI Responses API citation artifacts in all known formats
96-
// Private Use Area chars (U+E200-E2FF) used as citation delimiters by OpenAI
97-
let cleaned = text.replace(/[\uE200-\uE2FF]cite[\uE200-\uE2FF]\w*[\uE200-\uE2FF]/g, '') // PUA-bracketed citations e.g. \uE200cite\uE202turn1data\uE201
98-
cleaned = cleaned.replace(/\u3010[^\u3011]*\u3011/g, '') // 【...】 bracketed citations
99-
cleaned = cleaned.replace(/[\uE200-\uE2FF]/g, '') // any remaining PUA chars
100-
cleaned = cleaned.replace(/citeturn[\w?]*/g, '') // bare citeturn artifacts
101-
cleaned = cleaned.replace(/\bcite(?=\[|https?:\/\/)/g, '') // orphaned "cite" before links
102-
// Clean up leftover whitespace from stripped artifacts
103-
cleaned = cleaned.replace(/ {2,}/g, ' ')
97+
let cleaned = text.replace(/\u3010[^\u3011]*\u3011/g, '') // 【...】 bracketed citations
98+
cleaned = cleaned.replace(/citeturn[\w?]*\d*/g, '') // citeturn0search0, citeturn0?, citeturn0vfbsomething etc.
99+
cleaned = cleaned.replace(/\bcite(?=\[|https?:\/\/)/g, '') // orphaned "cite" before links
100+
// Clean up leftover whitespace/punctuation from stripped artifacts
101+
cleaned = cleaned.replace(/ {2,}/g, ' ').replace(/\.\s*\?\s*/g, '. ').replace(/\. \./g, '.')
104102

105103
const urlPlaceholders = []
106104
const URL_PLACEHOLDER = '\x00URL'
@@ -149,7 +147,25 @@ export default function Home() {
149147
}
150148
}, [isThinking])
151149

150+
const fetchRateInfo = useCallback(async () => {
151+
try {
152+
const response = await fetch('/api/rate-info')
153+
if (!response.ok) throw new Error(`HTTP ${response.status}`)
154+
const data = await response.json()
155+
setRateInfo({
156+
used: data.used ?? 0,
157+
limit: data.limit ?? 50,
158+
remaining: data.remaining ?? Math.max(0, (data.limit ?? 50) - (data.used ?? 0))
159+
})
160+
} catch (error) {
161+
// Keep existing state on error; not critical for user workflow
162+
console.error('Failed to fetch rate info', error)
163+
}
164+
}, [])
165+
152166
useEffect(() => {
167+
fetchRateInfo()
168+
153169
if (initialQuery) {
154170
handleSend()
155171
} else {
@@ -170,7 +186,7 @@ Here are some example queries you can try:
170186
171187
Feel free to ask about neural circuits, gene expression, connectome data, or any VFB-related topics!`)])
172188
}
173-
}, [])
189+
}, [fetchRateInfo])
174190

175191
const handleSend = async (messageText = null) => {
176192
const textToSend = (typeof messageText === 'string' ? messageText : null) || input
@@ -219,10 +235,12 @@ Feel free to ask about neural circuits, gene expression, connectome data, or any
219235
setMessages(prev => [...prev, makeMsg('assistant', data.response, { images: data.images })])
220236
if (data.newScene) setScene(data.newScene)
221237
setIsThinking(false)
238+
fetchRateInfo()
222239
return
223240
} else if (currentEvent === 'error') {
224241
setMessages(prev => [...prev, makeMsg('assistant', data.message)])
225242
setIsThinking(false)
243+
fetchRateInfo()
226244
return
227245
}
228246
} catch (parseError) {
@@ -234,6 +252,7 @@ Feel free to ask about neural circuits, gene expression, connectome data, or any
234252
} catch (error) {
235253
setMessages(prev => [...prev, makeMsg('assistant', 'Sorry, there was an error processing your request. Please try again.')])
236254
setIsThinking(false)
255+
fetchRateInfo()
237256
}
238257
}
239258

@@ -668,6 +687,7 @@ Feel free to ask about neural circuits, gene expression, connectome data, or any
668687
display: 'flex',
669688
gap: '8px',
670689
marginTop: '8px',
690+
alignItems: 'center',
671691
flexShrink: 0
672692
}}>
673693
<input
@@ -686,6 +706,15 @@ Feel free to ask about neural circuits, gene expression, connectome data, or any
686706
outline: 'none'
687707
}}
688708
/>
709+
<div style={{
710+
fontSize: '10px',
711+
color: '#333',
712+
opacity: 0.4,
713+
fontFamily: 'monospace',
714+
whiteSpace: 'nowrap'
715+
}}>
716+
{`${rateInfo.used}/${rateInfo.limit}`}
717+
</div>
689718
<button
690719
onClick={handleSend}
691720
disabled={isThinking}

docker-compose.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,10 @@ services:
1010
- OPENAI_API_KEY=${OPENAI_API_KEY}
1111
- OPENAI_BASE_URL=${OPENAI_BASE_URL:-https://api.openai.com/v1}
1212
- OPENAI_MODEL=${OPENAI_MODEL:-gpt-4o-mini}
13+
- RATE_LIMIT_PER_IP=${RATE_LIMIT_PER_IP:-50}
14+
volumes:
15+
- rate-data:/app/data
16+
17+
volumes:
18+
rate-data:
19+
driver: local

lib/rateLimit.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import fs from 'fs'
2+
import path from 'path'
3+
4+
const DATA_DIR = process.env.RATE_LIMIT_DATA_DIR || path.join(process.cwd(), 'data')
5+
const DATA_FILE = path.join(DATA_DIR, 'rate-limits.json')
6+
export const RATE_LIMIT = parseInt(process.env.RATE_LIMIT_PER_IP) || 50
7+
8+
function readData() {
9+
const today = new Date().toISOString().slice(0, 10)
10+
try {
11+
const raw = fs.readFileSync(DATA_FILE, 'utf8')
12+
const data = JSON.parse(raw)
13+
if (data.date !== today) {
14+
return { date: today, ips: {} }
15+
}
16+
return data
17+
} catch {
18+
return { date: today, ips: {} }
19+
}
20+
}
21+
22+
function writeData(data) {
23+
fs.mkdirSync(DATA_DIR, { recursive: true })
24+
const tmp = DATA_FILE + '.tmp'
25+
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8')
26+
fs.renameSync(tmp, DATA_FILE)
27+
}
28+
29+
export function checkAndIncrement(clientIp) {
30+
const data = readData()
31+
const used = data.ips[clientIp] || 0
32+
if (used >= RATE_LIMIT) {
33+
return { allowed: false, used, limit: RATE_LIMIT }
34+
}
35+
data.ips[clientIp] = used + 1
36+
writeData(data)
37+
return { allowed: true, used: used + 1, limit: RATE_LIMIT }
38+
}
39+
40+
export function getRateInfo(clientIp) {
41+
const data = readData()
42+
const used = data.ips[clientIp] || 0
43+
return { used, limit: RATE_LIMIT, remaining: RATE_LIMIT - used }
44+
}

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "vfb-chat-client",
3-
"version": "3.0.4",
3+
"version": "3.1.0",
44
"private": true,
55
"scripts": {
66
"dev": "next dev",

0 commit comments

Comments
 (0)