diff --git a/BillNote_frontend/src/pages/HomePage/components/ChatPanel.tsx b/BillNote_frontend/src/pages/HomePage/components/ChatPanel.tsx index 26c7a53..92eae13 100644 --- a/BillNote_frontend/src/pages/HomePage/components/ChatPanel.tsx +++ b/BillNote_frontend/src/pages/HomePage/components/ChatPanel.tsx @@ -7,7 +7,7 @@ import { Loader2, Trash2, ChevronDown, ChevronUp, BookOpen, UserRound, Bot } fro import { toast } from 'react-hot-toast' import { useChatStore } from '@/store/chatStore' import { useTaskStore } from '@/store/taskStore' -import { askQuestion, getChatStatus, indexTask, type ChatSource } from '@/services/chat' +import { askQuestion, getChatStatus, indexTask, type ChatSource, type IndexStatus } from '@/services/chat' interface ChatPanelProps { taskId: string @@ -46,8 +46,7 @@ function SourceBadges({ sources }: { sources: ChatSource[] }) { export default function ChatPanel({ taskId }: ChatPanelProps) { const [input, setInput] = useState('') const [loading, setLoading] = useState(false) - const [indexing, setIndexing] = useState(false) - const [indexed, setIndexed] = useState(null) + const [indexStatus, setIndexStatus] = useState(null) const messages = useChatStore(state => state.chatHistory[taskId]) ?? [] const addMessage = useChatStore(state => state.addMessage) @@ -60,36 +59,37 @@ export default function ChatPanel({ taskId }: ChatPanelProps) { [tasks, currentTaskId], ) - // 检查索引状态 + // 检查索引状态,未索引时自动触发,indexing 时轮询 useEffect(() => { if (!taskId) return let cancelled = false + let timer: ReturnType | null = null - const check = async () => { + const poll = async () => { try { const res = await getChatStatus(taskId) if (cancelled) return - if (res.indexed) { - setIndexed(true) - } else { - setIndexing(true) + setIndexStatus(res.status) + + if (res.status === 'idle') { + // 未索引,触发后台索引 await indexTask(taskId) - if (!cancelled) { - setIndexed(true) - setIndexing(false) - } + if (!cancelled) setIndexStatus('indexing') + } + + // indexing 状态持续轮询 + if (res.status === 'indexing' || res.status === 'idle') { + timer = setTimeout(poll, 2000) } } catch { - if (!cancelled) { - setIndexed(false) - setIndexing(false) - } + if (!cancelled) setIndexStatus('failed') } } - check() + poll() return () => { cancelled = true + if (timer) clearTimeout(timer) } }, [taskId]) @@ -178,16 +178,19 @@ export default function ChatPanel({ taskId }: ChatPanelProps) { [], ) - if (indexing) { + if (indexStatus === null || indexStatus === 'indexing' || indexStatus === 'idle') { return ( -
- - 正在索引笔记内容... +
+ +
+

正在索引笔记内容...

+

首次使用需下载 Embedding 模型(约 80MB),请耐心等待

+
) } - if (indexed === false) { + if (indexStatus === 'failed') { return (
索引失败,请重试 @@ -195,14 +198,12 @@ export default function ChatPanel({ taskId }: ChatPanelProps) { size="sm" variant="outline" onClick={async () => { - setIndexing(true) + setIndexStatus('indexing') try { await indexTask(taskId) - setIndexed(true) } catch { - toast.error('索引失败') - } finally { - setIndexing(false) + toast.error('索引请求失败') + setIndexStatus('failed') } }} > diff --git a/BillNote_frontend/src/services/chat.ts b/BillNote_frontend/src/services/chat.ts index 2091dd3..32f541f 100644 --- a/BillNote_frontend/src/services/chat.ts +++ b/BillNote_frontend/src/services/chat.ts @@ -18,8 +18,11 @@ export interface AskResponse { sources: ChatSource[] } +export type IndexStatus = 'idle' | 'indexing' | 'indexed' | 'failed' + export interface ChatStatusResponse { indexed: boolean + status: IndexStatus } export const indexTask = async (taskId: string): Promise => { diff --git a/backend/app/routers/chat.py b/backend/app/routers/chat.py index d9c0934..a5633c9 100644 --- a/backend/app/routers/chat.py +++ b/backend/app/routers/chat.py @@ -1,6 +1,4 @@ -from typing import Optional - -from fastapi import APIRouter +from fastapi import APIRouter, BackgroundTasks from pydantic import BaseModel from app.services.chat_service import chat as chat_service @@ -12,6 +10,9 @@ logger = get_logger(__name__) router = APIRouter() +# 索引状态追踪: task_id -> "indexing" | "indexed" | "failed" +_index_status: dict[str, str] = {} + class IndexRequest(BaseModel): task_id: str @@ -30,28 +31,54 @@ class AskRequest(BaseModel): model_name: str -@router.post("/chat/index") -def index_task(data: IndexRequest): - """为笔记建立向量索引。""" +def _do_index(task_id: str): + """后台执行索引任务。""" try: + _index_status[task_id] = "indexing" store = VectorStoreManager() - store.index_task(data.task_id) - return R.success(msg="索引完成") + store.index_task(task_id) + _index_status[task_id] = "indexed" + logger.info(f"索引完成: {task_id}") except Exception as e: - logger.error(f"索引失败: {e}") - return R.error(msg=f"索引失败: {str(e)}") + _index_status[task_id] = "failed" + logger.error(f"索引失败: {task_id}, {e}") + + +@router.post("/chat/index") +def index_task(data: IndexRequest, background_tasks: BackgroundTasks): + """触发后台索引,立即返回。""" + if _index_status.get(data.task_id) == "indexing": + return R.success(msg="正在索引中") + + # 如果已经索引过,直接返回 + store = VectorStoreManager() + if store.is_indexed(data.task_id): + _index_status[data.task_id] = "indexed" + return R.success(msg="已完成索引") + + _index_status[data.task_id] = "indexing" + background_tasks.add_task(_do_index, data.task_id) + return R.success(msg="开始索引") @router.get("/chat/status") def chat_status(task_id: str): - """检查笔记是否已建立向量索引。""" + """返回索引状态:idle / indexing / indexed / failed。""" try: + # 优先检查内存状态 + status = _index_status.get(task_id) + if status: + return R.success(data={"status": status, "indexed": status == "indexed"}) + + # 内存没有记录,检查持久化 store = VectorStoreManager() indexed = store.is_indexed(task_id) - return R.success(data={"indexed": indexed}) + if indexed: + _index_status[task_id] = "indexed" + return R.success(data={"status": "indexed" if indexed else "idle", "indexed": indexed}) except Exception as e: logger.error(f"查询索引状态失败: {e}") - return R.success(data={"indexed": False}) + return R.success(data={"status": "idle", "indexed": False}) @router.post("/chat/ask")