Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/api/v1/order/benefit/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { config } from '@/lib/server/config'

export const dynamic = 'force-dynamic'

export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
const cookieToken = request.cookies.get('token')?.value
const authHeader = request.headers.get('authorization')
console.log('[benefit] cookie:', cookieToken ? 'yes' : 'no', '| auth header:', authHeader || 'none')
Expand Down
2 changes: 2 additions & 0 deletions app/api/v1/search/stream/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -290,6 +290,8 @@ export async function POST(request: NextRequest) {
const authHeader = request.headers.get('authorization')
// cookie token → "Bearer <token>"; otherwise forward Authorization header as-is (e.g. "MR-xxx")
const authorization = cookieToken ? `Bearer ${cookieToken}` : authHeader || undefined
console.log("============================00003",process.env.USE_MOCK_SEARCH);

const stream = config.USE_MOCK_SEARCH
? mockSearchStream(query, page, pageSize)
: wispaperSearchStream(query, page, pageSize, authorization)
Expand Down
22 changes: 17 additions & 5 deletions components/LoadMoreButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,33 @@ import { useSearch } from '@/hooks/useSearch'
import { useT } from '@/i18n'

export default function LoadMoreButton() {
const papers = useMatrixStore((s) => s.papers)
const visibleCount = useMatrixStore((s) => s.visibleCount)
const hasMore = useMatrixStore((s) => s.hasMore)
const currentPage = useMatrixStore((s) => s.currentPage)
const incrementPage = useMatrixStore((s) => s.incrementPage)
const { loadMore, isSearching, isExtracting } = useSearch()
const showMore = useMatrixStore((s) => s.showMore)
const { loadMore, startExtractionForVisible, isSearching, isExtracting } = useSearch()
const t = useT()

if (!hasMore) return null
// Nothing more to show locally or from backend
const hasHiddenPapers = visibleCount < papers.length
if (!hasHiddenPapers && !hasMore) return null

const loading = isSearching || isExtracting

const handleClick = () => {
if (loading) return
const nextPage = currentPage + 1
incrementPage()
loadMore(nextPage)
if (hasHiddenPapers) {
// Reveal next 10 locally and extract them
showMore()
startExtractionForVisible()
} else {
// Fetch more from backend
const nextPage = currentPage + 1
incrementPage()
loadMore(nextPage)
}
}

return (
Expand Down
9 changes: 6 additions & 3 deletions components/MatrixTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,15 +23,18 @@ const FROZEN_TOTAL = FIXED_COLS_WIDTH.title + FIXED_COLS_WIDTH.year + FIXED_COLS
const DYNAMIC_COL_WIDTH = 200

export default function MatrixTable() {
const papers = useMatrixStore((s) => s.papers)
const allPapers = useMatrixStore((s) => s.papers)
const columns = useMatrixStore((s) => s.columns)
const isExtracting = useMatrixStore((s) => s.isExtracting)
const cells = useMatrixStore((s) => s.cells)
const newPaperIds = useMatrixStore((s) => s.newPaperIds)
const clearNewPaperId = useMatrixStore((s) => s.clearNewPaperId)
const totalSearched = useMatrixStore((s) => s.totalSearched)
const visibleCount = useMatrixStore((s) => s.visibleCount)
const t = useT()

const papers = useMemo(() => allPapers.slice(0, visibleCount), [allPapers, visibleCount])

// Notification counter for newly added papers
const [notifyCount, setNotifyCount] = useState(0)
const [showNotify, setShowNotify] = useState(false)
Expand Down Expand Up @@ -64,7 +67,7 @@ export default function MatrixTable() {
[cells],
)

if (papers.length === 0) {
if (allPapers.length === 0) {
return (
<div className="text-center py-16">
<div className="inline-flex items-center gap-2 text-gray-400">
Expand Down Expand Up @@ -129,7 +132,7 @@ export default function MatrixTable() {
)}
<span className="flex items-center gap-1">
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /></svg>
{t('statsAdded', { count: papers.length })}
{t('statsAdded', { count: allPapers.length })}
</span>
<span className="w-1 h-1 rounded-full bg-gray-300" />
<span className="flex items-center gap-1">
Expand Down
43 changes: 40 additions & 3 deletions components/SearchBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useMatrixStore } from '@/stores/useMatrixStore'
import { useSearch } from '@/hooks/useSearch'
import { useT } from '@/i18n'
import { quotaApi } from '@/lib/api'
import { AUTH_REQUIRED_EVENT } from '@/lib/auth-events'
import { getVisitorHeaders } from '@/lib/visitor'

export interface SearchBarHandle {
Expand All @@ -14,22 +15,35 @@ export interface SearchBarHandle {
const SearchBar = forwardRef<SearchBarHandle>(function SearchBar(_props, ref) {
const [inputValue, setInputValue] = useState('')
const [quota, setQuota] = useState<{ used: number; limit: number | null } | null>(null)
const [showLoginModal, setShowLoginModal] = useState(false)
const query = useMatrixStore((s) => s.query)
const { doSearch, isSearching } = useSearch()
const typingRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const prevSearchingRef = useRef(false)
const t = useT()

const fetchQuota = useCallback(() => {
fetch(quotaApi.benefit(), { headers: getVisitorHeaders(), credentials: 'include' })
.then((r) => r.ok ? r.json() : null)
fetch(quotaApi.benefit(), { method: 'POST', headers: getVisitorHeaders(), credentials: 'include' })
.then((r) => {
if (r.status === 401) {
setShowLoginModal(true)
return null
}
return r.ok ? r.json() : null
})
.then((data) => { if (data) setQuota(data) })
.catch(() => {})
}, [])
}, [t])

// Fetch quota on mount
useEffect(() => { fetchQuota() }, [fetchQuota])

useEffect(() => {
const onAuthRequired = () => setShowLoginModal((prev) => prev || true)
window.addEventListener(AUTH_REQUIRED_EVENT, onAuthRequired)
return () => window.removeEventListener(AUTH_REQUIRED_EVENT, onAuthRequired)
}, [])

// Refetch quota when search completes (isSearching: true → false)
useEffect(() => {
if (prevSearchingRef.current && !isSearching) {
Expand Down Expand Up @@ -134,6 +148,29 @@ const SearchBar = forwardRef<SearchBarHandle>(function SearchBar(_props, ref) {
: t('searchQuota', { used: String(quota.used), limit: String(quota.limit) })}
</div>
)}

{/* Login required modal */}
{showLoginModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl p-6 sm:p-8 max-w-sm w-full mx-4 animate-in fade-in zoom-in-95">
<div className="flex flex-col items-center text-center">
<div className="w-12 h-12 rounded-full bg-amber-100 flex items-center justify-center mb-4">
<svg className="w-6 h-6 text-amber-600" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m0 0v2m0-2h2m-2 0H10m9.364-7.364A9 9 0 1112 3a9 9 0 017.364 4.636z" />
</svg>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('loginRequired')}</h3>
<p className="text-sm text-gray-500 mb-6">{t('loginRequiredDesc')}</p>
<button
onClick={() => { window.location.href = '/app/search' }}
className="w-full px-4 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-xl hover:bg-indigo-700 transition-colors"
>
{t('loginButton')}
</button>
</div>
</div>
</div>
)}
</div>
)
})
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ services:
dockerfile: Dockerfile
container_name: table-search
ports:
- "9992:80"
- "9991:80"
env_file:
- ./.env.local
restart: unless-stopped
96 changes: 81 additions & 15 deletions hooks/useSearch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,23 @@ import { useRef, useCallback } from 'react'
import { useMatrixStore } from '@/stores/useMatrixStore'
import { SSEClient } from '@/lib/sse-client'
import { searchApi, extractApi, paperApi } from '@/lib/api'
import { emitAuthRequired, isAuthErrorMessage } from '@/lib/auth-events'
import { getVisitorHeaders } from '@/lib/visitor'
import type { Paper, Column, ColumnSuggestion } from '@/types'

function resolveErrorMessage(payload: unknown): string {
if (typeof payload === 'string') return payload
if (
payload &&
typeof payload === 'object' &&
'message' in payload &&
typeof payload.message === 'string'
) {
return payload.message
}
return ''
}

export function useSearch() {
const {
isSearching,
Expand Down Expand Up @@ -68,6 +82,9 @@ export function useSearch() {
onError: (err) => {
console.error('Extraction SSE error:', err)
setIsExtracting(false)
if (isAuthErrorMessage(err.message)) {
emitAuthRequired()
}
},
}, getVisitorHeaders())
},
Expand All @@ -88,6 +105,17 @@ export function useSearch() {
let newSessionId = ''
const paperIds: string[] = []
const columnIds: string[] = []
let extractionTriggered = false

const tryEarlyExtraction = () => {
if (extractionTriggered) return
const { visibleCount } = useMatrixStore.getState()
if (paperIds.length >= visibleCount && columnIds.length > 0) {
extractionTriggered = true
const visiblePaperIds = paperIds.slice(0, visibleCount)
startExtraction(newSessionId, visiblePaperIds, [...columnIds])
}
}

const url = opts?.onboarding ? searchApi.onboarding() : searchApi.stream()
const body = opts?.onboarding ? {} : { query, page: 1, page_size: 20 }
Expand All @@ -111,19 +139,33 @@ export function useSearch() {
}
addColumn(col)
columnIds.push(col.id)
// 收到 column 时检查是否已有足够论文,立即开始抽取
tryEarlyExtraction()
} else if (event === 'error') {
const message = resolveErrorMessage(data)
console.error('Search stream event error:', message || data)
setIsSearching(false)
if (isAuthErrorMessage(message)) {
emitAuthRequired()
}
}
},
onComplete: (data) => {
setIsSearching(false)
if (data?.searched) setTotalSearched(data.searched)
if (paperIds.length === 0) setHasMore(false)
// Use sorted order from store, not SSE arrival order
const sortedPaperIds = useMatrixStore.getState().papers.map((p) => p.id)
startExtraction(newSessionId, sortedPaperIds, columnIds)
// 如果论文不足 10 篇,在 complete 时触发抽取
if (!extractionTriggered && paperIds.length > 0 && columnIds.length > 0) {
extractionTriggered = true
startExtraction(newSessionId, paperIds, columnIds)
}
},
onError: (err) => {
console.error('Search SSE error:', err)
setIsSearching(false)
if (isAuthErrorMessage(err.message)) {
emitAuthRequired()
}
},
}, getVisitorHeaders())
},
Expand All @@ -134,8 +176,8 @@ export function useSearch() {

const extractColumn = useCallback(
async (columnId: string) => {
const { sessionId: sid, papers, columns: allCols } = useMatrixStore.getState()
const pids = papers.map((p) => p.id)
const { sessionId: sid, papers, columns: allCols, visibleCount } = useMatrixStore.getState()
const pids = papers.slice(0, visibleCount).map((p) => p.id)
if (!sid || pids.length === 0) return

const col = allCols.find((c) => c.id === columnId)
Expand All @@ -157,12 +199,33 @@ export function useSearch() {
onError: (err) => {
console.error('Column extraction error:', err)
setIsExtracting(false)
if (isAuthErrorMessage(err.message)) {
emitAuthRequired()
}
},
}, getVisitorHeaders())
},
[setIsExtracting, updateCell],
)

