mirror of
https://github.com/JefferyHcool/BiliNote.git
synced 2026-07-12 16:11:34 +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 { 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)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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> => {
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user