diff --git a/ai_knowledge_base_v2/apps/backend/app/api/chat.py b/ai_knowledge_base_v2/apps/backend/app/api/chat.py index 7a4f718..15f157c 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/chat.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/chat.py @@ -268,7 +268,13 @@ async def _chat_stream(payload: ChatCompletionRequest, db: Session, current_user waitingCount=queue_request.waiting_count, reasoningVisible=reasoning_visible, ) - chunks = ChatStreamService.stream_answer_async(db, current_user, payload.sessionId, payload.message) + chunks = ChatStreamService.stream_answer_async( + db, + current_user, + payload.sessionId, + payload.message, + retry_failed_question=payload.retry, + ) async for segment in ReasoningPolicyService.iter_segments(chunks): if segment.kind == "content": yield _sse_event("content", content=segment.content) diff --git a/ai_knowledge_base_v2/apps/backend/app/schemas/chat.py b/ai_knowledge_base_v2/apps/backend/app/schemas/chat.py index 931220c..0c19a86 100644 --- a/ai_knowledge_base_v2/apps/backend/app/schemas/chat.py +++ b/ai_knowledge_base_v2/apps/backend/app/schemas/chat.py @@ -37,6 +37,7 @@ class UpdateSessionTitleRequest(BaseModel): class ChatCompletionRequest(BaseModel): sessionId: int message: str = Field(min_length=1, max_length=4000) + retry: bool = False class StopChatRequest(BaseModel): diff --git a/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py b/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py index d9c8a3a..ca26f3a 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/chat_stream_service.py @@ -10,7 +10,7 @@ from fastapi import HTTPException, status from sqlalchemy import select from sqlalchemy.orm import Session -from app.models.chat import ChatMessage +from app.models.chat import ChatMessage, TopicSession from app.models.knowledge import KnowledgeRetrievalLog from app.models.user import User from app.services.ai_request_log_service import AiRequestLogService @@ -212,7 +212,14 @@ class ChatStreamService: db.commit() @staticmethod - async def stream_answer_async(db: Session, user: User, session_id: int, question: str) -> AsyncIterator[str]: + async def stream_answer_async( + db: Session, + user: User, + session_id: int, + question: str, + *, + retry_failed_question: bool = False, + ) -> AsyncIterator[str]: user = ChatService.prepare_daily_quota(db, user) session = ChatService._get_user_session(db, user, session_id) ChatService._ensure_quota(user) @@ -225,25 +232,37 @@ class ChatStreamService: now = _now() normalized_question = question.strip() - topic = TopicSessionService.get_or_create_active( - db, - user=user, - session=session, - question=normalized_question, - deduct_quota=entitlement.deduct_quota, + user_message = ( + _retryable_user_message( + db, + session_id=session.id, + user_id=user.id, + question=normalized_question, + ) + if retry_failed_question + else None ) - user_message = ChatMessage( - session_id=session.id, - topic_session_id=topic.id, - user_id=user.id, - role="user", - content=normalized_question, - message_status="FINISHED", - created_at=now, - ) - db.add(user_message) - TopicSessionService.attach_user_message(user_message, topic) - db.flush() + topic = db.get(TopicSession, user_message.topic_session_id) if user_message and user_message.topic_session_id else None + if user_message is None or topic is None: + topic = TopicSessionService.get_or_create_active( + db, + user=user, + session=session, + question=normalized_question, + deduct_quota=entitlement.deduct_quota, + ) + user_message = ChatMessage( + session_id=session.id, + topic_session_id=topic.id, + user_id=user.id, + role="user", + content=normalized_question, + message_status="FINISHED", + created_at=now, + ) + db.add(user_message) + TopicSessionService.attach_user_message(user_message, topic) + db.flush() history = list( db.scalars( @@ -362,6 +381,27 @@ def _now() -> datetime: return datetime.now(UTC).replace(tzinfo=None) +def _retryable_user_message( + db: Session, + *, + session_id: int, + user_id: int, + question: str, +) -> ChatMessage | None: + """Reuse only the latest unanswered user turn to avoid duplicate retry messages.""" + latest = db.scalar( + select(ChatMessage) + .where(ChatMessage.session_id == session_id, ChatMessage.user_id == user_id) + .order_by(ChatMessage.id.desc()) + .limit(1) + ) + if latest is None or latest.role != "user": + return None + if latest.content.strip() != question.strip(): + return None + return latest + + def _rough_token_count(text: str) -> int: return max(1, len(text.strip()) // 2) diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_chat_retry.py b/ai_knowledge_base_v2/apps/backend/tests/test_chat_retry.py new file mode 100644 index 0000000..7a71894 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/tests/test_chat_retry.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from app.models import Base +from app.models.chat import ChatMessage, ChatSession +from app.services.chat_stream_service import _retryable_user_message + + +def _db() -> Session: + engine = create_engine( + "sqlite:///:memory:", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + ) + Base.metadata.create_all(engine) + return Session(engine) + + +def test_retry_reuses_only_latest_unanswered_matching_user_message(): + with _db() as db: + session = ChatSession(id=1, user_id=7, title="重试测试", message_count=0, is_deleted=0) + user_message = ChatMessage( + id=1, + session_id=1, + user_id=7, + role="user", + content="原来的问题", + message_status="FINISHED", + ) + db.add_all([session, user_message]) + db.commit() + + retried = _retryable_user_message( + db, + session_id=1, + user_id=7, + question=" 原来的问题 ", + ) + assert retried is not None + assert retried.id == user_message.id + + assert _retryable_user_message(db, session_id=1, user_id=7, question="另一个问题") is None + + db.add( + ChatMessage( + id=2, + session_id=1, + user_id=7, + role="assistant", + content="已经回答", + message_status="FINISHED", + ) + ) + db.commit() + + assert _retryable_user_message(db, session_id=1, user_id=7, question="原来的问题") is None diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py index b8fd344..a2aca07 100644 --- a/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py +++ b/ai_knowledge_base_v2/apps/backend/tests/test_production_readiness.py @@ -127,7 +127,7 @@ def test_queued_chat_reports_position_and_completes(monkeypatch): monkeypatch.setattr(chat.ChatStreamService, "stream_answer_async", stream_answer) monkeypatch.setattr(chat.ReasoningPolicyService, "is_visible", lambda _db: False) - payload = type("Payload", (), {"sessionId": 1, "message": "问题"})() + payload = type("Payload", (), {"sessionId": 1, "message": "问题", "retry": False})() async def collect_events(): return [item async for item in chat._chat_stream(payload, object(), object())] diff --git a/ai_knowledge_base_v2/apps/user-client/src/App.vue b/ai_knowledge_base_v2/apps/user-client/src/App.vue index dd3e0be..d8fb630 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/App.vue +++ b/ai_knowledge_base_v2/apps/user-client/src/App.vue @@ -137,7 +137,8 @@ async function selectSession(sessionId: number, force = false) { } async function send(message: string, complete: (success: boolean) => void) { - if (!activeSessionId.value || sending.value) { + const sessionId = activeSessionId.value; + if (!sessionId || sending.value) { complete(false); return; } @@ -152,15 +153,35 @@ async function send(message: string, complete: (success: boolean) => void) { }; messages.value.push(userMessage, assistantMessage); const assistantIndex = messages.value.length - 1; + complete(true); + await messageList.value?.scrollToMessage(userMessage.id); + await runGeneration(sessionId, message, assistantIndex, false); +} + +async function retryMessage(messageId: string) { + const sessionId = activeSessionId.value; + if (!sessionId || sending.value) return; + const assistantIndex = messages.value.findIndex((item) => item.id === messageId); + const assistantMessage = messages.value[assistantIndex]; + if (!assistantMessage?.retryQuestion) return; + await messageList.value?.scrollToMessage(messageId); + await runGeneration(sessionId, assistantMessage.retryQuestion, assistantIndex, true); +} + +async function runGeneration(sessionId: number, message: string, assistantIndex: number, retry: boolean) { const currentAssistant = () => messages.value[assistantIndex]; + currentAssistant().content = ""; + currentAssistant().reasoning = ""; + currentAssistant().showReasoning = false; + currentAssistant().errorMessage = undefined; + currentAssistant().streaming = true; sending.value = true; followingOutput.value = true; activeAbortController.value = new AbortController(); let hasContent = false; - await messageList.value?.scrollToMessage(userMessage.id); try { await streamChat( - activeSessionId.value, + sessionId, message, async (chunk) => { if (!hasContent) { @@ -183,21 +204,22 @@ async function send(message: string, complete: (success: boolean) => void) { if (followingOutput.value) await messageList.value?.scrollToBottom(); }, activeAbortController.value.signal, + retry, ); currentAssistant().streaming = false; + currentAssistant().retryQuestion = undefined; currentAssistant().createdAt = new Date().toISOString(); - complete(true); await refreshSessionList(); await refreshProfile(); } catch (error) { currentAssistant().streaming = false; if (error instanceof DOMException && error.name === "AbortError") { currentAssistant().content = currentAssistant().content || "已停止生成"; - complete(true); + currentAssistant().retryQuestion = undefined; } else { - currentAssistant().content = currentAssistant().content || "回答生成失败,请稍后重试。"; - complete(false); - handleError(error, "AI 回复失败"); + currentAssistant().errorMessage = chatErrorMessage(error); + currentAssistant().retryQuestion = message; + showToast("本次回答未完成,可以点击重新生成"); } } finally { sending.value = false; @@ -205,6 +227,14 @@ async function send(message: string, complete: (success: boolean) => void) { } } +function chatErrorMessage(error: unknown) { + const message = error instanceof Error ? error.message : ""; + if (/429|too many|请求过多|排队/i.test(message)) return "当前请求较多,AI 服务暂时繁忙。"; + if (/timeout|超时/i.test(message)) return "等待 AI 回复超时,请稍后重新生成。"; + if (/network|网络|连接/i.test(message)) return "网络连接不稳定,本次回答没有完成。"; + return "AI 回答生成失败,本次不会扣除成功回答额度。"; +} + async function stop() { if (!activeSessionId.value || !sending.value) return; activeAbortController.value?.abort(); @@ -551,6 +581,7 @@ async function copyText(text: string) { :messages="messages" :loading-session="loadingSession" @follow-change="followingOutput = $event" + @retry="retryMessage" /> -import { Bot } from "@lucide/vue"; +import { Bot, RotateCcw } from "@lucide/vue"; import MarkdownIt from "markdown-it"; import { computed } from "vue"; @@ -11,6 +11,12 @@ const props = defineProps<{ showReasoning?: boolean; createdAt: string; streaming?: boolean; + errorMessage?: string; + canRetry?: boolean; +}>(); + +const emit = defineEmits<{ + retry: []; }>(); const markdown = new MarkdownIt({ breaks: true, html: false, linkify: true }); @@ -95,6 +101,13 @@ const displayTime = computed(() => { 思考中 +
{{ renderedContent }}
diff --git a/ai_knowledge_base_v2/apps/user-client/src/components/MessageList.vue b/ai_knowledge_base_v2/apps/user-client/src/components/MessageList.vue index 70d08b0..fef0a25 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/components/MessageList.vue +++ b/ai_knowledge_base_v2/apps/user-client/src/components/MessageList.vue @@ -11,6 +11,8 @@ export interface DisplayMessage { showReasoning?: boolean; createdAt: string; streaming?: boolean; + errorMessage?: string; + retryQuestion?: string; } defineProps<{ @@ -20,6 +22,7 @@ defineProps<{ const emit = defineEmits<{ followChange: [following: boolean]; + retry: [messageId: string]; }>(); const scroller = ref(null); @@ -63,6 +66,9 @@ defineExpose({ scrollToBottom, scrollToMessage }); :show-reasoning="message.showReasoning" :created-at="message.createdAt" :streaming="message.streaming" + :error-message="message.errorMessage" + :can-retry="Boolean(message.retryQuestion)" + @retry="emit('retry', message.id)" /> diff --git a/ai_knowledge_base_v2/apps/user-client/src/services/api.ts b/ai_knowledge_base_v2/apps/user-client/src/services/api.ts index d0bc41b..1e75fbb 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/services/api.ts +++ b/ai_knowledge_base_v2/apps/user-client/src/services/api.ts @@ -123,6 +123,7 @@ export async function streamChat( onReasoning?: (chunk: string) => void, onStatus?: (message: string, type: string, reasoningVisible: boolean) => void, signal?: AbortSignal, + retry = false, ) { const token = getToken(); const response = await fetch(`${API_BASE}/chat/completions`, { @@ -131,7 +132,7 @@ export async function streamChat( "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}), }, - body: JSON.stringify({ sessionId, message }), + body: JSON.stringify({ sessionId, message, retry }), signal, }); if (!response.ok || !response.body) { diff --git a/ai_knowledge_base_v2/apps/user-client/src/styles.css b/ai_knowledge_base_v2/apps/user-client/src/styles.css index 76639a8..0641c85 100644 --- a/ai_knowledge_base_v2/apps/user-client/src/styles.css +++ b/ai_knowledge_base_v2/apps/user-client/src/styles.css @@ -1400,6 +1400,51 @@ textarea:focus-visible { .generation-state span:nth-child(2) { animation-delay: 0.14s; } .generation-state span:nth-child(3) { animation-delay: 0.28s; margin-right: 4px; } +.message-error-state { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 9px 12px; + margin-top: 10px; + padding: 10px 11px; + border: 1px solid #ead5cf; + border-radius: 11px; + background: #fff8f5; + color: #8a5043; + font-size: 13px; + line-height: 1.5; +} + +.message-error-state span { + flex: 1 1 190px; +} + +.message-error-state button { + min-height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + padding: 0 11px; + border: 1px solid #bf7765; + border-radius: 9px; + background: #ffffff; + color: #8a4434; + font: inherit; + font-weight: 700; + white-space: nowrap; + cursor: pointer; +} + +.message-error-state button:hover { + background: #fff0eb; +} + +.message-error-state button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + @keyframes generation-pulse { 0%, 70%, 100% { opacity: 0.25; transform: translateY(0); } 35% { opacity: 1; transform: translateY(-2px); }