// ---------- 对新显示的论文触发提取 ----------

const startExtractionForVisible = useCallback(() => {
const { sessionId: sid, papers, columns, cells, visibleCount } = useMatrixStore.getState()
if (!sid || columns.length === 0) return

const columnIds = columns.map((c) => c.id)
// Find visible papers that haven't been extracted yet
const visiblePapers = papers.slice(0, visibleCount)
const unextractedIds = visiblePapers
.filter((p) => !cells.has(`${p.id}_${columnIds[0]}`))
.map((p) => p.id)

if (unextractedIds.length > 0) {
startExtraction(sid, unextractedIds, columnIds)
}
}, [startExtraction])

// ---------- 加载更多 + 回填 ----------

const loadMore = useCallback(
Expand All @@ -175,7 +238,6 @@ export function useSearch() {
searchClientRef.current = client

const newPaperIds: string[] = []
const existingColumnIds = state.columns.map((c) => c.id)

await client.connectPost(searchApi.stream(), {
query: state.query,
Expand All @@ -186,6 +248,13 @@ export function useSearch() {
if (event === 'paper') {
addPaper(data as Paper)
newPaperIds.push(data.id)
} else if (event === 'error') {
const message = resolveErrorMessage(data)
console.error('Load more stream event error:', message || data)
setIsSearching(false)
if (isAuthErrorMessage(message)) {
emitAuthRequired()
}
}
// 加载更多时忽略 session / column 事件
},
Expand All @@ -195,21 +264,18 @@ export function useSearch() {
setHasMore(false)
return
}
// 回填:用 store 中排序后的新论文 ID
const sortedNewIds = useMatrixStore.getState().papers
.filter((p) => newPaperIds.includes(p.id))
.map((p) => p.id)
if (existingColumnIds.length > 0) {
startExtraction(state.sessionId!, sortedNewIds, existingColumnIds)
}
// 不自动提取,等用户点 Load More 显示后再提取
},
onError: (err) => {
console.error('Load more error:', err)
setIsSearching(false)
if (isAuthErrorMessage(err.message)) {
emitAuthRequired()
}
},
}, getVisitorHeaders())
},
[addPaper, setIsSearching, setHasMore, startExtraction],
[addPaper, setIsSearching, setHasMore],
)

// ---------- 恢复后端 paper cache ----------
Expand All @@ -234,5 +300,5 @@ export function useSearch() {
[],
)

return { doSearch, extractColumn, loadMore, abortAll, restoreSession, isSearching, isExtracting }
return { doSearch, extractColumn, loadMore, startExtractionForVisible, abortAll, restoreSession, isSearching, isExtracting }
}
5 changes: 5 additions & 0 deletions i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@ const en: Messages = {
timeHoursAgo: '{count}h ago',
timeDaysAgo: '{count}d ago',

// Auth
loginRequired: 'Login Required',
loginRequiredDesc: 'Please log in to access this feature.',
loginButton: 'Go to Login',

// Language toggle
langEN: 'EN',
langZH: '中文',
Expand Down
5 changes: 5 additions & 0 deletions i18n/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export interface Messages {
timeHoursAgo: string // "{count}h ago"
timeDaysAgo: string // "{count}d ago"

// Auth
loginRequired: string
loginRequiredDesc: string
loginButton: string

// Language toggle
langEN: string
langZH: string
Expand Down
Loading
Loading