mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-06-25 17:53:52 +08:00
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:
@@ -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<boolean | null>(null)
|
||||
const [indexStatus, setIndexStatus] = useState<IndexStatus | null>(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<typeof setTimeout> | 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 (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-neutral-400">
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
<span className="text-sm">正在索引笔记内容...</span>
|
||||
<div className="flex h-full flex-col items-center justify-center gap-3 text-neutral-400">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm font-medium">正在索引笔记内容...</p>
|
||||
<p className="mt-1 text-xs">首次使用需下载 Embedding 模型(约 80MB),请耐心等待</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (indexed === false) {
|
||||
if (indexStatus === 'failed') {
|
||||
return (
|
||||
<div className="flex h-full flex-col items-center justify-center gap-2 text-neutral-400">
|
||||
<span className="text-sm">索引失败,请重试</span>
|
||||
@@ -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')
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -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<void> => {
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user