fix(chat): 索引改为后台异步执行,前端轮询状态并展示进度提示

后端:
- /chat/index 改为 BackgroundTasks 异步执行,立即返回
- /chat/status 返回细粒度状态(idle/indexing/indexed/failed)
- 内存追踪索引进度,避免重复触发

前端:
- ChatPanel 每 2 秒轮询索引状态,索引完成后自动停止
- 索引中显示"正在索引笔记内容..."及首次下载模型提示
- 索引失败显示重试按钮

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-03-23 14:46:37 +08:00
parent fdc888512a
commit 2f2eb646a4
3 changed files with 72 additions and 41 deletions

View File

@@ -7,7 +7,7 @@ import { Loader2, Trash2, ChevronDown, ChevronUp, BookOpen, UserRound, Bot } fro
import { toast } from 'react-hot-toast' import { toast } from 'react-hot-toast'
import { useChatStore } from '@/store/chatStore' import { useChatStore } from '@/store/chatStore'
import { useTaskStore } from '@/store/taskStore' 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 { interface ChatPanelProps {
taskId: string taskId: string
@@ -46,8 +46,7 @@ function SourceBadges({ sources }: { sources: ChatSource[] }) {
export default function ChatPanel({ taskId }: ChatPanelProps) { export default function ChatPanel({ taskId }: ChatPanelProps) {
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [indexing, setIndexing] = useState(false) const [indexStatus, setIndexStatus] = useState<IndexStatus | null>(null)
const [indexed, setIndexed] = useState<boolean | null>(null)
const messages = useChatStore(state => state.chatHistory[taskId]) ?? [] const messages = useChatStore(state => state.chatHistory[taskId]) ?? []
const addMessage = useChatStore(state => state.addMessage) const addMessage = useChatStore(state => state.addMessage)
@@ -60,36 +59,37 @@ export default function ChatPanel({ taskId }: ChatPanelProps) {
[tasks, currentTaskId], [tasks, currentTaskId],
) )
// 检查索引状态 // 检查索引状态未索引时自动触发indexing 时轮询
useEffect(() => { useEffect(() => {
if (!taskId) return if (!taskId) return
let cancelled = false let cancelled = false
let timer: ReturnType<typeof setTimeout> | null = null
const check = async () => { const poll = async () => {
try { try {
const res = await getChatStatus(taskId) const res = await getChatStatus(taskId)
if (cancelled) return if (cancelled) return
if (res.indexed) { setIndexStatus(res.status)
setIndexed(true)
} else { if (res.status === 'idle') {
setIndexing(true) // 未索引,触发后台索引
await indexTask(taskId) await indexTask(taskId)
if (!cancelled) { if (!cancelled) setIndexStatus('indexing')
setIndexed(true) }
setIndexing(false)
} // indexing 状态持续轮询
if (res.status === 'indexing' || res.status === 'idle') {
timer = setTimeout(poll, 2000)
} }
} catch { } catch {
if (!cancelled) { if (!cancelled) setIndexStatus('failed')
setIndexed(false)
setIndexing(false)
}
} }
} }
check() poll()
return () => { return () => {
cancelled = true cancelled = true
if (timer) clearTimeout(timer)
} }
}, [taskId]) }, [taskId])
@@ -178,16 +178,19 @@ export default function ChatPanel({ taskId }: ChatPanelProps) {
[], [],
) )
if (indexing) { if (indexStatus === null || indexStatus === 'indexing' || indexStatus === 'idle') {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-neutral-400"> <div className="flex h-full flex-col items-center justify-center gap-3 text-neutral-400">
<Loader2 className="h-5 w-5 animate-spin" /> <Loader2 className="h-6 w-6 animate-spin" />
<span className="text-sm">...</span> <div className="text-center">
<p className="text-sm font-medium">...</p>
<p className="mt-1 text-xs">使 Embedding 80MB</p>
</div>
</div> </div>
) )
} }
if (indexed === false) { if (indexStatus === 'failed') {
return ( return (
<div className="flex h-full flex-col items-center justify-center gap-2 text-neutral-400"> <div className="flex h-full flex-col items-center justify-center gap-2 text-neutral-400">
<span className="text-sm"></span> <span className="text-sm"></span>
@@ -195,14 +198,12 @@ export default function ChatPanel({ taskId }: ChatPanelProps) {
size="sm" size="sm"
variant="outline" variant="outline"
onClick={async () => { onClick={async () => {
setIndexing(true) setIndexStatus('indexing')
try { try {
await indexTask(taskId) await indexTask(taskId)
setIndexed(true)
} catch { } catch {
toast.error('索引失败') toast.error('索引请求失败')
} finally { setIndexStatus('failed')
setIndexing(false)
} }
}} }}
> >

View File

@@ -18,8 +18,11 @@ export interface AskResponse {
sources: ChatSource[] sources: ChatSource[]
} }
export type IndexStatus = 'idle' | 'indexing' | 'indexed' | 'failed'
export interface ChatStatusResponse { export interface ChatStatusResponse {
indexed: boolean indexed: boolean
status: IndexStatus
} }
export const indexTask = async (taskId: string): Promise<void> => { export const indexTask = async (taskId: string): Promise<void> => {

View File

@@ -1,6 +1,4 @@
from typing import Optional from fastapi import APIRouter, BackgroundTasks
from fastapi import APIRouter
from pydantic import BaseModel from pydantic import BaseModel
from app.services.chat_service import chat as chat_service from app.services.chat_service import chat as chat_service
@@ -12,6 +10,9 @@ logger = get_logger(__name__)
router = APIRouter() router = APIRouter()
# 索引状态追踪: task_id -> "indexing" | "indexed" | "failed"
_index_status: dict[str, str] = {}
class IndexRequest(BaseModel): class IndexRequest(BaseModel):
task_id: str task_id: str
@@ -30,28 +31,54 @@ class AskRequest(BaseModel):
model_name: str model_name: str
@router.post("/chat/index") def _do_index(task_id: str):
def index_task(data: IndexRequest): """后台执行索引任务。"""
"""为笔记建立向量索引。"""
try: try:
_index_status[task_id] = "indexing"
store = VectorStoreManager() store = VectorStoreManager()
store.index_task(data.task_id) store.index_task(task_id)
return R.success(msg="索引完成") _index_status[task_id] = "indexed"
logger.info(f"索引完成: {task_id}")
except Exception as e: except Exception as e:
logger.error(f"索引失败: {e}") _index_status[task_id] = "failed"
return R.error(msg=f"索引失败: {str(e)}") 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") @router.get("/chat/status")
def chat_status(task_id: str): def chat_status(task_id: str):
"""检查笔记是否已建立向量索引""" """返回索引状态idle / indexing / indexed / failed"""
try: try:
# 优先检查内存状态
status = _index_status.get(task_id)
if status:
return R.success(data={"status": status, "indexed": status == "indexed"})
# 内存没有记录,检查持久化
store = VectorStoreManager() store = VectorStoreManager()
indexed = store.is_indexed(task_id) 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: except Exception as e:
logger.error(f"查询索引状态失败: {e}") logger.error(f"查询索引状态失败: {e}")
return R.success(data={"indexed": False}) return R.success(data={"status": "idle", "indexed": False})
@router.post("/chat/ask") @router.post("/chat/ask")